diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 21005781d6..b8ccea3daf 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -857,6 +857,9 @@ impl App { app_server: &mut AppServerSession, event: TuiEvent, ) -> Result { + if self.handle_composer_copy_event(tui, &event, tui::Tui::copy_transcript_selection) { + return Ok(AppRunControl::Continue); + } // Resume arrives after suspension; retain the last painted phase across hidden owners. if matches!(&event, TuiEvent::Resume) || !tui.is_owned_screen() || self.overlay.is_some() { self.chat_widget diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 8cf43c4073..6f688197be 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -1,4 +1,4 @@ -//! Compose the owned transcript above the existing composer and route transcript-only gestures. +//! Compose the owned transcript above the composer and route their selection gestures. //! Reserve a cleared row between transcript content and the composer. Slash suggestions overlay //! already-painted rows so opening or closing them leaves transcript geometry unchanged. //! Fresh-thread decoration is painted separately in unused cells and never enters history. @@ -15,6 +15,30 @@ use crate::transcript_view::ViewAction; use ratatui::widgets::Widget; impl App { + /// Copy draft selections before interrupt/exit shortcuts and configurable chords can consume C. + pub(super) fn handle_composer_copy_event( + &mut self, + tui: &mut tui::Tui, + event: &TuiEvent, + copy: impl FnOnce(&mut tui::Tui, &str) -> Result, + ) -> bool { + if let TuiEvent::Key(key) = event + && tui.is_owned_screen() + && self.overlay.is_none() + && !self.transcript_view.has_active_interaction() + && let Some(text) = self.chat_widget.composer_selection_for_copy(*key) + { + self.cancel_pending_key_chord(); + self.chat_widget.end_composer_drag(); + let result = copy(tui, &text); + self.transcript_view + .show_copy_feedback(&result, text.chars().count()); + tui.frame_requester().schedule_frame(); + return true; + } + false + } + fn sync_owned_transcript(&mut self, width: u16) -> bool { let chat_widget = &self.chat_widget; let transcript_width = chat_widget.history_wrap_width(width); @@ -209,6 +233,12 @@ impl App { { self.transcript_view.end_drag(); } + if !tui.is_owned_screen() + || matches!(event, TuiEvent::FocusLost | TuiEvent::Resume) + || self.overlay.is_some() + { + self.chat_widget.end_composer_drag(); + } if !tui.is_owned_screen() || self.overlay.is_some() { return Ok(false); } @@ -230,15 +260,33 @@ impl App { } return Ok(false); } + if let TuiEvent::Mouse(mouse) = event { + let composer_ready = self.chat_widget.prepare_composer_mouse(*mouse); + if mouse.kind != crossterm::event::MouseEventKind::Moved { + let size = tui.prepare_draw_size()?; + self.render_owned_transcript(tui, size)?; + } + if composer_ready && self.chat_widget.handle_composer_mouse(*mouse) { + self.transcript_view.end_selection(&self.transcript_cells); + self.transcript_view.cancel_search(); + self.transcript_view.clear_activity_focus(); + tui.frame_requester().schedule_frame(); + return Ok(true); + } + } if !self.chat_widget.no_modal_or_popup_active() { + self.chat_widget.end_composer_drag(); return Ok(false); } - let input = matches!(event, TuiEvent::Key(key) if key.kind != KeyEventKind::Release) - || matches!(event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved); - if input { + if matches!(event, TuiEvent::Key(key) if key.kind != KeyEventKind::Release) { let size = tui.prepare_draw_size()?; self.render_owned_transcript(tui, size)?; } + if matches!(event, TuiEvent::Key(_)) + || matches!(event, TuiEvent::Mouse(mouse) if matches!(mouse.kind, crossterm::event::MouseEventKind::Down(_))) + { + self.chat_widget.end_composer_drag(); + } // Visible shortcut help owns Escape before returning a paused viewport to latest. // A transcript search or selection still owns Escape while it replaces the help footer. if let TuiEvent::Key(key) = event diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index f3ae83c09c..628386d25a 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -11,6 +11,12 @@ use crate::test_support::test_path_buf; use codex_app_server_protocol::AskForApproval; use codex_config::types::ApprovalsReviewer; use codex_protocol::models::PermissionProfile; +use crossterm::event::MouseButton::Left; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; +use crossterm::event::MouseEventKind::Down; +use crossterm::event::MouseEventKind::Drag; +use crossterm::event::MouseEventKind::Up; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; @@ -1127,3 +1133,88 @@ async fn find_refreshes_live_details_before_searching_the_first_query() -> Resul app_server.shutdown().await?; Ok(()) } + +fn mouse(kind: MouseEventKind, column: u16, row: u16) -> TuiEvent { + TuiEvent::Mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }) +} + +fn row_containing(tui: &tui::Tui, text: &str) -> u16 { + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + buffer_text(buffer) + .lines() + .position(|row| row.contains(text)) + .unwrap() as u16 +} + +#[tokio::test] +async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + let size = tui.terminal.size()?; + app.transcript_cells = vec![user_cell("transcript text")]; + app.chat_widget.apply_external_edit("hello world".into()); + app.render_owned_transcript(&mut tui, size)?; + let end = tui.terminal.last_known_cursor_pos; + let x = end.x - 11; + let y = end.y; + for event in [ + mouse(Down(Left), x, y), + mouse(Drag(Left), x + 5, y), + mouse(Up(Left), x + 5, y), + ] { + assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &event)?); + } + assert!(!app.transcript_view.has_active_interaction()); + let draft = app.chat_widget.capture_thread_input_state(); + let copy_event = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER)); + assert!( + app.handle_composer_copy_event(&mut tui, ©_event, |_, text| { + assert_eq!(text, "hello"); + Ok(crate::clipboard_copy::CopyStatus::Confirmed) + }) + ); + assert_eq!(app.chat_widget.capture_thread_input_state(), draft); + app.render_owned_transcript(&mut tui, size)?; + assert!(row_containing(&tui, "Copied 5 chars to host clipboard") < y); + + app.chat_widget.handle_key_event(KeyCode::Char('x').into()); + assert!(!app.handle_composer_copy_event(&mut tui, ©_event, |_, _| unreachable!())); + assert_eq!(app.chat_widget.composer_text_with_pending(), "x world"); + + // A transcript drag that crosses into the composer remains a transcript selection. + let transcript_y = row_containing(&tui, "transcript text"); + for event in [ + mouse(Down(Left), /*column*/ 1, transcript_y), + mouse(Drag(Left), x + 3, y), + mouse(Up(Left), x + 3, y), + ] { + app.handle_owned_transcript_event(&mut tui, &mut server, &event)?; + } + assert!(app.transcript_view.has_active_interaction()); + + // A fresh composer click takes ownership away from the transcript selection. + app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?; + assert!(!app.transcript_view.has_active_interaction()); + app.chat_widget.apply_external_edit("/".into()); + assert!(!app.chat_widget.no_modal_or_popup_active()); + assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?); + app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Drag(Left), x + 1, y))?; + assert!(app.chat_widget.no_modal_or_popup_active()); + app.chat_widget.open_feature_enable_prompt(Feature::Collab); + assert!( + !app.handle_composer_copy_event(&mut tui, ©_event, |_, _| panic!( + "modals own copy input" + ),) + ); + assert!(!app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?); + server.shutdown().await?; + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 67bf132e6d..139fda30e6 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -237,6 +237,7 @@ //! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state //! machine and treats the key stream as normal typing. When toggling from enabled → disabled, the //! composer flushes/clears any in-flight burst state so it cannot leak into subsequent input. +//! Mouse edits flush pending typing; selection and copy behavior lives in [`mouse`]. //! //! For the detailed burst state machine, see `codex-rs/tui/src/bottom_pane/paste_burst.rs`. //! @@ -365,6 +366,7 @@ mod draft_state; mod footer_state; mod history_search; mod inline_input; +mod mouse; mod paste_input; mod popup_state; mod reconnect; @@ -1802,8 +1804,10 @@ impl ChatComposer { pub fn attach_image(&mut self, path: PathBuf) { self.dismiss_sparkle(); let started_vim_edit = self.begin_direct_vim_edit(); + let elements_before = self.draft.textarea.element_payloads(); self.attachments .attach_image(&mut self.draft.textarea, path); + self.reconcile_deleted_elements(elements_before); if started_vim_edit { self.finish_vim_edit(); } @@ -1937,19 +1941,6 @@ impl ChatComposer { } } - pub(crate) fn insert_str(&mut self, text: &str) { - if !text.is_empty() && self.sparkle.draft.get() == sparkle::SparkleDraft::Untouched { - self.dismiss_sparkle(); - } - let started_vim_edit = self.begin_direct_vim_edit(); - self.draft.textarea.insert_str(text); - self.sync_bash_mode_from_text(); - self.sync_popups(); - if started_vim_edit { - self.finish_vim_edit(); - } - } - /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if !self.draft.input_enabled { @@ -2103,12 +2094,9 @@ impl ChatComposer { if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.apply_paste(pasted); } + let elements_before = self.draft.textarea.element_payloads(); self.draft.textarea.input(input); - - let text_after = self.draft.textarea.text(); - self.draft - .pending_pastes - .retain(|(placeholder, _)| text_after.contains(placeholder)); + self.reconcile_deleted_elements(elements_before); (InputResult::None, true) } @@ -3449,6 +3437,9 @@ impl ChatComposer { &mut self, key_event: &KeyEvent, ) -> Option<(InputResult, bool)> { + if self.draft.textarea.mouse_selection_range().is_some() { + return None; + } let removes_remote_image = matches!(key_event.code, KeyCode::Delete | KeyCode::Backspace) && self.attachments.selected_remote_image_index.is_some(); let started_vim_edit = removes_remote_image && self.begin_direct_vim_edit(); @@ -3555,9 +3546,11 @@ impl ChatComposer { ) }; if history_up_pressed || history_down_pressed { - if self - .history - .should_handle_navigation(&self.current_text(), self.history_navigation_cursor()) + if self.draft.textarea.mouse_selection_range().is_none() + && self.history.should_handle_navigation( + &self.current_text(), + self.history_navigation_cursor(), + ) { let replace_entry = if history_up_pressed { self.history.navigate_up(&self.app_event_tx) @@ -3935,7 +3928,7 @@ impl ChatComposer { self.popups.dismissed_mention_token = None; return; } - if !self.popups_enabled() { + if !self.popups_enabled() || self.draft.textarea.mouse_selection_range().is_some() { self.popups.active = ActivePopup::None; return; } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs new file mode 100644 index 0000000000..5dc30aba81 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs @@ -0,0 +1,67 @@ +//! Mouse gestures flush pending typing before layout and hit testing. Left dragging selects +//! editable text using the textarea's last rendered viewport and hides completion suggestions. +//! Double/triple clicks select words/logical lines using the transcript's shared gesture rules. +//! Copy shortcuts preserve the draft and selection and use fullscreen copy feedback above the input. + +use super::*; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; + +impl ChatComposer { + pub(crate) fn selection_for_copy(&mut self, key: KeyEvent) -> Option { + if !crate::text_selection::is_copy_key(key) + || !self.draft.input_enabled + || self.history_search.is_some() + || self.draft.textarea.vim_query().is_some() + { + return None; + } + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.apply_paste(pasted); + } + self.draft.paste_burst.clear_window_after_non_char(); + self.draft + .textarea + .mouse_selection_range() + .map(|range| self.draft.textarea.text()[range].to_owned()) + } + + pub(crate) fn end_mouse_drag(&mut self) { + self.draft.textarea.end_mouse_drag(); + } + + pub(crate) fn prepare_mouse(&mut self, event: MouseEvent) -> bool { + if !self.draft.input_enabled + || self.blocks_direct_input + || self.history_search.is_some() + || self.draft.textarea.vim_query().is_some() + { + self.end_mouse_drag(); + return false; + } + if matches!(event.kind, MouseEventKind::Down(_)) { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.apply_paste(pasted); + } + self.draft.paste_burst.clear_window_after_non_char(); + } + true + } + + /// Dispatch after preparation and rendering have refreshed the viewport. + pub(crate) fn handle_mouse(&mut self, event: MouseEvent) -> bool { + let handled = self + .draft + .textarea + .handle_mouse(event, *self.draft.textarea_state.borrow()); + if handled { + self.attachments.clear_remote_image_selection(); + self.sync_popups(); + } + handled + } +} + +#[cfg(test)] +#[path = "mouse_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs new file mode 100644 index 0000000000..5b5630da4d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs @@ -0,0 +1,107 @@ +//! Mouse edits preserve buffered typing and keep placeholder payloads in sync. + +use super::super::tests::new_test_composer; +use super::*; +use crossterm::event::MouseButton::Left; +use crossterm::event::MouseEventKind::Down; +use crossterm::event::MouseEventKind::Up; +use pretty_assertions::assert_eq; + +fn mouse(composer: &mut ChatComposer, area: Rect, kind: MouseEventKind, column: u16, row: u16) { + let event = MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }; + assert!(composer.prepare_mouse(event)); + composer.render(area, &mut Buffer::empty(area)); + assert!(composer.handle_mouse(event)); +} + +#[test] +fn composer_mouse_edits_flush_typing_and_reconcile_placeholders() { + let (mut composer, _rx) = new_test_composer(); + composer.insert_str("hello"); + composer + .draft + .paste_burst + .begin_with_retro_grabbed("\nworld".into(), Instant::now()); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4, + ); + composer.render(area, &mut Buffer::empty(area)); + let (x, y) = composer.cursor_pos(area).unwrap(); + mouse(&mut composer, area, Down(Left), x - 5, y); + composer.insert_str("X"); + assert_eq!(composer.current_text(), "hello\nXworld"); + composer.handle_paste("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1)); + for path in [Some("first.png"), Some("second.png"), None] { + composer.render(area, &mut Buffer::empty(area)); + let (x, y) = composer.cursor_pos(area).unwrap(); + // Double-clicking inside the label selects the entire atomic element. + for kind in [Down(Left), Up(Left), Down(Left), Up(Left)] { + mouse(&mut composer, area, kind, x - 1, y); + } + if let Some(path) = path { + let path = PathBuf::from(path); + composer.attach_image(path.clone()); + assert_eq!(composer.attachments.local_image_paths(), vec![path]); + } else { + composer.handle_key_event(KeyCode::Char('é').into()); + assert!(composer.attachments.is_empty()); + } + assert!(composer.draft.pending_pastes.is_empty()); + } +} + +#[test] +fn selected_text_navigation_does_not_recall_history_or_select_images() { + for (key, remote) in [ + (KeyCode::Up, false), + (KeyCode::Down, false), + (KeyCode::Up, true), + ] { + let (mut composer, _rx) = new_test_composer(); + for text in ["first", "second"] { + composer + .history + .record_local_submission(HistoryEntry::new(text.into())); + } + composer.handle_key_event(KeyCode::Up.into()); + if remote { + composer.set_remote_image_urls(vec!["https://example.com/image.png".into()]); + } + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 10, + ); + composer.render(area, &mut Buffer::empty(area)); + let (x, y) = composer.cursor_pos(area).unwrap(); + if remote { + composer.draft.textarea.set_cursor(/*pos*/ 0); + composer.handle_key_event(KeyCode::Up.into()); + } + let (start, end) = if key == KeyCode::Up { + (x, x - 6) + } else { + (x - 6, x) + }; + for (kind, column) in [ + (Down(Left), start), + (MouseEventKind::Drag(Left), end), + (Up(Left), end), + ] { + mouse(&mut composer, area, kind, column, y); + } + assert_eq!(composer.attachments.selected_remote_image_index, None); + composer.handle_key_event(key.into()); + assert_eq!( + ( + composer.current_text(), + composer.draft.textarea.mouse_selection_range(), + composer.attachments.selected_remote_image_index + ), + ("second".to_string(), None, None) + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs index 1d12477ff0..48ea632c1c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs @@ -4,6 +4,27 @@ use super::*; use crate::bottom_pane::paste_burst::FlushResult; impl ChatComposer { + pub(crate) fn insert_str(&mut self, text: &str) { + if !text.is_empty() && self.sparkle.draft.get() == sparkle::SparkleDraft::Untouched { + self.dismiss_sparkle(); + } + let started_vim_edit = self.begin_direct_vim_edit(); + let elements_before = self + .draft + .textarea + .mouse_selection_range() + .map(|_| self.draft.textarea.element_payloads()); + self.draft.textarea.insert_str(text); + if let Some(elements_before) = elements_before { + self.reconcile_deleted_elements(elements_before); + } + self.sync_bash_mode_from_text(); + self.sync_popups(); + if started_vim_edit { + self.finish_vim_edit(); + } + } + /// Include accepted but unflushed keys without changing live paste detection. pub(crate) fn recovery_snapshot(&self) -> ComposerDraftSnapshot { let mut snapshot = self.draft_snapshot(); @@ -108,6 +129,7 @@ impl ChatComposer { return true; } let started_vim_edit = self.begin_direct_vim_edit(); + let elements_before = self.draft.textarea.element_payloads(); let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); @@ -125,6 +147,7 @@ impl ChatComposer { self.insert_str(&pasted); } self.draft.paste_burst.clear_after_explicit_paste(); + self.reconcile_deleted_elements(elements_before); self.sync_popups(); if started_vim_edit { self.finish_vim_edit(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 1567975a5c..a34878f2cb 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1743,6 +1743,29 @@ impl BottomPane { self.can_launch_external_editor() } + pub(crate) fn end_composer_drag(&mut self) { + self.composer.end_mouse_drag(); + } + + pub(crate) fn composer_selection_for_copy(&mut self, key: KeyEvent) -> Option { + if self.has_active_view() || self.questions.as_ref().is_some_and(|q| q.expanded) { + return None; + } + self.composer.selection_for_copy(key) + } + + pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool { + if self.has_active_view() || self.questions.as_ref().is_some_and(|q| q.expanded) { + self.composer.end_mouse_drag(); + return false; + } + self.composer.prepare_mouse(event) + } + + pub(crate) fn handle_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool { + self.composer.handle_mouse(event) + } + pub(crate) fn show_view(&mut self, view: Box) { self.push_view(view); } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 13f91bdde9..60bd8e7e70 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -15,6 +15,7 @@ //! breaks, interior separators hang off the preceding row without changing the editable text. //! Visible web URLs carry their complete terminal hyperlink destination across wrapped rows; //! masked rendering never exposes hyperlink destinations. +//! Mouse selection uses those same visual rows and keeps graphemes and elements atomic. use crate::key_hint::KeyBindingListExt; use crate::key_hint::is_altgr; @@ -41,6 +42,7 @@ use ratatui::text::Span; use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; use std::borrow::Cow; +use std::cell::Cell; use std::cell::OnceCell; use std::cell::Ref; use std::cell::RefCell; @@ -49,6 +51,7 @@ use std::sync::Arc; use unicode_segmentation::UnicodeSegmentation; mod hyperlinks; +mod mouse; mod vim; mod vim_commands; mod vim_search; @@ -134,6 +137,9 @@ pub(crate) struct TextElementSnapshot { pub(crate) struct TextArea { text: String, cursor_pos: usize, + mouse_selection: Option, + last_click: Option<(std::time::Instant, u16, u16, u8)>, + rendered_area: Cell, wrap_cache: RefCell>, preferred_col: Option, elements: Vec, @@ -186,6 +192,9 @@ impl TextArea { Self { text: String::new(), cursor_pos: 0, + mouse_selection: None, + last_click: None, + rendered_area: Cell::new(Rect::default()), wrap_cache: RefCell::new(None), preferred_col: None, elements: Vec::new(), @@ -240,6 +249,7 @@ impl TextArea { } fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) { + self.mouse_selection = None; // Stage 1: replace the raw text and keep the cursor in a safe byte range. self.text = text.to_string(); self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); @@ -432,8 +442,9 @@ impl TextArea { } pub fn insert_str(&mut self, text: &str) { + let replaced_selection = !text.is_empty() && self.delete_mouse_selection(); self.record_vim_inserted_text(text); - if self.is_vim_replace_mode() { + if self.is_vim_replace_mode() && !replaced_selection { self.replace_vim_text(text); } else { self.insert_str_at(self.cursor_pos, text); @@ -441,6 +452,7 @@ impl TextArea { } pub fn insert_str_at(&mut self, pos: usize, text: &str) { + self.mouse_selection = None; self.clear_vim_replace_recovery(); let pos = self.clamp_pos_for_insertion(pos); self.text.insert_str(pos, text); @@ -464,6 +476,7 @@ impl TextArea { } fn replace_range_raw(&mut self, range: std::ops::Range, text: &str) { + self.mouse_selection = None; assert!(range.start <= range.end); let start = range.start.clamp(0, self.text.len()); let end = range.end.clamp(0, self.text.len()); @@ -501,6 +514,7 @@ impl TextArea { } pub fn set_cursor(&mut self, pos: usize) { + self.mouse_selection = None; self.cursor_pos = pos.clamp(0, self.text.len()); self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); self.preferred_col = None; @@ -548,18 +562,24 @@ impl TextArea { line_end: usize, target_col: usize, ) { + let pos = self.position_at_display_col_on_line(line_start, line_end, target_col); + self.cursor_pos = self.clamp_pos_to_nearest_boundary(pos); + } + + fn position_at_display_col_on_line( + &self, + line_start: usize, + line_end: usize, + target_col: usize, + ) -> usize { let mut width_so_far = 0usize; for (i, g) in self.text[line_start..line_end].grapheme_indices(true) { width_so_far += display_width(g); if width_so_far > target_col { - self.cursor_pos = line_start + i; - // Avoid landing inside an element; round to nearest boundary - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - return; + return line_start + i; } } - self.cursor_pos = line_end; - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); + line_end } fn beginning_of_line(&self, pos: usize) -> usize { @@ -594,7 +614,11 @@ impl TextArea { if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; } + self.last_click = None; if self.vim_enabled { + if self.is_vim_normal_mode() { + self.mouse_selection = None; + } self.handle_vim_input(event); } else { let keymap = self.editor_keymap.clone(); @@ -603,6 +627,8 @@ impl TextArea { } pub fn input_with_keymap(&mut self, event: KeyEvent, keymap: &EditorKeymap) { + self.last_click = None; + self.end_mouse_drag(); if keymap.insert_newline.is_pressed(event) { self.insert_str("\n"); return; @@ -718,6 +744,7 @@ impl TextArea { } tracing::debug!("Unhandled key event in TextArea: {:?}", event); + self.mouse_selection = None; } fn handle_vim_input(&mut self, event: KeyEvent) { @@ -742,6 +769,7 @@ impl TextArea { return; } if self.is_vim_replace_mode() + && self.mouse_selection_range().is_none() && self.editor_keymap.delete_backward.is_pressed(event) && self.apply_vim_insert_action(VimAction::RestoreReplacedCharacter) { @@ -752,6 +780,7 @@ impl TextArea { } fn leave_vim_insert_mode(&mut self) { + self.mouse_selection = None; let bol = self.beginning_of_current_line(); if self.cursor_pos > bol { self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos).max(bol); @@ -1648,6 +1677,7 @@ impl TextArea { let inserted_len = new.len(); let diff = inserted_len as isize - removed_len as isize; + self.mouse_selection = None; self.text.replace_range(range, new); self.wrap_cache.replace(None); self.preferred_col = None; @@ -1694,6 +1724,7 @@ impl TextArea { } pub fn insert_element(&mut self, text: &str) -> u64 { + self.delete_mouse_selection(); let start = self.clamp_pos_for_insertion(self.cursor_pos); self.insert_str_at(start, text); let end = start + text.len(); @@ -2123,6 +2154,7 @@ impl StatefulWidgetRef for &TextArea { type State = TextAreaState; fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + self.rendered_area.set(area); let lines = self.wrapped_lines(area.width); let scroll = self.effective_scroll(area, &lines, state.scroll); state.scroll = scroll; @@ -2141,6 +2173,7 @@ impl TextArea { state: &mut TextAreaState, mask_char: char, ) { + self.rendered_area.set(area); let lines = self.wrapped_lines(area.width); let scroll = self.effective_scroll(area, &lines, state.scroll); state.scroll = scroll; @@ -2162,6 +2195,7 @@ impl TextArea { base_style: Style, highlights: &[(Range, Style)], ) { + self.rendered_area.set(area); let lines = self.wrapped_lines(area.width); let scroll = self.effective_scroll(area, &lines, state.scroll); state.scroll = scroll; @@ -2197,12 +2231,18 @@ impl TextArea { base_style, ); - // Apply search highlights last so they remain visible over styled elements. + // Selection takes precedence over elements and search highlights. + let selection = self.mouse_selection_range(); let overlays = self .elements .iter() .map(|element| (&element.range, element_style)) - .chain(highlights.iter().map(|(range, style)| (range, *style))); + .chain(highlights.iter().map(|(range, style)| (range, *style))) + .chain( + selection + .as_ref() + .map(|range| (range, base_style.reversed())), + ); for (overlay_range, style) in overlays { let overlap_start = overlay_range.start.max(line_range.start); let overlap_end = overlay_range.end.min(line_range.end); diff --git a/codex-rs/tui/src/bottom_pane/textarea/mouse.rs b/codex-rs/tui/src/bottom_pane/textarea/mouse.rs new file mode 100644 index 0000000000..0d1cee54a3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/mouse.rs @@ -0,0 +1,129 @@ +//! Hit testing and editable mouse selections share the textarea's rendered wrap and scroll state. + +use super::*; +use crate::text_selection::SelectionUnit; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; +use ratatui::layout::Position; + +#[derive(Debug)] +pub(super) struct MouseSelection { + origin: Range, + unit: SelectionUnit, + dragging: bool, + moved: bool, +} + +impl TextArea { + pub(crate) fn end_mouse_drag(&mut self) { + if let Some(selection) = &mut self.mouse_selection { + selection.dragging = false; + } + } + + pub(crate) fn mouse_selection_range(&self) -> Option> { + let origin = &self.mouse_selection.as_ref()?.origin; + let range = origin.start.min(self.cursor_pos)..origin.end.max(self.cursor_pos); + (!range.is_empty()).then_some(range) + } + + pub(super) fn delete_mouse_selection(&mut self) -> bool { + let Some(range) = self.mouse_selection_range() else { + return false; + }; + // A pointer-selected edit cannot be replayed as a keyboard-relative Vim command. + self.vim_commands = VimCommandState::default(); + self.replace_range(range, ""); + true + } + + pub(crate) fn handle_mouse(&mut self, event: MouseEvent, state: TextAreaState) -> bool { + let area = self.rendered_area.get(); + let dragging = self.mouse_selection.as_ref().is_some_and(|s| s.dragging); + if area.is_empty() { + self.end_mouse_drag(); + return false; + } + match event.kind { + MouseEventKind::Down(MouseButton::Left) + if event.modifiers.is_empty() + && area.contains(Position::new(event.column, event.row)) => {} + MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) + if dragging => {} + _ => return false, + } + + if matches!(event.kind, MouseEventKind::Up(MouseButton::Left)) + && self.mouse_selection.as_ref().is_some_and(|s| !s.moved) + { + self.end_mouse_drag(); + return true; + } + let line = { + let lines = self.wrapped_lines(area.width); + let row = usize::from(state.scroll) + + usize::from(event.row.saturating_sub(area.y).min(area.height - 1)); + // Dragging beyond the viewport advances one wrapped row per event. + let row = if event.row < area.y { + row.saturating_sub(1) + } else if event.row >= area.bottom() { + row + 1 + } else { + row + }; + lines[row.min(lines.len() - 1)].clone() + }; + let col = usize::from(event.column.saturating_sub(area.x).min(area.width)); + let pos = self.position_at_display_col_on_line(line.start, line.end - 1, col); + let down = matches!(event.kind, MouseEventKind::Down(MouseButton::Left)); + let mut selection = if down { + if self.mouse_selection.is_none() { + self.last_click = None; + } + let clicks = + crate::text_selection::click_count(&mut self.last_click, event.column, event.row); + MouseSelection { + origin: pos..pos, + // Padding remains an insertion target on repeated clicks. + unit: SelectionUnit::from_clicks(if pos == line.end - 1 { 1 } else { clicks }), + dragging: true, + moved: false, + } + } else if let Some(selection) = self.mouse_selection.take() { + selection + } else { + return false; + }; + let unit = selection.unit; + let range = if unit == SelectionUnit::Character { + let pos = self.clamp_pos_to_nearest_boundary(pos); + pos..pos + } else if unit == SelectionUnit::Word + && let Some(element) = self.elements.iter().find(|e| e.range.contains(&pos)) + { + element.range.clone() + } else { + self.expand_range_to_element_boundaries(unit.range(&self.text, pos)) + }; + self.preferred_col = None; + self.clear_vim_replace_recovery(); + self.vim_pending = VimPending::None; + if down { + selection.origin = range.clone(); + } + self.cursor_pos = if range.start < selection.origin.start { + range.start + } else { + range.end + }; + selection.moved |= !down; + selection.dragging = !matches!(event.kind, MouseEventKind::Up(MouseButton::Left)); + self.mouse_selection = Some(selection); + true + } +} + +#[cfg(test)] +#[path = "mouse_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/textarea/mouse_tests.rs b/codex-rs/tui/src/bottom_pane/textarea/mouse_tests.rs new file mode 100644 index 0000000000..f9f95457b0 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/mouse_tests.rs @@ -0,0 +1,142 @@ +//! Pointer editing follows rendered rows and preserves atomic text boundaries. + +use super::*; +use crossterm::event::MouseButton::Left; +use crossterm::event::MouseEventKind::Down; +use crossterm::event::MouseEventKind::Drag; +use crossterm::event::MouseEventKind::Up; +use pretty_assertions::assert_eq; +use ratatui::style::Modifier; + +fn render(t: &TextArea, area: Rect, state: &mut TextAreaState) -> Buffer { + let mut buffer = Buffer::empty(area); + StatefulWidgetRef::render_ref(&t, area, &mut buffer, state); + buffer +} + +fn mouse(t: &mut TextArea, state: TextAreaState, kind: MouseEventKind, x: u16, y: u16) { + assert!(t.handle_mouse( + MouseEvent { + kind, + column: x, + row: y, + modifiers: KeyModifiers::NONE, + }, + state + )); +} + +#[test] +fn mouse_click_maps_wrapping_unicode_tabs_and_empty_lines() { + let area = Rect::new( + /*x*/ 3, /*y*/ 2, /*width*/ 8, /*height*/ 8, + ); + let mut t = TextArea::new(); + let text = "ab 界e\u{301}👩‍💻\n\nx\ty"; + t.insert_str(text); + let mut state = TextAreaState::default(); + render(&t, area, &mut state); + for (pos, _) in text + .grapheme_indices(/*is_extended*/ true) + .chain(std::iter::once((text.len(), ""))) + { + t.set_cursor(pos); + let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); + mouse(&mut t, state, Down(Left), x, y); + assert_eq!(t.cursor(), pos); + } + // Both cells of a wide grapheme resolve to its leading insertion boundary. + mouse(&mut t, state, Down(Left), /*x*/ 7, /*y*/ 2); + t.insert_str("!"); + assert_eq!(t.text(), "ab !界e\u{301}👩‍💻\n\nx\ty"); +} + +#[test] +fn mouse_drag_highlights_and_edits_wrapped_selection() { + let area = Rect::new( + /*x*/ 0, /*y*/ 1, /*width*/ 8, /*height*/ 1, + ); + let backward_word = KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL); + let forward_word = KeyEvent::new(KeyCode::Delete, KeyModifiers::CONTROL); + let kill_line = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL); + for (start, end, key, expected) in [ + ((1, 1), (3, 2), KeyCode::Char('X').into(), ("hXld!", 2)), + ((3, 1), (1, 0), KeyCode::Backspace.into(), ("hld!", 1)), + ((1, 1), (3, 2), KeyCode::Delete.into(), ("hld!", 1)), + ((3, 1), (1, 0), backward_word, ("hld!", 1)), + ((1, 1), (3, 2), forward_word, ("hld!", 1)), + ((1, 1), (3, 2), kill_line, ("hld!", 1)), + ] { + let mut t = TextArea::new(); + t.insert_str("hello world"); + if key.modifiers.is_empty() { + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyCode::Char('A').into()); + t.input(KeyCode::Char('!').into()); + t.input(KeyCode::Esc.into()); + t.input(KeyCode::Char('R').into()); + } else { + t.insert_str("!"); + } + if end.1 > start.1 { + t.set_cursor(/*pos*/ 0); + } + let mut state = TextAreaState::default(); + render(&t, area, &mut state); + mouse(&mut t, state, Down(Left), start.0, start.1); + mouse(&mut t, state, Drag(Left), end.0, end.1); + mouse(&mut t, state, Up(Left), end.0, end.1); + render(&t, area, &mut state); + assert_eq!(state.scroll, u16::from(end.1 > start.1)); + let buffer = render(&t, Rect { height: 2, ..area }, &mut state); + let rows = buffer + .content + .chunks(usize::from(area.width)) + .map(|row| { + row.iter() + .map(|cell| { + if cell.modifier.contains(Modifier::REVERSED) { + cell.symbol() + } else { + "·" + } + }) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!("mouse_wrapped_selection", rows); + t.input(key); + assert_eq!((t.text(), t.cursor()), expected); + if key.modifiers == KeyModifiers::CONTROL { + t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(t.text(), "hello world!"); + } else { + t.input(KeyCode::Esc.into()); + assert!(t.vim_repeat_actions().is_none()); + } + } +} + +#[test] +fn mouse_multiclick_preserves_the_initial_word_or_wrapped_line_when_reversing() { + for (clicks, selected, backward) in + [(2, "two", "one two"), (3, "one two six\n", "one two six\n")] + { + let mut t = TextArea::new(); + t.insert_str("one two six\nnext"); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 8, /*height*/ 4, + ); + let mut state = TextAreaState::default(); + render(&t, area, &mut state); + // Click timing is shared and tested by the transcript; exercise the editor's unit mapping. + mouse(&mut t, state, Down(Left), /*x*/ 5, /*y*/ 0); + t.last_click = Some((std::time::Instant::now(), 5, 0, clicks - 1)); + mouse(&mut t, state, Down(Left), /*x*/ 5, /*y*/ 0); + assert_eq!(&t.text()[t.mouse_selection_range().unwrap()], selected); + mouse(&mut t, state, Drag(Left), /*x*/ 1, /*y*/ 0); + mouse(&mut t, state, Up(Left), /*x*/ 1, /*y*/ 0); + assert_eq!(&t.text()[t.mouse_selection_range().unwrap()], backward); + } +} diff --git a/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__mouse__tests__mouse_wrapped_selection.snap b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__mouse__tests__mouse_wrapped_selection.snap new file mode 100644 index 0000000000..d055a71558 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__mouse__tests__mouse_wrapped_selection.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/textarea/mouse_tests.rs +assertion_line: 91 +expression: rows +--- +·ello ·· +wor····· 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 6502b8a26d..fee1cf16aa 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs @@ -224,16 +224,6 @@ impl TextArea { } pub(super) fn apply_vim_insert_action(&mut self, action: VimAction) -> bool { - let recording = self.vim_enabled - && matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) - && !self.vim_commands.replaying - && !self.vim_commands.pending_change.is_empty(); - let prior_len = self.text.len(); - if !self.apply_vim_editor_action(action.clone()) { - return false; - } - let changed = - self.text.len() != prior_len || matches!(action, VimAction::RestoreReplacedCharacter); let deletion = matches!( action, VimAction::DeleteBackward @@ -244,6 +234,26 @@ impl TextArea { | VimAction::KillLine | VimAction::KillLineEnd ); + if deletion && let Some(range) = self.mouse_selection_range() { + self.vim_commands = VimCommandState::default(); + if matches!(action, VimAction::DeleteBackward | VimAction::DeleteForward) { + self.replace_range(range, ""); + } else { + self.kill_range(range); + } + return true; + } + self.mouse_selection = None; + let recording = self.vim_enabled + && matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) + && !self.vim_commands.replaying + && !self.vim_commands.pending_change.is_empty(); + let prior_len = self.text.len(); + if !self.apply_vim_editor_action(action.clone()) { + return false; + } + let changed = + self.text.len() != prior_len || matches!(action, VimAction::RestoreReplacedCharacter); if recording && (changed || !deletion) { self.vim_commands .pending_change diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 3bb6437c55..7983d4e828 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -5,6 +5,22 @@ use crate::bottom_pane::BottomPaneView; use crate::clipboard_copy::CopyFormat; impl ChatWidget { + pub(crate) fn end_composer_drag(&mut self) { + self.bottom_pane.end_composer_drag(); + } + + pub(crate) fn composer_selection_for_copy(&mut self, key: KeyEvent) -> Option { + self.bottom_pane.composer_selection_for_copy(key) + } + + pub(crate) fn handle_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool { + self.bottom_pane.handle_composer_mouse(event) + } + + pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool { + self.bottom_pane.prepare_composer_mouse(event) + } + pub(crate) fn set_agents_navigation_enabled(&mut self, enabled: bool) { self.bottom_pane.set_agents_navigation_enabled(enabled); } diff --git a/codex-rs/tui/src/transcript_view/composer_gap.rs b/codex-rs/tui/src/transcript_view/composer_gap.rs index 8d71636873..ea7bfcddb6 100644 --- a/codex-rs/tui/src/transcript_view/composer_gap.rs +++ b/codex-rs/tui/src/transcript_view/composer_gap.rs @@ -89,8 +89,8 @@ impl CopyFeedback { "Copy unconfirmed".into(), ], Err(()) => [ - "Copy failed · /export saves chat".into(), - "Copy failed · /export".into(), + "Copy failed · try again".into(), + "Copy failed".into(), "Copy failed".into(), ], }; diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery.snap index cc189d77a8..6fa5ed0e44 100644 --- a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery.snap +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery.snap @@ -22,10 +22,10 @@ Ok(Unconfirmed), 18 columns Copy unconfirmed Err("unavailable"), 80 columns - Copy failed · /export saves chat + Copy failed · try again Err("unavailable"), 32 columns - Copy failed · /export + Copy failed · try again Err("unavailable"), 18 columns Copy failed