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()