From 39f427708738953ac01c40c3d9351236e8ea21c0 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 8 Oct 2026 20:33:23 +0000 Subject: [PATCH] Enable text selection in the fullscreen TUI footer (#52270) ## What changed Allow mouse selection and plain-text copying of rendered status and footer text, honoring `tui.copy_on_select` and existing selection keyboard controls. Keep the selected region stable while other content updates, preserve Unicode and terminal hyperlinks, and clear selections when their region becomes invalid. Defer warning clicks until a stationary mouse release so dragging selects text without opening the warning viewer. Preserve the composer draft and cursor while selecting footer text. ## Testing Add unit and snapshot coverage for visible-text copying, Unicode and hyperlinks, selection invalidation, keyboard ownership, and stationary clicks versus drags. Add a fullscreen integration test for warning selection and draft preservation. GitOrigin-RevId: dd14d7ede52e97e507be24c4d6ac0299f7388a9b --- codex-rs/config/src/types.rs | 6 +- codex-rs/core/config.schema.json | 6 +- codex-rs/tui/src/app.rs | 4 + codex-rs/tui/src/app/clipboard.rs | 6 + codex-rs/tui/src/app/footer_selection.rs | 67 ++++++ codex-rs/tui/src/app/history_ui.rs | 1 + codex-rs/tui/src/app/owned_transcript.rs | 51 +++-- .../tui/src/app/owned_transcript_tests.rs | 66 ++++++ codex-rs/tui/src/app/right_click_paste.rs | 6 + ...t__tests__fullscreen_footer_selection.snap | 29 +++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 14 +- .../bottom_pane/chat_composer/footer_state.rs | 3 + .../src/bottom_pane/chat_composer/mouse.rs | 4 + codex-rs/tui/src/bottom_pane/mod.rs | 4 + codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/constructor.rs | 1 + codex-rs/tui/src/chatwidget/interaction.rs | 4 + codex-rs/tui/src/chatwidget/rendering.rs | 1 + codex-rs/tui/src/footer_selection_tests.rs | 168 +++++++++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/pager_overlay/transcript.rs | 40 ++-- codex-rs/tui/src/rendered_selection.rs | 193 ++++++++++++++++++ ...icode_links_and_the_selected_revision.snap | 20 ++ codex-rs/tui/src/transcript_view/input.rs | 2 +- codex-rs/tui/src/transcript_view/selection.rs | 92 ++++++--- 25 files changed, 702 insertions(+), 88 deletions(-) create mode 100644 codex-rs/tui/src/app/footer_selection.rs create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_footer_selection.snap create mode 100644 codex-rs/tui/src/footer_selection_tests.rs create mode 100644 codex-rs/tui/src/rendered_selection.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__rendered_selection__tests__rendered_selection_preserves_unicode_links_and_the_selected_revision.snap diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 15f74c9e71..2a2ad0b07d 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -777,14 +777,14 @@ pub enum RightClickPaste { Off, } -/// When transcript mouse selections are copied on release. +/// When mouse selections are copied on release. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] #[serde(rename_all = "lowercase")] pub enum CopyOnSelect { /// Use the terminal-specific default. #[default] Auto, - /// Copy every nonempty transcript mouse selection on release. + /// Copy every nonempty transcript or footer mouse selection on release. Always, /// Require an explicit copy action. Never, @@ -889,7 +889,7 @@ pub struct Tui { #[schemars(schema_with = "crate::tui_mouse_scroll::schema")] pub mouse_scroll_speed: Option, - /// Copy selected transcript text when the mouse button is released. + /// Copy selected transcript or footer text when the mouse button is released. /// Defaults to `auto`: enabled except in direct terminals known to forward their native /// copy shortcut (Ghostty 1.2+, Kitty on macOS, Windows Terminal, and VS Code on Windows). /// Unknown terminals, Ghostty without a recognized version, and tmux/Zellij default to copying. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2bd4fda300..80c955ba49 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1382,7 +1382,7 @@ "type": "object" }, "CopyOnSelect": { - "description": "When transcript mouse selections are copied on release.", + "description": "When mouse selections are copied on release.", "oneOf": [ { "description": "Use the terminal-specific default.", @@ -1392,7 +1392,7 @@ "type": "string" }, { - "description": "Copy every nonempty transcript mouse selection on release.", + "description": "Copy every nonempty transcript or footer mouse selection on release.", "enum": [ "always" ], @@ -4611,7 +4611,7 @@ } ], "default": "auto", - "description": "Copy selected transcript text when the mouse button is released. Defaults to `auto`: enabled except in direct terminals known to forward their native copy shortcut (Ghostty 1.2+, Kitty on macOS, Windows Terminal, and VS Code on Windows). Unknown terminals, Ghostty without a recognized version, and tmux/Zellij default to copying." + "description": "Copy selected transcript or footer text when the mouse button is released. Defaults to `auto`: enabled except in direct terminals known to forward their native copy shortcut (Ghostty 1.2+, Kitty on macOS, Windows Terminal, and VS Code on Windows). Unknown terminals, Ghostty without a recognized version, and tmux/Zellij default to copying." }, "disable_paste_burst": { "description": "When true, disables burst-paste detection for typed input entirely. All characters are inserted as they are received, and no buffering or placeholder replacement will occur for fast keypress bursts. Overrides the legacy top-level `disable_paste_burst` setting. Defaults to `false`.", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5dfc3ad7b2..92ba41a3ce 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -238,6 +238,7 @@ mod new_session; mod turn_tips; pub(crate) use new_session::has_launch_setting; mod clipboard; +mod footer_selection; mod native_history; mod owned_transcript; mod pending_interactive_replay; @@ -855,6 +856,9 @@ impl App { self.finish_clipboard(tui, &event); let event = self.finish_right_click_paste(tui, event); let idle_draw = matches!(event, TuiEvent::Draw); + if self.handle_rendered_selection_event(tui, &event)? { + return Ok(AppRunControl::Continue); + } if matches!(&event, TuiEvent::Key(_)) && self.handle_composer_copy_event(tui, &event, |tui, text| { tui.copy_transcript_selection(text, crate::clipboard_copy::CopyFormat::PlainText) diff --git a/codex-rs/tui/src/app/clipboard.rs b/codex-rs/tui/src/app/clipboard.rs index 0ecad1d8b2..1c742bfd5f 100644 --- a/codex-rs/tui/src/app/clipboard.rs +++ b/codex-rs/tui/src/app/clipboard.rs @@ -39,6 +39,12 @@ impl App { self.transcript_view .show_copy_feedback(&completion.1, characters); } + { + let mut selection = self.chat_widget.rendered_selection.borrow_mut(); + let crate::rendered_selection::RenderedSelection { view, cells, .. } = + &mut *selection; + view.finish_copy(cells, completion, current); + } let follow = self.transcript_view .finish_copy(&self.transcript_cells, completion, current); diff --git a/codex-rs/tui/src/app/footer_selection.rs b/codex-rs/tui/src/app/footer_selection.rs new file mode 100644 index 0000000000..436300bb22 --- /dev/null +++ b/codex-rs/tui/src/app/footer_selection.rs @@ -0,0 +1,67 @@ +//! Route registered read-only selections before copy, interrupt, clicks, and paste shortcuts. +//! A stationary click is replayed to the existing widget handler after selection releases it. + +use super::*; +use crate::clipboard_copy::CopyFormat; +use crate::rendered_selection::RegionAction; +use crate::rendered_selection::RenderedSelection; +use crate::transcript_view::ViewAction; + +impl App { + pub(super) fn handle_rendered_selection_event( + &mut self, + tui: &mut tui::Tui, + event: &TuiEvent, + ) -> Result { + if !tui.is_owned_screen() + || self.overlay.is_some() + || !self.chat_widget.no_modal_or_popup_active() + || self.chat_widget.is_external_writer_view() + { + *self.chat_widget.rendered_selection.borrow_mut() = RenderedSelection::default(); + return Ok(false); + } + let refresh = matches!(event, TuiEvent::Mouse(mouse) + if self.chat_widget.rendered_selection.borrow().needs_refresh(*mouse)); + if refresh { + let size = tui.prepare_draw_size()?; + self.render_owned_transcript(tui, size)?; + } + let action = { + let mut selection = self.chat_widget.rendered_selection.borrow_mut(); + selection.view.copy_on_select = self + .local_settings + .copy_on_select(&codex_terminal_detection::terminal_info()); + selection.view.primary_selection = self.right_click_paste_environment.primary; + selection.handle_event(event, tui.terminal.previous_buffer()) + }; + let Some(action) = action else { + return Ok(false); + }; + self.cancel_pending_key_chord(); + match action { + RegionAction::Click(mouse) => { + self.chat_widget + .handle_warning_event(&TuiEvent::Mouse(mouse), &self.transcript_cells); + } + RegionAction::Selection(action) => { + self.transcript_view.end_selection(&self.transcript_cells); + self.transcript_view.cancel_search(); + self.transcript_view.clear_activity_focus(); + self.chat_widget.clear_composer_selection(); + let copied = { + let mut selection = self.chat_widget.rendered_selection.borrow_mut(); + let RenderedSelection { view, cells, .. } = &mut *selection; + view.copy_action(tui, cells, &action, CopyFormat::PlainText) + }; + if let Some((characters, result)) = copied { + self.transcript_view.show_copy_feedback(&result, characters); + } else if let ViewAction::OpenLink(url) = action { + self.open_url_in_browser(url); + } + } + } + tui.frame_requester().schedule_frame(); + Ok(true) + } +} diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index dcacf8da01..ca29929c13 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -398,6 +398,7 @@ impl App { self.native_history = Default::default(); self.cancel_pending_key_chord(); self.transcript_view = Default::default(); + *self.chat_widget.rendered_selection.borrow_mut() = Default::default(); self.last_rendered_history_tail = None; self.last_thread_usage_status_cell = None; self.pending_thread_usage_history_refresh = false; diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 3fcd0cd23c..7d51b3d686 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -87,7 +87,21 @@ impl App { }; self.sync_owned_transcript(screen_size.width); let transcript_width = self.chat_widget.history_wrap_width(screen_size.width); - let composer_hint = self.composer_hint(transcript_width); + let composer_hint = if self + .chat_widget + .rendered_selection + .borrow() + .view + .has_active_interaction() + { + Some( + ratatui::text::Line::from("Selecting footer · esc cancel") + .dim() + .into(), + ) + } else { + self.composer_hint(transcript_width) + }; let now = Instant::now(); let turn_tip = self.turn_tip(transcript_width, now, &tui.frame_requester()); let working_tip = turn_tip @@ -160,6 +174,7 @@ impl App { let mut blossom_tick = None; let mut transcript_bottom = available.saturating_sub(u16::from(composer_gap.is_none())); tui.draw(screen_size.height, |frame| { + chat_widget.rendered_selection.borrow_mut().areas.clear(); ratatui::widgets::Clear.render( Rect::new(/*x*/ 0, /*y*/ 0, screen_size.width, available), frame.buffer, @@ -270,6 +285,10 @@ impl App { .filter(|_| chat_widget.no_modal_or_popup_active()); feedback_tick = view.render_composer_gap(follow_area, composer_hint.as_ref(), frame.buffer, now); + chat_widget + .rendered_selection + .borrow_mut() + .render(frame.buffer); chat_widget.note_rendered_width(screen_size.width); let dialog = chat_widget.centered_dialog(); let (foreground, foreground_area): (&dyn Renderable, Rect) = @@ -576,27 +595,15 @@ impl App { } return self.handle_owned_backtrack_event(tui, event); }; - let resume_following = matches!(action, ViewAction::CopyAndFollow(_)); - let copy_on_select = matches!(action, ViewAction::CopyOnSelect(_)); - match action { - ViewAction::Changed => {} - ViewAction::PrimarySelection(text) => self.transcript_view.publish_primary(tui, &text), - ViewAction::Copy(text) - | ViewAction::CopyOnSelect(text) - | ViewAction::CopyAndFollow(text) => { - let result = self.transcript_view.copy_selected_text( - tui, - &self.transcript_cells, - &text, - !copy_on_select, - ); - if resume_following - && matches!(result, Ok(crate::clipboard_copy::CopyStatus::Pending(_))) - { - self.transcript_view.follow_pending_copy(); - } - } - ViewAction::OpenLink(url) => self.open_url_in_browser(url), + if let Some((characters, result)) = self.transcript_view.copy_action( + tui, + &self.transcript_cells, + &action, + crate::clipboard_copy::CopyFormat::Markdown, + ) { + self.transcript_view.show_copy_feedback(&result, characters); + } else if let ViewAction::OpenLink(url) = action { + self.open_url_in_browser(url); } self.request_owned_history(tui, app_server); tui.frame_requester().schedule_frame(); diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index 026fe11eac..ff6e0201bb 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -1780,6 +1780,72 @@ async fn fullscreen_composer_remote_images_leave_an_editable_prompt_row() -> Res "fullscreen_prompt_remote_images", normalize_snapshot_paths(frames.join("\n\n")) ); + + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} + +#[tokio::test] +async fn footer_selection_defers_warning_clicks_and_preserves_the_draft() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + app.local_settings.tui.copy_on_select = CopyOnSelect::Never; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + let size = tui.terminal.size()?; + app.chat_widget + .apply_external_edit("draft stays here".into()); + app.insert_history_cell( + &mut tui, + Box::new(history_cell::new_warning_event("Example warning".into())), + ); + app.render_owned_transcript(&mut tui, size)?; + let draft = app.chat_widget.capture_thread_input_state(); + let cursor = tui.terminal.last_known_cursor_pos; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let badge = buffer + .area + .positions() + .find(|position| buffer[*position].symbol() == "⚠") + .unwrap(); + for event in [ + mouse(Down(Left), badge.x, badge.y), + mouse(Drag(Left), badge.x + 11, badge.y), + mouse(Up(Left), badge.x + 11, badge.y), + ] { + assert!(app.handle_rendered_selection_event(&mut tui, &event)?); + assert!(!app.chat_widget.keymap_contexts().is_warnings()); + } + app.render_owned_transcript(&mut tui, size)?; + assert_eq!( + ( + app.chat_widget.capture_thread_input_state(), + tui.terminal.last_known_cursor_pos + ), + (draft, cursor) + ); + { + let mut selection = app.chat_widget.rendered_selection.borrow_mut(); + let crate::rendered_selection::RenderedSelection { view, cells, .. } = &mut *selection; + assert!(view.selected_text(cells).unwrap().contains("warning")); + } + insta::assert_snapshot!( + "fullscreen_footer_selection", + normalize_snapshot_paths(buffer_text( + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal) + )) + ); + assert!(app.handle_rendered_selection_event( + &mut tui, + &TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + )?); + // Dismissal consumes the first Escape, and a stationary click still opens the warning viewer. + for event in [ + mouse(Down(Left), badge.x + 2, badge.y), + mouse(Up(Left), badge.x + 2, badge.y), + ] { + assert!(app.handle_rendered_selection_event(&mut tui, &event)?); + } + assert!(app.chat_widget.keymap_contexts().is_warnings()); tui.set_owned_screen(/*owned*/ false)?; Ok(()) } diff --git a/codex-rs/tui/src/app/right_click_paste.rs b/codex-rs/tui/src/app/right_click_paste.rs index 53361e8684..4b912ec841 100644 --- a/codex-rs/tui/src/app/right_click_paste.rs +++ b/codex-rs/tui/src/app/right_click_paste.rs @@ -59,6 +59,12 @@ impl App { let allowed = match source { PasteSource::Clipboard => { !self.transcript_view.has_selection_range() + && !self + .chat_widget + .rendered_selection + .borrow() + .view + .has_selection_range() && self .right_click_paste_environment .allows(self.local_settings.tui.right_click_paste) diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_footer_selection.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_footer_selection.snap new file mode 100644 index 0000000000..c609b9ff9d --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_footer_selection.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/app/owned_transcript_tests.rs +assertion_line: 1545 +expression: "normalize_snapshot_paths(buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal)))" +--- + + + + + + + + + + + + + + + + + + + Selecting footer · esc cancel + +› draft stays here + + GPT-5.6-Sol default · /tmp/project + ⚠ 1 warning · f2 to view diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 7a36cd9959..56beebc795 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -248,7 +248,8 @@ //! Submission flushes expired characters and buffers before classifying Enter, independent of //! UI flush ticks. `disable_paste_burst` bypasses detection; setting it flushes and clears in-flight state. //! Mouse edits flush pending typing; selection and copy behavior lives in [`mouse`]. Confirmed -//! copies clear the selection while preserving the draft and cursor. +//! copies clear the selection while preserving the draft and cursor. Footer text uses a separate +//! read-only selection owner over its final rendered cells. //! //! See `codex-rs/tui/src/bottom_pane/paste_burst.rs` for the detailed state machine. //! @@ -752,6 +753,7 @@ impl ChatComposer { external_editor_key: default_keymap .primary_hint(KeymapContext::Global, "open_external_editor"), warning_notice_area: std::cell::Cell::default(), + selection_revision: std::cell::Cell::default(), show_warnings_key: default_keymap .primary_hint(KeymapContext::Global, "open_warnings"), show_transcript_key: default_keymap @@ -4800,6 +4802,16 @@ impl ChatComposer { if let Some((warning_area, line)) = warning_notice { line.render(warning_area, buf); } + if let Some(selection) = options.rendered_selection + && !self.popup_active() + && options.footer.is_none_or(|footer| !footer.is_interactive) + { + let revision = (options.warning_count, self.footer_props().is_task_running); + let changed = self.footer.selection_revision.replace(revision) != revision; + let mut selection = selection.borrow_mut(); + selection.register(status.intersection(buf.area), changed); + selection.register(footer_rect.intersection(buf.area), changed); + } let style = user_message_style(); Block::default().style(style).render(composer_rect, buf); if !remote_images_rect.is_empty() { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs index 5b8784a4fa..bcc92a3ed5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -70,6 +70,8 @@ pub(crate) enum CommandPopupPlacement { pub(crate) struct ComposerRenderOptions<'a> { /// Fullscreen limits the composer independently of activity and modal views. pub(crate) max_height: Option, + pub(crate) rendered_selection: + Option<&'a std::cell::RefCell>, /// Reserve a shared hint row independently of whether it currently contains a notice. pub(crate) composer_gap: Option<&'a crate::bottom_pane::ComposerGap>, pub(crate) working_tip: Option<&'a crate::turn_tip::TurnTip>, @@ -365,6 +367,7 @@ pub(super) struct FooterState { pub(super) show_transcript_key: Option, pub(super) show_warnings_key: Option, pub(super) warning_notice_area: std::cell::Cell>, + pub(super) selection_revision: std::cell::Cell<(usize, bool)>, pub(super) find_transcript_key: Option, pub(super) focus_activity_key: Option, pub(super) insert_newline_key: Option, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs index ec95356ef5..b20b533b5c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs @@ -62,6 +62,10 @@ impl ChatComposer { Some((char_count, result)) } + pub(crate) fn clear_mouse_selection(&mut self) { + self.draft.textarea.set_cursor(self.draft.textarea.cursor()); + } + pub(crate) fn end_mouse_drag(&mut self) { self.draft.textarea.end_mouse_drag(); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index bc8e6ce08d..630df124e9 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1802,6 +1802,10 @@ impl BottomPane { self.can_launch_external_editor() } + pub(crate) fn clear_composer_selection(&mut self) { + self.composer.clear_mouse_selection(); + } + pub(crate) fn end_composer_drag(&mut self) { self.composer.end_mouse_drag(); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8c77689ba2..16b13efb89 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -585,6 +585,7 @@ pub(crate) struct ChatWidget { // Remember the account's Reserve entry notice across chats and transient banner refreshes. luna_reserve_notice_account_id: Option, pub(crate) warning_display_state: WarningDisplayState, + pub(crate) rendered_selection: std::cell::RefCell, rate_limit_switch_prompt: RateLimitSwitchPromptState, add_credits_nudge_email_in_flight: Option, adaptive_chunking: AdaptiveChunkingPolicy, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index dc5adf7b73..9af84e80a7 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -172,6 +172,7 @@ impl ChatWidget { backend_banner_notice_model: None, luna_reserve_notice_account_id: None, warning_display_state: WarningDisplayState::default(), + rendered_selection: Default::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), add_credits_nudge_email_in_flight: None, adaptive_chunking: AdaptiveChunkingPolicy::default(), diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index ddabe0e3e2..5a8897ab91 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -15,6 +15,10 @@ pub(crate) enum KeyEventAction { } impl ChatWidget { + pub(crate) fn clear_composer_selection(&mut self) { + self.bottom_pane.clear_composer_selection(); + } + pub(crate) fn end_composer_drag(&mut self) { self.bottom_pane.end_composer_drag(); } diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index adfe1cee5c..eee242df2e 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -252,6 +252,7 @@ impl ChatWidget { } else { self.ambient_pet_wrap_reserved_cols() }; + options.rendered_selection = Some(&self.rendered_selection); options.warning_count = self.warning_display_state.count; options.textarea_right_reserve = right_reserve; options.separate_status_line = options.command_popup_placement diff --git a/codex-rs/tui/src/footer_selection_tests.rs b/codex-rs/tui/src/footer_selection_tests.rs new file mode 100644 index 0000000000..18937c0679 --- /dev/null +++ b/codex-rs/tui/src/footer_selection_tests.rs @@ -0,0 +1,168 @@ +//! Exercise the rendered-cell adapter; gesture and clipboard state machines have shared tests. + +use super::*; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +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::Stylize; +use ratatui::widgets::Widget; + +fn mouse(kind: MouseEventKind, column: u16, row: u16) -> TuiEvent { + TuiEvent::Mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }) +} + +#[test] +fn rendered_selection_preserves_unicode_links_and_the_selected_revision() { + let area = Rect::new( + /*x*/ 0, /*y*/ 2, /*width*/ 30, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + Line::from(vec![ + " ".into(), + "café 界 👩‍💻".cyan().underlined(), + " · 20%…".dim(), + ]) + .render(area, &mut buffer); + crate::terminal_hyperlinks::mark_underlined_hyperlink(&mut buffer, area, "https://example.com"); + let original = buffer.clone(); + let mut selection = RenderedSelection::default(); + selection.areas.clear(); + selection.register(area, /*invalidate*/ false); + selection.render(&mut buffer); + for event in [ + mouse(Down(Left), /*column*/ 2, /*row*/ 2), + mouse(Drag(Left), /*column*/ 12, /*row*/ 2), + mouse(Up(Left), /*column*/ 12, /*row*/ 2), + ] { + selection.handle_event(&event, &buffer); + } + assert_eq!( + selection.view.selected_text(&selection.cells).as_deref(), + Some("café 界 👩‍💻") + ); + buffer.reset(); + Line::from(" changed · 40%").render(area, &mut buffer); + selection.areas.clear(); + selection.register(area, /*invalidate*/ false); + selection.render(&mut buffer); + for position in area.positions() { + assert_eq!(buffer[position].symbol(), original[position].symbol()); + } + insta::assert_snapshot!(format!("{buffer:?}")); + // A new warning must replace the held revision even while the turn keeps running. + selection.register(area, /*invalidate*/ true); + assert!(!selection.view.has_selection_range()); +} + +#[test] +fn release_copy_and_cross_surface_drags_use_only_visible_text() { + let status = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 7, /*height*/ 1, + ); + let footer = Rect { y: 1, ..status }; + let mut buffer = Buffer::empty(status.union(footer)); + "branch…".render(status, &mut buffer); + "? shortcuts".render(footer, &mut buffer); + let mut selection = RenderedSelection::default(); + selection.view.copy_on_select = true; + selection.register(status, /*invalidate*/ false); + selection.register(footer, /*invalidate*/ false); + // Registration retains geometry only: select the final painted cells, not earlier content. + "commit…".render(status, &mut buffer); + selection.handle_event(&mouse(Down(Left), /*column*/ 0, /*row*/ 0), &buffer); + selection.handle_event(&mouse(Drag(Left), /*column*/ 19, /*row*/ 1), &buffer); + let Some(RegionAction::Selection(ViewAction::CopyOnSelect(text))) = + selection.handle_event(&mouse(Up(Left), /*column*/ 19, /*row*/ 1), &buffer) + else { + panic!("release must copy the selected status row"); + }; + assert_eq!(text, "commit…"); + assert!(selection.view.has_selection_range()); + // Collapsing the range keeps keyboard ownership, including Enter, with the footer. + for _ in text.chars() { + selection.handle_event(&TuiEvent::Key(KeyCode::Left.into()), &buffer); + } + assert!(!selection.view.has_selection_range()); + assert!(matches!( + selection.handle_event(&TuiEvent::Key(KeyCode::Enter.into()), &buffer), + Some(RegionAction::Selection(ViewAction::Changed)) + )); + selection.handle_event(&TuiEvent::Key(KeyCode::Right.into()), &buffer); + assert_eq!( + selection.view.selected_text(&selection.cells).as_deref(), + Some("c") + ); + // Invalidating another visible region must not release this selection. + selection.register(footer, /*invalidate*/ true); + assert!(selection.view.has_active_interaction()); + // Find belongs to the app, not a hidden search inside the footer. + assert!( + selection + .handle_event( + &TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)), + &buffer + ) + .is_none() + ); + assert!(!selection.view.has_active_interaction()); + // Resize invalidates both the highlight and its pointer anchor. + selection.handle_event(&mouse(Down(Left), /*column*/ 0, /*row*/ 1), &buffer); + selection.handle_event(&mouse(Drag(Left), /*column*/ 3, /*row*/ 1), &buffer); + selection.handle_event( + &TuiEvent::Resize(ratatui::layout::Size::new( + /*width*/ 10, /*height*/ 2, + )), + &buffer, + ); + assert!(!selection.view.has_active_interaction()); +} + +#[test] +fn stationary_clicks_are_deferred_but_dragging_back_does_not_activate() { + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + "⚠ 2 warnings".render(area, &mut buffer); + let mut selection = RenderedSelection::default(); + selection.register(area, /*invalidate*/ false); + assert!(matches!( + selection.handle_event(&mouse(Down(Left), /*column*/ 2, /*row*/ 0), &buffer), + Some(RegionAction::Selection(_)) + )); + assert!(matches!( + selection.handle_event(&mouse(Up(Left), /*column*/ 2, /*row*/ 0), &buffer), + Some(RegionAction::Click(_)) + )); + selection.handle_event(&mouse(Down(Left), /*column*/ 5, /*row*/ 0), &buffer); + selection.handle_event(&mouse(Drag(Left), /*column*/ 7, /*row*/ 0), &buffer); + assert!(!matches!( + selection.handle_event(&mouse(Up(Left), /*column*/ 5, /*row*/ 0), &buffer), + Some(RegionAction::Click(_)) + )); + // A stationary click after a selection releases the frozen revision. + for event in [ + mouse(Down(Left), /*column*/ 0, /*row*/ 0), + mouse(Drag(Left), /*column*/ 3, /*row*/ 0), + mouse(Up(Left), /*column*/ 3, /*row*/ 0), + ] { + selection.handle_event(&event, &buffer); + } + assert!(selection.view.has_selection_range()); + selection.handle_event(&mouse(Down(Left), /*column*/ 10, /*row*/ 0), &buffer); + assert!(matches!( + selection.handle_event(&mouse(Up(Left), /*column*/ 10, /*row*/ 0), &buffer), + Some(RegionAction::Click(_)) + )); + assert!(!selection.view.has_active_interaction()); +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index a30587b3cb..4f7c5fa050 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -173,6 +173,7 @@ mod keymap_setup; mod line_truncation; pub(crate) mod live_wrap; mod local_settings; +mod rendered_selection; pub use live_wrap::RowBuilder; mod local_chatgpt_auth; mod managed_new_thread_defaults; diff --git a/codex-rs/tui/src/pager_overlay/transcript.rs b/codex-rs/tui/src/pager_overlay/transcript.rs index 508d5c34f4..4aaae519b6 100644 --- a/codex-rs/tui/src/pager_overlay/transcript.rs +++ b/codex-rs/tui/src/pager_overlay/transcript.rs @@ -452,32 +452,20 @@ impl TranscriptOverlay { fn apply_action(&mut self, tui: &mut tui::Tui, action: ViewAction) { self.notice = None; - let resume_following = matches!(action, ViewAction::CopyAndFollow(_)); - let copy_on_select = matches!(action, ViewAction::CopyOnSelect(_)); - match action { - ViewAction::Changed => {} - ViewAction::PrimarySelection(text) => self.view.publish_primary(tui, &text), - ViewAction::Copy(text) - | ViewAction::CopyOnSelect(text) - | ViewAction::CopyAndFollow(text) => { - let result = self - .view - .copy_selected_text(tui, &self.cells, &text, !copy_on_select); - if resume_following - && matches!(result, Ok(crate::clipboard_copy::CopyStatus::Pending(_))) - { - self.view.follow_pending_copy(); - } - self.notice = Some(match result { - Ok(status) => status.message("selection"), - Err(error) => error, - }); - } - ViewAction::OpenLink(url) => { - if let Err(error) = webbrowser::open(&url) { - self.notice = Some(format!("Could not open link: {error}")); - } - } + if let Some((_, result)) = self.view.copy_action( + tui, + &self.cells, + &action, + crate::clipboard_copy::CopyFormat::Markdown, + ) { + self.notice = Some(match result { + Ok(status) => status.message("selection"), + Err(error) => error, + }); + } else if let ViewAction::OpenLink(url) = action + && let Err(error) = webbrowser::open(&url) + { + self.notice = Some(format!("Could not open link: {error}")); } } diff --git a/codex-rs/tui/src/rendered_selection.rs b/codex-rs/tui/src/rendered_selection.rs new file mode 100644 index 0000000000..4b076a2378 --- /dev/null +++ b/codex-rs/tui/src/rendered_selection.rs @@ -0,0 +1,193 @@ +//! Read-only selection for regions registered by the last owned-screen render. +//! Widgets register painted rectangles, and may invalidate their own region when its meaning +//! changes. Only the clicked rectangle is captured from the completed terminal frame; other +//! regions remain live. Gestures, keyboard selection and clipboard tickets use TranscriptView. + +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::transcript_view::TranscriptView; +use crate::transcript_view::ViewAction; +use crate::tui::TuiEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; +use ratatui::buffer::Buffer; +use ratatui::buffer::CellWidth; +use ratatui::layout::Position; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::text::Span; +use std::sync::Arc; + +#[derive(Default)] +pub(crate) struct RenderedSelection { + pub(crate) view: TranscriptView, + pub(crate) cells: Vec>, + pub(crate) areas: Vec, + snapshot: Option, + pressed: Option, +} + +pub(crate) enum RegionAction { + Selection(ViewAction), + // Replay clickable decorations only after a stationary release. + Click(MouseEvent), +} + +impl RenderedSelection { + fn clear(&mut self) { + self.view.jump_to_latest(); + self.snapshot = None; + self.pressed = None; + } + + /// Register a painted, passive region. Invalidation affects only a selection in this region. + pub(crate) fn register(&mut self, area: Rect, invalidate: bool) { + if invalidate + && self + .snapshot + .as_ref() + .is_some_and(|snapshot| snapshot.area == area) + { + self.clear(); + } + if !area.is_empty() { + self.areas.push(area); + } + } + + pub(crate) fn render(&mut self, buffer: &mut Buffer) { + if self + .snapshot + .as_ref() + .is_some_and(|snapshot| !self.areas.contains(&snapshot.area)) + { + self.clear(); + } + if !self.view.has_active_interaction() { + self.snapshot = None; + } + if let Some(snapshot) = &self.snapshot { + self.view.render(snapshot.area, buffer, &self.cells); + // Keep native hyperlinks and forced glyph widths from the original cells. + for position in snapshot.area.positions() { + buffer[position].set_symbol(snapshot[position].symbol()); + buffer[position].diff_option = snapshot[position].diff_option; + } + } + } + + pub(crate) fn needs_refresh(&self, event: MouseEvent) -> bool { + event.kind != MouseEventKind::Moved + && self + .areas + .iter() + .any(|area| area.contains(Position::new(event.column, event.row))) + } + + pub(crate) fn handle_event( + &mut self, + event: &TuiEvent, + completed: &Buffer, + ) -> Option { + match event { + TuiEvent::Mouse(mouse) => return self.handle_mouse(*mouse, completed), + TuiEvent::Key(key) if key.kind == KeyEventKind::Release => return None, + TuiEvent::Key(key) if self.view.has_active_interaction() => { + if let Some(action) = self.view.handle_selection_key(*key, &self.cells) { + return Some(RegionAction::Selection(action)); + } + } + TuiEvent::Key(_) + | TuiEvent::Paste(_) + | TuiEvent::Resize(_) + | TuiEvent::FocusLost + | TuiEvent::Resume => {} + TuiEvent::Draw | TuiEvent::FocusGained => return None, + } + self.clear(); + None + } + + fn handle_mouse(&mut self, mut event: MouseEvent, completed: &Buffer) -> Option { + match event.kind { + MouseEventKind::Down(MouseButton::Left) => { + let position = Position::new(event.column, event.row); + let Some(area) = self + .areas + .iter() + .copied() + .find(|area| area.contains(position)) + else { + self.clear(); + return None; + }; + if self + .snapshot + .as_ref() + .is_none_or(|snapshot| snapshot.area != area) + { + self.view.jump_to_latest(); + let area = area.intersection(completed.area); + let mut surface = Buffer::empty(area); + for position in area.positions() { + surface[position] = completed[position].clone(); + } + self.cells = vec![Arc::new(PlainHistoryCell::new(surface_lines(&surface)))]; + self.view.render(area, &mut surface.clone(), &self.cells); + self.snapshot = Some(surface); + } + self.pressed = Some(event); + } + MouseEventKind::Drag(MouseButton::Left) => self.pressed = None, + MouseEventKind::Up(MouseButton::Left) => { + if let Some(pressed) = self.pressed.take() + && (pressed.column, pressed.row) == (event.column, event.row) + && !self.view.has_selection_range() + { + self.view.end_selection(&self.cells); + return Some(RegionAction::Click(pressed)); + } + } + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + self.clear(); + return None; + } + _ => {} + } + let snapshot = self.snapshot.as_ref()?; + if matches!(event.kind, MouseEventKind::Drag(_) | MouseEventKind::Up(_)) { + event.column = event.column.clamp(snapshot.area.x, snapshot.area.right()); + event.row = event.row.clamp(snapshot.area.y, snapshot.area.bottom() - 1); + } + self.view + .handle_mouse(event, &self.cells) + .map(RegionAction::Selection) + } +} + +fn surface_lines(surface: &Buffer) -> Vec> { + (surface.area.y..surface.area.bottom()) + .map(|row| { + let mut spans = Vec::new(); + let mut column = surface.area.x; + while column < surface.area.right() { + let cell = &surface[(column, row)]; + spans.push(Span::styled( + codex_ansi_escape::ansi_escape_line(cell.symbol()).to_string(), + cell.style(), + )); + column += cell.cell_width().max(/*other*/ 1); + } + while spans.last().is_some_and(|span| span.content == " ") { + spans.pop(); + } + Line::from(spans) + }) + .collect() +} + +#[cfg(test)] +#[path = "footer_selection_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/snapshots/codex_tui__rendered_selection__tests__rendered_selection_preserves_unicode_links_and_the_selected_revision.snap b/codex-rs/tui/src/snapshots/codex_tui__rendered_selection__tests__rendered_selection_preserves_unicode_links_and_the_selected_revision.snap new file mode 100644 index 0000000000..cb5dcda8f8 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__rendered_selection__tests__rendered_selection_preserves_unicode_links_and_the_selected_revision.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/footer_selection_tests.rs +assertion_line: 38 +expression: "format!(\"{buffer:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 2, width: 30, height: 1 }, + content: [ + " ]8;;https://example.comc]8;;]8;;https://example.coma]8;;]8;;https://example.comf]8;;]8;;https://example.comé]8;; ]8;;https://example.com界]8;; ]8;;https://example.com👩‍💻]8;; · 20%… ", // hidden by multi-width symbols: [(8, " "), (11, " ")] + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: UNDERLINED | REVERSED, + x: 8, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED, + x: 9, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: UNDERLINED | REVERSED, + x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED, + x: 12, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 21, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index ced88a72b0..8b1c759a25 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -345,7 +345,7 @@ impl TranscriptView { Some(ViewAction::Changed) } - fn handle_selection_key( + pub(crate) fn handle_selection_key( &mut self, key: KeyEvent, cells: &[Arc], diff --git a/codex-rs/tui/src/transcript_view/selection.rs b/codex-rs/tui/src/transcript_view/selection.rs index 8e62595d3a..f62b427fb2 100644 --- a/codex-rs/tui/src/transcript_view/selection.rs +++ b/codex-rs/tui/src/transcript_view/selection.rs @@ -284,38 +284,6 @@ impl TranscriptView { } } - /// Both transcript surfaces honor the same automatic-copy and plaintext PRIMARY semantics. - pub(crate) fn copy_selected_text( - &mut self, - tui: &mut Tui, - cells: &[Arc], - text: &str, - clear_selection: bool, - ) -> CopyResult { - let publish_primary = !clear_selection && self.primary_selection; - let mut primary_owner = None; - let result = - self.copy_selected_text_with(cells, text, clear_selection, |copy_text, format| { - if publish_primary { - let (result, owner) = tui.clipboard.select( - copy_text.into(), - format, - CopyDestination::ClipboardAndPrimary(text.into()), - tui.frame_requester(), - ); - primary_owner = Some(owner); - result - } else { - tui.clipboard - .copy(copy_text.into(), format, tui.frame_requester()) - } - }); - if publish_primary && let Some(selection) = &mut self.selection { - selection.primary_owner = primary_owner; - } - result - } - /// Hide or cancel deferred publication without relinquishing the existing X11 selection. pub(crate) fn cancel_primary(&mut self) { if let Some(selection) = &mut self.selection { @@ -405,6 +373,66 @@ impl TranscriptView { result } + /// Apply selection copy keys consistently in owned, overlay and rendered-region views. + /// Plain UI regions keep their visible text; transcript copies retain Markdown metadata. + /// PRIMARY is always plaintext, and automatic copies update both X11 selections. + pub(crate) fn copy_action( + &mut self, + tui: &mut Tui, + cells: &[Arc], + action: &ViewAction, + format: crate::clipboard_copy::CopyFormat, + ) -> Option<(usize, CopyResult)> { + use crate::clipboard_copy::CopyFormat; + use crate::clipboard_copy::CopyStatus; + + let text = match action { + ViewAction::Copy(text) + | ViewAction::CopyOnSelect(text) + | ViewAction::CopyAndFollow(text) => text.as_str(), + ViewAction::PrimarySelection(text) => { + self.publish_primary(tui, text); + return None; + } + ViewAction::Changed | ViewAction::OpenLink(_) => return None, + }; + let clear_selection = !matches!(action, ViewAction::CopyOnSelect(_)); + let publish_primary = !clear_selection && self.primary_selection; + let mut primary_owner = None; + let result = + self.copy_selected_text_with(cells, text, clear_selection, |rich, rich_format| { + let (copy_text, format) = match format { + CopyFormat::PlainText => (text, CopyFormat::PlainText), + CopyFormat::Markdown => (rich, rich_format), + CopyFormat::MarkdownSelection(source) => { + (text, CopyFormat::MarkdownSelection(source)) + } + }; + if publish_primary { + let (result, owner) = tui.clipboard.select( + copy_text.into(), + format, + CopyDestination::ClipboardAndPrimary(text.into()), + tui.frame_requester(), + ); + primary_owner = Some(owner); + result + } else { + tui.clipboard + .copy(copy_text.into(), format, tui.frame_requester()) + } + }); + if publish_primary && let Some(selection) = &mut self.selection { + selection.primary_owner = primary_owner; + } + if matches!(action, ViewAction::CopyAndFollow(_)) + && matches!(result, Ok(CopyStatus::Pending(_))) + { + self.follow_pending_copy(); + } + Some((text.chars().count(), result)) + } + pub(crate) fn follow_pending_copy(&mut self) { if let Some(pending) = self .selection