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