From 37f53199a6b2450b94bf4c7ea41c261bbdb39742 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Fri, 2 Oct 2026 07:11:20 +0000 Subject: [PATCH] Use the shared text editor for command-center task renaming (#50216) ## What changed Replace the rename field with a single-line `TextArea` that honors configured editor bindings and the composer's Vim setting. Start renaming in insert mode and support Vim Escape recovery on legacy terminals. Strip carriage returns and newlines from titles and pasted text, keep Vim linewise pastes inline, and scroll horizontally at grapheme boundaries to keep the cursor visible. ## Testing Add single-line editor coverage for newline filtering, Vim commands, repeat edits, and inline yanks. Extend command-center tests to cover Vim renaming across status-filter changes and cursor placement in narrow layouts. GitOrigin-RevId: 8d320d6b6de9f77e18292169418383929ca6ceda --- codex-rs/tui/src/app/agent_center/input.rs | 48 ++++++++++++- codex-rs/tui/src/app/agent_center/render.rs | 65 ++++++++++------- codex-rs/tui/src/app/agent_center_tests.rs | 31 +++++++- codex-rs/tui/src/app/agents_overview.rs | 9 ++- .../src/app/agents_overview_actions_tests.rs | 2 +- codex-rs/tui/src/app/agents_overview_view.rs | 70 +++++++++++++++---- codex-rs/tui/src/app/event_dispatch.rs | 2 +- codex-rs/tui/src/app/input.rs | 44 ++++++++---- .../app/tests/navigation_reconnect_tests.rs | 23 +++--- codex-rs/tui/src/bottom_pane/textarea.rs | 54 ++++++++++++-- .../tui/src/bottom_pane/textarea/editing.rs | 4 ++ .../src/bottom_pane/textarea/single_line.rs | 47 +++++++++++++ .../bottom_pane/textarea/single_line_tests.rs | 40 +++++++++++ .../src/bottom_pane/textarea/vim_commands.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 4 ++ 15 files changed, 376 insertions(+), 71 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/textarea/single_line.rs create mode 100644 codex-rs/tui/src/bottom_pane/textarea/single_line_tests.rs diff --git a/codex-rs/tui/src/app/agent_center/input.rs b/codex-rs/tui/src/app/agent_center/input.rs index 538a077f56..0abc72f7a0 100644 --- a/codex-rs/tui/src/app/agent_center/input.rs +++ b/codex-rs/tui/src/app/agent_center/input.rs @@ -1,9 +1,55 @@ -//! Tab filters and shortcut help yield to configured task and list bindings. +//! Shared rename editing and task shortcuts respect configured editor and list bindings. use super::*; use crossterm::event::KeyEventKind; impl AgentsOverviewView { + pub(in crate::app::agents_overview_view) fn rename_key(&mut self, key: KeyEvent) -> bool { + let mut state = self.state(); + if state.rename_target.is_none() { + return false; + } + let input = &mut state.input; + let editing = input.should_handle_vim_insert_escape(key) || input.is_vim_operator_pending(); + if !editing { + match self.keymap.action_for(key) { + Some(ListAction::Cancel) => { + drop(state); + self.on_ctrl_c(); + return true; + } + Some(ListAction::Accept) if !is_plain_text_key_event(key) => { + let online = state.connection_notice.is_none() && !state.creating_worktree; + drop(state); + if online { + self.activate(); + } + return true; + } + _ => {} + } + } + let editor_binding = crate::keymap::keymap_action_ids() + .filter(|action| action.context == input.keymap_context()) + .any(|action| { + crate::keymap::bindings_for_action( + &self.editor_keymap, + action.context.config_name(), + action.action, + ) + .is_some_and(|bindings| bindings.is_pressed(key)) + }); + if editing + || is_plain_text_key_event(key) + || editor_binding + || !self.center_shortcut_keys.is_pressed(key) + { + input.input(key); + return !crate::keymap::is_dispatch_token_event(key); + } + false + } + pub(in crate::app::agents_overview_view) fn command_center_key( &mut self, key: KeyEvent, diff --git a/codex-rs/tui/src/app/agent_center/render.rs b/codex-rs/tui/src/app/agent_center/render.rs index 0232bef8d7..1d8adef4f8 100644 --- a/codex-rs/tui/src/app/agent_center/render.rs +++ b/codex-rs/tui/src/app/agent_center/render.rs @@ -1,9 +1,11 @@ -//! Status tabs, task rows and read-only details share a simple wide/narrow layout. +//! Status tabs, task rows, the rename editor and details share a wide/narrow layout. use super::hints::hint_line; use super::*; use crate::bottom_pane::render_filled_tab_bar; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crossterm::cursor::SetCursorStyle; +use ratatui::widgets::WidgetRef; use unicode_segmentation::UnicodeSegmentation; pub(super) fn row(area: Rect, offset: u16, height: u16) -> Rect { @@ -87,20 +89,28 @@ impl Renderable for AgentsOverviewView { 24 } + fn cursor_style(&self, _area: Rect) -> SetCursorStyle { + let state = self.state(); + if state.rename_target.is_some() && state.input.uses_vim_insert_cursor() { + SetCursorStyle::SteadyBar + } else { + SetCursorStyle::DefaultUserShape + } + } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { let layout = self.center_layout(area); let state = self.state(); if state.editing_metadata() && !layout.search.is_empty() { - let input = if state.rename_target.is_some() { - &state.input - } else { - &state.search - }; - let prefix = if state.rename_target.is_some() { - "Rename › " - } else { - "Search › " - }; + if state.rename_target.is_some() { + let mut area = layout.search; + let prefix_width = 9.min(area.width); + area.x += prefix_width; + area.width -= prefix_width; + return state.input.cursor_pos(area); + } + let input = &state.search; + let prefix = "Search › "; let available = usize::from(layout.search.width).saturating_sub(prefix.width() + 1); return Some(( layout.search.x @@ -178,21 +188,26 @@ impl Renderable for AgentsOverviewView { buf, ); if state.editing_metadata() { - let (label, input) = if state.rename_target.is_some() { - ("Rename › ", &state.input) - } else { - ("Search › ", &state.search) - }; - let available = usize::from(layout.search.width).saturating_sub(label.width() + 1); buf.set_style(layout.search, crate::bottom_pane::active_tab_style()); - line( - vec![ - label.cyan().bold(), - visible_suffix(input, available).to_owned().into(), - ], - layout.search, - buf, - ); + if state.rename_target.is_some() { + line("Rename › ".cyan().bold(), layout.search, buf); + let mut area = layout.search; + let prefix_width = 9.min(area.width); + area.x += prefix_width; + area.width -= prefix_width; + WidgetRef::render_ref(&&state.input, area, buf); + } else { + let label = "Search › "; + let available = usize::from(layout.search.width).saturating_sub(label.width() + 1); + line( + vec![ + label.cyan().bold(), + visible_suffix(&state.search, available).to_owned().into(), + ], + layout.search, + buf, + ); + } } let notice = state .connection_notice diff --git a/codex-rs/tui/src/app/agent_center_tests.rs b/codex-rs/tui/src/app/agent_center_tests.rs index 67e36e4668..2db72f03d2 100644 --- a/codex-rs/tui/src/app/agent_center_tests.rs +++ b/codex-rs/tui/src/app/agent_center_tests.rs @@ -199,6 +199,12 @@ async fn live_center_metadata_clips_at_grapheme_boundaries() { Some((36, 3)) ); if key == 'r' { + let cursor = view + .cursor_pos(Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 18, + )) + .unwrap(); + assert!(cursor.0 < 24); insta::assert_snapshot!("live_center_narrow_rename", rendered); } view.handle_key_event(KeyCode::Esc.into()); @@ -208,6 +214,7 @@ async fn live_center_metadata_clips_at_grapheme_boundaries() { #[tokio::test] async fn live_center_rename_retains_target_when_status_leaves_filter() -> Result<()> { let mut app = make_test_app().await; + app.chat_widget.toggle_vim_mode_and_notify(); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); app.app_event_tx = AppEventSender::new(tx); let id = ThreadId::new(); @@ -216,7 +223,7 @@ async fn live_center_rename_retains_target_when_status_leaves_filter() -> Result overview_thread( id, /*parent_thread_id*/ None, - "Target", + "Tar\nget", ThreadStatus::Idle, ), overview_thread( @@ -236,10 +243,32 @@ async fn live_center_rename_retains_target_when_status_leaves_filter() -> Result } } view.handle_key_event(KeyCode::Char('r').into()); + assert_eq!( + view.cursor_style(Rect::default()), + crossterm::cursor::SetCursorStyle::SteadyBar + ); threads[0].status = ThreadStatus::SystemError; view = app.agents_overview_view(threads.clone(), Some(id)); + if key == KeyCode::Enter { + for edit in [ + KeyCode::Esc, + KeyCode::Char('0'), + KeyCode::Char('c'), + KeyCode::Char('w'), + ] { + view.handle_key_event(edit.into()); + } + view.handle_paste("Target".into()); + } view.handle_key_event(KeyCode::Char('!').into()); view.handle_key_event(key.into()); + if key == KeyCode::Esc { + assert_eq!( + view.cursor_style(Rect::default()), + crossterm::cursor::SetCursorStyle::DefaultUserShape + ); + view.handle_key_event(KeyCode::Esc.into()); + } assert_eq!(view.rows[view.selected_index().unwrap()].thread_id, other); if key == KeyCode::Enter { let rename = rx.try_recv().unwrap(); diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 0dc2fc2f76..e0ad6f03a1 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -294,12 +294,12 @@ impl App { self.chat_widget.add_info_message( format!( "The rename target disappeared. Unsubmitted title: {}", - state.input + state.input.text() ), /*hint*/ None, ); state.rename_target = None; - state.input.clear(); + state.input.set_text_clearing_elements(""); } self.chat_widget .replace_bottom_pane_view_if_present(AGENTS_OVERVIEW_VIEW_ID, Box::new(view)); @@ -364,6 +364,11 @@ impl App { }); } + self.agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .vim_enabled = self.chat_widget.composer_is_vim_enabled(); AgentsOverviewView::new( rows, selected_thread_id, diff --git a/codex-rs/tui/src/app/agents_overview_actions_tests.rs b/codex-rs/tui/src/app/agents_overview_actions_tests.rs index c0f4d992c7..cc0ce9c895 100644 --- a/codex-rs/tui/src/app/agents_overview_actions_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_actions_tests.rs @@ -275,7 +275,7 @@ async fn hiding_rename_target_does_not_transfer_draft_to_neighbor() -> Result<() { let state = app.agents_overview.view_state.lock().unwrap(); assert_eq!( - (state.rename_target.is_some(), state.input.as_str()), + (state.rename_target.is_some(), state.input.text()), (false, "") ); } diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index e3693176e1..f047ae7116 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -17,6 +17,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::TextArea; use crate::bottom_pane::ViewCompletion; use crate::key_hint::KeyBindingListExt; use crate::key_hint::ShortcutHint; @@ -136,7 +137,8 @@ pub(super) struct AgentsOverviewViewState { page_height: usize, status_filter: usize, help: bool, - pub(super) input: String, + pub(super) input: TextArea, + pub(super) vim_enabled: bool, pub(super) key_chord_hint: Option>, pub(super) creating_worktree: bool, pub(super) refresh_failed: bool, @@ -154,6 +156,16 @@ pub(super) struct AgentsOverviewViewState { } impl AgentsOverviewViewState { + pub(super) fn set_rename_input(&mut self, text: &str, keymap: &RuntimeKeymap) { + let mut input = TextArea::new_single_line(); + input.set_keymap_bindings(keymap); + input.set_vim_enabled(self.vim_enabled); + input.set_text_clearing_elements(text); + input.set_cursor(input.text().len()); + input.enter_vim_insert_mode(); + self.input = input; + } + pub(super) fn editing_metadata(&self) -> bool { self.searching || self.rename_target.is_some() } @@ -170,6 +182,7 @@ pub(super) struct AgentsOverviewView { agents_keymap: AgentsKeymap, center_shortcut_keys: Vec, worktrees_enabled: bool, + editor_keymap: RuntimeKeymap, } impl AgentsOverviewView { @@ -214,7 +227,19 @@ impl AgentsOverviewView { .then_some(binding.chord.prefix) })) .collect(); + { + let mut state = state.lock().unwrap_or_else(PoisonError::into_inner); + let vim_enabled = state.vim_enabled; + if state.rename_target.is_some() { + state.input.set_keymap_bindings(&keymap); + if state.input.is_vim_enabled() != vim_enabled { + state.input.set_vim_enabled(vim_enabled); + state.input.enter_vim_insert_mode(); + } + } + } let mut view = Self { + editor_keymap: keymap.clone(), use_theme_colors, rows, project_groups, @@ -324,7 +349,7 @@ impl AgentsOverviewView { } return; } - let input = self.state().input.clone(); + let input = self.state().input.text().to_owned(); if self.state().rename_target.is_some() && !input.trim().is_empty() { if let Some(row) = self.selected_row() { self.app_event_tx @@ -334,7 +359,7 @@ impl AgentsOverviewView { }); } self.state().rename_target = None; - self.state().input.clear(); + self.state().input.set_text_clearing_elements(""); self.reconcile_command_center_selection(); } else if let Some(row) = self .selected_row() @@ -355,11 +380,9 @@ impl AgentsOverviewView { fn edit_input(&mut self, edit: impl FnOnce(&mut String)) -> bool { let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); let searching = state.searching; - edit(if searching { - &mut state.search - } else { - &mut state.input - }); + if searching { + edit(&mut state.search); + } drop(state); if searching { self.selected = self @@ -480,7 +503,19 @@ impl BottomPaneView for AgentsOverviewView { } fn keymap_contexts(&self) -> KeymapContextSet { - KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents) + let state = self.state(); + if state.rename_target.is_some() { + let contexts = KeymapContextSet::new(state.input.keymap_context()); + if state.input.is_vim_operator_pending() { + contexts + } else { + contexts + .with(KeymapContext::List) + .with(KeymapContext::Agents) + } + } else { + KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents) + } } fn completion(&self) -> Option { @@ -501,7 +536,7 @@ impl BottomPaneView for AgentsOverviewView { state.searching = false; state.rename_target = None; state.search.clear(); - state.input.clear(); + state.input.set_text_clearing_elements(""); drop(state); self.reconcile_command_center_selection(); return CancellationEvent::Handled; @@ -510,6 +545,12 @@ impl BottomPaneView for AgentsOverviewView { } fn handle_paste(&mut self, pasted: String) -> bool { + if self.state().rename_target.is_some() { + self.state() + .input + .insert_str(&crate::history_cell::sanitize_user_text(pasted.into())); + return true; + } if self.state().editing_metadata() { return self.edit_input(|input| { input.push_str(&crate::history_cell::sanitize_user_text(pasted.into())) @@ -527,7 +568,7 @@ impl BottomPaneView for AgentsOverviewView { if key.kind == crossterm::event::KeyEventKind::Release { return; } - if self.command_center_key(key) { + if self.rename_key(key) || self.command_center_key(key) { return; } if key.code == KeyCode::Backspace @@ -611,8 +652,11 @@ impl BottomPaneView for AgentsOverviewView { if self.agents_keymap.rename.is_pressed(key) { if let Some(row) = self.selected_row() { let mut state = self.state(); - if state.input.is_empty() { - state.input = row.thread.name.clone().unwrap_or_default(); + if state.rename_target.is_none() { + state.set_rename_input( + row.thread.name.as_deref().unwrap_or_default(), + &self.editor_keymap, + ); state.search.clear(); state.searching = false; state.rename_target = Some(row.thread_id); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index d4cd4d533a..4c4491f871 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2810,7 +2810,7 @@ impl App { } Err(error) => { if let Ok(mut state) = self.agents_overview.view_state.lock() { - state.input = name; + state.set_rename_input(&name, &self.keymap); state.rename_target = Some(thread_id); } self.repaint_agents_overview(); diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index a3a47e6091..2a8938dd3d 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -1,7 +1,8 @@ //! Keyboard input, external editor, and status-line dispatch for the TUI app. //! //! This module owns global key bindings that sit above ChatWidget, including transcript overlay -//! entry, Ctrl-L clear, external editor launch, and agent navigation shortcuts. +//! entry, Ctrl-L clear, external editor launch, agent navigation shortcuts, and legacy Vim +//! Escape recovery for the active composer or command-center rename editor. use super::*; use crate::app_backtrack::SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE; @@ -11,25 +12,40 @@ use crate::keymap::keymap_action_ids; impl App { pub(super) fn should_recover_vim_insert_escape(&self, key_event: KeyEvent) -> bool { let active_contexts = self.active_keymap_contexts(); + let rename_active = self + .chat_widget + .selected_index_for_active_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some(); + let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + let insert_escape = if rename_active { + let state = self + .agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.rename_target.is_some() && state.input.should_handle_vim_insert_escape(escape) + } else { + self.chat_widget.no_modal_or_popup_active() + && self.chat_widget.should_handle_vim_insert_escape(escape) + }; // Legacy terminals encode Alt+character and Escape+character identically. Active // bindings and either stroke of a chord win; inactive bindings do not consume input. cfg!(unix) && !self.enhanced_keys_supported && self.overlay.is_none() && !self.transcript_view.is_search_editing() - && self.chat_widget.no_modal_or_popup_active() + && insert_escape && matches!(key_event.code, KeyCode::Char(_)) && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) && (key_event.modifiers == KeyModifiers::ALT || key_event.modifiers == (KeyModifiers::ALT | KeyModifiers::SHIFT)) - && self - .chat_widget - .should_handle_vim_insert_escape(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) // ChatWidget's fixed image-paste shortcut is not in the configurable keymap. - && !(key_event.kind == KeyEventKind::Press + && !(!rename_active + && key_event.kind == KeyEventKind::Press && matches!(key_event.code, KeyCode::Char('v' | 'V'))) // Empty-draft agent navigation also has fixed legacy-terminal fallbacks. - && !(self.chat_widget.composer_text_with_pending().is_empty() + && !(!rename_active + && self.chat_widget.composer_text_with_pending().is_empty() && (previous_agent_shortcut_matches(key_event, /*allow_word_motion_fallback*/ true) || next_agent_shortcut_matches(key_event, /*allow_word_motion_fallback*/ true))) && !keymap_action_ids() @@ -111,12 +127,14 @@ impl App { } if !was_pending && contexts.contains(crate::keymap::KeymapContext::Agents) - && self - .agents_overview - .view_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .editing_metadata() + && { + let state = self + .agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.editing_metadata() && state.rename_target.is_none() + } && crate::key_hint::is_plain_text_key_event(key_event) { return Some(key_event); diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index 457f5970b5..b2f5b3eb29 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -8,6 +8,7 @@ use crate::app::reconnect::reconnect; use crate::app_event::AgentsOverviewThreadRefresh; use crate::app_event::WindowsSandboxEnableMode; use crate::app_server_session::ThreadParamsMode; +use crate::bottom_pane::BottomPaneView; use codex_app_server_client::AppServerEvent; use pretty_assertions::assert_eq; use serde_json::json; @@ -135,7 +136,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv app.agents_overview.initialized = overview_initialized; // This task was unarchived elsewhere while disconnected. app.agents_overview.removed_threads.insert(added); - let view = app.agents_overview_view( + let mut view = app.agents_overview_view( stale.clone(), Some(if previous_thread.is_some() { vanished @@ -143,16 +144,20 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv selected }), ); + view.handle_key_event(KeyCode::Char('r').into()); + view.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + view.handle_paste("Keep this task draft".into()); app.agents_overview.visible_thread_ids = view.thread_ids(); app.chat_widget.show_bottom_pane_view(Box::new(view)); - app.agents_overview.view_state.lock().unwrap().input = "Keep this task draft".into(); - app.agents_overview.view_state.lock().unwrap().rename_target = - Some(if previous_thread.is_some() { - vanished - } else { - selected - }); - let draft = |app: &App| app.agents_overview.view_state.lock().unwrap().input.clone(); + let draft = |app: &App| { + app.agents_overview + .view_state + .lock() + .unwrap() + .input + .text() + .to_string() + }; let stale_request = Uuid::new_v4(); app.agents_overview.request_id = Some(stale_request); app.agents_overview.refresh_pending = true; diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index e92031784d..6a21beba8f 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -1,5 +1,5 @@ //! The textarea owns editable composer text, placeholder elements, cursor/wrap state, and a -//! single-entry kill buffer. +//! single-entry kill buffer. Single-line inputs ignore line breaks and scroll horizontally. //! //! Whole-buffer replacement APIs intentionally rebuild only the visible draft state. They clear //! element ranges and derived cursor/wrapping caches, but they keep the kill buffer intact so a @@ -55,6 +55,7 @@ use unicode_segmentation::UnicodeSegmentation; mod editing; mod hyperlinks; mod mouse; +mod single_line; mod vim; mod vim_commands; mod vim_search; @@ -141,6 +142,7 @@ pub(crate) struct TextElementSnapshot { #[derive(Debug)] pub(crate) struct TextArea { text: String, + single_line: bool, cursor_pos: usize, mouse_selection: Option, last_click: Option<(std::time::Instant, u16, u16, u8)>, @@ -199,11 +201,18 @@ pub(crate) struct KillBufferSnapshot { kind: KillBufferKind, } +impl Default for TextArea { + fn default() -> Self { + Self::new() + } +} + impl TextArea { pub fn new() -> Self { let defaults = RuntimeKeymap::defaults(); Self { text: String::new(), + single_line: false, cursor_pos: 0, mouse_selection: None, last_click: None, @@ -262,6 +271,8 @@ impl TextArea { } fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) { + let filtered = self.filter_line_breaks(text); + let text = filtered.as_ref(); self.mouse_selection = None; // Stage 1: replace the raw text and keep the cursor in a safe byte range. self.text = text.to_string(); @@ -455,6 +466,8 @@ impl TextArea { } pub fn insert_str_at(&mut self, pos: usize, text: &str) { + let filtered = self.filter_line_breaks(text); + let text = filtered.as_ref(); self.mouse_selection = None; self.clear_vim_replace_recovery(); let pos = self.clamp_pos_for_insertion(pos); @@ -479,6 +492,8 @@ impl TextArea { } fn replace_range_raw(&mut self, range: std::ops::Range, text: &str) { + let filtered = self.filter_line_breaks(text); + let text = filtered.as_ref(); self.mouse_selection = None; assert!(range.start <= range.end); let start = range.start.clamp(0, self.text.len()); @@ -524,6 +539,9 @@ impl TextArea { } pub fn desired_height(&self, width: u16) -> u16 { + if self.single_line { + return 1; + } self.wrapped_lines(width).len() as u16 } @@ -540,6 +558,10 @@ impl TextArea { return None; } + if self.single_line { + let (_, col) = self.single_line_viewport(area.width); + return Some((area.x + col, area.y)); + } let lines = self.wrapped_lines(area.width); let effective_scroll = self.effective_scroll(area, &lines, state); let (i, col) = wrapping::cursor_position(&self.text, &lines, area.width, self.cursor_pos)?; @@ -824,11 +846,15 @@ impl TextArea { return; } if self.vim_normal_keymap.open_line_below.is_pressed(event) { - self.start_vim_edit(VimAction::Insert(VimInsertPosition::OpenBelow)); + if !self.single_line { + self.start_vim_edit(VimAction::Insert(VimInsertPosition::OpenBelow)); + } return; } if self.vim_normal_keymap.open_line_above.is_pressed(event) { - self.start_vim_edit(VimAction::Insert(VimInsertPosition::OpenAbove)); + if !self.single_line { + self.start_vim_edit(VimAction::Insert(VimInsertPosition::OpenAbove)); + } return; } if self.vim_normal_keymap.move_left.is_pressed(event) { @@ -1359,7 +1385,7 @@ impl TextArea { if self.kill_buffer.is_empty() { return; } - if self.kill_buffer_kind == KillBufferKind::Linewise { + if self.kill_buffer_kind == KillBufferKind::Linewise && !self.single_line { self.paste_line_after_current_line(); return; } @@ -1659,6 +1685,8 @@ impl TextArea { /// Use this when the element payload is an identifier (e.g. a placeholder) that must be /// updated without converting the element back into normal text. pub fn replace_element_payload(&mut self, old: &str, new: &str) -> bool { + let filtered = self.filter_line_breaks(new); + let new = filtered.as_ref(); let Some(idx) = self .elements .iter() @@ -2131,6 +2159,10 @@ impl TextArea { impl WidgetRef for &TextArea { fn render_ref(&self, area: Rect, buf: &mut Buffer) { + if self.single_line { + StatefulWidgetRef::render_ref(self, area, buf, &mut TextAreaState::default()); + return; + } let lines = self.wrapped_lines(area.width); self.render_lines( area, @@ -2148,6 +2180,20 @@ impl StatefulWidgetRef for &TextArea { fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { self.rendered_area.set(area); + if self.single_line { + if !area.is_empty() { + let (start, _) = self.single_line_viewport(area.width); + self.render_lines( + area, + buf, + std::slice::from_ref(&(start..self.text.len() + 1)), + 0..1, + Style::default(), + &[], + ); + } + return; + } let lines = self.wrapped_lines(area.width); let scroll = self.effective_scroll(area, &lines, *state); state.scroll = scroll; diff --git a/codex-rs/tui/src/bottom_pane/textarea/editing.rs b/codex-rs/tui/src/bottom_pane/textarea/editing.rs index 6c97ea69dd..9cb7f82602 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/editing.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/editing.rs @@ -39,6 +39,8 @@ impl TextArea { } pub(crate) fn insert_str_at_target(&mut self, target: EditTarget, text: &str) { + let filtered = self.filter_line_breaks(text); + let text = filtered.as_ref(); // Empty text must not delete a selection or create a Vim replacement step. if text.is_empty() { if !self.is_vim_replace_mode() { @@ -64,6 +66,8 @@ impl TextArea { } pub(crate) fn insert_element_at_target(&mut self, target: EditTarget, text: &str) -> u64 { + let filtered = self.filter_line_breaks(text); + let text = filtered.as_ref(); let start = target.start(); if target.replaces_text() { self.vim_commands = VimCommandState::default(); diff --git a/codex-rs/tui/src/bottom_pane/textarea/single_line.rs b/codex-rs/tui/src/bottom_pane/textarea/single_line.rs new file mode 100644 index 0000000000..7a05c2871d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/single_line.rs @@ -0,0 +1,47 @@ +//! Single-line editing strips line breaks and keeps a grapheme-aligned cursor viewport. + +use super::*; + +impl TextArea { + pub(crate) fn new_single_line() -> Self { + Self { + single_line: true, + ..Self::new() + } + } + + pub(super) fn filter_line_breaks<'a>(&self, text: &'a str) -> Cow<'a, str> { + if self.single_line && text.contains(['\r', '\n']) { + Cow::Owned(text.replace(['\r', '\n'], "")) + } else { + Cow::Borrowed(text) + } + } + + pub(super) fn single_line_viewport(&self, width: u16) -> (usize, u16) { + let cursor_width = self.text[self.cursor_pos..] + .graphemes(/*is_extended*/ true) + .next() + .map_or(/*default*/ 1, display_width) + .max(/*other*/ 1) + .min(usize::from(width)); + let mut start = self.cursor_pos; + let mut column = 0; + for (offset, grapheme) in self.text[..self.cursor_pos] + .grapheme_indices(/*is_extended*/ true) + .rev() + { + let grapheme_width = display_width(grapheme); + if column + grapheme_width > usize::from(width).saturating_sub(cursor_width) { + break; + } + start = offset; + column += grapheme_width; + } + (start, column as u16) + } +} + +#[cfg(test)] +#[path = "single_line_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/textarea/single_line_tests.rs b/codex-rs/tui/src/bottom_pane/textarea/single_line_tests.rs new file mode 100644 index 0000000000..0d67a065eb --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/single_line_tests.rs @@ -0,0 +1,40 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn single_line_vim_newline_commands_are_noops_and_yanks_stay_inline() { + let mut input = TextArea::new_single_line(); + input.set_vim_enabled(/*enabled*/ true); + input.set_text_clearing_elements("one\r\ntwo"); + assert_eq!(input.text(), "onetwo"); + input.set_text_clearing_elements(""); + input.enter_vim_insert_mode(); + input.insert_str("word"); + for event in [ + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), + ] { + input.input(event); + } + assert_eq!(input.text(), "word"); + input.input(KeyCode::Esc.into()); + input.input(KeyCode::Char('0').into()); + input.input(KeyCode::Char('.').into()); + assert_eq!(input.text(), "wordword"); + let cursor = input.cursor_pos; + for code in ['o', 'O'] { + input.input(KeyCode::Char(code).into()); + assert_eq!( + (input.text(), input.cursor_pos, input.is_vim_normal_mode()), + ("wordword", cursor, true) + ); + } + input.input(KeyCode::Char('r').into()); + input.input(KeyCode::Enter.into()); + assert_eq!((input.text(), input.cursor_pos), ("wordword", cursor)); + for code in ['y', 'y', 'p'] { + input.input(KeyCode::Char(code).into()); + } + assert_eq!(input.text(), "wordwordwordword"); +} diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs b/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs index fee1cf16aa..143054f495 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs @@ -548,7 +548,9 @@ impl TextArea { pub(super) fn handle_vim_pending_command(&mut self, pending: VimPending, event: KeyEvent) { match pending { VimPending::Replace => { - if let Some(ch) = vim_command_char(event) { + if let Some(ch) = vim_command_char(event) + && !(self.single_line && matches!(ch, '\r' | '\n')) + { self.start_vim_edit(VimAction::Replace(ch)); } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ccaaa52657..807a8fcbb1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1720,6 +1720,10 @@ impl ChatWidget { self.bottom_pane.composer_is_empty() && !self.bottom_pane.is_in_paste_burst() } + pub(crate) fn composer_is_vim_enabled(&self) -> bool { + self.bottom_pane.composer_is_vim_enabled() + } + #[cfg(test)] pub(crate) fn is_task_running_for_test(&self) -> bool { self.bottom_pane.is_task_running()