From d7d9f2b9b53b53568b30c4dea7e095ec276ebe9c Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 19 Sep 2026 23:50:42 +0000 Subject: [PATCH] Add selection and copying to the transcript viewer (#46732) ## What changed - Add mouse selection, word and line selection, drag autoscrolling, and keyboard selection with `Ctrl+Space`. Support copying selected text and opening links with a modified click. - Anchor reading positions and selections to source text so resizing, streaming completion, and history pagination preserve the displayed content. Preserve hard line breaks, indentation, tabs, and graphemes when copying, while excluding display gutters and soft wraps. - Rejoin activity groups split across history pages while retaining the reader's displayed revision. - Distinguish confirmed clipboard writes from unconfirmed terminal requests, and retain native clipboard ownership after the transcript overlay closes. - Manage mouse capture and keyboard modes across overlay transitions, editor handoffs, and suspend/resume. ## Testing Add regression tests for selection during live updates and pagination, source-text copying across resize, clipboard confirmation and lifetime, and terminal mode restoration. GitOrigin-RevId: 6b2438f576567cbba755b2405915a25acf009b8d --- codex-rs/tui/src/app.rs | 7 +- codex-rs/tui/src/app/activity_groups.rs | 81 + codex-rs/tui/src/app/activity_groups_tests.rs | 270 ++++ .../tui/src/app/agents_overview_actions.rs | 2 +- codex-rs/tui/src/app/history_pagination.rs | 14 +- codex-rs/tui/src/app/input.rs | 5 + codex-rs/tui/src/app/reconnect.rs | 4 + codex-rs/tui/src/app/startup.rs | 10 + codex-rs/tui/src/app/tests/key_chords.rs | 82 + .../app/tests/session_lifecycle_requests.rs | 6 +- ...ment_policy__misalignment_findings_38.snap | 4 +- ..._composer__transcript_flag_off_viewer.snap | 9 +- codex-rs/tui/src/app/transcript_export.rs | 1 + .../tui/src/app/transcript_export_tests.rs | 3 + codex-rs/tui/src/app_backtrack.rs | 35 +- .../tui/src/app_backtrack/legacy_input.rs | 98 +- codex-rs/tui/src/bottom_pane/mod.rs | 6 + ..._user_verification_full_screen_bottom.snap | 3 +- .../tui/src/chatwidget/activity_groups.rs | 19 + codex-rs/tui/src/chatwidget/interaction.rs | 29 +- .../src/chatwidget/tests/slash_commands.rs | 20 +- ...sts__slash_commands__unconfirmed_copy.snap | 6 + codex-rs/tui/src/chatwidget/tool_lifecycle.rs | 17 + .../tui/src/chatwidget/transcript_export.rs | 11 +- codex-rs/tui/src/clipboard_copy.rs | 117 +- .../tui/src/clipboard_copy/routing_tests.rs | 19 +- codex-rs/tui/src/custom_terminal.rs | 5 +- codex-rs/tui/src/cwd_prompt.rs | 2 +- codex-rs/tui/src/exec_cell/model.rs | 1 - .../external_agent_config_migration/mod.rs | 2 +- .../external_agent_config_migration/source.rs | 2 +- .../tui/src/history_cell/computer_activity.rs | 2 - codex-rs/tui/src/history_cell/search.rs | 5 + codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/model_migration.rs | 2 +- .../tui/src/onboarding/onboarding_screen.rs | 2 +- codex-rs/tui/src/pager_overlay.rs | 79 +- .../tui/src/pager_overlay/anchor_tests.rs | 175 -- ...leaves_gutters_and_padding_unreversed.snap | 48 - ...ript_overlay_apply_patch_scroll_vt100.snap | 15 - ...anscript_overlay_follows_resized_tail.snap | 18 - ...ript_overlay_paginated_history_states.snap | 47 - ..._transcript_overlay_renders_live_tail.snap | 14 - ...ts__transcript_overlay_snapshot_basic.snap | 57 - ...__transcript_overlay_completed_stream.snap | 14 + ...ript_overlay_paginated_history_states.snap | 48 + ...ay_paginated_history_states_non_macos.snap | 48 + codex-rs/tui/src/pager_overlay/transcript.rs | 424 ++--- .../tui/src/pager_overlay/transcript_tests.rs | 1419 +++++++++-------- codex-rs/tui/src/resume_picker.rs | 24 +- ...al_tests__patch_approval_pager_bottom.snap | 2 +- ...roval_tests__patch_approval_pager_top.snap | 3 +- ...erlay_visualization_becomes_available.snap | 31 +- ..._tests__static_overlay_snapshot_basic.snap | 3 +- ...ests__static_overlay_wraps_long_lines.snap | 3 +- ...sts__wheel_stops_selection_autoscroll.snap | 18 + codex-rs/tui/src/startup_draft.rs | 4 +- codex-rs/tui/src/startup_hooks_review.rs | 2 +- ...ipt_overlay_status_rate_limit_refresh.snap | 41 +- codex-rs/tui/src/terminal_hyperlinks.rs | 2 +- codex-rs/tui/src/thread_transcript.rs | 20 - .../src/thread_transcript/computer_groups.rs | 5 - .../thread_transcript/exploration_groups.rs | 4 - codex-rs/tui/src/transcript_mode.rs | 24 + codex-rs/tui/src/transcript_view.rs | 356 +++-- codex-rs/tui/src/transcript_view/activity.rs | 33 + .../tui/src/transcript_view/activity_tests.rs | 65 + .../src/transcript_view/diff_source_tests.rs | 76 + codex-rs/tui/src/transcript_view/footer.rs | 220 +++ .../tui/src/transcript_view/footer_tests.rs | 164 ++ codex-rs/tui/src/transcript_view/input.rs | 292 ++++ codex-rs/tui/src/transcript_view/layout.rs | 97 +- .../tui/src/transcript_view/layout_tests.rs | 44 +- codex-rs/tui/src/transcript_view/mutations.rs | 251 +++ .../src/transcript_view/mutations_tests.rs | 356 +++++ .../src/transcript_view/prompt_style_tests.rs | 141 ++ codex-rs/tui/src/transcript_view/selection.rs | 469 ++++++ .../src/transcript_view/selection_tests.rs | 573 +++++++ codex-rs/tui/src/transcript_view/snapshot.rs | 92 ++ ...oter__tests__compact_navigation_hints.snap | 17 + ...mpt_style_tests__prompt_margin_narrow.snap | 22 + ...ll_tests__owned_recap_selected_narrow.snap | 15 + ...w__text__tab_tests__hard_wrapped_tabs.snap | 17 + ...tests__styled_tab_stops_and_selection.snap | 20 + ...ew__text__tests__compact_command_copy.snap | 36 + ...w__text__tests__detailed_command_copy.snap | 27 + .../src/transcript_view/source_cell_tests.rs | 324 ++++ codex-rs/tui/src/transcript_view/text.rs | 473 ++++-- .../tui/src/transcript_view/text_logical.rs | 103 ++ codex-rs/tui/src/transcript_view/text_tabs.rs | 303 ++++ .../src/transcript_view/text_tabs_tests.rs | 167 ++ .../tui/src/transcript_view/text_tests.rs | 338 ++++ codex-rs/tui/src/transcript_view_tests.rs | 803 ++++++++-- codex-rs/tui/src/tui.rs | 201 ++- codex-rs/tui/src/tui/alternate_screen.rs | 197 +++ .../tui/src/tui/alternate_screen_tests.rs | 451 ++++++ codex-rs/tui/src/tui/event_stream.rs | 40 +- codex-rs/tui/src/tui/job_control.rs | 24 +- codex-rs/tui/src/tui/keyboard_modes.rs | 22 +- codex-rs/tui/src/tui/owned_screen_tests.rs | 208 +++ codex-rs/tui/src/tui/screen_size.rs | 13 +- codex-rs/tui/src/tui/selection_clipboard.rs | 26 + .../tui/src/tui/selection_clipboard_tests.rs | 53 + codex-rs/tui/src/unarchive_prompt.rs | 2 +- codex-rs/tui/src/update_prompt.rs | 2 +- 105 files changed, 8716 insertions(+), 1911 deletions(-) create mode 100644 codex-rs/tui/src/app/activity_groups.rs create mode 100644 codex-rs/tui/src/app/activity_groups_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__unconfirmed_copy.snap delete mode 100644 codex-rs/tui/src/pager_overlay/anchor_tests.rs delete mode 100644 codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__anchor_tests__prompt_highlight_leaves_gutters_and_padding_unreversed.snap delete mode 100644 codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_apply_patch_scroll_vt100.snap delete mode 100644 codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_follows_resized_tail.snap delete mode 100644 codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_paginated_history_states.snap delete mode 100644 codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_renders_live_tail.snap delete mode 100644 codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_snapshot_basic.snap create mode 100644 codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_completed_stream.snap create mode 100644 codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_paginated_history_states.snap create mode 100644 codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_paginated_history_states_non_macos.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__transcript_view__tests__wheel_stops_selection_autoscroll.snap create mode 100644 codex-rs/tui/src/transcript_mode.rs create mode 100644 codex-rs/tui/src/transcript_view/activity.rs create mode 100644 codex-rs/tui/src/transcript_view/activity_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/diff_source_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/footer.rs create mode 100644 codex-rs/tui/src/transcript_view/footer_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/input.rs create mode 100644 codex-rs/tui/src/transcript_view/mutations.rs create mode 100644 codex-rs/tui/src/transcript_view/mutations_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/prompt_style_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/selection.rs create mode 100644 codex-rs/tui/src/transcript_view/selection_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/snapshot.rs create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__compact_navigation_hints.snap create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__text__prompt_style_tests__prompt_margin_narrow.snap create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__text__source_cell_tests__owned_recap_selected_narrow.snap create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__text__tab_tests__hard_wrapped_tabs.snap create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__text__tab_tests__styled_tab_stops_and_selection.snap create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__text__tests__compact_command_copy.snap create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__text__tests__detailed_command_copy.snap create mode 100644 codex-rs/tui/src/transcript_view/source_cell_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/text_logical.rs create mode 100644 codex-rs/tui/src/transcript_view/text_tabs.rs create mode 100644 codex-rs/tui/src/transcript_view/text_tabs_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/text_tests.rs create mode 100644 codex-rs/tui/src/tui/alternate_screen.rs create mode 100644 codex-rs/tui/src/tui/alternate_screen_tests.rs create mode 100644 codex-rs/tui/src/tui/owned_screen_tests.rs create mode 100644 codex-rs/tui/src/tui/selection_clipboard.rs create mode 100644 codex-rs/tui/src/tui/selection_clipboard_tests.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 978f0bddae..0f0ba76944 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -197,6 +197,7 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use toml::Value as TomlValue; use uuid::Uuid; +mod activity_groups; mod agent_message_consolidation; mod agent_navigation; mod agent_picker; @@ -850,6 +851,7 @@ impl App { ) -> Result { if self.reconnect.offline && let TuiEvent::Key(key) = &event + && !matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(*key)) && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) && key.modifiers.contains(KeyModifiers::CONTROL) && let KeyCode::Char(character) = key.code @@ -863,7 +865,7 @@ impl App { let screen_size = tui.screen_size_for_event(&event)?; if !matches!( &event, - TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost + TuiEvent::Key(_) | TuiEvent::Mouse(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost ) { self.expire_pending_key_chord(); self.handle_draw_pre_render(tui, screen_size)?; @@ -888,6 +890,7 @@ impl App { }; if self.reconnect.offline + && !matches!(&self.overlay, Some(Overlay::Transcript(_))) && let TuiEvent::Key(key) = &event { if self.reconnect.presentation == reconnect::ReconnectPresentation::Overview { @@ -999,7 +1002,7 @@ impl App { self.app_event_tx.send(AppEvent::LaunchExternalEditor); } } - TuiEvent::FocusLost => {} + TuiEvent::FocusLost | TuiEvent::Mouse(_) => {} } } Ok(AppRunControl::Continue) diff --git a/codex-rs/tui/src/app/activity_groups.rs b/codex-rs/tui/src/app/activity_groups.rs new file mode 100644 index 0000000000..1102d77c8a --- /dev/null +++ b/codex-rs/tui/src/app/activity_groups.rs @@ -0,0 +1,81 @@ +//! Repair activity groups split by transport pages without rewriting terminal scrollback. +//! +//! Only persisted member adjacency within one turn permits joins. Overlay callbacks retain the +//! displayed source revision before canonical cells are replaced or absorbed into the live tail. + +use super::*; +use crate::thread_transcript::join_computer_groups; +use crate::thread_transcript::join_exploration_groups; + +impl App { + pub(super) fn join_older_activity_group(&mut self, boundary: usize, turns: &[Turn]) { + let Some(start) = self.transcript_cells[..boundary] + .iter() + .rposition(|cell| !crate::thread_transcript::is_hidden_activity_detail(cell)) + else { + return; + }; + let mut newer_index = self.transcript_cells[boundary..] + .iter() + .position(|cell| !crate::thread_transcript::is_hidden_activity_detail(cell)) + .map_or(self.transcript_cells.len(), |offset| boundary + offset); + // A reasoning-only page belongs to the preceding group even when an answer or turn + // separator follows it. Verify identities before touching either side of that boundary. + if start + 1 < newer_index { + let Some(older) = crate::thread_transcript::fold_trailing_activity_details( + &self.transcript_cells[start], + &self.transcript_cells[start + 1..newer_index], + turns, + ) else { + return; + }; + let range = start..newer_index; + self.native_history + .consolidate(&self.transcript_cells[range.clone()], &older); + if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { + overlay.regroup_cells(range.clone(), Arc::clone(&older)); + } + self.transcript_cells.splice(range, [older]); + newer_index = start + 1; + } + if newer_index == self.transcript_cells.len() { + let Some((previous_revision, hydrated_revision)) = self + .chat_widget + .prepend_active_computer_history(self.transcript_cells[start].as_ref(), turns) + .or_else(|| { + self.chat_widget.prepend_active_exploration_history( + self.transcript_cells[start].as_ref(), + turns, + ) + }) + else { + return; + }; + if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { + overlay.absorb_tail_into_live(previous_revision, hydrated_revision); + } + self.transcript_cells.pop(); + self.native_history.retain(&self.transcript_cells); + return; + } + let Some(newer) = self.transcript_cells.get(newer_index) else { + return; + }; + let Some(group) = join_computer_groups(&self.transcript_cells[start], newer, turns) + .or_else(|| join_exploration_groups(&self.transcript_cells[start], newer, turns)) + else { + return; + }; + let range = start..newer_index + 1; + self.native_history + .consolidate(&self.transcript_cells[range.clone()], &group); + if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { + overlay.regroup_cells(range.clone(), Arc::clone(&group)); + } + self.transcript_cells.splice(range, [group]); + } +} + +#[cfg(test)] +#[path = "activity_groups_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/activity_groups_tests.rs b/codex-rs/tui/src/app/activity_groups_tests.rs new file mode 100644 index 0000000000..6796b7b1ce --- /dev/null +++ b/codex-rs/tui/src/app/activity_groups_tests.rs @@ -0,0 +1,270 @@ +//! Page transport cannot change activity chronology, rich details, or pending live completion. + +use super::*; +use crate::app::test_support::make_test_app; +use crate::thread_transcript::RawReasoningVisibility; +use crate::thread_transcript::thread_items_to_transcript_cells; +use codex_app_server_protocol::TurnItemsView; +use pretty_assertions::assert_eq; + +#[derive(Clone, Copy, Debug)] +enum ActivityKind { + Computer, + Exploration, +} + +fn call(id: &str, kind: ActivityKind, status: &str) -> ThreadItem { + let value = match kind { + ActivityKind::Computer => serde_json::json!({ + "type": "mcpToolCall", "id": id, "server": "cua_repl", "tool": "js", + "status": status, "arguments": {"title": format!("Inspect {id}")}, + "result": {"content": [{"type": "text", "text": format!("Result {id}")}]}, + "durationMs": 10 + }), + ActivityKind::Exploration => serde_json::json!({ + "type": "commandExecution", "id": id, "command": format!("cat {id}.rs"), + "cwd": crate::test_support::test_path_buf("/workspace"), "source": "agent", + "status": status, "commandActions": [{"type": "read", "command": format!("cat {id}.rs"), "name": format!("{id}.rs"), "path": format!("{id}.rs")}], + "aggregatedOutput": format!("Result {id}"), "exitCode": 0, "durationMs": 10 + }), + }; + serde_json::from_value(value).expect("activity fixture") +} + +fn reasoning(id: &str) -> ThreadItem { + ThreadItem::Reasoning { + id: id.to_owned(), + summary: vec![format!( + "**Considering {id}**\n\nRetained reasoning for {id}." + )], + content: Vec::new(), + } +} + +fn turn(items: Vec) -> Turn { + Turn { + id: "turn".to_owned(), + items, + items_view: TurnItemsView::Full, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + } +} + +fn project(app: &App, items: &[ThreadItem]) -> Vec> { + thread_items_to_transcript_cells( + /*thread_id*/ None, + &app.config.cwd, + items.iter().cloned(), + RawReasoningVisibility::Hidden, + Some(&app.config), + ) +} + +#[tokio::test] +async fn older_page_hydration_keeps_pending_computer_and_exploration_completion() { + for kind in [ActivityKind::Computer, ActivityKind::Exploration] { + let mut app = make_test_app().await; + match kind { + ActivityKind::Computer => { + app.chat_widget.handle_mcp_tool_call_completed_now(call( + "newer", + kind, + "completed", + )); + app.chat_widget.handle_mcp_tool_call_started_now(call( + "pending", + kind, + "inProgress", + )); + } + ActivityKind::Exploration => { + app.chat_widget.handle_command_execution_completed_now(call( + "newer", + kind, + "completed", + )); + app.chat_widget.handle_command_execution_started_now(call( + "pending", + kind, + "inProgress", + )); + } + } + let mut current = turn(vec![ + call("older", kind, "completed"), + reasoning("boundary"), + call("newer", kind, "completed"), + call("pending", kind, "inProgress"), + ]); + current.status = TurnStatus::InProgress; + // Separate projections model reasoning arriving on its own intervening page. + app.transcript_cells = project(&app, ¤t.items[..1]); + app.transcript_cells + .extend(project(&app, ¤t.items[1..2])); + app.join_older_activity_group(/*boundary*/ 1, std::slice::from_ref(¤t)); + assert!(app.transcript_cells.is_empty()); + match kind { + ActivityKind::Computer => app.chat_widget.handle_mcp_tool_call_completed_now(call( + "pending", + kind, + "completed", + )), + ActivityKind::Exploration => app + .chat_widget + .handle_command_execution_completed_now(call("pending", kind, "completed")), + } + current.items[3] = call("pending", kind, "completed"); + let expected = project(&app, ¤t.items); + assert_eq!( + app.chat_widget + .active_cell_transcript_lines(/*width*/ 80) + .unwrap(), + expected[0].transcript_lines(/*width*/ 80), + "{kind:?}" + ); + } +} + +#[tokio::test] +async fn trailing_reasoning_identity_rejects_identical_text_from_another_turn() { + for kind in [ActivityKind::Computer, ActivityKind::Exploration] { + let mut app = make_test_app().await; + let mut same_turn = reasoning("same-turn-detail"); + let mut next_turn = reasoning("next-turn-detail"); + for item in [&mut same_turn, &mut next_turn] { + if let ThreadItem::Reasoning { summary, .. } = item { + *summary = vec!["Exactly the same reasoning text".to_owned()]; + } + } + let first = turn(vec![call("call", kind, "completed"), same_turn.clone()]); + let mut second = turn(vec![next_turn.clone()]); + second.id = "next-turn".to_owned(); + let turns = vec![first, second]; + for detail in [same_turn, next_turn] { + let older = project(&app, &turns[0].items[..1]).remove(/*index*/ 0); + let trailing = project(&app, std::slice::from_ref(&detail)).remove(/*index*/ 0); + app.transcript_cells = vec![Arc::clone(&older), Arc::clone(&trailing)]; + app.join_older_activity_group(/*boundary*/ 1, &turns); + if detail.id() == "same-turn-detail" { + let expected = project(&app, &turns[0].items); + assert_eq!(app.transcript_cells.len(), 1); + assert_eq!(app.transcript_cells[0].raw_lines(), expected[0].raw_lines()); + } else { + assert_eq!(app.transcript_cells.len(), 2); + assert!(Arc::ptr_eq(&app.transcript_cells[0], &older)); + assert!(Arc::ptr_eq(&app.transcript_cells[1], &trailing)); + } + } + // Live reasoning without persisted identity cannot prove an older-page relationship. + let unidentified: Arc = Arc::new(history_cell::ReasoningSummaryCell::new( + String::new(), + "Exactly the same reasoning text".to_owned(), + app.config.cwd.as_path(), + /*transcript_only*/ true, + )); + app.transcript_cells = project(&app, &turns[0].items[..1]); + app.transcript_cells.push(Arc::clone(&unidentified)); + app.join_older_activity_group(/*boundary*/ 1, &turns); + assert_eq!(app.transcript_cells.len(), 2); + assert!(Arc::ptr_eq(&app.transcript_cells[1], &unidentified)); + } +} + +#[tokio::test] +async fn every_page_split_folds_reasoning_before_answer_and_completion_boundaries() { + for kind in [ActivityKind::Computer, ActivityKind::Exploration] { + for answer in [None, Some("The answer")] { + let mut app = make_test_app().await; + let mut items = vec![call("call", kind, "completed"), reasoning("detail")]; + if let Some(text) = answer { + items.push(ThreadItem::AgentMessage { + id: "answer".to_owned(), + text: text.to_owned(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }); + } + let current = turn(items); + let separator: Arc = + Arc::new(history_cell::FinalMessageSeparator::new( + /*elapsed_seconds*/ Some(65), + /*runtime_metrics*/ None, + )); + let mut expected = project(&app, ¤t.items); + expected.push(Arc::clone(&separator)); + for split in 1..current.items.len() { + let older = project(&app, ¤t.items[..split]); + let mut newer = project(&app, ¤t.items[split..]); + newer.push(Arc::clone(&separator)); + let boundary = older.len(); + app.transcript_cells = older.into_iter().chain(newer).collect(); + app.join_older_activity_group(boundary, std::slice::from_ref(¤t)); + for width in [24, 80] { + let rendered = |cells: &[Arc]| { + cells + .iter() + .map(|cell| { + ( + cell.display_hyperlink_lines(width), + cell.transcript_hyperlink_lines(width), + cell.raw_lines(), + ) + }) + .collect::>() + }; + assert_eq!( + rendered(&app.transcript_cells), + rendered(&expected), + "{kind:?}, answer {answer:?}, split {split}" + ); + } + assert!(Arc::ptr_eq( + app.transcript_cells.last().unwrap(), + &separator + )); + } + } + } +} + +#[tokio::test] +async fn failed_exploration_retains_following_reasoning_at_every_page_split() { + let mut app = make_test_app().await; + let mut failed = call("failed", ActivityKind::Exploration, "failed"); + if let ThreadItem::CommandExecution { exit_code, .. } = &mut failed { + *exit_code = Some(1); + } + let current = turn(vec![ + call("first", ActivityKind::Exploration, "completed"), + failed, + reasoning("after-failure"), + ]); + let expected = project(&app, ¤t.items); + assert_eq!(expected.len(), 1); + for split in 1..current.items.len() { + let older = project(&app, ¤t.items[..split]); + let boundary = older.len(); + app.transcript_cells = older + .into_iter() + .chain(project(&app, ¤t.items[split..])) + .collect(); + app.join_older_activity_group(boundary, std::slice::from_ref(¤t)); + let rendered = |cells: &[Arc]| { + cells + .iter() + .map(|cell| cell.transcript_hyperlink_lines(/*width*/ 80)) + .collect::>() + }; + assert_eq!( + rendered(&app.transcript_cells), + rendered(&expected), + "split {split}" + ); + } +} diff --git a/codex-rs/tui/src/app/agents_overview_actions.rs b/codex-rs/tui/src/app/agents_overview_actions.rs index dd9cbd77b3..6ca011cc8d 100644 --- a/codex-rs/tui/src/app/agents_overview_actions.rs +++ b/codex-rs/tui/src/app/agents_overview_actions.rs @@ -89,7 +89,7 @@ async fn run_lifecycle_modal( Some(event) = events.next() => { tui.screen_size_for_event(&event)?; match event { - tui::TuiEvent::Key(_) | tui::TuiEvent::Paste(_) | tui::TuiEvent::FocusLost => {} + tui::TuiEvent::Key(_) | tui::TuiEvent::Paste(_) | tui::TuiEvent::FocusLost | tui::TuiEvent::Mouse(_) => {} tui::TuiEvent::Draw | tui::TuiEvent::Resize(_) | tui::TuiEvent::Resume | tui::TuiEvent::FocusGained => { tui.draw(u16::MAX, |frame| { progress.render(frame.area(), frame.buffer_mut()); diff --git a/codex-rs/tui/src/app/history_pagination.rs b/codex-rs/tui/src/app/history_pagination.rs index 469f9b73ee..ce3622e3fd 100644 --- a/codex-rs/tui/src/app/history_pagination.rs +++ b/codex-rs/tui/src/app/history_pagination.rs @@ -1,6 +1,7 @@ //! Load bounded history pages into the transcript before updating its owned or inline viewport. use std::collections::HashSet; +use std::ops::Range; use super::*; use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT; @@ -118,7 +119,10 @@ impl App { &cwd, visibility, ); - self.prepend_older_transcript_cells(cells, width); + let inserted = self.prepend_older_transcript_cells(cells, width); + if !inserted.is_empty() { + self.join_older_activity_group(inserted.end, &turns); + } merge_older_turns(&mut store.lock().await.turns, turns); self.scrollback_has_older_history = app_server.has_older_history(thread_id); @@ -225,7 +229,11 @@ impl App { } /// Insert each page once, keeping session headers and any backtrack selection in place. - fn prepend_older_transcript_cells(&mut self, cells: Vec>, width: u16) { + fn prepend_older_transcript_cells( + &mut self, + cells: Vec>, + width: u16, + ) -> Range { if self.backtrack.overlay_preview_active { self.backtrack.nth_user_message = self.backtrack.nth_user_message.saturating_add( cells @@ -247,7 +255,9 @@ impl App { }) .map_or(/*default*/ 0, |index| index.saturating_add(/*rhs*/ 1)) }; + let inserted = index..index + cells.len(); self.transcript_cells.splice(index..index, cells); + inserted } /// Preserve the legacy overlay and inline scrollback refill behavior. diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 31e358643e..dda527f719 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -54,6 +54,11 @@ impl App { tui: &mut tui::Tui, key_event: KeyEvent, ) -> Option { + if matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key_event)) + { + self.cancel_pending_key_chord(); + return Some(key_event); + } let contexts = self.active_keymap_contexts(); let was_pending = self.key_chord_matcher.is_pending(); if !was_pending diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index f3315f9f07..dd7cc5b723 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -122,6 +122,10 @@ pub(super) async fn reconnect( } impl App { + pub(crate) fn is_offline(&self) -> bool { + self.reconnect.offline + } + // Preserve local choices for future input, without replaying failed settings writes or // changing the server's authorization for work that was already admitted. pub(super) fn restore_runtime_permissions( diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index e21e6bcdae..4c3d7e27f1 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -992,6 +992,16 @@ See the Codex keymap documentation for supported actions and examples." Ok(exit_reason) } else { loop { + // Reconnect can dismiss an overlay from the server-event path. + if app.overlay.is_none() { + if !tui.is_owned_screen() && tui.is_alt_screen_active() { + app.close_transcript_overlay(tui); + } else if let Err(err) = + tui.set_overlay_input(crate::tui::OverlayInput::Default) + { + break Err(err.into()); + } + } if app.pending_open_resume_picker { app.pending_open_resume_picker = false; match Box::pin(app.open_resume_picker(tui, &mut app_server)).await { diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 04436675a5..03636e9c27 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -8,6 +8,7 @@ use super::start_config_write_test_app_server; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::chatwidget::tests::helpers::render_bottom_popup; +use crate::chatwidget::tests::helpers::set_active_cell; use crate::keymap::KeymapContext; use crate::test_support::test_path_display; use crate::tui::Tui; @@ -635,3 +636,84 @@ async fn command_center_chords_do_not_capture_search_text() -> Result<()> { assert!(app.key_chord_matcher.is_pending()); Ok(()) } + +#[tokio::test] +async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Result<()> { + let (mut app, mut tui, app_server) = chord_app().await?; + app.keymap = RuntimeKeymap::from_config(&serde_json::from_value(serde_json::json!({ + "pager": {"scroll_up": ["ctrl-space x", "ctrl-home x", "ctrl-end x"]} + }))?) + .expect("valid pager chords"); + app.open_transcript_overlay(&mut tui); + for code in [KeyCode::Home, KeyCode::End, KeyCode::Char(' ')] { + let key = KeyEvent::new(code, KeyModifiers::CONTROL); + assert_eq!(app.route_key_chord_event(&mut tui, key), Some(key)); + assert!(!app.key_chord_matcher.is_pending()); + } + app.close_transcript_overlay(&mut tui); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn selection_after_live_commit_uses_the_refreshed_frame() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + let area = ratatui::layout::Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12, + ); + let mut overlay = + crate::pager_overlay::TranscriptOverlay::new(Vec::new(), app.keymap.pager.clone()); + overlay.sync_live_tail( + /*width*/ 40, + /*key*/ None, + |_| Some(vec!["committed output".into()]), + ); + overlay.render(area, &mut ratatui::buffer::Buffer::empty(area)); + overlay.insert_cell(std::sync::Arc::new( + crate::history_cell::PlainHistoryCell::new(vec!["committed output".into()]), + )); + app.overlay = Some(crate::pager_overlay::Overlay::Transcript(overlay)); + // The active cell has completed, but no Draw has reached the overlay yet. + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)), + ) + .await?; + let Some(crate::pager_overlay::Overlay::Transcript(overlay)) = &mut app.overlay else { + panic!("overlay closed") + }; + assert!(overlay.has_active_interaction()); + let mut buffer = ratatui::buffer::Buffer::empty(area); + overlay.render(area, &mut buffer); + let text = buffer + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert_eq!(text.matches("committed output").count(), 1); + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ) + .await?; + set_active_cell( + &mut app.chat_widget, + Box::new(crate::history_cell::StreamingAgentTailCell::new( + vec!["live continuation wraps at the resized width".into()], + /*is_first_line*/ false, + )), + ); + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Resize(ratatui::layout::Size::new( + /*width*/ 20, /*height*/ 12, + )), + ) + .await?; + app.close_transcript_overlay(&mut tui); + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index c8c5cbd1e0..181cbabc02 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -2668,16 +2668,16 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri }; // Inspect the loaded start without Home, which now requests all older pages. render_overlay(overlay); - overlay.scroll(isize::MIN); + overlay.set_highlight_cell(Some(0)); let partial = render_overlay(overlay); - assert!(partial.contains("Earlier messages are available — scroll up to load them")); + assert!(partial.contains("Earlier messages available.")); assert!(!partial.contains("OpenAI Codex")); assert!(!partial.contains("This is a test announcement")); assert!(!partial.contains('%')); overlay.set_history_state(crate::pager_overlay::TranscriptHistoryState::LoadingOlder); let loading = render_overlay(overlay); - assert!(loading.contains("Loading earlier messages...")); + assert!(loading.contains("Loading earlier messages…")); assert!(!loading.contains("OpenAI Codex")); assert!(!loading.contains('%')); } else { diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_findings_38.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_findings_38.snap index 853ddd2089..14afa640fc 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_findings_38.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_findings_38.snap @@ -1,6 +1,6 @@ --- source: tui/src/app/tests/misalignment_policy_tests.rs -assertion_line: 75 +assertion_line: 89 expression: terminal.backend() --- "/ What we detected/ / / / / / / / / / " @@ -16,6 +16,6 @@ expression: terminal.backend() " " "The proposed action exceeded the " "───────────────────────────────── 0% ─" -" ↑/↓ to scroll pgup/pgdn to page h" +" ↑/↓ to scroll · pgup/pgdn to page · h" " q close " " " diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap index 857ca84bd8..34b9b1a425 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap @@ -1,6 +1,7 @@ --- source: tui/src/app/tests/transcript_composer.rs -expression: text +assertion_line: 267 +expression: buffer_text(&buffer) --- / T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / @@ -10,6 +11,6 @@ expression: text › second -───────────────────────────────────────────────────────────────────────────────────────────── 100% ─ - ↑/↓ to scroll pgup/pgdn to page home/end to jump - q close esc to edit prev +Ctrl+Space select + ↑/↓ to scroll · pgup/pgdn to page · home/end to jump + q close · esc edit previous diff --git a/codex-rs/tui/src/app/transcript_export.rs b/codex-rs/tui/src/app/transcript_export.rs index 74d8a5416e..c2b80a5423 100644 --- a/codex-rs/tui/src/app/transcript_export.rs +++ b/codex-rs/tui/src/app/transcript_export.rs @@ -250,6 +250,7 @@ fn render_markdown_transcript(cells: &[Arc]) -> Result Result<()> { - if matches!( - &event, - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained - ) && let Some(Overlay::Transcript(t)) = &mut self.overlay + 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 + || matches!( + &event, + TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained + )) + && let Some(Overlay::Transcript(t)) = &mut self.overlay { let active_key = self.chat_widget.active_cell_transcript_key(); let chat_widget = &self.chat_widget; - tui.draw(u16::MAX, |frame| { - let width = frame.area().width.max(1); - t.sync_live_tail(width, active_key, |w| { - chat_widget.active_cell_transcript_hyperlink_lines(w) - }); - t.render(frame.area(), frame.buffer); - })?; + tui.set_overlay_input(tui::OverlayInput::Transcript)?; + t.motion = crate::motion::MotionMode::from_animations_enabled( + self.local_settings.tui.animations, + ); + let size = tui.prepare_draw_size()?; + t.sync_live_tail(size.width.max(/*other*/ 1), active_key, |width| { + chat_widget.active_cell_transcript_hyperlink_lines(width) + }); + // Selection must capture the same committed cells and live tail that were painted. + let result = if input { t.draw(tui) } else { Ok(()) }; + if let Err(error) = result.and_then(|()| t.handle_event(tui, event)) { + let _ = tui.set_overlay_input(tui::OverlayInput::Default); + return Err(error.into()); + } let close_overlay = t.is_done(); if !close_overlay && active_key.is_some_and(|key| key.animation_tick.is_some()) - && t.live_tail_visible() + && t.is_scrolled_to_bottom() { tui.frame_requester() .schedule_frame_in(std::time::Duration::from_millis(50)); diff --git a/codex-rs/tui/src/app_backtrack/legacy_input.rs b/codex-rs/tui/src/app_backtrack/legacy_input.rs index f6515e0eeb..f508578499 100644 --- a/codex-rs/tui/src/app_backtrack/legacy_input.rs +++ b/codex-rs/tui/src/app_backtrack/legacy_input.rs @@ -6,6 +6,43 @@ use super::*; impl App { + fn request_legacy_transcript_history( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + request: Option, + ) { + if let Some(state) = request + && let Some(thread_id) = self.chat_widget.thread_id() + && app_server.has_older_history(thread_id) + && self.request_older_history_page(app_server, thread_id) + && let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() + { + overlay.set_history_state(state); + tui.frame_requester().schedule_frame(); + } + } + + fn legacy_history_key_request(&mut self, event: &TuiEvent) -> Option { + let (TuiEvent::Key(key), Some(Overlay::Transcript(overlay))) = (event, &mut self.overlay) + else { + return None; + }; + if overlay.has_active_interaction() { + return None; + } + if overlay.should_load_from_start(*key) { + return Some(TranscriptHistoryState::LoadingBeginning); + } + let older_prompt = self.backtrack.overlay_preview_active + && self.backtrack.nth_user_message == 0 + && matches!(key.code, KeyCode::Esc | KeyCode::Left) + && key.modifiers.is_empty() + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat); + (older_prompt || overlay.should_load_older(*key)) + .then_some(TranscriptHistoryState::LoadingOlder) + } + pub(super) fn handle_legacy_transcript_event( &mut self, tui: &mut tui::Tui, @@ -21,37 +58,42 @@ impl App { { overlay.cancel_pending_jump(); } - if let TuiEvent::Key(key_event) = &event - && let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() - && (overlay.should_load_older(*key_event) - || (self.backtrack.overlay_preview_active - && self.backtrack.nth_user_message == 0 - && matches!(key_event.code, KeyCode::Esc | KeyCode::Left) - && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat))) - && let Some(thread_id) = self.chat_widget.thread_id() - && app_server.has_older_history(thread_id) - && self.request_older_history_page(app_server, thread_id) - { - if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { - overlay.set_history_state(if overlay.should_load_from_start(*key_event) { - TranscriptHistoryState::LoadingBeginning + let request = self.legacy_history_key_request(&event); + let explicit_input = matches!(event, TuiEvent::Key(_)) + || matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved); + let interaction_active = matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.has_active_interaction()); + if interaction_active || self.is_offline() { + self.overlay_forward_event(tui, event)?; + } else if self.backtrack.overlay_preview_active { + self.handle_backtrack_preview_event(tui, event)?; + } else { + match event { + TuiEvent::Key(KeyEvent { + code: KeyCode::Esc, + kind: KeyEventKind::Press, + .. + }) => self.begin_overlay_backtrack_preview(tui), + event => self.overlay_forward_event(tui, event)?, + } + } + let request = request.or_else(|| { + let Some(Overlay::Transcript(overlay)) = &mut self.overlay else { + return None; + }; + let state = overlay.history_state(); + if (overlay.needs_history() || state == TranscriptHistoryState::LoadingBeginning) + && (explicit_input || state != TranscriptHistoryState::Failed) + { + Some(if state == TranscriptHistoryState::LoadingBeginning { + state } else { TranscriptHistoryState::LoadingOlder - }); + }) + } else { + None } - tui.frame_requester().schedule_frame(); - } - if self.backtrack.overlay_preview_active { - return self.handle_backtrack_preview_event(tui, event); - } - match event { - TuiEvent::Key(KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => self.begin_overlay_backtrack_preview(tui), - event => self.overlay_forward_event(tui, event)?, - } + }); + self.request_legacy_transcript_history(tui, app_server, request); Ok(true) } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index e8a8402008..b3bf6c00e6 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -2204,6 +2204,12 @@ impl Renderable for BottomPane { } } +/// Transcript status and selection feedback, shared with the composer in fullscreen mode. +pub(crate) struct TranscriptFooter { + pub(crate) text: ratatui::text::Text<'static>, + pub(crate) cursor_column: Option, +} + #[cfg(test)] mod tests { #[path = "actionable_banner_tests.rs"] diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_full_screen_bottom.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_full_screen_bottom.snap index 49abaf8928..70a52ea1fb 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_full_screen_bottom.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_full_screen_bottom.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/user_verification_tests.rs +assertion_line: 200 expression: "format!(\"{buf:?}\")" --- Buffer { @@ -14,7 +15,7 @@ Buffer { "Deployment details. Deployment details. ", "Final approval details. ", "───────────────────────────────── 100% ─", - " ↑/↓ to scroll pgup/pgdn to page hom", + " ↑/↓ to scroll · pgup/pgdn to page · hom", " q close ", " ", ], diff --git a/codex-rs/tui/src/chatwidget/activity_groups.rs b/codex-rs/tui/src/chatwidget/activity_groups.rs index acc543ff2a..232d6c0f8e 100644 --- a/codex-rs/tui/src/chatwidget/activity_groups.rs +++ b/codex-rs/tui/src/chatwidget/activity_groups.rs @@ -39,4 +39,23 @@ impl ChatWidget { } Err(cell) } + + /// Hydrate only the validated older portion, leaving current calls and their clocks intact. + pub(crate) fn prepend_active_exploration_history( + &mut self, + older: &dyn HistoryCell, + turns: &[Turn], + ) -> Option<(u64, u64)> { + let previous_revision = self.transcript.active_cell_revision; + let active = self + .transcript + .active_cell + .as_mut()? + .as_any_mut() + .downcast_mut::()?; + let older = crate::thread_transcript::older_exploration_group(older, active, turns)?; + active.prepend(older); + self.bump_active_cell_revision(); + Some((previous_revision, self.transcript.active_cell_revision)) + } } diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 881e4b0b77..fbfd16b9e4 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -329,16 +329,14 @@ impl ChatWidget { /// Inner implementation with an injectable clipboard backend for testing. pub(super) fn copy_last_agent_markdown_with( &mut self, - copy_fn: impl FnOnce(&str) -> Result, String>, + copy_fn: impl FnOnce(&str) -> Result, ) { match self.transcript.last_agent_markdown.clone() { Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) { - Ok(lease) => { - if let Some(lease) = lease { - self.clipboard_lease = Some(lease); - } + Ok(outcome) => { + let status = outcome.store(&mut self.clipboard_lease); self.add_to_history(history_cell::new_info_event( - "Copied last message to clipboard".into(), + status.message("last message"), /*hint*/ None, )); } @@ -363,20 +361,23 @@ impl ChatWidget { &mut self, text: &str, label: &str, - copy_fn: impl FnOnce(&str) -> Result, String>, + copy_fn: impl FnOnce(&str) -> Result, ) { - match copy_fn(text) { - Ok(lease) => { - if let Some(lease) = lease { - self.clipboard_lease = Some(lease); - } - self.add_info_message(format!("Copied {label} to clipboard"), /*hint*/ None); - } + match self.write_clipboard(text, copy_fn) { + Ok(status) => self.add_info_message(status.message(label), /*hint*/ None), Err(error) => self.add_error_message(format!("Copy failed: {error}")), } self.request_redraw(); } + fn write_clipboard( + &mut self, + text: &str, + copy_fn: impl FnOnce(&str) -> Result, + ) -> Result { + Ok(copy_fn(text)?.store(&mut self.clipboard_lease)) + } + #[cfg(test)] pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> { self.transcript.last_agent_markdown.as_deref() diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2ffbfb61cc..998c866077 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1826,7 +1826,7 @@ async fn slash_copy_picker_copies_status_fields_and_preserves_source_after_copyi ); chat.copy_selection_with(value, label, |text| { assert_eq!(text, value); - Ok(None) + Ok(crate::clipboard_copy::CopyOutcome::Copied(None)) }); drain_insert_history(&mut rx); assert!(chat.bottom_pane.no_modal_or_popup_active()); @@ -2371,7 +2371,9 @@ async fn slash_copy_preserves_native_lease_after_terminal_copy_or_failure() { chat.copy_last_agent_markdown_with(|markdown| { assert_eq!(markdown, "copy me"); - Ok(Some(crate::clipboard_copy::ClipboardLease::test())) + Ok(crate::clipboard_copy::CopyOutcome::Copied(Some( + crate::clipboard_copy::ClipboardLease::test(), + ))) }); assert!(chat.clipboard_lease.is_some()); @@ -2383,10 +2385,10 @@ async fn slash_copy_preserves_native_lease_after_terminal_copy_or_failure() { "expected success message, got {rendered:?}" ); - chat.copy_last_agent_markdown_with(|_| Ok(None)); + chat.copy_last_agent_markdown_with(|_| Ok(crate::clipboard_copy::CopyOutcome::Requested)); assert!(chat.clipboard_lease.is_some()); let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); - assert!(rendered.contains("Copied last message to clipboard")); + insta::assert_snapshot!("unconfirmed_copy", rendered); chat.copy_last_agent_markdown_with(|markdown| { assert_eq!(markdown, "copy me"); @@ -2404,16 +2406,20 @@ async fn slash_copy_preserves_native_lease_after_terminal_copy_or_failure() { chat.copy_selection_with("print('ok')\n", "python code", |content| { assert_eq!(content, "print('ok')\n"); - Ok(Some(crate::clipboard_copy::ClipboardLease::test())) + Ok(crate::clipboard_copy::CopyOutcome::Copied(Some( + crate::clipboard_copy::ClipboardLease::test(), + ))) }); assert!(chat.clipboard_lease.is_some()); let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); assert!(rendered.contains("Copied python code to clipboard")); - chat.copy_selection_with("terminal copy", "code", |_| Ok(None)); + chat.copy_selection_with("terminal copy", "code", |_| { + Ok(crate::clipboard_copy::CopyOutcome::Requested) + }); assert!(chat.clipboard_lease.is_some()); let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); - assert!(rendered.contains("Copied code to clipboard")); + assert!(rendered.contains("Copy unconfirmed; /export saves chat")); chat.copy_selection_with("print('blocked')\n", "python code", |content| { assert_eq!(content, "print('blocked')\n"); diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__unconfirmed_copy.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__unconfirmed_copy.snap new file mode 100644 index 0000000000..033a306ac9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__unconfirmed_copy.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +assertion_line: 2404 +expression: rendered +--- +• Copy unconfirmed; /export saves chat diff --git a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs index f29c013dd9..73f51dc301 100644 --- a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs @@ -244,6 +244,23 @@ impl ChatWidget { self.flush_active_cell(); } + /// Preserve pending calls and timers, returning the revisions before and after hydration. + pub(crate) fn prepend_active_computer_history( + &mut self, + older: &dyn HistoryCell, + turns: &[Turn], + ) -> Option<(u64, u64)> { + let previous_revision = self.transcript.active_cell_revision; + let active = self.transcript.active_cell.as_mut().and_then(|cell| { + cell.as_any_mut() + .downcast_mut::() + })?; + let older = crate::thread_transcript::older_computer_group(older, active, turns)?; + active.prepend(older); + self.bump_active_cell_revision(); + Some((previous_revision, self.transcript.active_cell_revision)) + } + /// Reuse only adjacent computer calls; all other active cells form a transcript boundary. fn update_computer_activity( &mut self, diff --git a/codex-rs/tui/src/chatwidget/transcript_export.rs b/codex-rs/tui/src/chatwidget/transcript_export.rs index e99e9795bc..ed235d8041 100644 --- a/codex-rs/tui/src/chatwidget/transcript_export.rs +++ b/codex-rs/tui/src/chatwidget/transcript_export.rs @@ -10,14 +10,9 @@ impl ChatWidget { markdown, crate::clipboard_copy::CopyFormat::PlainText, ) { - Ok(lease) => { - if let Some(lease) = lease { - self.clipboard_lease = Some(lease); - } - self.add_info_message( - "Copied conversation to clipboard".to_string(), - /*hint*/ None, - ); + Ok(outcome) => { + let status = outcome.store(&mut self.clipboard_lease); + self.add_info_message(status.message("conversation"), /*hint*/ None); } Err(error) => self.add_error_message(format!("Copy failed: {error}")), } diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index f97b12110e..1529734c02 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -15,7 +15,9 @@ //! //! Empty selections fail without modifying a clipboard. //! Markdown copies also offer HTML on the native clipboard. Terminal and WSL -//! fallbacks retain the original text. Image paste lives in `clipboard_paste`. +//! fallbacks retain the original text. Terminal writes are unacknowledged requests, +//! so callers must distinguish them from confirmed native clipboard writes. +//! Image paste lives in `clipboard_paste`. mod tmux; @@ -37,21 +39,55 @@ pub(crate) enum CopyFormat { Markdown, } +/// A native clipboard write or an unacknowledged request to the user's terminal. +pub(crate) enum CopyOutcome { + Copied(Option), + Requested, +} + +impl CopyOutcome { + /// Replace native ownership only when a backend supplies a new lease. + pub(crate) fn store(self, lease: &mut Option) -> CopyStatus { + match self { + Self::Copied(next_lease) => { + if let Some(next_lease) = next_lease { + *lease = Some(next_lease); + } + CopyStatus::Confirmed + } + Self::Requested => CopyStatus::Unconfirmed, + } + } +} + +/// Whether a clipboard backend confirmed the write or only sent a request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CopyStatus { + Confirmed, + Unconfirmed, +} + +impl CopyStatus { + pub(crate) fn message(self, label: &str) -> String { + match self { + Self::Confirmed => format!("Copied {label} to clipboard"), + Self::Unconfirmed => "Copy unconfirmed; /export saves chat".to_string(), + } + } +} + /// Copy text to the system clipboard. /// /// Try native copying, then independently attempt terminal forwarding in tmux or /// SSH. A terminal send is best effort and does not confirm clipboard delivery. /// Terminal forwarding may replace native HTML with plain text. /// -/// A returned lease replaces the previous native lease. `None` means no new -/// lease is needed; callers must retain any existing lease so terminal copies -/// do not release ownership of an earlier native clipboard value. +/// Native or WSL success returns `Copied`, even if terminal forwarding fails. +/// A terminal-only send returns `Requested`. Store the outcome to retain any +/// existing native lease unless the backend supplies a replacement. /// /// OSC 52 is supported by kitty, WezTerm, iTerm2, Ghostty, and others. -pub(crate) fn copy_to_clipboard( - text: &str, - format: CopyFormat, -) -> Result, String> { +pub(crate) fn copy_to_clipboard(text: &str, format: CopyFormat) -> Result { copy_to_clipboard_with( text, format, @@ -71,8 +107,8 @@ pub(crate) fn copy_to_clipboard( /// /// On Linux/X11 and some Wayland compositors, clipboard contents are served by the /// owning process. Dropping the `arboard::Clipboard` before the user pastes causes -/// the content to vanish. Store this lease on the widget that triggered the copy so -/// the handle lives as long as the TUI does. On non-Linux native paths and OSC 52 +/// the content to vanish. Store this lease on a session-lived owner so closing a +/// transient overlay does not release the clipboard contents. On non-Linux native paths and OSC 52 /// paths the lease is `None` — those backends do not require process-lifetime /// ownership. pub(crate) struct ClipboardLease { @@ -114,7 +150,7 @@ fn copy_to_clipboard_with( osc52_copy_fn: impl Fn(&str) -> Result<(), String>, arboard_copy_fn: impl Fn(&str, Option<&str>) -> Result, String>, wsl_copy_fn: impl Fn(&str) -> Result<(), String>, -) -> Result, String> { +) -> Result { if text.is_empty() { return Err("nothing to copy: the selected content is empty".to_string()); } @@ -157,10 +193,10 @@ fn copy_to_clipboard_with( // forward even when no SSH variables were inherited or native copying succeeded. let terminal_result = (environment.tmux_session || environment.ssh_session).then(terminal_copy); match native_result { - Ok(lease) => Ok(lease), + Ok(lease) => Ok(CopyOutcome::Copied(lease)), Err(native_error) => terminal_result .unwrap_or_else(terminal_copy) - .map(|()| None) + .map(|()| CopyOutcome::Requested) .map_err(|terminal_error| { format!("{native_error}; terminal clipboard: {terminal_error}") }), @@ -392,6 +428,7 @@ mod tests { use super::CopyEnvironment; use super::CopyFormat; + use super::CopyOutcome; use super::OSC52_MAX_RAW_BYTES; use super::copy_to_clipboard_with; use super::osc52_sequence; @@ -498,7 +535,7 @@ mod tests { }, ); - assert!(matches!(result, Ok(None))); + assert!(matches!(result, Ok(CopyOutcome::Copied(None)))); assert_eq!(tmux_calls.get(), 0); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 1); @@ -574,7 +611,7 @@ mod tests { }, ); - assert!(matches!(result, Ok(None))); + assert!(matches!(result, Ok(CopyOutcome::Copied(None)))); assert_eq!(tmux_calls.get(), 1); assert_eq!(osc_calls.get(), 0); assert_eq!(native_calls.get(), 1); @@ -609,7 +646,7 @@ mod tests { }, ); - assert!(matches!(result, Ok(None))); + assert!(matches!(result, Ok(CopyOutcome::Copied(None)))); assert_eq!(tmux_calls.get(), 1); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 1); @@ -662,7 +699,7 @@ mod tests { }, ); - assert!(matches!(result, Ok(Some(_)))); + assert!(matches!(result, Ok(CopyOutcome::Copied(Some(_))))); assert_eq!(osc_calls.get(), 0); assert_eq!(native_calls.get(), 1); assert_eq!(wsl_calls.get(), 0); @@ -689,7 +726,7 @@ mod tests { }, |_| panic!("native copy should succeed"), ); - assert!(matches!(result, Ok(None))); + assert!(matches!(result, Ok(CopyOutcome::Copied(None)))); } } @@ -718,12 +755,50 @@ mod tests { }, ); - assert!(matches!(result, Ok(None))); + assert!(matches!(result, Ok(CopyOutcome::Requested))); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 1); assert_eq!(wsl_calls.get(), 0); } + #[test] + fn local_tmux_fallback_prefers_tmux_when_native_fails() { + let tmux_calls = Cell::new(/*value*/ 0_u8); + let osc_calls = Cell::new(/*value*/ 0_u8); + let native_calls = Cell::new(/*value*/ 0_u8); + let wsl_calls = Cell::new(/*value*/ 0_u8); + let result = copy_to_clipboard_with( + "hello", + CopyFormat::PlainText, + CopyEnvironment { + tmux_session: true, + ..local_environment() + }, + |_| { + tmux_calls.set(tmux_calls.get() + 1); + Ok(()) + }, + |_| { + osc_calls.set(osc_calls.get() + 1); + Ok(()) + }, + |_, _| { + native_calls.set(native_calls.get() + 1); + Err("native unavailable".into()) + }, + |_| { + wsl_calls.set(wsl_calls.get() + 1); + Ok(()) + }, + ); + + assert!(matches!(result, Ok(CopyOutcome::Requested))); + assert_eq!(tmux_calls.get(), 1); + assert_eq!(osc_calls.get(), 0); + assert_eq!(native_calls.get(), 1); + assert_eq!(wsl_calls.get(), 0); + } + #[test] fn local_wsl_native_failure_uses_powershell_and_skips_osc52_on_success() { let osc_calls = Cell::new(0_u8); @@ -748,7 +823,7 @@ mod tests { }, ); - assert!(matches!(result, Ok(None))); + assert!(matches!(result, Ok(CopyOutcome::Copied(None)))); assert_eq!(osc_calls.get(), 0); assert_eq!(native_calls.get(), 1); assert_eq!(wsl_calls.get(), 1); @@ -778,7 +853,7 @@ mod tests { }, ); - assert!(matches!(result, Ok(None))); + assert!(matches!(result, Ok(CopyOutcome::Requested))); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 1); assert_eq!(wsl_calls.get(), 1); diff --git a/codex-rs/tui/src/clipboard_copy/routing_tests.rs b/codex-rs/tui/src/clipboard_copy/routing_tests.rs index efb4e1879d..3638e3494e 100644 --- a/codex-rs/tui/src/clipboard_copy/routing_tests.rs +++ b/codex-rs/tui/src/clipboard_copy/routing_tests.rs @@ -3,6 +3,8 @@ use super::ClipboardLease; use super::CopyEnvironment; use super::CopyFormat; +use super::CopyOutcome; +use super::CopyStatus; use super::OSC52_MAX_RAW_BYTES; use super::copy_to_clipboard_with; use pretty_assertions::assert_eq; @@ -35,7 +37,7 @@ fn local_tmux_preserves_native_html_when_terminal_silently_rejects_copy() { }, |_| panic!("native copy succeeded"), ); - assert!(matches!(result, Ok(Some(_)))); + assert!(matches!(result, Ok(CopyOutcome::Copied(Some(_))))); assert_eq!(calls.into_inner(), vec!["native", "tmux"]); } @@ -65,7 +67,7 @@ fn copy_uses_osc52_when_tmux_fails_even_if_native_copy_succeeds() { }, |_| panic!("native copy succeeded"), ); - assert!(matches!(result, Ok(Some(_)))); + assert!(matches!(result, Ok(CopyOutcome::Copied(Some(_))))); assert_eq!(calls.into_inner(), vec!["native", "tmux", "osc52"]); } @@ -105,7 +107,7 @@ fn oversized_tmux_copy_preserves_native_copy() { }, |_| panic!("PowerShell fallback should not be needed"), ); - assert!(result.is_ok()); + assert!(matches!(result, Ok(CopyOutcome::Copied(Some(_))))); assert_eq!(calls.into_inner(), expected_calls); } } @@ -123,3 +125,14 @@ fn empty_copy_reports_failure_without_touching_clipboards() { ); insta::assert_snapshot!(result.err().expect("empty selection should fail")); } + +#[test] +fn confirmed_copy_without_new_lease_preserves_native_ownership() { + let mut lease = Some(ClipboardLease::test()); + + assert_eq!( + CopyOutcome::Copied(None).store(&mut lease), + CopyStatus::Confirmed + ); + assert!(lease.is_some()); +} diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 0023bd8fd7..6f7d5cf5a0 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -27,6 +27,7 @@ use std::io::Write; use crossterm::cursor::MoveTo; use crossterm::cursor::SetCursorStyle; use crossterm::queue; +use crossterm::style::Colored; use crossterm::style::Colors; use crossterm::style::Print; use crossterm::style::SetAttribute; @@ -714,6 +715,8 @@ fn draw(writer: &mut impl Write, commands: I) -> io::Result<()> where I: Iterator, { + // Disabled crossterm colors emit an empty SGR that also resets non-color attributes. + let color_enabled = !Colored::ansi_color_disabled_memoized(); let mut fg = Color::Reset; let mut bg = Color::Reset; let mut modifier = Modifier::empty(); @@ -748,7 +751,7 @@ where diff.queue(writer)?; modifier = cell.modifier; } - if cell.fg != fg || cell.bg != bg { + if color_enabled && (cell.fg != fg || cell.bg != bg) { queue!( writer, SetColors(Colors::new( diff --git a/codex-rs/tui/src/cwd_prompt.rs b/codex-rs/tui/src/cwd_prompt.rs index 0a004635a9..8c74e95ec9 100644 --- a/codex-rs/tui/src/cwd_prompt.rs +++ b/codex-rs/tui/src/cwd_prompt.rs @@ -140,7 +140,7 @@ pub(crate) async fn run_cwd_selection_prompt( tui.screen_size_for_event(&event)?; match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) | TuiEvent::FocusLost => {} + TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Mouse(_) => {} TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index 7707a78a11..a99ea49095 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -130,7 +130,6 @@ impl ExecCell { } /// Preserve live clocks and pending output when a validated older page extends exploration. - #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] pub(crate) fn prepend(&mut self, older: Self) { self.group.prepend(older.group); } diff --git a/codex-rs/tui/src/external_agent_config_migration/mod.rs b/codex-rs/tui/src/external_agent_config_migration/mod.rs index 96431da9b5..0e0bcc21f0 100644 --- a/codex-rs/tui/src/external_agent_config_migration/mod.rs +++ b/codex-rs/tui/src/external_agent_config_migration/mod.rs @@ -108,7 +108,7 @@ pub(crate) async fn run_external_agent_config_migration_prompt( let _ = tui.screen_size_for_event(&event); match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) | TuiEvent::FocusLost => {} + TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Mouse(_) => {} TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { let _ = tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); diff --git a/codex-rs/tui/src/external_agent_config_migration/source.rs b/codex-rs/tui/src/external_agent_config_migration/source.rs index b87241857d..bfdd6b8956 100644 --- a/codex-rs/tui/src/external_agent_config_migration/source.rs +++ b/codex-rs/tui/src/external_agent_config_migration/source.rs @@ -67,7 +67,7 @@ pub(crate) async fn run_external_agent_config_source_prompt( let _ = tui.screen_size_for_event(&event); match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) | TuiEvent::FocusLost => {} + TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Mouse(_) => {} TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { let _ = tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); diff --git a/codex-rs/tui/src/history_cell/computer_activity.rs b/codex-rs/tui/src/history_cell/computer_activity.rs index cc1e112e81..e25d57b982 100644 --- a/codex-rs/tui/src/history_cell/computer_activity.rs +++ b/codex-rs/tui/src/history_cell/computer_activity.rs @@ -29,13 +29,11 @@ impl ComputerActivityCell { lines } - #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] pub(crate) fn call_ids(&self) -> impl Iterator { self.group.calls.iter().map(McpToolCallCell::call_id) } /// Prepend validated historical calls without recreating pending live calls or their clocks. - #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] pub(crate) fn prepend(&mut self, older: Self) { self.group.prepend(older.group); } diff --git a/codex-rs/tui/src/history_cell/search.rs b/codex-rs/tui/src/history_cell/search.rs index 38fc56945a..e32ccd9184 100644 --- a/codex-rs/tui/src/history_cell/search.rs +++ b/codex-rs/tui/src/history_cell/search.rs @@ -148,6 +148,11 @@ impl HistoryCell for WebSearchCell { PrefixedWrappedHistoryCell::new(self.summary(), vec!["• ".dim()], " ").display_lines(width) } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + PrefixedWrappedHistoryCell::new(self.summary(), vec!["• ".dim()], " ") + .display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { plain_lines(vec![self.summary()]) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 469f7773b4..ff27a18ff5 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -219,6 +219,7 @@ mod thread_transcript; mod token_usage; mod tool_output; mod tooltips; +mod transcript_mode; mod transcript_reflow; mod transcript_view; mod tui; diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 36d7da1d73..5b11b89ba3 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -159,7 +159,7 @@ pub(crate) async fn run_model_migration_prompt( let _ = alt.tui.screen_size_for_event(&event); match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) | TuiEvent::FocusLost => {} + TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Mouse(_) => {} TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { let _ = alt.tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 351cd1adc6..2a39a0bc7f 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -607,7 +607,7 @@ async fn run_onboarding_screen( frame.render_widget_ref(&onboarding_screen, frame.area()); }); } - TuiEvent::FocusLost => {} + TuiEvent::FocusLost | TuiEvent::Mouse(_) => {} } } } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 0e4b9abff5..f8f35ae041 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -1,31 +1,22 @@ -//! Overlay UIs rendered in an alternate screen. +//! Static pager overlays and standalone transcript adapters. //! -//! This module implements the pager-style overlays used by the TUI, including the transcript -//! overlay (`Ctrl+T`) that renders a full history view separate from the main viewport. -//! -//! The transcript overlay renders committed transcript cells plus an optional render-only live tail -//! derived from the current in-flight active cell. Because rebuilding wrapped `Line`s on every draw -//! can be expensive, that live tail is cached and only recomputed when its cache key changes, which -//! is derived from the terminal width (wrapping), an active-cell revision (in-place mutations), the -//! stream-continuation flag (spacing), and an animation tick (time-based spinner/shimmer output). -//! -//! The transcript overlay live tail is kept in sync by `App` during draws: `App` supplies an -//! `ActiveCellTranscriptKey` and a function to compute the active cell transcript lines, and -//! `TranscriptOverlay::sync_live_tail` uses the key to decide when the cached tail must be -//! recomputed. `ChatWidget` is responsible for producing a key that changes when the active cell -//! mutates in place or when its transcript output is time-dependent. +//! Static content retains its generic pager. Transcript previews share the main conversation +//! viewport, including its scrolling, selection, search and bounded text layouts. mod scrolling; mod transcript; pub(crate) use transcript::TranscriptOverlay; +#[cfg(test)] +#[path = "pager_overlay/transcript_tests.rs"] +mod transcript_tests; + use std::io::Result; use std::sync::Arc; use crate::chatwidget::ActiveCellTranscriptKey; use crate::history_cell::HistoryCell; -use crate::history_cell::SessionInfoCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; @@ -51,14 +42,14 @@ use ratatui::widgets::Wrap; use scrolling::render_offset_content; pub(crate) enum Overlay { - Transcript(Box), - Static(Box), + Transcript(TranscriptOverlay), + Static(StaticOverlay), Analytics(Box), } impl Overlay { pub(crate) fn new_transcript(cells: Vec>, keymap: PagerKeymap) -> Self { - Self::Transcript(Box::new(TranscriptOverlay::new(cells, keymap))) + Self::Transcript(TranscriptOverlay::new(cells, keymap)) } pub(crate) fn new_static_with_lines( @@ -66,7 +57,7 @@ impl Overlay { title: String, keymap: PagerKeymap, ) -> Self { - Self::Static(Box::new(StaticOverlay::with_title(lines, title, keymap))) + Self::Static(StaticOverlay::with_title(lines, title, keymap)) } pub(crate) fn new_static_with_renderables( @@ -74,19 +65,26 @@ impl Overlay { title: String, keymap: PagerKeymap, ) -> Self { - Self::Static(Box::new(StaticOverlay::with_renderables( - renderables, - title, - keymap, - ))) + Self::Static(StaticOverlay::with_renderables(renderables, title, keymap)) } pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { - match self { + let input = match self { + Overlay::Transcript(_) => tui::OverlayInput::Transcript, + Overlay::Static(_) => tui::OverlayInput::StaticPager, + Overlay::Analytics(_) => tui::OverlayInput::Default, + }; + tui.set_overlay_input(input)?; + let result = match self { Overlay::Transcript(o) => o.handle_event(tui, event), Overlay::Static(o) => o.handle_event(tui, event), Overlay::Analytics(o) => o.handle_event(tui, event), + }; + if result.is_err() || self.is_done() { + let restore = tui.set_overlay_input(tui::OverlayInput::Default); + return result.and(restore); } + result } pub(crate) fn is_done(&self) -> bool { @@ -112,7 +110,7 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec, & let mut first = true; for (keys, desc) in pairs { if !first { - spans.push(" ".into()); + spans.push(" · ".dim()); } for (i, key) in keys.iter().enumerate() { if i > 0 { @@ -251,7 +249,7 @@ impl PagerView { total_len: usize, ) { let sep_y = content_area.bottom(); - let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1); + let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, /*height*/ 1); Span::from("─".repeat(sep_rect.width as usize)) .dim() @@ -272,7 +270,7 @@ impl PagerView { let pct_x = sep_rect.x + sep_rect.width - pct_w - 1; Span::from(pct_text) .dim() - .render(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf); + .render(Rect::new(pct_x, sep_rect.y, pct_w, /*height*/ 1), buf); } fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> { @@ -396,15 +394,6 @@ impl TranscriptHistoryState { Self::LoadingOlder | Self::LoadingBeginning | Self::Partial | Self::Failed ) } - - pub(crate) fn session_header_placeholder(self) -> Option<&'static str> { - match self { - Self::LoadingOlder | Self::LoadingBeginning => Some("Loading earlier messages..."), - Self::Partial => Some("Earlier messages are available — scroll up to load them"), - Self::Failed => Some("Earlier messages unavailable — scroll up to retry"), - Self::Idle | Self::Complete => None, - } - } } pub(crate) struct StaticOverlay { @@ -413,6 +402,8 @@ pub(crate) struct StaticOverlay { } impl StaticOverlay { + const HINTS_HEIGHT: u16 = 3; + pub(crate) fn with_title( lines: Vec>, title: String, @@ -449,9 +440,9 @@ impl StaticOverlay { } pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { - let top_h = area.height.saturating_sub(3); + let top_h = area.height.saturating_sub(Self::HINTS_HEIGHT); let top = Rect::new(area.x, area.y, area.width, top_h); - let bottom = Rect::new(area.x, area.y + top_h, area.width, 3); + let bottom = Rect::new(area.x, area.y + top_h, area.width, Self::HINTS_HEIGHT); self.view.render(top, buf); self.render_hints(bottom, buf); } @@ -488,7 +479,6 @@ mod tests { use pretty_assertions::assert_eq; use ratatui::Terminal; use ratatui::backend::TestBackend; - use ratatui::text::Text; fn paragraph_block(label: &str, lines: usize) -> Box { let text = Text::from( @@ -542,27 +532,26 @@ mod tests { } #[test] + fn static_overlay_snapshot_basic() { // Prepare a static overlay with a few lines and a title let mut overlay = static_overlay( vec!["one".into(), "two".into(), "three".into()], "S T A T I C", ); - let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); + let mut term = Terminal::new(TestBackend::new(/*width*/ 40, /*height*/ 10)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); assert_snapshot!(term.backend()); } - /// Render transcript overlay and return visible line numbers (`line-NN`) in order. - #[test] fn static_overlay_wraps_long_lines() { let mut overlay = static_overlay( vec!["a very long line that should wrap when rendered within a narrow pager overlay width".into()], "S T A T I C", ); - let mut term = Terminal::new(TestBackend::new(24, 8)).expect("term"); + let mut term = Terminal::new(TestBackend::new(/*width*/ 24, /*height*/ 8)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); assert_snapshot!(term.backend()); diff --git a/codex-rs/tui/src/pager_overlay/anchor_tests.rs b/codex-rs/tui/src/pager_overlay/anchor_tests.rs deleted file mode 100644 index 440f0aa378..0000000000 --- a/codex-rs/tui/src/pager_overlay/anchor_tests.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! Reading and prompt backtracking follow current entries through live updates and completion. - -use super::*; -use crate::history_cell::PlainHistoryCell; -use crate::history_cell::UserHistoryCell; -use crate::keymap::RuntimeKeymap; -use pretty_assertions::assert_eq; -use ratatui::style::Modifier; - -fn user(message: &str) -> Arc { - Arc::new(UserHistoryCell { - message: message.to_owned(), - text_elements: Vec::new(), - local_image_paths: Vec::new(), - remote_image_urls: Vec::new(), - spoken: false, - }) -} - -fn render(overlay: &mut TranscriptOverlay, area: Rect) -> Buffer { - let mut buffer = Buffer::empty(area); - overlay.render(area, &mut buffer); - buffer -} - -fn content_rows(overlay: &TranscriptOverlay, buffer: &Buffer) -> Vec { - let area = overlay.content_area; - (area.top()..area.bottom()) - .map(|y| { - (area.left()..area.right()) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - .trim_end() - .to_owned() - }) - .collect() -} - -#[test] -fn reading_live_output_updates_in_place_and_stays_on_the_committed_entry() { - let mut overlay = - TranscriptOverlay::new(vec![user("first prompt")], RuntimeKeymap::defaults().pager); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 9, - ); - for revision in ["streaming", "updated"] { - let lines = (0..8) - .map(|row| HyperlinkLine::from(format!("{revision} row {row}"))) - .collect(); - overlay.sync_live_tail(area.width, /*key*/ None, |_| Some(lines)); - if revision == "streaming" { - render(&mut overlay, area); - overlay.scroll(/*rows*/ -2); - } - let buffer = render(&mut overlay, area); - assert_eq!( - content_rows(&overlay, &buffer), - (2..6) - .map(|row| format!("{revision} row {row}")) - .collect::>() - ); - assert!(!overlay.view.is_following()); - } - overlay.insert_cell(Arc::new(PlainHistoryCell::new( - (0..8) - .map(|row| Line::from(format!("completed row {row}"))) - .collect(), - ))); - overlay.sync_live_tail(area.width, /*key*/ None, |_| None); - overlay.insert_cell(user("queued prompt")); - let completed = render(&mut overlay, area); - assert_eq!( - content_rows(&overlay, &completed), - [ - "completed row 2", - "completed row 3", - "completed row 4", - "completed row 5" - ] - ); - overlay.set_highlight_cell(Some(2)); - let highlighted = render(&mut overlay, area); - assert!( - content_rows(&overlay, &highlighted) - .iter() - .any(|row| row == "› queued prompt") - ); - assert!(!overlay.view.is_following()); - overlay.view.jump_to_latest(); - let latest = render(&mut overlay, area); - let latest = content_rows(&overlay, &latest).join("\n"); - assert!(latest.contains("queued prompt")); - assert!(!latest.contains("updated row")); -} - -#[test] -fn backtracking_reveals_a_prompt_when_only_its_final_padding_is_visible() { - let mut overlay = TranscriptOverlay::new( - vec![ - user("first prompt"), - Arc::new(PlainHistoryCell::new(vec![ - "tail zero".into(), - "tail one".into(), - "tail two".into(), - ])), - ], - RuntimeKeymap::defaults().pager, - ); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10, - ); - let before = render(&mut overlay, area); - assert_eq!( - content_rows(&overlay, &before), - ["", "", "tail zero", "tail one", "tail two"] - ); - - let first_prompt_index = 0; - overlay.set_highlight_cell(Some(first_prompt_index)); - let highlighted = render(&mut overlay, area); - let rows = content_rows(&overlay, &highlighted); - let prompt_row = rows - .iter() - .position(|row| row == "› first prompt") - .expect("backtracking reveals the prompt body, not just its padding"); - let y = overlay.content_area.y + prompt_row as u16; - assert!( - (2..2 + "first prompt".len() as u16) - .all(|x| highlighted[(x, y)].modifier.contains(Modifier::REVERSED)) - ); -} - -#[test] -fn prompt_highlight_leaves_gutters_and_padding_unreversed() { - crate::terminal_palette::with_test_default_colors( - crate::terminal_probe::DefaultColors { - fg: (32, 32, 32), - bg: (255, 255, 255), - }, - || { - let mut snapshots = Vec::new(); - for (width, height) in [(40, 10), (12, 12)] { - let mut overlay = TranscriptOverlay::new( - vec![ - Arc::new(PlainHistoryCell::new(vec!["prior".into()])), - user("alpha beta gamma"), - ], - RuntimeKeymap::defaults().pager, - ); - let first_prompt_index = 1; - overlay.set_highlight_cell(Some(first_prompt_index)); - let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); - let buffer = render(&mut overlay, area); - let content = Buffer { - area: Rect::new( - /*x*/ 0, - /*y*/ 0, - width, - overlay.content_area.height, - ), - content: buffer - .content - .chunks(usize::from(width)) - .skip(usize::from(overlay.content_area.y)) - .take(usize::from(overlay.content_area.height)) - .flatten() - .cloned() - .collect(), - }; - snapshots.push(format!("{width} columns\n{content:?}")); - } - insta::assert_snapshot!(snapshots.join("\n\n")); - }, - ); -} diff --git a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__anchor_tests__prompt_highlight_leaves_gutters_and_padding_unreversed.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__anchor_tests__prompt_highlight_leaves_gutters_and_padding_unreversed.snap deleted file mode 100644 index 2b318f2d2a..0000000000 --- a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__anchor_tests__prompt_highlight_leaves_gutters_and_padding_unreversed.snap +++ /dev/null @@ -1,48 +0,0 @@ ---- -source: tui/src/pager_overlay/anchor_tests.rs -assertion_line: 187 -expression: "snapshots.join(\"\\n\\n\")" ---- -40 columns -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 5 }, - content: [ - "prior ", - " ", - " ", - "› alpha beta gamma ", - " ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: BOLD | DIM, - x: 2, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: REVERSED, - x: 18, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE, - ] -} - -12 columns -Buffer { - area: Rect { x: 0, y: 0, width: 12, height: 7 }, - content: [ - "prior ", - " ", - " ", - "› alpha ", - " beta ", - " gamma ", - " ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: BOLD | DIM, - x: 2, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: REVERSED, - x: 7, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE, - x: 2, y: 4, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: REVERSED, - x: 6, y: 4, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE, - x: 2, y: 5, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: REVERSED, - x: 7, y: 5, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_apply_patch_scroll_vt100.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_apply_patch_scroll_vt100.snap deleted file mode 100644 index d54bae4a09..0000000000 --- a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_apply_patch_scroll_vt100.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui/src/pager_overlay/transcript_tests.rs -expression: snapshot ---- -/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / -• Added foo.txt (+2 -0) - 1 +hello - 2 +world - -• Added foo.txt (+2 -0) - 1 +hello - 2 +world -──────────────────────────────────────────────────────────────────────────────── - ↑/↓ to scroll pgup/pgdn to page home/end to jump - q close esc to edit prev diff --git a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_follows_resized_tail.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_follows_resized_tail.snap deleted file mode 100644 index 2b5a1776f9..0000000000 --- a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_follows_resized_tail.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui/src/pager_overlay/transcript_tests.rs -expression: term.backend() ---- -"/ T R A N S C R I P T / / / / / / / / / " -"line16 " -" " -"line17 " -" " -"line18 " -" " -"line19 " -" " -"tail " -"───────────────────────────────── 100% ─" -" ↑/↓ to scroll pgup/pgdn to page hom" -" q close esc to edit prev " -" " diff --git a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_paginated_history_states.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_paginated_history_states.snap deleted file mode 100644 index ec975f94de..0000000000 --- a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_paginated_history_states.snap +++ /dev/null @@ -1,47 +0,0 @@ ---- -source: tui/src/pager_overlay/transcript_tests.rs -expression: snapshots ---- ---- loading --- -/ T R A N S C R I P T / / / / / / / / / / / / loading older history... -recent transcript -~ -~ -~ -~ -──────────────────────────────────────────────────────────────────────── - ↑/↓ to scroll pgup/pgdn to page home/end to jump - q close esc to edit prev - ---- partial --- -/ T R A N S C R I P T / / / / / / / partial history | PgUp for earlier -recent transcript -~ -~ -~ -~ -──────────────────────────────────────────────────────────────────────── - ↑/↓ to scroll pgup/pgdn to page home/end to jump - q close esc to edit prev - ---- failed --- -/ T R A N S C R I P T / / / / / / / history unavailable | PgUp to retry -recent transcript -~ -~ -~ -~ -──────────────────────────────────────────────────────────────────────── - ↑/↓ to scroll pgup/pgdn to page home/end to jump - q close esc to edit prev - ---- complete --- -/ T R A N S C R I P T / / / / / / / / / / / / / / / / start of history -recent transcript -~ -~ -~ -~ -───────────────────────────────────────────────────────────────── 100% ─ - ↑/↓ to scroll pgup/pgdn to page home/end to jump - q close esc to edit prev diff --git a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_renders_live_tail.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_renders_live_tail.snap deleted file mode 100644 index f269e26e0e..0000000000 --- a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_renders_live_tail.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/pager_overlay/transcript_tests.rs -expression: term.backend() ---- -"/ T R A N S C R I P T / / / / / / / / / " -"alpha " -" " -"tail " -"~ " -"~ " -"───────────────────────────────── 100% ─" -" ↑/↓ to scroll pgup/pgdn to page hom" -" q close esc to edit prev " -" " diff --git a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_snapshot_basic.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_snapshot_basic.snap deleted file mode 100644 index b55c9f66ed..0000000000 --- a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_snapshot_basic.snap +++ /dev/null @@ -1,57 +0,0 @@ ---- -source: tui/src/pager_overlay/transcript_tests.rs -expression: "format!(\"{:?}\", term.backend().buffer())" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 24, height: 16 }, - content: [ - "/ T R A N S C R I P T / ", - " indented styled 漢字", // hidden by multi-width symbols: [(21, " "), (23, " ")] - "ガ text ", // hidden by multi-width symbols: [(1, " ")] - "abc-def ghi ", - "continuation spaces and ", - "a-long-hyphenated-token ", - "+ added text ", - " ", - "• A styled ]8;;https://example.com/transcriptl]8;;]8;;https://example.com/transcripti]8;;]8;;https://example.com/transcriptn]8;;]8;;https://example.com/transcriptk]8;; ", - " (]8;;https://example.com/transcripth]8;;]8;;https://example.com/transcriptt]8;;]8;;https://example.com/transcriptt]8;;]8;;https://example.com/transcriptp]8;;]8;;https://example.com/transcripts]8;;]8;;https://example.com/transcript:]8;;]8;;https://example.com/transcript/]8;;]8;;https://example.com/transcript/]8;;]8;;https://example.com/transcripte]8;;]8;;https://example.com/transcriptx]8;;]8;;https://example.com/transcripta]8;;]8;;https://example.com/transcriptm]8;;]8;;https://example.com/transcriptp]8;;]8;;https://example.com/transcriptl]8;;]8;;https://example.com/transcripte]8;;]8;;https://example.com/transcript.]8;;]8;;https://example.com/transcriptc]8;;]8;;https://example.com/transcripto]8;;]8;;https://example.com/transcriptm]8;;]8;;https://example.com/transcript/]8;;]8;;https://example.com/transcriptt]8;;", - "]8;;https://example.com/transcriptr]8;;]8;;https://example.com/transcripta]8;;]8;;https://example.com/transcriptn]8;;]8;;https://example.com/transcripts]8;;]8;;https://example.com/transcriptc]8;;]8;;https://example.com/transcriptr]8;;]8;;https://example.com/transcripti]8;;]8;;https://example.com/transcriptp]8;;]8;;https://example.com/transcriptt]8;;) ", - "~ ", - "───────────────── 100% ─", - " ↑/↓ to scroll pgup/pg", - " q close esc to edit p", - " ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 0, y: 1, fg: Green, bg: Reset, underline: Reset, modifier: NONE, - x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 21, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 22, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 23, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 1, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 7, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 6, fg: Reset, bg: Green, underline: Reset, modifier: NONE, - x: 12, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 10, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 11, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: UNDERLINED, - x: 15, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 3, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: UNDERLINED, - x: 9, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 1, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 4, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 17, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 1, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 2, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 11, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 14, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 0, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_completed_stream.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_completed_stream.snap new file mode 100644 index 0000000000..ea9c917d17 --- /dev/null +++ b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_completed_stream.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/pager_overlay/transcript_tests.rs +assertion_line: 614 +expression: "buffer_to_text(&actual, area)" +--- +/ T R A N S C R I P T / / / / / / / / / +first +last + + + +Ctrl+Space select + ↑/↓ to scroll · pgup/pgdn to page · hom + q close · esc edit previous diff --git a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_paginated_history_states.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_paginated_history_states.snap new file mode 100644 index 0000000000..8fbbcb3744 --- /dev/null +++ b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_paginated_history_states.snap @@ -0,0 +1,48 @@ +--- +source: tui/src/pager_overlay/transcript_tests.rs +assertion_line: 373 +expression: snapshots +--- +--- loading --- +/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / +recent transcript + + + + +↑ Loading earlier messages… + + + +--- partial --- +/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / +recent transcript + + + + +Earlier messages available. Ctrl+Space select + ↑/↓ to scroll · pgup/pgdn to page · home/end to jump + q close · esc edit previous + +--- failed --- +/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / +recent transcript + + + + +Retry history: ⌥+>, + pub(super) view: Box, + pub(crate) motion: MotionMode, + pub(super) cells: Vec>, keymap: PagerKeymap, - highlight_cell: Option, - reveal_highlight: bool, + pub(super) highlight_cell: Option, + pending_highlight: Option, content_area: Rect, + cursor: Option<(u16, u16)>, + notice: Option, is_done: bool, } impl TranscriptOverlay { pub(crate) fn new(cells: Vec>, keymap: PagerKeymap) -> Self { + let mut view = TranscriptView::default(); + view.set_presentation(/*detailed*/ true, HistoryRenderMode::Rich); Self { - view: TranscriptView::default(), + view: Box::new(view), + motion: MotionMode::Reduced, cells, keymap, highlight_cell: None, - reveal_highlight: false, + pending_highlight: None, content_area: Rect::default(), + cursor: None, + notice: None, is_done: false, } } pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { Clear.render(area, buf); - let top_height = area.height.saturating_sub(/*rhs*/ 3); - let top = Rect::new(area.x, area.y, area.width, top_height); + let chrome_height = 5; + let content_height = area.height.saturating_sub(chrome_height); self.content_area = Rect::new( area.x, area.y.saturating_add(/*rhs*/ 1), area.width, - top_height.saturating_sub(/*rhs*/ 2), - ) - .intersection(area); - let mut rendered = - self.view - .render(self.content_area, buf, &self.cells, self.highlight_cell); - if std::mem::take(&mut self.reveal_highlight) - && let Some(index) = self.highlight_cell - { + content_height, + ); + self.view.render(self.content_area, buf, &self.cells); + if let Some(index) = self.pending_highlight.take() { self.view.ensure_entry_visible(&self.cells, index); - rendered = self - .view - .render(self.content_area, buf, &self.cells, self.highlight_cell); - } - if self.view.history == TranscriptHistoryState::LoadingBeginning - && self.view.is_following() - && !self.content_area.is_empty() - { - // Home may arrive before the first draw; retain the first real viewport. - self.view.scroll(&self.cells, /*rows*/ 0); - } - if area.width > 0 { - for y in self.content_area.y + rendered..self.content_area.bottom() { - "~".render(Rect::new(area.x, y, /*width*/ 1, /*height*/ 1), buf); - } + self.view.render(self.content_area, buf, &self.cells); } let header = Rect::new(area.x, area.y, area.width, area.height.min(/*other*/ 1)); - Span::from("/ ".repeat(usize::from(area.width) / 2)) + Span::from("/ ".repeat(area.width as usize / 2)) .dim() .render(header, buf); "/ T R A N S C R I P T".dim().render(header, buf); - self.render_history_state(top, buf); - let separator = Rect::new( + let status = Rect::new( area.x, self.content_area.bottom(), area.width, /*height*/ 1, ) .intersection(area); - "─" - .repeat(usize::from(separator.width)) - .dim() - .render(separator, buf); - // An exact intermediate percentage would require laying out every offscreen cell. - // Only the loaded tail has a known percentage without defeating bounded rendering. - if self.view.is_following() - && !self.view.history.has_unloaded_history() - && separator.width >= 7 + self.cursor = None; + + let hints = Rect::new(area.x, status.bottom(), area.width, /*height*/ 2).intersection(area); + let latest_navigation = self + .keymap + .primary_hint("jump_bottom", &self.keymap.jump_bottom) + .map_or_else(String::new, |hint| { + format!("{} latest", hint.display_label()) + }); + if let Some(mut footer) = + self.view + .footer_with_navigation(status.width, self.motion, &latest_navigation) { - " 100% ".dim().render( - Rect::new( - separator.right() - 7, - separator.y, - /*width*/ 6, - separator.height, - ), + self.cursor = footer + .cursor_column + .map(|column| (status.x + column, status.y)); + if let Some(notice) = &self.notice { + let notice = Line::from(notice.clone()).dim(); + footer.text = notice.into(); + } + Paragraph::new(footer.text).render( + Rect::new(status.x, status.y, status.width, /*height*/ 3).intersection(area), buf, ); + } else { + if let Some(notice) = &self.notice { + Line::from(notice.as_str()).dim().render(status, buf); + } else { + self.view + .status_line_with_navigation("Ctrl+Space select", self.motion) + .render(status, buf); + } + self.render_hints(hints, buf); } - let footer = - Rect::new(area.x, area.y + top_height, area.width, /*height*/ 3).intersection(area); - self.render_hints(footer, buf); + } + + pub(crate) fn draw(&mut self, tui: &mut tui::Tui) -> Result<()> { + tui.draw(u16::MAX, |frame| { + self.render(frame.area(), frame.buffer); + if let Some(cursor) = self.cursor { + frame.set_cursor_position(cursor); + } + })?; + let loading = self.view.is_loading_history() && self.motion == MotionMode::Animated; + if loading { + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + } + Ok(()) } pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { - match event { - TuiEvent::Key(key) => { - if self.keymap.close.is_pressed(key) || self.keymap.close_transcript.is_pressed(key) - { - self.is_done = true; - } else if self.navigate(key) { + if matches!(event, TuiEvent::Resume) { + self.view.end_drag(); + } + // Apply a queued prompt jump before navigation can move away from it. + if self.pending_highlight.is_some() + && (matches!(&event, TuiEvent::Key(key) if key.kind != KeyEventKind::Release) + || matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != MouseEventKind::Moved)) + { + self.draw(tui)?; + } + let action = match event { + TuiEvent::Key(key) => self.handle_key(key), + TuiEvent::Mouse(mut mouse) => { + if matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) { + mouse.column = self.content_area.x; + mouse.row = self.content_area.bottom().saturating_sub(1); + } + self.view.handle_mouse(mouse, &self.cells) + } + TuiEvent::Paste(_) => None, + TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::FocusGained | TuiEvent::Resume => { + let dragging = + matches!(event, TuiEvent::Draw) && self.view.tick_selection(&self.cells); + if dragging { tui.frame_requester() .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); } - Ok(()) + self.draw(tui)?; + return Ok(()); } - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { - tui.draw(u16::MAX, |frame| self.render(frame.area(), frame.buffer))?; - Ok(()) + TuiEvent::FocusLost => { + self.view.end_drag(); + None } - _ => Ok(()), + }; + if let Some(action) = action { + self.apply_action(tui, action); + tui.frame_requester().schedule_frame(); } + Ok(()) + } + + pub(crate) fn is_done(&self) -> bool { + self.is_done + } + + pub(crate) fn is_scrolled_to_bottom(&self) -> bool { + self.view.is_following() + } + + pub(crate) fn owns_interaction_key(&self, key: KeyEvent) -> bool { + self.view.owns_interaction_key(key) + } + + pub(crate) fn has_active_interaction(&self) -> bool { + self.view.has_active_interaction() + } + + pub(crate) fn needs_history(&mut self) -> bool { + self.view.needs_history(&self.cells) + } + + pub(crate) fn history_state(&self) -> TranscriptHistoryState { + self.view.history } pub(crate) fn set_history_state( @@ -126,13 +197,6 @@ impl TranscriptOverlay { state: TranscriptHistoryState, ) -> TranscriptHistoryState { let previous = std::mem::replace(&mut self.view.history, state); - if state == TranscriptHistoryState::LoadingBeginning - && previous != TranscriptHistoryState::LoadingBeginning - && !self.content_area.is_empty() - { - // App marks loading before forwarding Home, so record the current position here. - self.view.scroll(&self.cells, /*rows*/ 0); - } if previous == TranscriptHistoryState::LoadingBeginning && state == TranscriptHistoryState::Complete { @@ -143,7 +207,7 @@ impl TranscriptOverlay { pub(crate) fn should_load_older(&mut self, key: KeyEvent) -> bool { self.should_load_from_start(key) - || (self.view.near_start(&self.cells) + || (self.view.needs_history(&self.cells) && [ &self.keymap.scroll_up, &self.keymap.page_up, @@ -158,13 +222,10 @@ impl TranscriptOverlay { } pub(crate) fn insert_cell(&mut self, cell: Arc) { - let index = self.cells.len(); - self.view - .replace_range(&self.cells, index..index + 1, &cell); self.cells.push(cell); } - /// Prepends history and returns the insertion index for the canonical transcript. + /// Insert old history after the session header; stable viewport anchors keep their content. pub(crate) fn prepend(&mut self, cells: Vec>) -> usize { if cells.is_empty() { return 0; @@ -174,30 +235,49 @@ impl TranscriptOverlay { .iter() .rposition(|cell| { cell.as_any().is::() - || cell - .as_any() - .is::() + || cell.as_any().is::() }) .map_or(/*default*/ 0, |index| index + 1); let added = cells.len(); self.cells.splice(index..index, cells); - if let Some(highlight) = self - .highlight_cell - .as_mut() - .filter(|highlight| **highlight >= index) - { - *highlight += added; + for highlight in [&mut self.highlight_cell, &mut self.pending_highlight] { + if let Some(highlight) = highlight.as_mut().filter(|highlight| **highlight >= index) { + *highlight += added; + } } - self.view.history_changed(&self.cells); + self.view.set_highlight(self.highlight_cell); + self.view.history_loaded(&self.cells, index..index + added); index } pub(crate) fn replace_cells(&mut self, cells: Vec>) { self.cells = cells; - self.view.history_changed(&self.cells); + self.view.history_loaded(&self.cells, 0..0); self.highlight_cell = self .highlight_cell .filter(|index| *index < self.cells.len()); + self.pending_highlight = self + .pending_highlight + .filter(|index| *index < self.cells.len()); + self.view.set_highlight(self.highlight_cell); + } + + /// Grouping may change compact previews; retain the reader's revision before replacing cells. + pub(crate) fn regroup_cells( + &mut self, + range: std::ops::Range, + consolidated: Arc, + ) { + self.view + .replace_group(&self.cells, range.clone(), &consolidated); + self.consolidate_cells(range, consolidated); + } + + /// The removed tail's index continues to identify the live group that absorbed its calls. + pub(crate) fn absorb_tail_into_live(&mut self, previous_revision: u64, hydrated_revision: u64) { + self.view + .absorb_tail_into_live(&self.cells, previous_revision, hydrated_revision); + self.cells.pop(); } pub(crate) fn consolidate_cells( @@ -212,16 +292,19 @@ impl TranscriptOverlay { } self.view .replace_range(&self.cells, start..end, &consolidated); - self.highlight_cell = self.highlight_cell.map(|index| { - if index < start { - index - } else if index < end { - start - } else { - index - (end - start - 1) - } - }); + for highlight in [&mut self.highlight_cell, &mut self.pending_highlight] { + *highlight = highlight.map(|index| { + if index < start { + index + } else if index < end { + start + } else { + index - (end - start - 1) + } + }); + } self.cells.splice(start..end, [consolidated]); + self.view.set_highlight(self.highlight_cell); } pub(crate) fn sync_live_tail( @@ -229,36 +312,43 @@ impl TranscriptOverlay { width: u16, key: Option, compute_lines: impl FnOnce(u16) -> Option>, - ) { - self.view.sync_live_tail(width, key, compute_lines); - } - - /// Explicit prompt navigation supersedes Home; passive highlight restoration does not. - pub(crate) fn cancel_pending_jump(&mut self) { - self.view.cancel_beginning(); + ) -> bool { + self.view.sync_live_tail(width, key, compute_lines) } pub(crate) fn set_highlight_cell(&mut self, cell: Option) { self.highlight_cell = cell.filter(|index| *index < self.cells.len()); - self.reveal_highlight = self.highlight_cell.is_some(); + self.pending_highlight = self.highlight_cell; + self.view.set_highlight(self.highlight_cell); } - pub(crate) fn scroll(&mut self, rows: isize) { - if std::mem::take(&mut self.reveal_highlight) - && let Some(index) = self.highlight_cell - { - self.view.ensure_entry_visible(&self.cells, index); + fn handle_key(&mut self, key: KeyEvent) -> Option { + if key.kind == KeyEventKind::Release { + return None; } - self.view.scroll(&self.cells, rows); + if self.view.has_active_interaction() + && let Some(action) = self.view.handle_key(key, &self.cells) + { + return Some(action); + } + if self.keymap.close.is_pressed(key) || self.keymap.close_transcript.is_pressed(key) { + self.is_done = true; + return Some(ViewAction::Changed); + } + if self.navigate(key) { + return Some(ViewAction::Changed); + } + + self.view.handle_key(key, &self.cells) + } + + pub(crate) fn cancel_pending_jump(&mut self) { + self.view.cancel_beginning(); } fn navigate(&mut self, key: KeyEvent) -> bool { if self.keymap.jump_top.is_pressed(key) { - if self.content_area.is_empty() && self.view.history.has_unloaded_history() { - self.set_history_state(TranscriptHistoryState::LoadingBeginning); - } else { - self.view.jump_to_beginning(&self.cells); - } + self.view.jump_to_beginning(&self.cells); return true; } if self.keymap.jump_bottom.is_pressed(key) { @@ -280,83 +370,55 @@ impl TranscriptOverlay { let Some(delta) = delta else { return false; }; - self.view.cancel_beginning(); - if !self.content_area.is_empty() { - self.scroll(delta); - } + self.view.scroll(&self.cells, delta); true } - pub(crate) fn live_tail_visible(&self) -> bool { - self.view.live_tail_visible() - } - - pub(crate) fn is_done(&self) -> bool { - self.is_done + fn apply_action(&mut self, tui: &mut tui::Tui, action: ViewAction) { + self.notice = None; + let resume_following = matches!(action, ViewAction::CopyAndFollow(_)); + match action { + ViewAction::Changed => {} + ViewAction::Copy(text) | ViewAction::CopyAndFollow(text) => { + let result = self + .view + .copy_selected_text_with(&self.cells, &text, |text| { + tui.copy_transcript_selection(text) + }); + if resume_following + && matches!(result, Ok(crate::clipboard_copy::CopyStatus::Confirmed)) + { + self.view.jump_to_latest(); + } + 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}")); + } + } + } } fn render_hints(&self, area: Rect, buf: &mut Buffer) { - let line1 = Rect::new(area.x, area.y, area.width, /*height*/ 1).intersection(area); - let line2 = Rect::new( - area.x, - area.y.saturating_add(/*rhs*/ 1), - area.width, - /*height*/ 1, - ) - .intersection(area); - render_navigation_hints(line1, buf, &self.keymap); - - let mut pairs: Vec<(Vec, &str)> = vec![( + let first = Rect::new(area.x, area.y, area.width, /*height*/ 1).intersection(area); + render_navigation_hints(first, buf, &self.keymap); + let second = Rect::new(area.x, first.bottom(), area.width, /*height*/ 1).intersection(area); + let mut pairs = vec![( first_or_empty(&self.keymap, "close", &self.keymap.close), "close", )]; + pairs.push((vec![key_hint::plain(KeyCode::Esc).into()], "edit previous")); if self.highlight_cell.is_some() { - pairs.push(( - vec![ - key_hint::plain(KeyCode::Esc).into(), - key_hint::plain(KeyCode::Left).into(), - ], - "to edit prev", - )); pairs.push((vec![key_hint::plain(KeyCode::Right).into()], "to edit next")); pairs.push(( vec![key_hint::plain(KeyCode::Enter).into()], "to edit message", )); - } else { - pairs.push((vec![key_hint::plain(KeyCode::Esc).into()], "to edit prev")); } - render_key_hints(line2, buf, &pairs); - } - - fn render_history_state(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 { - return; - } - let label = match self.view.history { - TranscriptHistoryState::Idle => return, - TranscriptHistoryState::LoadingOlder | TranscriptHistoryState::LoadingBeginning => { - " loading older history... " - } - TranscriptHistoryState::Partial => " partial history | PgUp for earlier ", - TranscriptHistoryState::Failed => " history unavailable | PgUp to retry ", - TranscriptHistoryState::Complete => " start of history ", - }; - let width = (label.chars().count() as u16).min(area.width); - let status_area = Rect::new( - area.right().saturating_sub(width), - area.y, - width, - /*height*/ 1, - ); - Span::from(label).dim().render(status_area, buf); + render_key_hints(second, buf, &pairs); } } - -#[cfg(test)] -#[path = "transcript_tests.rs"] -mod tests; - -#[cfg(test)] -#[path = "anchor_tests.rs"] -mod anchor_tests; diff --git a/codex-rs/tui/src/pager_overlay/transcript_tests.rs b/codex-rs/tui/src/pager_overlay/transcript_tests.rs index 714886d25f..855ae23bbe 100644 --- a/codex-rs/tui/src/pager_overlay/transcript_tests.rs +++ b/codex-rs/tui/src/pager_overlay/transcript_tests.rs @@ -1,62 +1,26 @@ -//! Transcript overlay navigation, pagination, and rendering tests. +//! Standalone transcript behavior exercised through the shared viewport. use super::*; -use crate::diff_model::FileChange; -use crate::exec_cell::CommandOutput; use crate::history_cell; -use crate::history_cell::HistoryCell; -use crate::history_cell::ReviewDecision; -use crate::history_cell::new_patch_event; -use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource; -use codex_protocol::parse_command::ParsedCommand; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; -use ratatui::Terminal; -use ratatui::backend::TestBackend; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::Ordering; -use std::time::Duration; #[derive(Debug)] struct TestCell { lines: Vec>, } -impl crate::history_cell::HistoryCell for TestCell { +impl HistoryCell for TestCell { fn display_lines(&self, _width: u16) -> Vec> { self.lines.clone() } - fn raw_lines(&self) -> Vec> { self.lines.clone() } - - fn transcript_lines(&self, _width: u16) -> Vec> { - self.lines.clone() - } } -#[derive(Debug)] -struct LayoutCountingCell { - layout_calls: Arc, -} - -impl crate::history_cell::HistoryCell for LayoutCountingCell { - fn display_lines(&self, _width: u16) -> Vec> { - self.layout_calls.fetch_add(/*val*/ 1, Ordering::Relaxed); - vec![Line::from("counted")] - } - - fn raw_lines(&self) -> Vec> { - vec![Line::from("counted")] - } -} - -fn default_pager_keymap() -> crate::keymap::PagerKeymap { +fn default_pager_keymap() -> PagerKeymap { crate::keymap::RuntimeKeymap::defaults().pager } @@ -64,6 +28,244 @@ fn transcript_overlay(cells: Vec>) -> TranscriptOverlay { TranscriptOverlay::new(cells, default_pager_keymap()) } +#[test] +fn scrolling_prepend_and_resize_preserve_reading_content() { + let cells = (0..30) + .map(|index| { + Arc::new(TestCell { + lines: vec![format!("line {index}").into()], + }) as Arc + }) + .collect(); + let mut overlay = transcript_overlay(cells); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12, + ); + overlay.render(area, &mut Buffer::empty(area)); + overlay.view.jump_to_entry(&overlay.cells, /*index*/ 12); + let mut before = Buffer::empty(area); + overlay.render(area, &mut before); + overlay.prepend(vec![Arc::new(TestCell { + lines: vec!["older".into()], + })]); + let mut after = Buffer::empty(area); + overlay.render(area, &mut after); + assert_eq!(after, before); + let narrow = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 9, + ); + let mut resized = Buffer::empty(narrow); + overlay.render(narrow, &mut resized); + assert!(buffer_to_text(&resized, narrow).contains("line 12")); + assert!(!overlay.is_scrolled_to_bottom()); +} + +#[tokio::test] +async fn activity_hint_uses_the_configured_pager_jump_binding() -> Result<()> { + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut snapshots = Vec::new(); + for (label, binding) in [ + ("default", Some(key_hint::plain(KeyCode::End))), + ("remapped", Some(key_hint::plain(KeyCode::F(8)))), + ("unbound", None), + ] { + let mut keymap = default_pager_keymap(); + keymap.jump_bottom = binding.into_iter().collect(); + let mut overlay = TranscriptOverlay::new( + vec![Arc::new(TestCell { + lines: (0..20).map(|row| format!("row {row}").into()).collect(), + })], + keymap, + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 64, /*height*/ 12, + ); + overlay.render(area, &mut Buffer::empty(area)); + overlay.view.jump_to_entry(&overlay.cells, /*index*/ 0); + overlay.insert_cell(Arc::new(TestCell { + lines: vec!["new output".into()], + })); + let mut buffer = Buffer::empty(area); + overlay.render(area, &mut buffer); + let status = Rect::new(/*x*/ 0, /*y*/ 8, area.width, /*height*/ 1); + snapshots.push(format!( + "{label}: {}", + buffer_to_text(&buffer, status).trim() + )); + + let (code, modifiers) = binding + .unwrap_or_else(|| key_hint::plain(KeyCode::End)) + .parts(); + overlay.handle_event(&mut tui, TuiEvent::Key(KeyEvent::new(code, modifiers)))?; + assert_eq!(overlay.is_scrolled_to_bottom(), binding.is_some()); + } + assert_snapshot!(snapshots.join("\n"), @" + default: New activity · end latest + remapped: New activity · f8 latest + unbound: New activity + "); + Ok(()) +} + +#[tokio::test] +async fn pager_bindings_use_rendered_page_height_and_keep_control_f_navigation() -> Result<()> { + let mut overlay = transcript_overlay( + (0..40) + .map(|index| { + Arc::new(TestCell { + lines: vec![format!("line {index}").into()], + }) as Arc + }) + .collect(), + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 11, + ); + overlay.render(area, &mut Buffer::empty(area)); + overlay.view.jump_to_entry(&overlay.cells, /*index*/ 8); + let mut initial = Buffer::empty(area); + overlay.render(area, &mut initial); + let mut tui = crate::tui::test_support::make_test_tui()?; + for key in [ + KeyEvent::new(KeyCode::Char('f'), crossterm::event::KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::PageUp, crossterm::event::KeyModifiers::NONE), + ] { + overlay.handle_event(&mut tui, TuiEvent::Key(key))?; + overlay.render(area, &mut Buffer::empty(area)); + } + let mut restored = Buffer::empty(area); + overlay.render(area, &mut restored); + assert_eq!(restored, initial); + assert!(!overlay.view.has_active_interaction()); + Ok(()) +} + +#[tokio::test] +async fn focus_loss_stops_edge_drag_and_preserves_selection_after_focus_returns() -> Result<()> { + use crossterm::event::KeyModifiers; + use crossterm::event::MouseButton; + use crossterm::event::MouseEvent; + use crossterm::event::MouseEventKind; + use ratatui::layout::Size; + + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { + lines: (0..30).map(|row| format!("row {row:02}").into()).collect(), + })]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12, + ); + overlay.render(area, &mut Buffer::empty(area)); + overlay.view.jump_to_entry(&overlay.cells, /*index*/ 0); + overlay.render(area, &mut Buffer::empty(area)); + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.terminal.resize(Size::new(area.width, area.height))?; + for (kind, column, row) in [ + (MouseEventKind::Down(MouseButton::Left), 0, 2), + (MouseEventKind::Drag(MouseButton::Left), 6, 7), + ] { + overlay.handle_event( + &mut tui, + TuiEvent::Mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }), + )?; + } + assert!(overlay.view.tick_selection(&overlay.cells)); + let mut before = Buffer::empty(area); + overlay.render(area, &mut before); + let selected = overlay.view.selected_text(&overlay.cells); + assert_eq!( + selected.as_deref(), + Some("row 01\nrow 02\nrow 03\nrow 04\nrow 05\nrow 06\nrow 07") + ); + tui.screen_size_for_event(&TuiEvent::Draw)?; + overlay.draw(&mut tui)?; + let mut repainted = Buffer::empty(area); + overlay.render(area, &mut repainted); + assert_eq!(repainted, before); + + for event in [ + TuiEvent::Resume, + TuiEvent::FocusLost, + TuiEvent::Draw, + TuiEvent::FocusGained, + TuiEvent::Draw, + ] { + let resumed = matches!(event, TuiEvent::Resume); + tui.screen_size_for_event(&event)?; + overlay.handle_event(&mut tui, event)?; + if resumed { + let rendered = + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + assert!(buffer_to_text(rendered, rendered.area).contains("row 01")); + } + assert!(!overlay.view.tick_selection(&overlay.cells)); + let mut after = Buffer::empty(area); + overlay.render(area, &mut after); + assert_eq!(after, before); + assert_eq!(overlay.view.selected_text(&overlay.cells), selected); + } + let content_and_status = Rect::new( + /*x*/ 0, /*y*/ 1, /*width*/ 40, /*height*/ 8, + ); + assert_snapshot!(buffer_to_text(&before, content_and_status), @" + row 01 + row 02 + row 03 + row 04 + row 05 + row 06 + row 07 + enter copy & follow · esc clear + "); + overlay.view.jump_to_latest(); + overlay.render(area, &mut Buffer::empty(area)); + overlay.handle_event( + &mut tui, + TuiEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 1, + row: 2, + modifiers: KeyModifiers::NONE, + }), + )?; + assert_eq!(overlay.view.selected_text(&overlay.cells), None); + overlay.handle_event(&mut tui, TuiEvent::FocusLost)?; + overlay.handle_event(&mut tui, TuiEvent::FocusGained)?; + overlay.insert_cell(Arc::new(TestCell { + lines: vec!["new output".into()], + })); + let mut after = Buffer::empty(area); + overlay.render(area, &mut after); + assert!(overlay.is_scrolled_to_bottom()); + assert_eq!(overlay.view.selected_text(&overlay.cells), None); + assert!(buffer_to_text(&after, area).contains("new output")); + Ok(()) +} + +fn buffer_to_text(buf: &Buffer, area: Rect) -> String { + let mut out = String::new(); + for y in area.y..area.bottom() { + for x in area.x..area.right() { + let symbol = buf[(x, y)].symbol(); + if symbol.is_empty() { + out.push(' '); + } else { + out.push(symbol.chars().next().unwrap_or(' ')); + } + } + // Trim trailing spaces for stability. + while out.ends_with(' ') { + out.pop(); + } + out.push('\n'); + } + out +} + #[test] fn jump_top_requests_older_history_from_the_bottom() { let mut overlay = transcript_overlay(vec![Arc::new(TestCell { @@ -80,123 +282,505 @@ fn jump_top_requests_older_history_from_the_bottom() { ))); } +#[tokio::test] +async fn jump_top_holds_the_visible_page_until_all_history_arrives() -> Result<()> { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { + lines: (0..8).map(|index| format!("line {index}").into()).collect(), + })]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 27, /*height*/ 8, + ); + let content = Rect::new( + /*x*/ 0, /*y*/ 1, /*width*/ 27, /*height*/ 3, + ); + let content_and_status = Rect::new( + /*x*/ 0, /*y*/ 1, /*width*/ 27, /*height*/ 4, + ); + overlay.set_history_state(TranscriptHistoryState::Partial); + let mut initial = Buffer::empty(area); + overlay.render(area, &mut initial); + let mut tui = crate::tui::test_support::make_test_tui()?; + let home = KeyEvent::new(KeyCode::Home, crossterm::event::KeyModifiers::NONE); + overlay.handle_event(&mut tui, TuiEvent::Key(home))?; + let mut pending = Buffer::empty(area); + overlay.render(area, &mut pending); + assert_snapshot!(buffer_to_text(&pending, content_and_status), @" + line 5 + line 6 + line 7 + ↑ Loading… · end latest + "); + for label in ["earlier", "oldest"] { + overlay.prepend(vec![Arc::new(TestCell { + lines: ["zero", "one", "two"] + .map(|row| format!("{label} {row}").into()) + .into(), + })]); + overlay.handle_event(&mut tui, TuiEvent::Key(home))?; + overlay.render(area, &mut pending); + assert_eq!( + buffer_to_text(&pending, content), + buffer_to_text(&initial, content) + ); + assert_eq!( + overlay.history_state(), + TranscriptHistoryState::LoadingBeginning + ); + } + overlay.set_history_state(TranscriptHistoryState::Complete); + let mut complete = Buffer::empty(area); + overlay.render(area, &mut complete); + assert_snapshot!(buffer_to_text(&complete, content_and_status), @" + oldest zero + oldest one + oldest two + end latest + "); + overlay.handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new( + KeyCode::End, + crossterm::event::KeyModifiers::NONE, + )), + )?; + overlay.handle_event(&mut tui, TuiEvent::Key(home))?; + overlay.render(area, &mut pending); + assert_eq!(pending, complete); + Ok(()) +} + #[test] -fn first_upward_key_requests_history_when_the_loaded_start_is_visible() { +fn highlighting_reveals_prompt_body_when_only_padding_is_visible() { + let mut overlay = transcript_overlay(vec![ + Arc::new(history_cell::new_user_prompt( + "first prompt".into(), + Vec::new(), + Vec::new(), + Vec::new(), + )), + Arc::new(history_cell::PlainHistoryCell::new(vec![ + "tail one".into(), + "tail two".into(), + "tail three".into(), + ])), + ]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 120, /*height*/ 10, + ); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + assert!(!buffer_to_text(&buf, area).contains("first prompt")); + overlay.set_highlight_cell(Some(0)); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + let text = buffer_to_text(&buf, area); + assert!(text.contains("first prompt"), "{text}"); + let highlighted = buf + .content() + .iter() + .filter(|cell| cell.modifier.contains(ratatui::style::Modifier::REVERSED)) + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert_eq!(highlighted, "first prompt"); +} + +#[test] +fn transcript_overlay_snapshots_paginated_history_states() { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { + lines: vec![Line::from("recent transcript")], + })]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 72, /*height*/ 10, + ); + let mut snapshots = String::new(); + + for (name, state) in [ + ("loading", TranscriptHistoryState::LoadingOlder), + ("partial", TranscriptHistoryState::Partial), + ("failed", TranscriptHistoryState::Failed), + ("complete", TranscriptHistoryState::Complete), + ] { + overlay.set_history_state(state); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + snapshots.push_str(&format!("--- {name} ---\n{}", buffer_to_text(&buf, area))); + } + + let snapshot_name = if cfg!(target_os = "macos") { + "transcript_overlay_paginated_history_states" + } else { + "transcript_overlay_paginated_history_states_non_macos" + }; + assert_snapshot!(snapshot_name, snapshots); +} + +#[test] +fn transcript_overlay_preserves_semantic_web_links() { + let destination = "https://example.com/a/very/long/path"; + let mut overlay = transcript_overlay(vec![Arc::new(history_cell::AgentMarkdownCell::new( + destination.to_string(), + std::path::Path::new("/tmp"), + ))]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 10, + ); + let mut buf = Buffer::empty(area); + + overlay.render(area, &mut buf); + + assert!(area.positions().any(|position| { + buf[position] + .symbol() + .contains(&format!("\x1b]8;;{destination}\x07")) + })); +} + +#[test] +fn transcript_overlay_preserves_live_tail_when_prepending_history() { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { + lines: vec![Line::from("recent")], + })]); + overlay.sync_live_tail( + /*width*/ 40, + Some(ActiveCellTranscriptKey { + cacheable: true, + revision: 1, + is_stream_continuation: false, + animation_tick: None, + }), + |_| Some(vec![HyperlinkLine::from("live tail")]), + ); + overlay.prepend(vec![Arc::new(TestCell { + lines: vec![Line::from("older")], + })]); + let area = Rect::new( /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10, ); - for committed in [false, true] { - for (line_count, near_start) in [(0, true), (3, true), (7, true), (30, false)] { - let lines = (0..line_count) - .map(|index| Line::from(format!("line {index}"))) - .collect::>(); - let cells = if committed && !lines.is_empty() { - vec![Arc::new(TestCell { - lines: lines.clone(), - }) as Arc] - } else { - Vec::new() - }; - let mut overlay = transcript_overlay(cells); - if !committed { - overlay.sync_live_tail(area.width, /*key*/ None, |_| { - Some(lines.into_iter().map(HyperlinkLine::from).collect()) - }); - } - overlay.set_history_state(TranscriptHistoryState::Partial); - overlay.render(area, &mut Buffer::empty(area)); - assert!(overlay.view.is_following()); + let mut buffer = Buffer::empty(area); + overlay.render(area, &mut buffer); + let rendered = buffer_to_text(&buffer, area); + assert!(rendered.contains("older")); + assert!(rendered.contains("recent")); + assert!(rendered.contains("live tail")); +} - // App checks pagination before forwarding the first navigation key. - for key in [ - KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), - KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), - ] { - assert_eq!(overlay.should_load_older(key), near_start); +#[test] +fn transcript_overlay_absorbs_older_tail_into_live_without_duplicate_cells() { + let header: Arc = Arc::new(history_cell::SessionHeaderHistoryCell::new( + "test model".to_owned(), + /*reasoning_effort*/ None, + /*show_fast_status*/ false, + std::path::PathBuf::from("/project"), + "test", + )); + let mut overlay = transcript_overlay(vec![Arc::clone(&header)]); + let key = ActiveCellTranscriptKey { + cacheable: true, + revision: 1, + is_stream_continuation: false, + animation_tick: None, + }; + overlay.sync_live_tail(/*width*/ 40, Some(key), |_| { + Some(vec![HyperlinkLine::from("current group")]) + }); + overlay.prepend(vec![Arc::new(TestCell { + lines: vec!["older group".into()], + })]); + overlay.set_highlight_cell(Some(1)); + overlay.absorb_tail_into_live(/*previous_revision*/ 1, /*hydrated_revision*/ 2); + overlay.sync_live_tail( + /*width*/ 40, + Some(ActiveCellTranscriptKey { revision: 2, ..key }), + |_| Some(vec![HyperlinkLine::from("hydrated group")]), + ); + + assert_eq!(overlay.cells.len(), 1); + assert!(Arc::ptr_eq(&overlay.cells[0], &header)); + assert_eq!(overlay.highlight_cell, Some(1)); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10, + ); + let mut buffer = Buffer::empty(area); + overlay.render(area, &mut buffer); + let rendered = buffer_to_text(&buffer, area); + assert!(rendered.contains("hydrated group")); + assert!(!rendered.contains("older group")); + assert!(!rendered.contains("New activity")); +} + +#[test] +fn transcript_overlay_live_tail_preserves_semantic_web_links() { + let destination = "https://example.com/a/streamed/path"; + let cell = + history_cell::AgentMarkdownCell::new(destination.to_string(), std::path::Path::new("/tmp")); + let mut overlay = transcript_overlay(Vec::new()); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 10, + ); + let mut buf = Buffer::empty(area); + + overlay.sync_live_tail( + area.width, + Some(ActiveCellTranscriptKey { + cacheable: true, + revision: 1, + is_stream_continuation: false, + animation_tick: None, + }), + |width| Some(cell.transcript_hyperlink_lines(width)), + ); + overlay.render(area, &mut buf); + + assert!(area.positions().any(|position| { + buf[position] + .symbol() + .contains(&format!("\x1b]8;;{destination}\x07")) + })); +} + +#[test] +fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { + lines: vec![Line::from("alpha")], + })]); + + let calls = std::cell::Cell::new(/*value*/ 0usize); + let key = ActiveCellTranscriptKey { + cacheable: true, + revision: 1, + is_stream_continuation: false, + animation_tick: None, + }; + + overlay.sync_live_tail(/*width*/ 40, Some(key), |_| { + calls.set(calls.get() + 1); + Some(vec![HyperlinkLine::from("tail")]) + }); + overlay.sync_live_tail(/*width*/ 40, Some(key), |_| { + calls.set(calls.get() + 1); + Some(vec![HyperlinkLine::from("tail2")]) + }); + + assert_eq!(calls.get(), 1); +} + +#[test] +fn transcript_overlay_history_rebuild_preserves_only_the_live_tail() { + for replace in [false, true] { + for tail in [ + None, + Some(Vec::new()), + Some(vec![HyperlinkLine::from("live")]), + ] { + let mut overlay = transcript_overlay( + ["first", "last"] + .map(|line| { + Arc::new(TestCell { + lines: vec![line.into()], + }) as Arc + }) + .to_vec(), + ); + let key = tail.as_ref().map(|_| ActiveCellTranscriptKey { + cacheable: true, + revision: 1, + is_stream_continuation: false, + animation_tick: None, + }); + overlay.sync_live_tail(/*width*/ 40, key, |_| tail.clone()); + let consolidated = Arc::new(TestCell { + lines: vec!["first".into(), "last".into()], + }); + if replace { + overlay.replace_cells(vec![consolidated]); + } else { + overlay.consolidate_cells(0..2, consolidated); } - assert!(!overlay.should_load_older(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE,))); - if line_count == 30 { - let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE); - overlay.scroll(/*rows*/ -1); - assert!( - !overlay.should_load_older(up), - "one row must not prefetch a distant page" - ); - overlay.scroll(/*rows*/ -18); - assert!( - !overlay.should_load_older(up), - "row six is outside the five-row prefetch threshold" - ); - overlay.scroll(/*rows*/ -1); - assert!( - overlay.should_load_older(up), - "prefetch at row five even before a draw" + // A draw may arrive after the active tail has already been cleared. + overlay.sync_live_tail(/*width*/ 40, key, |_| tail.clone()); + let mut reopened = transcript_overlay(overlay.cells.clone()); + reopened.sync_live_tail(/*width*/ 40, key, |_| tail.clone()); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10, + ); + let mut actual = Buffer::empty(area); + let mut expected = Buffer::empty(area); + overlay.render(area, &mut actual); + reopened.render(area, &mut expected); + assert_eq!(actual, expected); + if tail.is_none() { + assert_snapshot!( + "transcript_overlay_completed_stream", + buffer_to_text(&actual, area) ); } } } } +#[test] +fn transcript_overlay_consolidation_remaps_highlight_inside_range() { + let mut overlay = transcript_overlay( + (0..6) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line{i}"))], + }) as Arc + }) + .collect(), + ); + overlay.set_highlight_cell(Some(3)); + + overlay.consolidate_cells( + 2..5, + Arc::new(TestCell { + lines: vec![Line::from("consolidated")], + }), + ); + + assert_eq!( + overlay.highlight_cell, + Some(2), + "highlight inside consolidated range should point to replacement cell", + ); +} + +#[test] +fn transcript_overlay_consolidation_remaps_highlight_after_range() { + let mut overlay = transcript_overlay( + (0..7) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line{i}"))], + }) as Arc + }) + .collect(), + ); + overlay.set_highlight_cell(Some(6)); + + overlay.prepend(vec![Arc::new(TestCell { + lines: vec!["older".into()], + })]); + overlay.regroup_cells( + 3..6, + Arc::new(TestCell { + lines: vec![Line::from("consolidated")], + }), + ); + + assert_eq!( + overlay.highlight_cell, + Some(5), + "highlight after consolidated range should shift left by removed cells", + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 7, + ); + let mut buffer = Buffer::empty(area); + overlay.render(area, &mut buffer); + let selected = buffer + .content() + .iter() + .filter(|cell| cell.modifier.contains(ratatui::style::Modifier::REVERSED)) + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert_eq!(selected, "line6"); +} + +#[test] +fn completing_agent_and_plan_streams_preserves_reading_in_later_fragments() { + use crate::history_cell::HistoryRenderMode; + use crate::streaming::controller::PlanStreamController; + use crate::streaming::controller::StreamController; + + let cwd = std::path::Path::new("/workspace"); + let source = (0..12) + .map(|index| { + format!("Passage {index:02} alpha beta gamma delta epsilon zeta theta.\n\n") + }) + .collect::(); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 9, + ); + let render = |overlay: &mut TranscriptOverlay, area: Rect| { + let mut buffer = Buffer::empty(area); + overlay.render(area, &mut buffer); + buffer + }; + for plan in [false, true] { + let mut cells = vec![Arc::new(history_cell::PlainHistoryCell::new(vec![ + "preceding prompt".into(), + ])) as Arc]; + let replacement: Arc = if plan { + let mut controller = PlanStreamController::new( + Some(usize::from(area.width - 4)), + cwd, + HistoryRenderMode::Rich, + ); + controller.push(&source); + while let (Some(cell), _) = controller.on_commit_tick_batch(/*max_lines*/ 1) { + cells.push(cell.into()); + } + let (tail, _) = controller.finalize(); + if let Some(tail) = tail { + cells.push(tail.into()); + } + Arc::new(crate::history_cell::new_proposed_plan(source.clone(), cwd)) + } else { + let mut controller = StreamController::new( + Some(usize::from(area.width - 2)), + cwd, + HistoryRenderMode::Rich, + ); + controller.push(&source); + while let (Some(cell), _) = controller.on_commit_tick_batch(/*max_lines*/ 1) { + cells.push(cell.into()); + } + let (tail, _) = controller.finalize(); + if let Some(tail) = tail { + cells.push(tail.into()); + } + Arc::new(crate::history_cell::AgentMarkdownCell::new( + source.clone(), + cwd, + )) + }; + assert!( + cells.len() > 3, + "the stream must span multiple committed fragments" + ); + for resize_before_draw in [false, true] { + let mut overlay = transcript_overlay(cells.clone()); + render(&mut overlay, area); + overlay.view.jump_to_entry(&overlay.cells, /*index*/ 2); + overlay.view.scroll(&overlay.cells, /*rows*/ 1); + let before = render(&mut overlay, area); + assert!(buffer_to_text(&before, area).contains("Passage")); + let end = overlay.cells.len(); + overlay.consolidate_cells(1..end, Arc::clone(&replacement)); + if resize_before_draw { + render(&mut overlay, Rect { width: 80, ..area }); + } + let completed = render(&mut overlay, area); + // Stream completion changes the activity footer, but not the passage being read. + let content = Rect { + y: 1, + height: area.height - 5, + ..area + }; + assert_eq!( + buffer_to_text(&completed, content), + buffer_to_text(&before, content), + ); + assert!(!overlay.is_scrolled_to_bottom()); + } + } +} + #[tokio::test] -async fn loading_beginning_keeps_the_entry_until_history_is_complete() -> Result<()> { - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10, - ); - let mut tui = crate::tui::test_support::make_test_tui()?; - for already_rendered in [false, true] { - let mut overlay = transcript_overlay(vec![Arc::new(TestCell { - lines: vec!["committed".into()], - })]); - let live = (0..20) - .map(|index| Line::from(format!("live {index}"))) - .collect::>(); - overlay.sync_live_tail(area.width, /*key*/ None, |_| { - Some(live.iter().cloned().map(HyperlinkLine::from).collect()) - }); - overlay.set_history_state(TranscriptHistoryState::Partial); - if already_rendered { - overlay.render(area, &mut Buffer::empty(area)); - } - - // App marks loading before forwarding Home to the overlay. - overlay.set_history_state(TranscriptHistoryState::LoadingBeginning); - overlay.handle_event( - &mut tui, - TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)), - )?; - let mut before = Buffer::empty(area); - overlay.render(area, &mut before); - assert!(!overlay.view.is_following()); - assert_eq!( - overlay.view.history, - TranscriptHistoryState::LoadingBeginning - ); - - overlay.insert_cell(Arc::new(TestCell { lines: live })); - overlay.sync_live_tail(area.width, /*key*/ None, |_| { - Some( - (20..40) - .map(|index| HyperlinkLine::from(format!("live {index}"))) - .collect(), - ) - }); - overlay.prepend(vec![Arc::new(TestCell { - lines: vec!["oldest".into()], - })]); - let mut actual = Buffer::empty(area); - overlay.render(area, &mut actual); - assert_eq!(actual, before); - - overlay.set_history_state(TranscriptHistoryState::Complete); - overlay.render(area, &mut actual); - assert!(buffer_to_text(&actual, overlay.content_area).starts_with("oldest\n")); - } - Ok(()) -} - -#[test] -fn navigation_supersedes_home_before_and_after_the_first_draw() { +async fn navigation_supersedes_home_before_and_after_the_first_draw() { + let mut tui = crate::tui::test_support::make_test_tui().expect("tui"); let area = Rect::new( /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10, ); @@ -222,9 +806,18 @@ fn navigation_supersedes_home_before_and_after_the_first_draw() { expected.render(area, &mut Buffer::empty(area)); actual.render(area, &mut Buffer::empty(area)); } - expected.navigate(key); - actual.navigate(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); - actual.navigate(key); + expected + .handle_event(&mut tui, TuiEvent::Key(key)) + .expect("navigate"); + actual + .handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)), + ) + .expect("Home"); + actual + .handle_event(&mut tui, TuiEvent::Key(key)) + .expect("navigate"); assert_eq!(actual.view.history, TranscriptHistoryState::LoadingOlder); actual.prepend(vec![Arc::new(TestCell { lines: vec!["received older history".into()], @@ -235,529 +828,19 @@ fn navigation_supersedes_home_before_and_after_the_first_draw() { expected.render(area, &mut expected_buffer); actual.render(area, &mut actual_buffer); assert_eq!( - buffer_to_text(&actual_buffer, actual.content_area), - buffer_to_text(&expected_buffer, expected.content_area) + buffer_to_text( + &actual_buffer, + Rect::new( + /*x*/ 0, /*y*/ 1, /*width*/ 40, /*height*/ 5 + ) + ), + buffer_to_text( + &expected_buffer, + Rect::new( + /*x*/ 0, /*y*/ 1, /*width*/ 40, /*height*/ 5 + ) + ) ); } } } - -#[test] -fn transcript_overlay_snapshots_paginated_history_states() { - let mut overlay = transcript_overlay(vec![Arc::new(TestCell { - lines: vec![Line::from("recent transcript")], - })]); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 72, /*height*/ 10, - ); - let mut snapshots = String::new(); - - for (name, state) in [ - ("loading", TranscriptHistoryState::LoadingOlder), - ("partial", TranscriptHistoryState::Partial), - ("failed", TranscriptHistoryState::Failed), - ("complete", TranscriptHistoryState::Complete), - ] { - overlay.set_history_state(TranscriptHistoryState::Partial); - overlay.navigate(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); - overlay.navigate(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); - overlay.set_history_state(state); - let mut buf = Buffer::empty(area); - overlay.render(area, &mut buf); - snapshots.push_str(&format!("--- {name} ---\n{}", buffer_to_text(&buf, area))); - } - - assert_snapshot!("transcript_overlay_paginated_history_states", snapshots); -} - -#[test] -fn transcript_overlay_snapshot_basic() { - let mut overlay = transcript_overlay(vec![ - Arc::new(TestCell { - lines: vec![ - Line::from(vec![" indented ".green(), "styled 漢字 ガ text".bold()]), - Line::from("abc-def ghi continuation spaces and a-long-hyphenated-token"), - Line::from("+ added text").on_green(), - ], - }), - Arc::new(history_cell::AgentMarkdownCell::new( - "A **styled** [link](https://example.com/transcript)".to_string(), - std::path::Path::new("/tmp"), - )), - ]); - let mut term = Terminal::new(TestBackend::new(24, 16)).expect("term"); - term.draw(|f| overlay.render(f.area(), f.buffer_mut())) - .expect("draw"); - assert_snapshot!(format!("{:?}", term.backend().buffer())); -} - -#[test] -fn transcript_overlay_preserves_complete_wrapped_links_in_history_and_live_output() { - let destination = "https://example.com/a/very/long/path"; - let cell: Arc = Arc::new(history_cell::AgentMarkdownCell::new( - destination.to_string(), - std::path::Path::new("/tmp"), - )); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 10, - ); - for live in [false, true] { - let mut overlay = transcript_overlay(if live { Vec::new() } else { vec![cell.clone()] }); - if live { - overlay.sync_live_tail(area.width, /*key*/ None, |width| { - Some(cell.transcript_hyperlink_lines(width)) - }); - } - let mut buf = Buffer::empty(area); - overlay.render(area, &mut buf); - let linked_text = area - .positions() - .filter_map(|position| { - let symbol = buf[position].symbol(); - symbol - .contains(&format!("\x1b]8;;{destination}\x07")) - .then(|| crate::terminal_hyperlinks::strip_osc8(symbol)) - }) - .collect::(); - assert_eq!(linked_text, destination); - } -} - -#[test] -fn transcript_overlay_renders_live_tail() { - let mut overlay = transcript_overlay(vec![Arc::new(TestCell { - lines: vec![Line::from("alpha")], - })]); - overlay.sync_live_tail( - /*width*/ 40, - Some(ActiveCellTranscriptKey { - cacheable: true, - revision: 1, - is_stream_continuation: false, - animation_tick: None, - }), - |_| Some(vec![HyperlinkLine::from("tail")]), - ); - - let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); - term.draw(|f| overlay.render(f.area(), f.buffer_mut())) - .expect("draw"); - assert_snapshot!(term.backend()); - overlay.navigate(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); - term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut())) - .expect("draw"); - assert!(overlay.live_tail_visible()); -} - -#[test] -fn transcript_overlay_prepend_preserves_reading_highlight_and_live_tail() { - for follow in [false, true] { - let mut overlay = transcript_overlay(vec![ - Arc::new(TestCell { - lines: vec!["newer one".into(), "newer two".into()], - }), - Arc::new(TestCell { - lines: (0..20) - .map(|i| Line::from(format!("reading {i}"))) - .collect(), - }), - ]); - overlay.highlight_cell = Some(1); - overlay.sync_live_tail( - /*width*/ 40, - Some(ActiveCellTranscriptKey { - cacheable: true, - revision: 1, - is_stream_continuation: false, - animation_tick: None, - }), - |_| Some(vec![HyperlinkLine::from("live tail")]), - ); - let mut term = Terminal::new(TestBackend::new(/*width*/ 40, /*height*/ 8)).expect("term"); - term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut())) - .expect("draw"); - if !follow { - overlay.view.jump_to_entry(&overlay.cells, /*index*/ 1); - } - term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut())) - .expect("draw"); - let content_area = overlay.content_area; - let before = buffer_to_text(term.backend().buffer(), content_area); - overlay.reveal_highlight = true; - overlay.prepend(vec![Arc::new(TestCell { - lines: vec!["older one".into(), "older two".into(), "older three".into()], - })]); - assert_eq!( - (overlay.highlight_cell, overlay.reveal_highlight), - (Some(2), true) - ); - overlay.reveal_highlight = false; - term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut())) - .expect("draw prepended"); - assert_eq!( - buffer_to_text(term.backend().buffer(), content_area), - before - ); - assert_eq!(overlay.view.is_following(), follow); - overlay.view.jump_to_latest(); - term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut())) - .expect("draw tail"); - assert!(buffer_to_text(term.backend().buffer(), content_area).contains("live tail")); - } -} - -#[test] -fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() { - let mut overlay = transcript_overlay(vec![Arc::new(TestCell { - lines: vec![Line::from("alpha")], - })]); - - let calls = std::cell::Cell::new(0usize); - let key = ActiveCellTranscriptKey { - cacheable: true, - revision: 1, - is_stream_continuation: false, - animation_tick: None, - }; - - overlay.sync_live_tail(/*width*/ 40, Some(key), |_| { - calls.set(calls.get() + 1); - Some(vec![HyperlinkLine::from("tail")]) - }); - overlay.sync_live_tail(/*width*/ 40, Some(key), |_| { - calls.set(calls.get() + 1); - Some(vec![HyperlinkLine::from("tail2")]) - }); - - assert_eq!(calls.get(), 1); -} - -fn buffer_to_text(buf: &Buffer, area: Rect) -> String { - let mut out = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - let symbol = buf[(x, y)].symbol(); - if symbol.is_empty() { - out.push(' '); - } else { - out.push(symbol.chars().next().unwrap_or(' ')); - } - } - // Trim trailing spaces for stability. - while out.ends_with(' ') { - out.pop(); - } - out.push('\n'); - } - out -} - -#[test] -fn transcript_overlay_apply_patch_scroll_vt100_clears_previous_page() { - let cwd = PathBuf::from("/repo"); - let mut cells: Vec> = Vec::new(); - - let mut approval_changes = HashMap::new(); - approval_changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\nworld\n".to_string(), - }, - ); - let approval_cell: Arc = Arc::new(new_patch_event(approval_changes, &cwd)); - cells.push(approval_cell); - - let mut apply_changes = HashMap::new(); - apply_changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\nworld\n".to_string(), - }, - ); - let apply_begin_cell: Arc = Arc::new(new_patch_event(apply_changes, &cwd)); - cells.push(apply_begin_cell); - - let apply_end_cell: Arc = history_cell::new_approval_decision_cell( - history_cell::ApprovalDecisionSubject::Command(vec!["ls".into()]), - ReviewDecision::Approved, - history_cell::ApprovalDecisionActor::User, - ) - .into(); - cells.push(apply_end_cell); - - let mut exec_cell = crate::exec_cell::new_active_exec_command( - "exec-1".into(), - vec!["bash".into(), "-lc".into(), "ls".into()], - vec![ParsedCommand::Unknown { cmd: "ls".into() }], - ExecCommandSource::Agent, - /*interaction_input*/ None, - /*animations_enabled*/ true, - ); - exec_cell.complete_call( - "exec-1", - CommandOutput::new(/*exit_code*/ 0, "src\nREADME.md\n".into()), - Duration::from_millis(420), - ); - let exec_cell: Arc = Arc::new(exec_cell); - cells.push(exec_cell); - - let mut overlay = transcript_overlay(cells); - let area = Rect::new(0, 0, 80, 12); - let mut buf = Buffer::empty(area); - - overlay.render(area, &mut buf); - overlay.view.jump_to_entry(&overlay.cells, /*index*/ 0); - overlay.render(area, &mut buf); - - let snapshot = buffer_to_text(&buf, area); - assert_snapshot!("transcript_overlay_apply_patch_scroll_vt100", snapshot); -} - -#[test] -fn transcript_overlay_keeps_scroll_pinned_at_bottom() { - let mut overlay = transcript_overlay( - (0..20) - .map(|i| { - Arc::new(TestCell { - lines: vec![Line::from(format!("line{i}"))], - }) as Arc - }) - .collect(), - ); - let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term"); - term.draw(|f| overlay.render(f.area(), f.buffer_mut())) - .expect("draw"); - - assert!( - overlay.view.is_following(), - "expected initial render to leave view at bottom" - ); - - for height in [9, 14] { - term.backend_mut().resize(/*width*/ 40, height); - term.draw(|f| overlay.render(f.area(), f.buffer_mut())) - .expect("draw after composer height change"); - assert!(overlay.view.is_following()); - } - - overlay.insert_cell(Arc::new(TestCell { - lines: vec!["tail".into()], - })); - - assert!(overlay.view.is_following()); - term.draw(|f| overlay.render(f.area(), f.buffer_mut())) - .expect("draw committed tail"); - assert_snapshot!("transcript_overlay_follows_resized_tail", term.backend()); -} - -#[test] -fn transcript_overlay_consolidation_remaps_highlight() { - for (selected, expected) in [(3, 2), (6, 4)] { - let mut overlay = transcript_overlay( - (0..7) - .map(|i| { - Arc::new(TestCell { - lines: vec![Line::from(format!("line{i}"))], - }) as Arc - }) - .collect(), - ); - overlay.set_highlight_cell(Some(selected)); - overlay.consolidate_cells( - 2..5, - Arc::new(TestCell { - lines: vec![Line::from("consolidated")], - }), - ); - assert_eq!(overlay.highlight_cell, Some(expected)); - } -} - -#[test] -fn transcript_overlay_paging_is_continuous_and_round_trips() { - let mut overlay = transcript_overlay(vec![Arc::new(TestCell { - lines: (0..50) - .map(|i| Line::from(format!("line-{i:02}"))) - .collect(), - })]); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 15, - ); - let mut buffer = Buffer::empty(area); - overlay.render(area, &mut buffer); - overlay.view.jump_to_entry(&overlay.cells, /*index*/ 0); - for start in [0, 10] { - overlay.render(area, &mut buffer); - assert_eq!( - buffer_to_text(&buffer, overlay.content_area), - (start..start + 10) - .map(|i| format!("line-{i:02}\n")) - .collect::() - ); - overlay.navigate(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)); - } - overlay.scroll(/*rows*/ -17); - overlay.render(area, &mut buffer); - let before = buffer.clone(); - for key in [KeyCode::PageDown, KeyCode::PageUp] { - overlay.navigate(KeyEvent::new(key, KeyModifiers::NONE)); - overlay.render(area, &mut buffer); - } - assert_eq!(buffer, before); -} - -#[tokio::test] -async fn half_page_uses_the_last_rendered_content_height() -> Result<()> { - let mut overlay = transcript_overlay(vec![Arc::new(TestCell { - lines: (0..50) - .map(|i| Line::from(format!("line-{i:02}"))) - .collect(), - })]); - let transcript_area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10, - ); - let mut buf = Buffer::empty(transcript_area); - overlay.render(transcript_area, &mut buf); - let page_height = usize::from(overlay.content_area.height); - let mut tui = crate::tui::test_support::make_test_tui()?; - tui.terminal.set_viewport_area(Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 24, - )); - overlay.view.jump_to_entry(&overlay.cells, /*index*/ 0); - overlay.scroll(/*rows*/ 10); - - overlay.handle_event( - &mut tui, - TuiEvent::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)), - )?; - - overlay.render(transcript_area, &mut buf); - let first = 10 + page_height.div_ceil(/*rhs*/ 2); - assert!(buffer_to_text(&buf, overlay.content_area).starts_with(&format!("line-{first:02}\n"))); - Ok(()) -} - -#[test] -fn transcript_highlight_scrolls_offscreen_entries_in_both_directions() { - let mut overlay = transcript_overlay( - (0..20) - .map(|index| { - Arc::new(TestCell { - lines: vec![Line::from(format!("entry {index}"))], - }) as Arc - }) - .collect(), - ); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12, - ); - let mut buffer = Buffer::empty(area); - overlay.render(area, &mut buffer); - for index in [0, 19, 0] { - overlay.set_highlight_cell(Some(index)); - overlay.render(area, &mut buffer); - assert!(buffer_to_text(&buffer, overlay.content_area).contains(&format!("entry {index}"))); - } -} - -#[test] -fn transcript_footer_does_not_format_offscreen_history_for_a_percentage() { - let counts = (0..200) - .map(|_| Arc::new(AtomicUsize::new(/*v*/ 0))) - .collect::>(); - let mut overlay = transcript_overlay( - counts - .iter() - .map(|count| { - Arc::new(LayoutCountingCell { - layout_calls: Arc::clone(count), - }) as Arc - }) - .collect(), - ); - let area = Rect::new( - /*x*/ 2, /*y*/ 1, /*width*/ 72, /*height*/ 12, - ); - let mut buffer = Buffer::empty(area); - overlay.set_history_state(TranscriptHistoryState::Partial); - overlay.render(area, &mut buffer); - assert_eq!( - overlay.content_area, - Rect::new( - /*x*/ 2, /*y*/ 2, /*width*/ 72, /*height*/ 7 - ) - ); - assert!(!buffer_to_text(&buffer, area).contains('%')); - let before = counts - .iter() - .map(|count| count.load(Ordering::Relaxed)) - .collect::>(); - assert!(before[..190].iter().all(|count| *count == 0)); - assert!((1..10).contains(&before.iter().sum::())); - overlay.set_history_state(TranscriptHistoryState::Complete); - overlay.render(area, &mut buffer); - assert!(buffer_to_text(&buffer, area).contains("100%")); - assert_eq!( - counts - .iter() - .map(|count| count.load(Ordering::Relaxed)) - .collect::>(), - before - ); - for selection in [Some(199), Some(198), None] { - overlay.set_highlight_cell(selection); - overlay.render(area, &mut buffer); - assert_eq!( - counts - .iter() - .map(|count| count.load(Ordering::Relaxed)) - .collect::>(), - before - ); - if selection.is_some() { - assert!(buffer_to_text(&buffer, area).contains("edit next")); - } - } - overlay.view.jump_to_latest(); - overlay.insert_cell(Arc::new(TestCell { - lines: vec!["new output".into()], - })); - overlay.render(area, &mut buffer); - assert_eq!( - counts - .iter() - .map(|count| count.load(Ordering::Relaxed)) - .collect::>(), - before - ); - overlay.scroll(/*rows*/ -1); - overlay.render(area, &mut buffer); - assert!(!buffer_to_text(&buffer, area).contains('%')); -} - -#[test] -fn transcript_overlay_clips_zero_and_short_viewports_to_their_area() { - let canvas = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 16, - ); - for width in [0, 1, 40] { - for height in 0..=6 { - let area = Rect::new(/*x*/ 2, /*y*/ 3, width, height); - let mut overlay = transcript_overlay(vec![Arc::new(TestCell { - lines: vec!["content".into()], - })]); - let mut buffer = Buffer::filled(canvas, Cell::from('.')); - overlay.render(area, &mut buffer); - for position in canvas - .positions() - .filter(|position| !area.contains(*position)) - { - assert_eq!( - buffer[position], - Cell::from('.'), - "area={area:?}, position={position:?}" - ); - } - } - } -} diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 03779c731c..648938eefd 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -628,7 +628,7 @@ async fn run_session_picker_with_loader( state.open_pending_transcript_if_ready(); } } - TuiEvent::FocusLost => {} + TuiEvent::FocusLost | TuiEvent::Mouse(_) => {} } } Some(event) = background_events.next() => { @@ -1086,6 +1086,11 @@ impl PickerState { } fn route_key_chord(&mut self, key: KeyEvent) -> Option { + if matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key)) + { + self.chord_matcher.cancel(); + return Some(key); + } let context = if self.overlay.is_some() { crate::keymap::KeymapContext::Pager } else { @@ -5175,6 +5180,23 @@ mod tests { assert!(matches!(state.overlay, Some(Overlay::Transcript(_)))); assert_eq!(state.pending_transcript_open, None); + let keymap = RuntimeKeymap::from_config( + &serde_json::from_value(serde_json::json!({ + "pager": {"scroll_up": ["ctrl-space x", "ctrl-x ctrl-space"]} + })) + .unwrap(), + ) + .unwrap(); + state.chord_keymap = keymap.chords; + let select = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL); + assert_eq!(state.route_key_chord(select), Some(select)); + assert_eq!( + state.route_key_chord(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + None + ); + assert!(state.chord_matcher.is_pending()); + assert_eq!(state.route_key_chord(select), Some(select)); + assert!(!state.chord_matcher.is_pending()); } #[tokio::test] diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_bottom.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_bottom.snap index 65c5f23184..3964a4d9f7 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_bottom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_bottom.snap @@ -13,7 +13,7 @@ Buffer { " ", " 1 -removed ", "───────────────────────────────────────── 100% ─", - " ↑/↓ to scroll pgup/pgdn to page home/end to", + " ↑/↓ to scroll · pgup/pgdn to page · home/end to", " q close ", " ", ], diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_top.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_top.snap index f02fa7b834..5f07ffbae4 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_top.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_top.snap @@ -1,5 +1,6 @@ --- source: tui/src/app/tests/patch_approval_tests.rs +assertion_line: 184 expression: "format!(\"{first:?}\")" --- Buffer { @@ -13,7 +14,7 @@ Buffer { " ", "m-updated.txt (+1 -1) ", "─────────────────────────────────────────── 0% ─", - " ↑/↓ to scroll pgup/pgdn to page home/end to", + " ↑/↓ to scroll · pgup/pgdn to page · home/end to", " q close ", " ", ], diff --git a/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__transcript_overlay_visualization_becomes_available.snap b/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__transcript_overlay_visualization_becomes_available.snap index 4c4ed08743..102ed9f8c2 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__transcript_overlay_visualization_becomes_available.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__transcript_overlay_visualization_becomes_available.snap @@ -1,19 +1,20 @@ --- source: tui/src/inline_visualization_tests.rs +assertion_line: 412 expression: "format!(\"before:\\n{unavailable}\\n\\nafter:\\n{available}\")" --- before: / T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / • Visualization unavailable on this device. -~ -~ -~ -~ -~ -~ -───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 100% ─ - ↑/↓ to scroll pgup/pgdn to page home/end to jump - q close esc to edit prev + + + + + + +Ctrl+Space select + ↑/↓ to scroll · pgup/pgdn to page · home/end to jump + q close · esc edit previous after: @@ -22,9 +23,9 @@ after: file:// • next message -~ -~ -~ -───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 100% ─ - ↑/↓ to scroll pgup/pgdn to page home/end to jump - q close esc to edit prev + + + +Ctrl+Space select + ↑/↓ to scroll · pgup/pgdn to page · home/end to jump + q close · esc edit previous diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap index 65ebd42c51..835faf8579 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap @@ -1,5 +1,6 @@ --- source: tui/src/pager_overlay.rs +assertion_line: 584 expression: term.backend() --- "/ S T A T I C / / / / / / / / / / / / / " @@ -9,6 +10,6 @@ expression: term.backend() "~ " "~ " "───────────────────────────────── 100% ─" -" ↑/↓ to scroll pgup/pgdn to page hom" +" ↑/↓ to scroll · pgup/pgdn to page · hom" " q close " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap index b71c911ea5..7619e85350 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap @@ -1,5 +1,6 @@ --- source: tui/src/pager_overlay.rs +assertion_line: 596 expression: term.backend() --- "/ S T A T I C / / / / / " @@ -7,6 +8,6 @@ expression: term.backend() "should wrap when " "rendered within a narrow" "─────────────────── 0% ─" -" ↑/↓ to scroll pgup/pg" +" ↑/↓ to scroll · pgup/pg" " q close " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__transcript_view__tests__wheel_stops_selection_autoscroll.snap b/codex-rs/tui/src/snapshots/codex_tui__transcript_view__tests__wheel_stops_selection_autoscroll.snap new file mode 100644 index 0000000000..bddf2511a9 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__transcript_view__tests__wheel_stops_selection_autoscroll.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/transcript_view/tests.rs +assertion_line: 265 +expression: "frames.join(\"\\n\\n\")" +--- +row 18 +row 19 +row 20 +row 21 +row 22 +row 23 + +row 26 +row 27 +row 28 +row 29 +row 30 +row 31 diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index 816b3e6003..185911ce23 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -341,7 +341,7 @@ impl StartupDraftPump { } return Ok(()); } - TuiEvent::FocusLost => { + TuiEvent::Mouse(_) | TuiEvent::FocusLost => { self.pending_paste_newline = Some((started_at, newlines)); return Ok(()); } @@ -399,7 +399,7 @@ impl StartupDraftPump { } } TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {} - TuiEvent::FocusLost => return Ok(()), + TuiEvent::Mouse(_) | TuiEvent::FocusLost => return Ok(()), } if self.initial_screen == StartupDraftInitialScreen::Composer { self.draw(tui, screen_size)?; diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index 160253586e..dcd20954b1 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -184,7 +184,7 @@ async fn run_startup_hooks_review_app( } } } - TuiEvent::Paste(_) | TuiEvent::FocusLost => {} + TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Mouse(_) => {} TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { draw_view(tui, &view)?; } diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__transcript_overlay_status_rate_limit_refresh.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__transcript_overlay_status_rate_limit_refresh.snap index 9dce4bfbba..406a11348e 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__transcript_overlay_status_rate_limit_refresh.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__transcript_overlay_status_rate_limit_refresh.snap @@ -1,5 +1,6 @@ --- source: tui/src/status/tests.rs +assertion_line: 1825 expression: "format!(\"before:\\n{before}\\n\\nafter:\\n{after}\")" --- before: @@ -20,18 +21,18 @@ before: │ Token usage: 0 total (0 input + 0 output) │ │ Limits: refresh requested; run /status again shortly. │ ╰──────────────────────────────────────────────────────────────────────────╯ -~ -~ -~ -~ -~ -~ -~ -~ -~ -───────────────────────────────────────────────────────────────────────── 100% ─ - ↑/↓ to scroll pgup/pgdn to page home/end to jump - q close esc to edit prev + + + + + + + + + +Ctrl+Space select + ↑/↓ to scroll · pgup/pgdn to page · home/end to jump + q close · esc edit previous after: @@ -56,11 +57,11 @@ after: ╰───────────────────────────────────────────────────────────────────────────╯ next message -~ -~ -~ -~ -~ -───────────────────────────────────────────────────────────────────────── 100% ─ - ↑/↓ to scroll pgup/pgdn to page home/end to jump - q close esc to edit prev + + + + + +Ctrl+Space select + ↑/↓ to scroll · pgup/pgdn to page · home/end to jump + q close · esc edit previous diff --git a/codex-rs/tui/src/terminal_hyperlinks.rs b/codex-rs/tui/src/terminal_hyperlinks.rs index a5fead1649..6db9897f3f 100644 --- a/codex-rs/tui/src/terminal_hyperlinks.rs +++ b/codex-rs/tui/src/terminal_hyperlinks.rs @@ -130,7 +130,7 @@ impl TerminalHyperlink { } } - fn terminal_destination(&self) -> Option { + pub(crate) fn terminal_destination(&self) -> Option { match self.destination_kind { DestinationKind::Web => web_destination(&self.destination), DestinationKind::TrustedFile => trusted_file_destination(&self.destination), diff --git a/codex-rs/tui/src/thread_transcript.rs b/codex-rs/tui/src/thread_transcript.rs index 0d77c0e31a..babf331fa2 100644 --- a/codex-rs/tui/src/thread_transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -30,35 +30,15 @@ mod exploration_groups; mod other_items; pub(crate) mod tools; -#[allow( - unused_imports, - reason = "Used by later layers of the TUI refresh stack." -)] pub(crate) use activity_pages::fold_trailing_activity_details; -#[allow( - unused_imports, - reason = "Used by later layers of the TUI refresh stack." -)] pub(crate) use activity_pages::is_hidden_activity_detail; #[allow( unused_imports, reason = "Used by later layers of the TUI refresh stack." )] pub(crate) use computer_groups::join_computer_groups; -#[allow( - unused_imports, - reason = "Used by later layers of the TUI refresh stack." -)] pub(crate) use computer_groups::older_computer_group; -#[allow( - unused_imports, - reason = "Used by later layers of the TUI refresh stack." -)] pub(crate) use exploration_groups::join_exploration_groups; -#[allow( - unused_imports, - reason = "Used by later layers of the TUI refresh stack." -)] pub(crate) use exploration_groups::older_exploration_group; pub(crate) type TranscriptCells = Vec>; diff --git a/codex-rs/tui/src/thread_transcript/computer_groups.rs b/codex-rs/tui/src/thread_transcript/computer_groups.rs index d77dcc2f4f..90efce872e 100644 --- a/codex-rs/tui/src/thread_transcript/computer_groups.rs +++ b/codex-rs/tui/src/thread_transcript/computer_groups.rs @@ -14,7 +14,6 @@ pub(super) fn append(group: &mut ComputerActivityCell, call: McpHistory) { } /// Rebuild only an adjacent computer group, using its persisted IDs to verify the turn boundary. -#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] pub(crate) fn join_computer_groups( older: &Arc, newer: &Arc, @@ -32,7 +31,6 @@ pub(crate) fn join_computer_groups( } /// Restore only the older half; the caller retains the live half and its pending calls/timers. -#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] pub(crate) fn older_computer_group( older: &dyn HistoryCell, newer: &ComputerActivityCell, @@ -51,7 +49,6 @@ pub(crate) fn older_computer_group( Some(group) } -#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] fn adjacent_items<'a>( older: &ComputerActivityCell, newer: &ComputerActivityCell, @@ -62,7 +59,6 @@ fn adjacent_items<'a>( } /// Validate source adjacency without mistaking invisible reasoning for an activity boundary. -#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] pub(super) fn adjacent_activity_items<'a>( ids: &[&str], turns: &'a [Turn], @@ -90,7 +86,6 @@ pub(super) fn adjacent_activity_items<'a>( None } -#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] pub(super) fn completed_group(items: &[ThreadItem]) -> Option { let mut group = None; for item in items { diff --git a/codex-rs/tui/src/thread_transcript/exploration_groups.rs b/codex-rs/tui/src/thread_transcript/exploration_groups.rs index a7d9569040..5b6194e5f2 100644 --- a/codex-rs/tui/src/thread_transcript/exploration_groups.rs +++ b/codex-rs/tui/src/thread_transcript/exploration_groups.rs @@ -7,7 +7,6 @@ use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::Turn; use std::sync::Arc; -#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] pub(crate) fn join_exploration_groups( older: &Arc, newer: &Arc, @@ -25,7 +24,6 @@ pub(crate) fn join_exploration_groups( Some(Arc::new(group)) } -#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] pub(crate) fn older_exploration_group( older: &dyn HistoryCell, newer: &ExecCell, @@ -44,7 +42,6 @@ pub(crate) fn older_exploration_group( Some(group) } -#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] fn adjacent_items<'a>( older: &ExecCell, newer: &ExecCell, @@ -61,7 +58,6 @@ fn adjacent_items<'a>( super::computer_groups::adjacent_activity_items(&ids, turns) } -#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] pub(super) fn completed_group(items: &[ThreadItem]) -> Option { let mut group: Option = None; for item in items { diff --git a/codex-rs/tui/src/transcript_mode.rs b/codex-rs/tui/src/transcript_mode.rs new file mode 100644 index 0000000000..43dc3ccc59 --- /dev/null +++ b/codex-rs/tui/src/transcript_mode.rs @@ -0,0 +1,24 @@ +//! Effective transcript ownership for this running TUI, independent of backend feature updates. +//! +//! Terminal restrictions take precedence over the configured ownership preference. The launch-time +//! mode survives session changes so native scrollback never receives retained-only updates. + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TranscriptMode { + Owned, + Terminal, +} + +impl TranscriptMode { + pub(crate) fn resolve(owned_enabled: bool, alternate_screen_enabled: bool) -> Self { + if owned_enabled && alternate_screen_enabled { + Self::Owned + } else { + Self::Terminal + } + } + + pub(crate) fn is_owned(self) -> bool { + self == Self::Owned + } +} diff --git a/codex-rs/tui/src/transcript_view.rs b/codex-rs/tui/src/transcript_view.rs index 1af821afe9..b891c0babf 100644 --- a/codex-rs/tui/src/transcript_view.rs +++ b/codex-rs/tui/src/transcript_view.rs @@ -1,17 +1,23 @@ //! A conversation viewport over the app's retained cells and its current live tail. //! -//! The app owns history and pagination. This view owns reading state and bounded -//! layout caches. Reading positions track entry identity and a display-row offset. -//! Resize and replacement clamp that offset; live content always uses its current revision. +//! The app owns history and pagination. This view owns only reading/selection state and bounded +//! layout caches. Anchors address content inside an entry, so prepending history never renumbers +//! them and rewrapping does not turn a reading position into an unrelated screen row. +mod activity; +mod footer; +mod input; mod layout; +mod mutations; +mod selection; +mod snapshot; mod text; use std::sync::Arc; use crate::chatwidget::ActiveCellTranscriptKey; use crate::history_cell::HistoryCell; -use crate::history_cell::UserHistoryCell; +use crate::history_cell::HistoryRenderMode; use crate::pager_overlay::TranscriptHistoryState; use crate::terminal_hyperlinks::HyperlinkLine; use ratatui::buffer::Buffer; @@ -22,8 +28,13 @@ use ratatui::widgets::Clear; use ratatui::widgets::Widget; use layout::LayoutCache; +use selection::Selection; +use snapshot::ViewSnapshot; use text::TextLayout; +pub(crate) use input::JumpTarget; +pub(crate) use input::ViewAction; + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] enum EntryKey { Cell(usize), @@ -40,7 +51,10 @@ impl EntryKey { struct Anchor { key: EntryKey, index: usize, - row: usize, + offset: usize, + // Synthetic rows share source offsets: positive biases precede source (separators), + // while negative biases follow it (disclosure controls). + row_bias: isize, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -57,8 +71,7 @@ struct VisibleRow { key: EntryKey, } -/// Entry-based scrolling for the detailed transcript overlay. -#[derive(Default)] +/// Shared scrolling and interaction state for compact and detailed transcript presentations. pub(crate) struct TranscriptView { position: Position, cache: LayoutCache, @@ -68,26 +81,65 @@ pub(crate) struct TranscriptView { live_continuation: bool, area: Rect, visible: Vec, + selection: Option, + held_reading: Option, + detailed: bool, + mode: HistoryRenderMode, pub(crate) history: TranscriptHistoryState, + highlight: Option, + saved_position: Option, + unseen_activity: bool, + tail_visible: bool, + last_tail: Option, + last_click: Option<(std::time::Instant, u16, u16, u8)>, +} + +impl Default for TranscriptView { + fn default() -> Self { + Self { + position: Position::Latest, + cache: LayoutCache::default(), + live: None, + live_separated: None, + live_key: None, + live_continuation: false, + area: Rect::default(), + visible: Vec::new(), + selection: None, + held_reading: None, + detailed: false, + mode: HistoryRenderMode::Rich, + history: TranscriptHistoryState::Idle, + highlight: None, + saved_position: None, + unseen_activity: false, + tail_visible: true, + last_tail: None, + last_click: None, + } + } } impl TranscriptView { - pub(crate) fn render( - &mut self, - area: Rect, - buf: &mut Buffer, - cells: &[Arc], - highlight: Option, - ) -> u16 { + pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer, cells: &[Arc]) { self.cache.begin_frame(); + self.sync_history_tail(cells); + let current_cells = cells; + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); Clear.render(area, buf); + if area.width != self.area.width { + self.rewrap_snapshot(area.width); + } self.area = area; + self.normalize_selection(cells); self.visible.clear(); if area.is_empty() { - return 0; + self.tail_visible = false; + return; } - let mut rendered = 0; - let (mut index, mut row) = self.start(cells); + let start = self.start(cells); + let (mut index, mut row) = start; for y in area.top()..area.bottom() { let Some(layout) = self.layout(cells, index) else { break; @@ -95,6 +147,7 @@ impl TranscriptView { if row >= layout.row_count() { index += 1; row = 0; + // Empty entries are legal (for example hidden reasoning). let Some(next) = self.next_nonempty(cells, index) else { break; }; @@ -105,12 +158,9 @@ impl TranscriptView { }; let key = self.entry_key(cells, index); let row_area = Rect::new(area.x, y, area.width, /*height*/ 1); - if (index == 0 || row > 0) - && cells - .get(index) - .is_some_and(|cell| cell.as_any().is::()) - { - buf.set_style(row_area, crate::style::history_prompt_style()); + layout.render(row_area, buf, row); + if self.highlight == Some(index) && self.selection.is_none() { + layout.highlight(0..layout.text().len(), row_area, buf, row); } self.visible.push(VisibleRow { index, @@ -119,30 +169,23 @@ impl TranscriptView { key, }); row += 1; - rendered += 1; } - let mut y = area.y; - for rows in self - .visible - .chunk_by(|a, b| Arc::ptr_eq(&a.layout, &b.layout)) + if let Some(pointer) = self + .selection + .as_ref() + .filter(|selection| selection.dragging && selection.moved) + .and_then(|selection| selection.pointer) { - let first = &rows[0]; - let height = rows.len() as u16; - first - .layout - .render(Rect::new(area.x, y, area.width, height), buf, first.row); - if highlight == Some(first.index) { - for (offset, row) in rows.iter().enumerate() { - row.layout.highlight( - Rect::new(area.x, y + offset as u16, area.width, /*height*/ 1), - buf, - row.row, - ); - } - } - y += height; + self.extend_selection(pointer.x, pointer.y); + } + self.tail_visible = self.current_tail_is_visible(current_cells); + if self.tail_visible { + self.unseen_activity = false; + } + self.render_selection(buf); + if self.history == TranscriptHistoryState::LoadingBeginning && self.is_following() { + self.scroll(current_cells, /*rows*/ 0); } - rendered } pub(crate) fn sync_live_tail( @@ -150,23 +193,65 @@ impl TranscriptView { width: u16, key: Option, lines: impl FnOnce(u16) -> Option>, - ) { + ) -> bool { + self.sync_live_layout(width, key, |width| { + lines(width).map(|lines| TextLayout::new(lines, width)) + }) + } + + fn sync_live_layout( + &mut self, + width: u16, + key: Option, + layout: impl FnOnce(u16) -> Option, + ) -> bool { let next = key.map(|key| (width, key)); if key.is_some_and(|key| key.cacheable) && self.live_key == next { - return; + return false; } + let revision_changed = self.live_key.is_none() + || self.live_key.map(|(_, key)| key.revision) != next.map(|(_, key)| key.revision); self.live_key = next; self.live_separated = None; self.live_continuation = key.is_some_and(|key| key.is_stream_continuation); - self.live = lines(width).map(|lines| Arc::new(TextLayout::new(lines, width))); + let live = layout(width).map(Arc::new); + if self.live.as_ref().map(|layout| layout.text()) + != live.as_ref().map(|layout| layout.text()) + && !self.is_following() + && revision_changed + && live.is_some() + { + self.unseen_activity = true; + } + let changed = self.live.is_some() || live.is_some(); + self.live = live; + changed + } + + pub(crate) fn set_presentation(&mut self, detailed: bool, mode: HistoryRenderMode) { + if self.detailed == detailed && self.mode == mode { + return; + } + self.selection = None; + self.release_live_reading(); + self.cache.clear(); + self.live_key = None; + // Search temporarily expands content without changing either presentation's position. + if self.detailed != detailed { + let previous = self.position; + self.position = self.saved_position.take().unwrap_or(previous); + self.saved_position = Some(previous); + } + self.detailed = detailed; + self.mode = mode; + } + + pub(crate) fn has_active_interaction(&self) -> bool { + self.selection.is_some() } pub(crate) fn is_following(&self) -> bool { - self.position == Position::Latest - } - - pub(crate) fn live_tail_visible(&self) -> bool { - self.visible.iter().any(|row| row.key == EntryKey::Live) + self.selection.is_none() && self.position == Position::Latest } /// Hold the current reading position until every older page has arrived. @@ -185,12 +270,22 @@ impl TranscriptView { pub(crate) fn jump_to_latest(&mut self) { self.cancel_beginning(); self.position = Position::Latest; + self.selection = None; + self.release_live_reading(); + self.unseen_activity = false; } pub(crate) fn scroll(&mut self, cells: &[Arc], rows: isize) { if rows != 0 { + self.last_click = None; self.cancel_beginning(); } + if self.area.is_empty() { + return; + } + let current_cells = cells; + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); let start = self.start(cells); let (index, row) = self.move_rows(cells, start.0, start.1, rows); if rows < 0 @@ -200,27 +295,44 @@ impl TranscriptView { { return; } + if (index, row) != start + && let Some(selection) = &mut self.selection + { + selection.resume_on_empty = false; + } // Anchor visible content, not a hidden header that stays ahead of every older page. let index = self.next_nonempty(cells, index).unwrap_or(index); let bottom = self.bottom_start(cells); if rows > 0 && (index, row) >= bottom { - self.jump_to_latest(); + if self.selection.is_none() { + self.jump_to_latest(); + } else { + self.position = Position::Latest; + } return; } - if self.layout(cells, index).is_some() { + if let Some(layout) = self.layout(cells, index) { + let offset = layout.position_at(row, /*column*/ 0); self.position = Position::Reading(Anchor { key: self.entry_key(cells, index), index, - row, + offset, + row_bias: layout.row_for_offset(offset) as isize - row as isize, }); + self.hold_live_reading(current_cells, layout); } } pub(crate) fn jump_to_entry(&mut self, cells: &[Arc], index: usize) { + self.cancel_beginning(); + self.release_live_reading(); + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); self.position = Position::Reading(Anchor { key: self.entry_key(cells, index), index, - row: 0, + offset: 0, + row_bias: 0, }); } @@ -231,67 +343,49 @@ impl TranscriptView { } } - /// Reveal an entire entry when prompt backtracking changes the highlight. pub(crate) fn ensure_entry_visible(&mut self, cells: &[Arc], index: usize) { - let key = cells.get(index).map_or(EntryKey::Live, EntryKey::cell); - let mut visible = self - .visible - .iter() - .filter(|row| row.index == index && row.key == key); - let fully_visible = visible - .next() - .is_some_and(|first| first.row == 0 && visible.count() + 1 == first.layout.row_count()); - if !fully_visible { + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); + if !self.visible.iter().any(|row| { + let start = row.layout.position_at(row.row, /*column*/ 0); + let end = row.layout.position_at(row.row, self.area.width); + row.index == index && !row.layout.text()[start..end].trim().is_empty() + }) { + // Restoring a highlight after pagination must not cancel an explicit Home jump. + let history = self.history; self.jump_to_entry(cells, index); + self.history = history; } } - /// Keep readers on the replacement entry without translating source offsets. - pub(crate) fn replace_range( - &mut self, - cells: &[Arc], - range: std::ops::Range, - replacement: &Arc, - ) { - if let Position::Reading(anchor) = self.position - && range.contains(&self.resolve(cells, anchor)) - { - self.position = Position::Reading(Anchor { - key: EntryKey::cell(replacement), - index: range.start, - ..anchor - }); - } + pub(crate) fn set_highlight(&mut self, index: Option) { + self.highlight = index; } - /// Preserve display rows when retained entries gain or lose a leading separator. - pub(crate) fn history_changed(&mut self, cells: &[Arc]) { - if let Position::Reading(anchor) = self.position { - let index = self.resolve(cells, anchor); - let separated = cells.get(index).is_some_and(|cell| { - EntryKey::cell(cell) == anchor.key && !cell.is_stream_continuation() - }); - let row = if separated && anchor.index == 0 && index > 0 { - anchor.row.saturating_add(/*rhs*/ 1) - } else if separated && anchor.index > 0 && index == 0 { - anchor.row.saturating_sub(/*rhs*/ 1) - } else { - anchor.row - }; - self.position = Position::Reading(Anchor { - key: self.entry_key(cells, index), - index, - row, - }); - } + pub(crate) fn needs_history(&mut self, cells: &[Arc]) -> bool { + self.near_start(cells) } pub(crate) fn near_start(&mut self, cells: &[Arc]) -> bool { - if self.is_following() && self.area.is_empty() { + if self.area.is_empty() && self.is_following() { return false; } let (index, row) = self.start(cells); - self.move_rows(cells, index, row, -(self.area.height as isize)) == (0, 0) + let threshold = usize::from(self.area.height); + let mut distance = row; + if distance > threshold { + return false; + } + for previous in (0..index).rev() { + distance = distance.saturating_add( + self.layout(cells, previous) + .map_or(/*default*/ 0, |layout| layout.row_count()), + ); + if distance > threshold { + return false; + } + } + true } fn start(&mut self, cells: &[Arc]) -> (usize, usize) { @@ -300,12 +394,15 @@ impl TranscriptView { Position::Reading(anchor) => { let index = self.resolve(cells, anchor); let row = self.layout(cells, index).map_or(/*default*/ 0, |layout| { - anchor.row.min(layout.row_count().saturating_sub(/*rhs*/ 1)) + layout + .row_for_offset(anchor.offset) + .saturating_add_signed(anchor.row_bias.saturating_neg()) + .min(layout.row_count().saturating_sub(/*rhs*/ 1)) }); self.position = Position::Reading(Anchor { key: self.entry_key(cells, index), index, - row, + ..anchor }); (index, row) } @@ -313,7 +410,12 @@ impl TranscriptView { } fn bottom_start(&mut self, cells: &[Arc]) -> (usize, usize) { - let count = cells.len() + usize::from(self.live.is_some()); + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); + let has_live = self.snapshot().map_or(self.live.is_some(), |snapshot| { + snapshot.pinned.contains_key(&EntryKey::Live) + }); + let count = cells.len() + usize::from(has_live); let Some(last) = count.checked_sub(/*rhs*/ 1) else { return (0, 0); }; @@ -363,12 +465,10 @@ impl TranscriptView { } fn resolve(&self, cells: &[Arc], anchor: Anchor) -> usize { + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); if anchor.key == EntryKey::Live { - return if self.live.is_some() { - cells.len() - } else { - cells.len().saturating_sub(/*rhs*/ 1) - }; + return cells.len(); } if cells .get(anchor.index) @@ -383,40 +483,12 @@ impl TranscriptView { } fn entry_key(&self, cells: &[Arc], index: usize) -> EntryKey { + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); cells.get(index).map_or(EntryKey::Live, EntryKey::cell) } } -impl TranscriptView { - fn layout(&mut self, cells: &[Arc], index: usize) -> Option> { - let Some(cell) = cells.get(index) else { - if index != cells.len() { - return None; - } - let live = self.live.as_ref()?; - if cells.is_empty() || self.live_continuation { - return Some(Arc::clone(live)); - } - return Some(Arc::clone(self.live_separated.get_or_insert_with(|| { - Arc::new(live.as_ref().clone().with_leading_separator()) - }))); - }; - let width = self.area.width.max(/*other*/ 1); - if cell.as_any().is::() - && let Some(placeholder) = self.history.session_header_placeholder() - { - return Some(Arc::new(TextLayout::new( - vec![HyperlinkLine::from(Line::from(placeholder).dim())], - width, - ))); - } - Some( - self.cache - .get(cell, width, index > 0 && !cell.is_stream_continuation()), - ) - } -} - #[cfg(test)] #[path = "transcript_view_tests.rs"] mod tests; diff --git a/codex-rs/tui/src/transcript_view/activity.rs b/codex-rs/tui/src/transcript_view/activity.rs new file mode 100644 index 0000000000..7d9c090843 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/activity.rs @@ -0,0 +1,33 @@ +//! A tail update is unseen only until its current final row has been painted. +//! Retained selections and reading snapshots may still hide a newer revision at the bottom. + +use super::*; + +impl TranscriptView { + pub(crate) fn can_return_to_latest(&self) -> bool { + !self.is_following() + } + + pub(super) fn current_tail_is_visible(&mut self, cells: &[Arc]) -> bool { + // Empty live previews and hidden reasoning cells do not obscure the actual tail. + for index in (0..=cells.len()).rev() { + let Some(layout) = self.current_layout(cells, index) else { + continue; + }; + if layout.row_count() == 0 { + continue; + } + let key = cells.get(index).map_or(EntryKey::Live, EntryKey::cell); + return self.visible.last().is_some_and(|visible| { + visible.key == key + && visible.layout.text() == layout.text() + && visible.row + 1 == layout.row_count() + }); + } + true + } +} + +#[cfg(test)] +#[path = "activity_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/transcript_view/activity_tests.rs b/codex-rs/tui/src/transcript_view/activity_tests.rs new file mode 100644 index 0000000000..eeadf2641d --- /dev/null +++ b/codex-rs/tui/src/transcript_view/activity_tests.rs @@ -0,0 +1,65 @@ +//! Activity follows visible rows and Escape respects transcript interaction ownership. + +use super::*; +use crate::history_cell::PlainHistoryCell; +use crate::motion::MotionMode; +use pretty_assertions::assert_eq; + +fn cell(text: &str) -> Arc { + Arc::new(PlainHistoryCell::new( + text.lines().map(|line| line.to_owned().into()).collect(), + )) +} + +fn render(view: &mut TranscriptView, cells: &[Arc], height: u16) { + let area = Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 32, height); + view.render(area, &mut Buffer::empty(area), cells); +} + +#[test] +fn visible_updates_and_empty_tail_cells_do_not_report_activity() { + let mut cells = vec![cell("one\ntwo\nthree\nfour")]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*height*/ 3); + view.scroll(&cells, /*rows*/ -3); + cells.push(cell("")); + render(&mut view, &cells, /*height*/ 3); + assert!(!view.unseen_activity); + cells.extend([cell("visible output"), cell("")]); + render(&mut view, &cells, /*height*/ 3); + assert!(view.unseen_activity); + render(&mut view, &cells, /*height*/ 8); + assert!(!view.unseen_activity); + assert_eq!( + view.footer(/*width*/ 80, MotionMode::Reduced) + .unwrap() + .text + .to_string(), + "esc latest" + ); +} + +#[test] +fn unchanged_live_revisions_do_not_report_activity_but_hidden_text_does() { + let cells = vec![cell("one\ntwo\nthree\nfour")]; + let mut view = TranscriptView::default(); + for (revision, text, unseen) in [ + (1, "tail", false), + (2, "tail", false), + (3, "new tail", true), + ] { + view.sync_live_tail( + /*width*/ 32, + Some(ActiveCellTranscriptKey { + revision, + animation_tick: None, + is_stream_continuation: false, + cacheable: true, + }), + |_| Some(vec![HyperlinkLine::from(text)]), + ); + render(&mut view, &cells, /*height*/ 3); + assert_eq!(view.unseen_activity, unseen); + view.scroll(&cells, /*rows*/ -3); + } +} diff --git a/codex-rs/tui/src/transcript_view/diff_source_tests.rs b/codex-rs/tui/src/transcript_view/diff_source_tests.rs new file mode 100644 index 0000000000..cd2651fd2b --- /dev/null +++ b/codex-rs/tui/src/transcript_view/diff_source_tests.rs @@ -0,0 +1,76 @@ +//! Diff copy text retains code indentation and signs while gutters and soft wraps stay visual. + +use super::*; +use crate::diff_model::FileChange; +use crate::history_cell::HistoryCell; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +#[test] +fn wrapped_added_code_keeps_indentation_and_graphemes_when_copied_and_resized() { + let code = + "\tlet message = \"a long value with 界 e\u{301} and 👩‍💻 inside\";\n return message;\n"; + let patch = crate::history_cell::new_patch_event( + HashMap::from([( + PathBuf::from("test.rs"), + FileChange::Add { + content: code.to_string(), + }, + )]), + Path::new("/project"), + ); + let layout = TextLayout::new( + patch.display_hyperlink_lines(/*width*/ 40), + /*width*/ 40, + ); + let expected = "• Added test.rs (+2 -0)\n+ let message = \"a long value with 界 e\u{301} and 👩‍💻 inside\";\n+ return message;"; + assert_eq!(layout.text(), expected); + assert_eq!(layout.rewrap(/*width*/ 18).text(), expected); + assert!(layout.row_count() > 3); +} + +#[test] +fn annotated_diff_preserves_the_existing_hard_wrapped_visual_output() { + let changes = HashMap::from([( + PathBuf::from("test.rs"), + FileChange::Add { + content: " let answer = calculate_the_result(first_argument, second_argument);\n" + .to_string(), + }, + )]); + let lines = crate::diff_render::create_diff_summary_with_links( + &changes, + Path::new("/project"), + /*wrap_cols*/ 40, + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 8, + ); + let mut expected = Buffer::empty(area); + HyperlinkParagraph::new(&lines, Style::default()).render(area, &mut expected); + let mut actual = Buffer::empty(area); + TextLayout::new(lines, /*width*/ 40).render(area, &mut actual, /*start_row*/ 0); + assert_eq!(actual, expected); +} + +#[test] +fn updated_diff_copy_includes_signs_but_no_line_number_gutters() { + let patch = crate::history_cell::new_patch_event( + HashMap::from([(PathBuf::from("example.txt"), FileChange::Update { + unified_diff: "--- a/example.txt\n+++ b/example.txt\n@@ -1,2 +1,2 @@\n unchanged\n-old long value with several words\n+new long value with several words\n".to_string(), + move_path: None, + })]), + Path::new("/project"), + ); + let layout = TextLayout::new( + patch.display_hyperlink_lines(/*width*/ 20), + /*width*/ 20, + ); + let body = layout.text().split_once('\n').expect("header then diff").1; + assert_eq!( + body, + " unchanged\n-old long value with several words\n+new long value with several words" + ); +} diff --git a/codex-rs/tui/src/transcript_view/footer.rs b/codex-rs/tui/src/transcript_view/footer.rs new file mode 100644 index 0000000000..9528b68c7d --- /dev/null +++ b/codex-rs/tui/src/transcript_view/footer.rs @@ -0,0 +1,220 @@ +//! Project transcript interactions and page-loading feedback into the existing footer. +//! Loading never inserts transcript rows; the owner schedules motion only while a page is pending. +//! Surface owners supply the latest shortcut because legacy pagers reserve Escape for editing. +//! Known navigation labels style keys separately from prose; transcript and query text stay untouched. + +use super::*; +use crate::bottom_pane::TranscriptFooter; +use crate::footer_hint::first_fitting_line; +use crate::key_hint::key_label_spans; +use crate::motion::MotionMode; +use crate::motion::shimmer_text; +use crate::style::accent_color; +use crossterm::event::KeyCode; +use unicode_width::UnicodeWidthStr; + +impl TranscriptView { + pub(crate) fn footer_with_navigation( + &self, + width: u16, + motion: MotionMode, + latest_navigation: &str, + ) -> Option { + // A mouse-down anchor owns the gesture, but has no text to copy yet. Compare + // source positions so movement across wrapping or padding is not a selection. + let has_selected_text = self.selection.as_ref().is_some_and(|selection| { + (selection.start.key, selection.start.offset) + != (selection.end.key, selection.end.offset) + }); + + if has_selected_text { + return Some(TranscriptFooter { + text: first_fitting_line( + [ + self.status_line_with_navigation( + "ctrl+c copy · enter copy & follow · esc clear", + motion, + ), + selection_hint(width), + ], + width, + ) + .into(), + cursor_column: None, + }); + } + + let pending = self.is_loading_history() || self.history == TranscriptHistoryState::Failed; + let can_return = self.selection.is_none() && self.can_return_to_latest(); + (pending || self.unseen_activity || can_return).then(|| { + let navigation = if self.can_return_to_latest() { + latest_navigation + } else { + "" + }; + let mut status = self.status_line_with_navigation(navigation, motion); + // Loading must not resize the composer or move its caret. Shorten secondary text + // before clipping either shortcut; failed loads prioritize their retry action. + if status.width() > usize::from(width) { + status = if self.is_loading_history() { + let loading = if navigation.is_empty() { + if "↑ Loading…".width() <= usize::from(width) { + "Loading…" + } else { + "…" + } + } else if "↑ Loading… · ".width() + navigation.width() <= usize::from(width) + { + "Loading… · " + } else { + "… " + }; + let mut spans = vec!["↑ ".fg(accent_color())]; + spans.extend(shimmer_text(loading, motion)); + spans.extend(navigation_line(navigation).spans); + Line::from(spans) + } else if self.history == TranscriptHistoryState::Failed { + let mut spans = vec!["Retry: ".dim()]; + spans.extend(key_label_spans(&JumpTarget::Beginning.hint_label())); + Line::from(spans) + } else if navigation.is_empty() { + Line::from("New activity").dim() + } else if self.unseen_activity { + let mut spans = vec!["New · ".dim()]; + spans.extend(navigation_line(navigation).spans); + Line::from(spans) + } else { + navigation_line(navigation) + }; + } + let retry = if self.history == TranscriptHistoryState::Failed { + format!( + "{} retry", + crate::key_hint::ctrl(KeyCode::Home).display_label() + ) + } else { + navigation.to_owned() + }; + TranscriptFooter { + text: first_fitting_line( + [status, navigation_line(&retry), navigation_line(navigation)], + width, + ) + .into(), + cursor_column: None, + } + }) + } + + pub(crate) fn status_line_with_navigation( + &self, + navigation: &str, + motion: MotionMode, + ) -> Line<'static> { + if self.is_loading_history() { + let mut spans = vec!["↑ ".fg(accent_color())]; + spans.extend(shimmer_text("Loading earlier messages…", motion)); + if self.unseen_activity { + spans.push(" · New activity".dim()); + } + if !navigation.is_empty() { + spans.push(" · ".dim()); + spans.extend(navigation_line(navigation).spans); + } + return Line::from(spans); + } + if self.selection.is_some() && self.history == TranscriptHistoryState::Failed { + let mut spans = navigation_line(navigation).spans; + spans.push(" · Retry: ".dim()); + spans.extend(key_label_spans(&JumpTarget::Beginning.hint_label())); + return Line::from(spans); + } + let history = match self.history { + TranscriptHistoryState::Failed => { + let mut spans = vec!["Retry history: ".dim()]; + spans.extend(key_label_spans(&JumpTarget::Beginning.hint_label())); + spans.push(". ".dim()); + spans + } + TranscriptHistoryState::Partial => vec!["Earlier messages available. ".dim()], + TranscriptHistoryState::LoadingOlder + | TranscriptHistoryState::LoadingBeginning + | TranscriptHistoryState::Complete + | TranscriptHistoryState::Idle => Vec::new(), + }; + let activity = if self.unseen_activity { + if history.is_empty() && navigation.is_empty() { + "New activity" + } else { + "New activity · " + } + } else { + "" + }; + let mut spans = vec![activity.dim()]; + spans.extend(history); + spans.extend(navigation_line(navigation).spans); + Line::from(spans) + } + + pub(crate) fn is_loading_history(&self) -> bool { + matches!( + self.history, + TranscriptHistoryState::LoadingOlder | TranscriptHistoryState::LoadingBeginning + ) + } +} + +fn selection_hint(width: u16) -> Line<'static> { + first_fitting_line( + [ + "ctrl+c copy · enter copy & follow · esc clear", + "enter copy & follow · esc clear", + "enter copy+↓ · esc", + "esc clear", + ] + .map(navigation_line), + width, + ) +} + +/// Style only the explicit key-label prefixes of trusted navigation hints. +/// +/// Callers supply UI-authored hints, including runtime key chords followed by a known action. +/// Unknown segments remain dim prose; this must never be applied to transcript or query content. +pub(super) fn navigation_line(navigation: &str) -> Line<'static> { + let mut spans = Vec::new(); + for (index, hint) in navigation.split(" · ").enumerate() { + if index > 0 { + spans.push(" · ".dim()); + } + let action = [ + " copy & follow", + " clear selection", + " copy+↓", + " previous", + " latest", + " retry", + " select", + " copy", + " clear", + " next", + " close", + ] + .into_iter() + .find(|action| hint.ends_with(*action)); + if let Some(action) = action { + spans.extend(key_label_spans(&hint[..hint.len() - action.len()])); + spans.push(action.dim()); + } else if hint == "esc" { + spans.extend(key_label_spans(hint)); + } else { + spans.push(hint.to_owned().dim()); + } + } + Line::from(spans) +} + +#[cfg(test)] +#[path = "footer_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/transcript_view/footer_tests.rs b/codex-rs/tui/src/transcript_view/footer_tests.rs new file mode 100644 index 0000000000..0bf3dc9515 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/footer_tests.rs @@ -0,0 +1,164 @@ +//! Loading feedback preserves the transcript's selection, search editor, and passive context. + +use super::*; +use crate::history_cell::PlainHistoryCell; +use crate::style::accent_color; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; + +fn render(view: &mut TranscriptView, cells: &[Arc]) -> Buffer { + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 3, + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer, cells); + buffer +} + +#[test] +fn loading_preserves_selected_content_and_copy_action() { + let cells: Vec> = vec![Arc::new(PlainHistoryCell::new(vec![ + "selected words".into(), + ]))]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + view.end_drag(); + let before = render(&mut view, &cells); + let position = view.position; + + for state in [ + TranscriptHistoryState::LoadingOlder, + TranscriptHistoryState::LoadingBeginning, + ] { + view.history = state; + let footer = view + .footer(/*width*/ 80, MotionMode::Reduced) + .expect("loading selection footer"); + assert_eq!(footer.cursor_column, None); + insta::allow_duplicates! { + insta::assert_snapshot!(footer.text.to_string(), @"↑ Loading earlier messages… · ctrl+c copy · enter copy & follow · esc clear"); + } + let Some(ViewAction::Copy(copied)) = view.handle_key( + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + &cells, + ) else { + panic!("copy selection while loading"); + }; + assert_eq!(copied, "selected words"); + assert_eq!( + (render(&mut view, &cells), view.position), + (before.clone(), position) + ); + } + view.history = TranscriptHistoryState::Complete; + let footer = view + .footer(/*width*/ 80, MotionMode::Reduced) + .expect("selection footer after loading"); + insta::assert_snapshot!(footer.text.to_string(), @"ctrl+c copy · enter copy & follow · esc clear"); + assert_eq!( + view.selected_text(&cells), + Some("selected words".to_string()) + ); + view.history = TranscriptHistoryState::Failed; + insta::assert_snapshot!(view.footer(/*width*/ 38, MotionMode::Reduced).unwrap().text.to_string(), + @"enter copy & follow · esc clear"); +} + +#[test] +fn loading_completion_restores_unseen_activity_and_failure_stops_motion() { + let mut cells: Vec> = vec![Arc::new(PlainHistoryCell::new(vec![ + "one".into(), + "two".into(), + "three".into(), + "four".into(), + ]))]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + view.scroll(&cells, /*rows*/ -1); + cells.push(Arc::new(PlainHistoryCell::new(vec!["new output".into()]))); + render(&mut view, &cells); + let before = view + .footer(/*width*/ 80, MotionMode::Reduced) + .expect("unseen activity footer") + .text; + view.history = TranscriptHistoryState::LoadingOlder; + let loading = view + .footer(/*width*/ 80, MotionMode::Reduced) + .expect("loading footer"); + assert_eq!(loading.text.lines[0].spans[0], "↑ ".fg(accent_color())); + insta::assert_snapshot!(loading.text.to_string(), @"↑ Loading earlier messages… · New activity · esc latest"); + view.history = TranscriptHistoryState::Failed; + assert!(!view.is_loading_history()); + let failed = view + .footer(/*width*/ 80, MotionMode::Reduced) + .expect("failed history footer"); + insta::assert_snapshot!(failed.text.to_string(), @"New activity · Retry history: ⌥+> = vec![Arc::new(PlainHistoryCell::new(vec![ + "retained history".into(), + ]))]; + let mut snapshots = Vec::new(); + for width in [26, 38, 78] { + for history in [ + TranscriptHistoryState::LoadingOlder, + TranscriptHistoryState::LoadingBeginning, + TranscriptHistoryState::Failed, + TranscriptHistoryState::Complete, + ] { + let mut view = TranscriptView::default(); + view.jump_to_entry(&cells, /*index*/ 0); + view.history = history; + view.unseen_activity = true; + let footer = view + .footer(width, MotionMode::Reduced) + .expect("activity footer"); + assert_eq!(footer.text.height(), 1); + assert!(footer.text.width() <= usize::from(width)); + snapshots.push(format!("{width} columns, {history:?}: {}", footer.text)); + } + } + insta::assert_snapshot!("compact_navigation_hints", snapshots.join("\n")); +} + +#[derive(Debug)] +struct ActivityCell; + +impl HistoryCell for ActivityCell { + fn display_lines(&self, _width: u16) -> Vec> { + vec!["Activity summary".into()] + } + + fn raw_lines(&self) -> Vec> { + self.transcript_lines(/*width*/ 80) + } + + fn transcript_lines(&self, _width: u16) -> Vec> { + vec!["Activity summary".into(), "Hidden details".into()] + } + + fn activity_ids(&self) -> Vec { + vec!["sample-activity".into()] + } +} + +impl TranscriptView { + pub(crate) fn footer(&self, width: u16, motion: MotionMode) -> Option { + self.footer_with_navigation(width, motion, "esc latest") + } +} diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs new file mode 100644 index 0000000000..97c5ba79bd --- /dev/null +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -0,0 +1,292 @@ +//! Transcript gestures leave ordinary typing and composer editing with the existing input path. + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; +use ratatui::layout::Position as ScreenPosition; +use std::time::Duration; +use std::time::Instant; + +use super::*; + +pub(crate) enum ViewAction { + Changed, + Copy(String), + CopyAndFollow(String), + OpenLink(String), +} + +/// Transcript jumps shared with input routing so legacy terminals preserve their Alt modifier. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum JumpTarget { + Beginning, + Latest, +} + +impl JumpTarget { + /// Include the Alt alternative for terminals that do not send Ctrl+Home/End. + pub(crate) fn hint_label(self) -> String { + let (control, alternate) = match self { + Self::Beginning => (KeyCode::Home, KeyCode::Char('<')), + Self::Latest => (KeyCode::End, KeyCode::Char('>')), + }; + let alternate = crate::key_hint::alt(alternate).display_label(); + format!( + "{alternate}/{}", + crate::key_hint::ctrl(control).display_label() + ) + } + + pub(crate) fn from_key(key: KeyEvent) -> Option { + if key.kind == KeyEventKind::Release { + return None; + } + match (key.code, key.modifiers) { + (KeyCode::Home, KeyModifiers::CONTROL) => Some(Self::Beginning), + (KeyCode::End, KeyModifiers::CONTROL) => Some(Self::Latest), + // Zellij can omit the shifted alternate character from enhanced key events. + (KeyCode::Char(','), modifiers) + if modifiers == (KeyModifiers::ALT | KeyModifiers::SHIFT) => + { + Some(Self::Beginning) + } + (KeyCode::Char('.'), modifiers) + if modifiers == (KeyModifiers::ALT | KeyModifiers::SHIFT) => + { + Some(Self::Latest) + } + // Terminals differ on whether shifted punctuation retains the Shift modifier. + (KeyCode::Char('<'), modifiers) + if modifiers.difference(KeyModifiers::SHIFT) == KeyModifiers::ALT => + { + Some(Self::Beginning) + } + (KeyCode::Char('>'), modifiers) + if modifiers.difference(KeyModifiers::SHIFT) == KeyModifiers::ALT => + { + Some(Self::Latest) + } + _ => None, + } + } +} + +impl TranscriptView { + /// Keep selection and Find control keys ahead of configurable chord prefixes. + pub(crate) fn owns_interaction_key(&self, key: KeyEvent) -> bool { + if key.kind == KeyEventKind::Release { + return false; + } + let (code, modifiers) = crate::key_hint::normalize_key_parts(key.code, key.modifiers); + if (code == KeyCode::Char(' ') && modifiers == KeyModifiers::CONTROL) + || JumpTarget::from_key(key).is_some() + { + return true; + } + if self.selection.is_some() { + return (code == KeyCode::Char('c') + && matches!(modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER)) + || (code == KeyCode::Char('c') + && modifiers == (KeyModifiers::CONTROL | KeyModifiers::SHIFT)) + || code == KeyCode::Esc + || (code == KeyCode::Enter && modifiers == KeyModifiers::NONE) + || (matches!(modifiers, KeyModifiers::NONE | KeyModifiers::SHIFT) + && matches!( + code, + KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down + )) + || (modifiers == KeyModifiers::NONE + && matches!(code, KeyCode::PageUp | KeyCode::PageDown)); + } + false + } + + pub(crate) fn handle_key( + &mut self, + key: KeyEvent, + cells: &[Arc], + ) -> Option { + if key.kind == KeyEventKind::Release { + return None; + } + + if self.selection.is_some() { + if let Some(action) = self.handle_selection_key(key, cells) { + return Some(action); + } + self.end_selection(cells); + } + let (code, modifiers) = crate::key_hint::normalize_key_parts(key.code, key.modifiers); + if modifiers == KeyModifiers::CONTROL && code == KeyCode::Char(' ') { + self.begin_selection(cells, self.area.x, self.area.y, /*clicks*/ 1); + if let Some(selection) = &mut self.selection { + selection.dragging = false; + selection.resume_on_empty = false; + } + return Some(ViewAction::Changed); + } + + self.handle_scroll_key(key, cells) + } + + pub(crate) fn handle_mouse( + &mut self, + event: MouseEvent, + cells: &[Arc], + ) -> Option { + let inside = self + .area + .contains(ScreenPosition::new(event.column, event.row)); + let dragging = self + .selection + .as_ref() + .is_some_and(|selection| selection.dragging); + if !inside && !dragging { + return None; + } + // Wheel navigation supersedes the last drag position. A real drag event can resume it. + if matches!( + event.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) && let Some(selection) = &mut self.selection + { + selection.pointer = None; + } + match event.kind { + MouseEventKind::ScrollUp => self.scroll(cells, /*rows*/ -3), + MouseEventKind::ScrollDown => self.scroll(cells, /*rows*/ 3), + MouseEventKind::Down(MouseButton::Left) => return self.pointer_down(event, cells), + MouseEventKind::Drag(MouseButton::Left) if dragging => { + self.extend_selection(event.column, event.row); + } + MouseEventKind::Up(MouseButton::Left) if dragging => { + if self + .selection + .as_ref() + .is_some_and(|selection| selection.moved && selection.pointer.is_some()) + { + self.extend_selection(event.column, event.row); + } + self.end_drag(); + if self.selected_text(cells).is_none() { + self.end_selection(cells); + } + } + _ => return None, + } + Some(ViewAction::Changed) + } + + fn handle_selection_key( + &mut self, + key: KeyEvent, + cells: &[Arc], + ) -> Option { + // Kitty keyboard reporting delivers macOS Cmd+C as Super+C, including over SSH to + // non-macOS hosts. Ghostty forwards it when there is no terminal-native selection. + // Crossterm can encode Ctrl+Shift+C as uppercase C with only Control set. + let (code, modifiers) = crate::key_hint::normalize_key_parts(key.code, key.modifiers); + if (matches!(modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER) + && code == KeyCode::Char('c')) + || (modifiers == (KeyModifiers::CONTROL | KeyModifiers::SHIFT) + && code == KeyCode::Char('c')) + || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Enter) + { + return Some( + self.selected_text(cells) + .filter(|text| !text.is_empty()) + .map_or(ViewAction::Changed, |text| { + if key.code == KeyCode::Enter { + ViewAction::CopyAndFollow(text) + } else { + ViewAction::Copy(text) + } + }), + ); + } + if key.code == KeyCode::Esc { + self.end_selection(cells); + return Some(ViewAction::Changed); + } + if matches!(key.modifiers, KeyModifiers::NONE | KeyModifiers::SHIFT) + && self.selection_key(cells, key.code) + { + return Some(ViewAction::Changed); + } + self.handle_scroll_key(key, cells) + } + + fn handle_scroll_key( + &mut self, + key: KeyEvent, + cells: &[Arc], + ) -> Option { + let jump = JumpTarget::from_key(key); + match (key.code, key.modifiers) { + (KeyCode::PageUp, KeyModifiers::NONE) => { + self.scroll( + cells, + -(self.area.height.saturating_sub(/*rhs*/ 1).max(/*other*/ 1) as isize), + ); + } + (KeyCode::PageDown, KeyModifiers::NONE) => { + self.scroll( + cells, + self.area.height.saturating_sub(/*rhs*/ 1).max(/*other*/ 1) as isize, + ); + } + (KeyCode::Esc, KeyModifiers::NONE) if self.can_return_to_latest() => { + self.jump_to_latest(); + } + _ if jump == Some(JumpTarget::Beginning) => { + self.jump_to_beginning(cells); + } + _ if jump == Some(JumpTarget::Latest) => self.jump_to_latest(), + _ => return None, + } + Some(ViewAction::Changed) + } + + fn pointer_down( + &mut self, + event: MouseEvent, + cells: &[Arc], + ) -> Option { + let visible = self + .visible + .get(usize::from(event.row.checked_sub(self.area.y)?))?; + if event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER) + { + return visible + .layout + .link_at(visible.row, event.column.saturating_sub(self.area.x)) + .map(ViewAction::OpenLink); + } + let now = Instant::now(); + let clicks = self + .last_click + .filter(|(at, column, row, _)| { + now.duration_since(*at) < Duration::from_millis(/*millis*/ 400) + && *column == event.column + && *row == event.row + }) + .map_or(/*default*/ 1, |(_, _, _, clicks)| clicks % 3 + 1); + self.last_click = Some((now, event.column, event.row, clicks)); + if clicks >= 2 + && visible + .layout + .position_at(visible.row, event.column.saturating_sub(self.area.x)) + == visible.layout.position_at(visible.row, u16::MAX) + { + return None; + } + self.begin_selection(cells, event.column, event.row, clicks); + Some(ViewAction::Changed) + } +} diff --git a/codex-rs/tui/src/transcript_view/layout.rs b/codex-rs/tui/src/transcript_view/layout.rs index 9528170377..b869314fac 100644 --- a/codex-rs/tui/src/transcript_view/layout.rs +++ b/codex-rs/tui/src/transcript_view/layout.rs @@ -5,9 +5,7 @@ use std::sync::Weak; -use super::TextLayout; -use crate::history_cell::HistoryCell; -use std::sync::Arc; +use super::*; const MAX_CACHED_ENTRIES: usize = 64; const MAX_CACHED_TEXT_BYTES: usize = 8 * 1024 * 1024; @@ -30,12 +28,84 @@ struct RenderState { struct CachedLayout { source: Weak, width: u16, - separated: bool, + presentation: CellPresentation, layout: Arc, rendered_frame: u64, animation_tick: Option, } +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) struct CellPresentation { + separated: bool, +} + +impl TranscriptView { + pub(super) fn layout( + &mut self, + cells: &[Arc], + index: usize, + ) -> Option> { + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); + if index > cells.len() { + return None; + } + let key = self.entry_key(cells, index); + if let Some(layout) = self + .snapshot() + .and_then(|snapshot| snapshot.pinned.get(&key)) + { + return Some(Arc::clone(layout)); + } + if index == cells.len() && self.snapshot().is_some() { + return None; + } + self.current_layout(cells, index) + } + + /// Resolve the current revision without a reader's retained snapshot. + pub(super) fn current_layout( + &mut self, + cells: &[Arc], + index: usize, + ) -> Option> { + let Some(cell) = cells.get(index) else { + if index != cells.len() { + return None; + } + let live = self.live.as_ref()?; + if cells.is_empty() || self.live_continuation { + return Some(Arc::clone(live)); + } + let width = self.area.width.max(/*other*/ 1); + return Some(Arc::clone(self.live_separated.get_or_insert_with(|| { + Arc::new(live.rewrap(width).with_leading_separator()) + }))); + }; + let width = self.area.width.max(/*other*/ 1); + // Session information belongs before all history, including pages not loaded yet. + // Pinned displayed revisions above remain visible; hidden headers never enter the cache. + if self.history.has_unloaded_history() + && cell.as_any().is::() + { + return Some(Arc::new(TextLayout::new(Vec::new(), width))); + } + let detailed = self.detailed; + let mode = self.mode; + let separated = index > 0 && !cell.is_stream_continuation(); + let presentation = CellPresentation { separated }; + Some(self.cache.get(cell, width, presentation, || { + if detailed { + TextLayout::new(cell.transcript_hyperlink_lines(width), width) + } else if mode == HistoryRenderMode::Rich { + TextLayout::new(cell.compact_hyperlink_lines(width), width) + } else { + TextLayout::new(cell.display_hyperlink_lines_for_mode(width, mode), width) + } + })) + } +} + impl LayoutCache { pub(super) fn begin_frame(&mut self) { self.frame = self.frame.wrapping_add(/*rhs*/ 1); @@ -55,13 +125,14 @@ impl LayoutCache { &mut self, cell: &Arc, width: u16, - separated: bool, + presentation: CellPresentation, + render: impl FnOnce() -> TextLayout, ) -> Arc { let source = Arc::downgrade(cell); let animation_tick = cell.transcript_animation_tick(); if let Some(index) = self.entries.iter().position(|entry| { entry.width == width - && entry.separated == separated + && entry.presentation == presentation && entry.source.ptr_eq(&source) && (entry.rendered_frame == self.frame || (cell.has_stable_transcript_height() @@ -74,8 +145,8 @@ impl LayoutCache { } return layout; } - let layout = TextLayout::new(cell.transcript_hyperlink_lines(width), width); - let layout = Arc::new(if separated { + let layout = render(); + let layout = Arc::new(if presentation.separated { layout.with_leading_separator() } else { layout @@ -84,7 +155,7 @@ impl LayoutCache { self.entries.push(CachedLayout { source, width, - separated, + presentation, layout: Arc::clone(&layout), rendered_frame: self.frame, animation_tick, @@ -93,17 +164,21 @@ impl LayoutCache { layout } + pub(super) fn clear(&mut self) { + self.entries.clear(); + } + fn evict(&mut self) { let mut bytes = self .entries .iter() - .map(|entry| entry.layout.byte_len) + .map(|entry| entry.layout.text().len()) .sum::(); // Retain a single oversized entry rather than repeatedly laying it out while visible. while self.entries.len() > 1 && (self.entries.len() > MAX_CACHED_ENTRIES || bytes > MAX_CACHED_TEXT_BYTES) { - bytes -= self.entries.remove(/*index*/ 0).layout.byte_len; + bytes -= self.entries.remove(/*index*/ 0).layout.text().len(); } } } diff --git a/codex-rs/tui/src/transcript_view/layout_tests.rs b/codex-rs/tui/src/transcript_view/layout_tests.rs index 614bb76f0d..dedbbba0bc 100644 --- a/codex-rs/tui/src/transcript_view/layout_tests.rs +++ b/codex-rs/tui/src/transcript_view/layout_tests.rs @@ -41,21 +41,29 @@ fn layouts_refresh_for_width_animation_and_mutable_frames() { }); let history = cell.clone() as Arc; let mut cache = LayoutCache::default(); + let get = |cache: &mut LayoutCache, width| { + cache.get( + &history, + width, + CellPresentation { separated: false }, + || TextLayout::new(history.transcript_hyperlink_lines(width), width), + ) + }; cache.begin_frame(); - cache.get(&history, /*width*/ 20, /*separated*/ false); - cache.get(&history, /*width*/ 20, /*separated*/ false); + get(&mut cache, /*width*/ 20); + get(&mut cache, /*width*/ 20); assert_eq!(cell.renders.load(Ordering::Relaxed), 1); cache.begin_frame(); - cache.get(&history, /*width*/ 20, /*separated*/ false); + get(&mut cache, /*width*/ 20); assert_eq!( cell.renders.load(Ordering::Relaxed), 1 + usize::from(mutable) ); cell.tick.store(/*val*/ 2, Ordering::Relaxed); cache.begin_frame(); - let next = cache.get(&history, /*width*/ 20, /*separated*/ false); - assert_eq!(next.rows[0].line.line.to_string(), "tick 2"); - cache.get(&history, /*width*/ 10, /*separated*/ false); + let next = get(&mut cache, /*width*/ 20); + assert_eq!(next.text(), "tick 2"); + get(&mut cache, /*width*/ 10); assert_eq!( cell.renders.load(Ordering::Relaxed), 3 + usize::from(mutable) @@ -67,7 +75,7 @@ fn layouts_refresh_for_width_animation_and_mutable_frames() { }, || { cache.begin_frame(); - cache.get(&history, /*width*/ 10, /*separated*/ false); + get(&mut cache, /*width*/ 10); assert_eq!( cell.renders.load(Ordering::Relaxed), 4 + usize::from(mutable) @@ -83,17 +91,31 @@ fn recent_entries_are_reused_and_old_entries_are_evicted() { let mut cache = LayoutCache::default(); cache.begin_frame(); for cell in &cells { + let history = cell.clone() as Arc; cache.get( - &(cell.clone() as Arc), + &history, /*width*/ 20, - /*separated*/ false, + CellPresentation { separated: false }, + || { + TextLayout::new( + history.transcript_hyperlink_lines(/*width*/ 20), + /*width*/ 20, + ) + }, ); } for index in [99, 98, 0] { + let history = cells[index].clone() as Arc; cache.get( - &(cells[index].clone() as Arc), + &history, /*width*/ 20, - /*separated*/ false, + CellPresentation { separated: false }, + || { + TextLayout::new( + history.transcript_hyperlink_lines(/*width*/ 20), + /*width*/ 20, + ) + }, ); } assert_eq!( diff --git a/codex-rs/tui/src/transcript_view/mutations.rs b/codex-rs/tui/src/transcript_view/mutations.rs new file mode 100644 index 0000000000..248d292541 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/mutations.rs @@ -0,0 +1,251 @@ +//! Carry reading anchors through commits and retain displayed or searched groups across page joins. + +use std::ops::Range; + +use super::*; + +impl TranscriptView { + pub(crate) fn history_loaded( + &mut self, + cells: &[Arc], + inserted: Range, + ) { + // With only a header and live output, the first older page extends the canonical tail. + if !inserted.is_empty() + && inserted.end == cells.len() + && self.last_tail == cells[..inserted.start].last().map(EntryKey::cell) + { + self.last_tail = cells.last().map(EntryKey::cell); + } + self.prepend_snapshot_history(cells, inserted); + if let Position::Reading(anchor) = self.position { + let index = self.resolve(cells, anchor); + self.position = Position::Reading(Anchor { + key: self.entry_key(cells, index), + index, + ..anchor + }); + } + } + + /// Refresh unseen activity before either footer measurement or transcript painting. + pub(crate) fn sync_history_tail(&mut self, cells: &[Arc]) { + let tail = cells.last().map(EntryKey::cell); + if !self.is_following() && self.last_tail.is_some() && self.last_tail != tail { + let previous = cells + .iter() + .rposition(|cell| Some(EntryKey::cell(cell)) == self.last_tail); + self.unseen_activity |= previous.is_none_or(|index| { + (index + 1..cells.len()).any(|index| { + self.current_layout(cells, index) + .is_some_and(|layout| layout.row_count() > 0) + }) + }); + } + self.last_tail = tail; + if self.is_following() { + self.unseen_activity = false; + } + } + + /// Replacements restart search and move affected readers; held readers and selections keep their snapshot. + pub(crate) fn replace_range( + &mut self, + cells: &[Arc], + range: Range, + replacement: &Arc, + ) { + if range.is_empty() || self.snapshot().is_some() { + return; + } + let reading = match self.position { + Position::Reading(anchor) if range.contains(&self.resolve(cells, anchor)) => { + Some(anchor) + } + _ => None, + }; + if reading.is_none() { + return; + } + let layouts = range + .clone() + .filter_map(|index| self.layout(cells, index)) + .collect::>(); + let replacement_key = EntryKey::cell(replacement); + if let Some(anchor) = reading { + self.position = Position::Reading(remap( + anchor, + self.resolve(cells, anchor), + range.start, + replacement_key, + &layouts, + )); + } + } + + /// Join unseen groups while retaining displayed groups and the search frontier. + /// + /// Call after inserting the older page and before replacing the old group cells. Unlike + /// streamed text finalization, group summaries cannot preserve offsets by concatenation. + /// Search keeps both sides of the join so it can finish scanning the newly inserted page. + pub(crate) fn replace_group( + &mut self, + cells: &[Arc], + range: Range, + replacement: &Arc, + ) { + if range.is_empty() { + return; + } + // Older history may extend the final group without introducing new activity. + if range.end == cells.len() && self.last_tail == cells.last().map(EntryKey::cell) { + self.last_tail = Some(EntryKey::cell(replacement)); + } + let reading = match self.position { + Position::Reading(anchor) => Some(anchor.key), + Position::Latest => None, + }; + let visible = self.visible.iter().any(|row| { + cells[range.clone()] + .iter() + .any(|cell| EntryKey::cell(cell) == row.key) + }); + if let Some(snapshot) = self.snapshot_mut() { + let replaced = &cells[range]; + // Pins retain the initial viewport and selected text; later scrolling adds no pins. + if visible + || replaced.iter().any(|cell| { + let key = EntryKey::cell(cell); + snapshot.pinned.contains_key(&key) || reading == Some(key) + }) + { + return; + } + let Some(start) = snapshot.cells.windows(replaced.len()).position(|retained| { + retained + .iter() + .zip(replaced) + .all(|(a, b)| Arc::ptr_eq(a, b)) + }) else { + return; + }; + let mut joined = snapshot.cells.to_vec(); + joined.splice(start..start + replaced.len(), [Arc::clone(replacement)]); + snapshot.cells = joined.into(); + return; + } + if matches!(self.position, Position::Reading(anchor) + if cells[range].iter().any(|cell| EntryKey::cell(cell) == anchor.key)) + { + self.held_reading = Some(self.capture_snapshot(cells)); + } + } + + /// Retain displayed revisions when an older completed group joins the active group. + /// + /// Call after `history_loaded` and the live mutation, before removing the canonical tail. + /// The widget revisions bracket only hydration so actual pending activity remains visible. + pub(crate) fn absorb_tail_into_live( + &mut self, + cells: &[Arc], + previous_revision: u64, + hydrated_revision: u64, + ) { + let Some((tail, preceding)) = cells.split_last() else { + return; + }; + let tail_key = EntryKey::cell(tail); + if self.last_tail == Some(tail_key) { + self.last_tail = preceding.last().map(EntryKey::cell); + } + let following = self.is_following(); + if let Some((_, key)) = &mut self.live_key { + if !following && key.revision != previous_revision { + self.unseen_activity = true; + } + key.revision = hydrated_revision; + // Force fresh lines even if the next draw receives this exact hydrated revision. + key.cacheable = false; + } + if self.snapshot().is_some() { + return; + } + if let Position::Reading(anchor) = self.position + && (anchor.key == tail_key || anchor.key == EntryKey::Live) + { + self.held_reading = Some(self.capture_snapshot(cells)); + } + } + + /// Extend the frozen history only with the explicitly inserted older page. + /// + /// Current commits and replacements remain outside the snapshot. A retained session header + /// identifies the insertion boundary even when it precedes the first history page. + pub(super) fn prepend_snapshot_history( + &mut self, + cells: &[Arc], + inserted: Range, + ) { + let Some(snapshot) = self.snapshot_mut() else { + return; + }; + let existing = snapshot + .cells + .iter() + .map(EntryKey::cell) + .collect::>(); + let older = cells[inserted.clone()] + .iter() + .filter(|cell| !existing.contains(&EntryKey::cell(cell))) + .cloned() + .collect::>(); + if older.is_empty() { + return; + } + let index = cells[..inserted.start] + .iter() + .rev() + .find_map(|cell| { + snapshot + .cells + .iter() + .position(|retained| Arc::ptr_eq(retained, cell)) + }) + .map_or(/*default*/ 0, |index| index + 1); + let mut extended = snapshot.cells.to_vec(); + extended.splice(index..index, older); + snapshot.cells = extended.into(); + } +} + +#[cfg(test)] +#[path = "mutations_tests.rs"] +mod tests; + +fn remap( + anchor: Anchor, + index: usize, + first: usize, + key: EntryKey, + layouts: &[Arc], +) -> Anchor { + let preceding = layouts + .iter() + .enumerate() + .take(index - first) + .filter(|(_, layout)| layout.row_count() > 0) + .map(|(index, layout)| { + layout.text().len() + + layouts[index + 1..] + .iter() + .find(|next| next.row_count() > 0) + .map_or(0, |next| layout.separator_after(next).len()) + }) + .sum::(); + Anchor { + key, + index: first, + offset: preceding.saturating_add(anchor.offset), + row_bias: anchor.row_bias, + } +} diff --git a/codex-rs/tui/src/transcript_view/mutations_tests.rs b/codex-rs/tui/src/transcript_view/mutations_tests.rs new file mode 100644 index 0000000000..4b9daa7a70 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/mutations_tests.rs @@ -0,0 +1,356 @@ +//! Page joins preserve the displayed source until reading or selection leaves a retired group. + +use super::*; +use crate::history_cell::PlainHistoryCell; +use pretty_assertions::assert_eq; + +fn cell(text: &str) -> Arc { + Arc::new(PlainHistoryCell::new( + text.lines().map(|line| line.to_owned().into()).collect(), + )) +} + +fn render(view: &mut TranscriptView, cells: &[Arc], width: u16) -> Buffer { + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 2); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer, cells); + buffer +} + +fn live(view: &mut TranscriptView, revision: u64, text: &str) { + view.sync_live_tail( + /*width*/ 20, + Some(ActiveCellTranscriptKey { + cacheable: true, + revision, + is_stream_continuation: false, + animation_tick: None, + }), + |width| Some(cell(text).display_hyperlink_lines(width)), + ); +} + +#[test] +fn page_join_holds_the_reading_revision_until_navigation_reaches_a_surviving_cell() { + let mut cells = vec![cell("one\ntwo\nthree\nfour"), cell("tail\nlast")]; + let mut view = TranscriptView::default(); + view.jump_to_entry(&cells, /*index*/ 0); + render(&mut view, &cells, /*width*/ 20); + view.scroll(&cells, /*rows*/ 1); + let before = render(&mut view, &cells, /*width*/ 20); + + cells.splice(0..0, [cell("ancient"), cell("older group")]); + view.history_loaded(&cells, 0..2); + let replacement = cell("merged summary\nchanged preview"); + view.replace_group(&cells, 1..3, &replacement); + cells.splice(1..3, [replacement]); + cells.push(cell("new output")); + + assert_eq!(render(&mut view, &cells, /*width*/ 20), before); + view.scroll(&cells, /*rows*/ 1); + assert!(view.held_reading.is_some()); + let Position::Reading(anchor) = view.position else { + panic!("reading the held group"); + }; + assert_eq!(anchor.offset, "one\ntwo\n".len()); + render(&mut view, &cells, /*width*/ 8); + assert_eq!(view.position, Position::Reading(anchor)); + + view.scroll(&cells, /*rows*/ -100); + assert!(view.snapshot().is_none()); + assert_eq!( + view.position, + Position::Reading(Anchor { + key: EntryKey::cell(&cells[0]), + index: 0, + offset: 0, + row_bias: 0, + }) + ); + view.jump_to_latest(); + let latest = render(&mut view, &cells, /*width*/ 20); + let mut fresh = TranscriptView::default(); + assert_eq!(latest, render(&mut fresh, &cells, /*width*/ 20)); +} + +#[test] +fn page_join_keeps_selection_and_clearing_it_retains_the_reading_position() { + let mut cells = vec![cell("one\ntwo\nthree\nfour"), cell("tail\nlast")]; + let mut view = TranscriptView::default(); + view.jump_to_entry(&cells, /*index*/ 0); + render(&mut view, &cells, /*width*/ 20); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 1, /*clicks*/ 3); + view.end_drag(); + let before = render(&mut view, &cells, /*width*/ 20); + let selected = view.selected_text(&cells); + assert_eq!(selected, Some("two\n".to_owned())); + + cells.splice(0..0, [cell("ancient"), cell("older group")]); + view.history_loaded(&cells, 0..2); + let replacement = cell("merged summary\nchanged preview"); + view.replace_group(&cells, 1..3, &replacement); + cells.splice(1..3, [replacement]); + assert_eq!(render(&mut view, &cells, /*width*/ 20), before); + + cells.insert(/*index*/ 0, cell("earliest page")); + view.history_loaded(&cells, 0..1); + render(&mut view, &cells, /*width*/ 8); + assert_eq!(view.selected_text(&cells), selected); + view.end_selection(&cells); + assert!(view.selection.is_none()); + assert!(view.held_reading.is_some()); + view.scroll(&cells, /*rows*/ 1); + assert!(view.held_reading.is_some()); + view.scroll(&cells, /*rows*/ -100); + assert!(view.snapshot().is_none()); +} + +#[test] +fn page_join_merges_unseen_groups_while_an_unrelated_selection_is_held() { + let mut cells = vec![cell("newer group"), cell("tail\nlast")]; + let mut view = TranscriptView::default(); + view.jump_to_entry(&cells, /*index*/ 1); + render(&mut view, &cells, /*width*/ 20); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 1, /*clicks*/ 3); + view.end_drag(); + let before = render(&mut view, &cells, /*width*/ 20); + let selected = view.selected_text(&cells); + assert_eq!(selected, Some("last".to_owned())); + + cells.insert(/*index*/ 0, cell("older group")); + view.history_loaded(&cells, 0..1); + let replacement = cell("merged summary\nchanged preview"); + view.replace_group(&cells, 0..2, &replacement); + cells.splice(0..2, [replacement]); + assert_eq!(render(&mut view, &cells, /*width*/ 20), before); + assert_eq!(view.selected_text(&cells), selected); + + view.scroll(&cells, /*rows*/ -100); + let buffer = render(&mut view, &cells, /*width*/ 20); + let lines = buffer + .content + .chunks(/*chunk_size*/ 20) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim_end() + .to_owned() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(lines, @" + merged summary + changed preview + "); + assert_eq!(view.selected_text(&cells), selected); +} + +#[test] +fn page_join_keeps_groups_scrolled_into_view_after_selecting_elsewhere() { + let mut cells = vec![ + cell("before"), + cell("older group"), + cell("newer group"), + cell("tail\nlast"), + ]; + let mut view = TranscriptView::default(); + view.jump_to_entry(&cells, /*index*/ 3); + render(&mut view, &cells, /*width*/ 20); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 1, /*clicks*/ 3); + view.end_drag(); + view.scroll(&cells, /*rows*/ -100); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 8, + ); + let mut before = Buffer::empty(area); + view.render(area, &mut before, &cells); + let position = view.position; + + let replacement = cell("merged summary\nchanged preview"); + view.replace_group(&cells, 1..3, &replacement); + cells.splice(1..3, [replacement]); + let mut after = Buffer::empty(area); + view.render(area, &mut after, &cells); + + assert_eq!(after, before); + assert_eq!(view.position, position); + assert_eq!(view.selected_text(&cells), Some("last".to_owned())); +} + +#[test] +fn scrolling_forward_out_of_a_replaced_group_resolves_the_surviving_cell_index() { + let mut cells = vec![cell("one\ntwo"), cell("tail\na\nb\nc\nd\ne")]; + let mut view = TranscriptView::default(); + view.jump_to_entry(&cells, /*index*/ 0); + render(&mut view, &cells, /*width*/ 20); + cells.insert(/*index*/ 0, cell("older group")); + view.history_loaded(&cells, 0..1); + let replacement = cell("merged summary"); + view.replace_group(&cells, 0..2, &replacement); + cells.splice(0..2, [replacement]); + + view.scroll(&cells, /*rows*/ 3); + assert!(view.snapshot().is_none()); + assert_eq!( + view.position, + Position::Reading(Anchor { + key: EntryKey::cell(&cells[1]), + index: 1, + offset: 0, + row_bias: 0, + }) + ); +} + +#[test] +fn page_join_does_not_hold_latest_or_a_reader_outside_the_replaced_group() { + let mut cells = vec![cell("before"), cell("group"), cell("tail\nlast")]; + let mut latest = TranscriptView::default(); + let mut reader = TranscriptView::default(); + reader.jump_to_entry(&cells, /*index*/ 0); + let position = reader.position; + let replacement = cell("changed group"); + latest.replace_group(&cells, 1..2, &replacement); + reader.replace_group(&cells, 1..2, &replacement); + cells.splice(1..2, [replacement]); + + assert!(latest.is_following()); + assert!(latest.snapshot().is_none()); + assert!(reader.snapshot().is_none()); + assert_eq!(reader.position, position); +} + +#[test] +fn regrouping_the_tail_for_older_pages_does_not_report_new_activity() { + let mut cells = vec![cell("one\ntwo\nthree\nfour")]; + let mut view = TranscriptView::default(); + view.jump_to_entry(&cells, /*index*/ 0); + render(&mut view, &cells, /*width*/ 20); + + for older in ["older group", "earliest group"] { + cells.insert(/*index*/ 0, cell(older)); + view.history_loaded(&cells, 0..1); + let replacement = cell("merged summary"); + view.replace_group(&cells, 0..2, &replacement); + cells.splice(0..2, [replacement]); + render(&mut view, &cells, /*width*/ 20); + assert!(!view.unseen_activity); + } + + cells.push(cell("new output")); + render(&mut view, &cells, /*width*/ 20); + assert!(view.unseen_activity); +} + +#[test] +fn regrouping_preserves_new_activity_that_has_not_yet_been_drawn() { + let mut cells = vec![cell("one\ntwo\nthree\nfour")]; + let mut view = TranscriptView::default(); + view.jump_to_entry(&cells, /*index*/ 0); + render(&mut view, &cells, /*width*/ 20); + cells.push(cell("new output")); + let replacement = cell("merged summary"); + view.replace_group(&cells, 0..2, &replacement); + cells.splice(0..2, [replacement]); + + render(&mut view, &cells, /*width*/ 20); + assert!(view.unseen_activity); +} + +#[test] +fn absorbing_older_history_keeps_live_readers_and_selections_on_their_displayed_revision() { + for selecting in [false, true] { + let mut cells = vec![cell("header")]; + let mut view = TranscriptView::default(); + live( + &mut view, + /*revision*/ 1, + "zero\none\ntwo\nthree\nfour\nfive", + ); + render(&mut view, &cells, /*width*/ 20); + view.scroll(&cells, /*rows*/ -2); + render(&mut view, &cells, /*width*/ 20); + if selecting { + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + view.end_drag(); + } + let before = render(&mut view, &cells, /*width*/ 20); + let selected = view.selected_text(&cells); + + cells.extend([cell("older turn"), cell("older group")]); + view.history_loaded(&cells, 1..3); + view.absorb_tail_into_live( + &cells, /*previous_revision*/ 1, /*hydrated_revision*/ 2, + ); + cells.pop(); + live( + &mut view, + /*revision*/ 2, + "hydrated group\ncurrent preview", + ); + + assert_eq!(render(&mut view, &cells, /*width*/ 20), before); + assert_eq!(view.selected_text(&cells), selected); + assert!(!view.unseen_activity); + assert_eq!(view.snapshot_cells().expect("held revision").len(), 3); + view.jump_to_latest(); + render(&mut view, &cells, /*width*/ 20); + assert!(view.snapshot().is_none()); + assert_eq!( + view.live.as_ref().expect("hydrated tail").text(), + "hydrated group\ncurrent preview" + ); + } +} + +#[test] +fn absorbing_a_read_canonical_tail_holds_it_until_navigation_leaves() { + let mut cells = vec![cell("header")]; + let mut view = TranscriptView::default(); + live(&mut view, /*revision*/ 1, "current live tail"); + render(&mut view, &cells, /*width*/ 20); + cells.push(cell("older one\nolder two\nolder three")); + view.history_loaded(&cells, 1..2); + view.jump_to_entry(&cells, /*index*/ 1); + let before = render(&mut view, &cells, /*width*/ 20); + + view.absorb_tail_into_live( + &cells, /*previous_revision*/ 1, /*hydrated_revision*/ 2, + ); + cells.pop(); + live( + &mut view, + /*revision*/ 2, + "hydrated group\ncurrent preview", + ); + assert_eq!(render(&mut view, &cells, /*width*/ 20), before); + assert!(!view.unseen_activity); + view.scroll(&cells, /*rows*/ -100); + assert!(view.snapshot().is_none()); +} + +#[test] +fn absorbing_older_history_excludes_only_the_hydration_revision_from_new_activity() { + for (previous_revision, hydrated_revision, next_revision, expected_unseen) in + [(1, 2, 2, false), (2, 3, 3, true), (1, 2, 3, true)] + { + let mut cells = vec![cell("header")]; + let mut view = TranscriptView::default(); + live(&mut view, /*revision*/ 1, "original live tail"); + view.jump_to_entry(&cells, /*index*/ 0); + render(&mut view, &cells, /*width*/ 20); + cells.extend([cell("older turn"), cell("older group")]); + view.history_loaded(&cells, 1..3); + view.absorb_tail_into_live(&cells, previous_revision, hydrated_revision); + cells.pop(); + live(&mut view, next_revision, "updated live tail"); + render(&mut view, &cells, /*width*/ 20); + + assert_eq!(view.unseen_activity, expected_unseen); + assert_eq!( + view.live.as_ref().expect("updated tail").text(), + "updated live tail" + ); + } +} diff --git a/codex-rs/tui/src/transcript_view/prompt_style_tests.rs b/codex-rs/tui/src/transcript_view/prompt_style_tests.rs new file mode 100644 index 0000000000..f011c48705 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/prompt_style_tests.rs @@ -0,0 +1,141 @@ +//! Full-row prompt styles and source-free wrapping margins survive viewport painting and resize. + +use super::TextLayout; +use crate::history_cell::HistoryCell; +use crate::history_cell::new_user_prompt; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::HyperlinkParagraph; +use crate::terminal_hyperlinks::LogicalLineSource; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Widget; + +#[test] +fn visible_rows_fill_inherited_styles_without_painting_outside_the_viewport() { + let style = Style::default().green().on_dark_gray().italic(); + let layout = TextLayout::new( + vec![ + HyperlinkLine::from("offscreen"), + HyperlinkLine::new(Line::from("").style(style)), + HyperlinkLine::new(Line::from(vec!["A".bold(), "界".cyan()]).style(style)), + HyperlinkLine::new(Line::from("").style(style)), + ], + /*width*/ 9, + ); + let outer = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 13, /*height*/ 6, + ); + let viewport = Rect::new( + /*x*/ 2, /*y*/ 1, /*width*/ 9, /*height*/ 4, + ); + let mut actual = Buffer::empty(outer); + layout.render(viewport, &mut actual, /*start_row*/ 1); + + let mut expected = Buffer::empty(outer); + expected.set_style( + Rect::new( + /*x*/ 2, /*y*/ 1, /*width*/ 9, /*height*/ 3, + ), + style, + ); + expected[(2, 2)].set_symbol("A").set_style(style.bold()); + expected[(3, 2)].set_symbol("界").set_style(style.cyan()); + assert_eq!(actual, expected); +} + +#[test] +fn prompt_margin_and_blank_background_survive_resize_without_changing_copy() { + let message = + "1234567890123456789012345678901234567890123456789012345678\n\n 界e\u{301}👩‍💻 trailing"; + let cell = new_user_prompt(message.into(), Vec::new(), Vec::new(), Vec::new()); + // Fix the inherited style without changing the process-wide terminal palette. + let style = Style::default().on_dark_gray(); + let initial_lines = styled_prompt_lines(&cell, /*width*/ 60, style); + let mut layout = TextLayout::new(initial_lines, /*width*/ 60); + let expected_copy = format!("\n{message}\n"); + + for (name, width) in [ + ("prompt_margin_wide", 60), + ("prompt_margin_narrow", 20), + ("prompt_margin_restored", 60), + ] { + layout = layout.rewrap(width); + let fresh_lines = styled_prompt_lines(&cell, width, style); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + fresh_lines.len().try_into().expect("short prompt"), + ); + let mut actual = Buffer::empty(area); + layout.render(area, &mut actual, /*start_row*/ 0); + let mut expected = Buffer::empty(area); + HyperlinkParagraph::new(&fresh_lines, style).render(area, &mut expected); + + assert_eq!( + (layout.text(), layout.row_count()), + (expected_copy.as_str(), fresh_lines.len()), + ); + assert_eq!(actual, expected); + assert_eq!( + (0..area.height) + .map(|row| actual[(width - 1, row)].symbol()) + .collect::>(), + vec![" "; fresh_lines.len()], + ); + if width == 20 { + insta::assert_snapshot!(name, format!("{actual:?}")); + } + } + + // The last source character on the first body row precedes the reserved column. + assert_eq!( + ( + layout.position_at(/*row*/ 1, /*column*/ 58), + layout.position_at(/*row*/ 1, /*column*/ 59), + layout.position_at(/*row*/ 2, /*column*/ 2), + ), + (57, 58, 58), + ); +} + +#[test] +fn reserved_margin_uses_the_same_alignment_for_painting_and_selection() { + let style = Style::default().on_dark_gray(); + let mut line = HyperlinkLine::new(Line::from("ab").right_aligned().style(style)); + let mut source = LogicalLineSource::from_line(&line.line); + source.right_reserve = 2; + line.source = Some(source); + let layout = TextLayout::new(vec![line], /*width*/ 8); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 8, /*height*/ 1, + ); + let mut actual = Buffer::empty(area); + layout.render(area, &mut actual, /*start_row*/ 0); + layout.highlight(0..2, area, &mut actual, /*start_row*/ 0); + + let mut expected = Buffer::empty(area); + expected.set_style(area, style); + expected.set_string(/*x*/ 4, /*y*/ 0, "ab", style.reversed()); + assert_eq!(actual, expected); + assert_eq!( + (0..8) + .map(|column| layout.position_at(/*row*/ 0, column)) + .collect::>(), + vec![0, 0, 0, 0, 0, 1, 2, 2], + ); +} + +fn styled_prompt_lines(cell: &dyn HistoryCell, width: u16, style: Style) -> Vec { + cell.display_hyperlink_lines(width) + .into_iter() + .map(|mut line| { + line.line.style = style; + line + }) + .collect() +} diff --git a/codex-rs/tui/src/transcript_view/selection.rs b/codex-rs/tui/src/transcript_view/selection.rs new file mode 100644 index 0000000000..1d37b152e8 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/selection.rs @@ -0,0 +1,469 @@ +//! Selection freezes entry order and displayed revisions while canonical history keeps advancing. +//! The snapshot shares cell ownership; only visible and selected text layouts are pinned. + +use crossterm::event::KeyCode; +use ratatui::layout::Position as ScreenPosition; +use unicode_segmentation::UnicodeSegmentation; + +use super::*; + +pub(super) struct Selection { + pub(super) snapshot: ViewSnapshot, + pub(super) start: Anchor, + pub(super) end: Anchor, + pub(super) dragging: bool, + pub(super) moved: bool, + pub(super) resume_on_empty: bool, + pub(super) pointer: Option, + origin: (Anchor, Anchor), + unit: SelectionUnit, + preferred_column: Option, +} + +#[derive(Clone, Copy)] +enum SelectionUnit { + Character, + Word, + Line, +} + +impl SelectionUnit { + fn range(self, layout: &TextLayout, offset: usize) -> std::ops::Range { + match self { + Self::Character => offset..offset, + Self::Word => layout.word_range(offset), + Self::Line => layout.line_range(offset), + } + } +} + +impl TranscriptView { + pub(super) fn begin_selection( + &mut self, + cells: &[Arc], + column: u16, + row: u16, + clicks: u8, + ) { + let Some((anchor, layout)) = self.hit_test(column, row) else { + return; + }; + self.cancel_beginning(); + let unit = match clicks { + 2 => SelectionUnit::Word, + 3 => SelectionUnit::Line, + _ => SelectionUnit::Character, + }; + let range = unit.range(&layout, anchor.offset); + let snapshot = self.capture_snapshot(cells); + self.held_reading = None; + let start = Anchor { + offset: range.start, + ..anchor + }; + let end = Anchor { + offset: range.end, + ..anchor + }; + let was_following = self.is_following(); + self.selection = Some(Selection { + snapshot, + start, + end, + origin: (start, end), + unit, + preferred_column: None, + dragging: true, + moved: false, + resume_on_empty: was_following, + pointer: Some(ScreenPosition::new(column, row)), + }); + if was_following { + self.hold_position(); + } + } + + /// Rejoin current history, retaining a replaced group until navigation leaves its revision. + /// Real selections keep their reading position, including a retired live revision. + pub(crate) fn end_selection(&mut self, cells: &[Arc]) { + let Some(selection) = self.selection.take() else { + return; + }; + let empty = (selection.start.key, selection.start.offset) + == (selection.end.key, selection.end.offset); + if empty && selection.resume_on_empty { + self.position = Position::Latest; + self.release_live_reading(); + return; + } + if self.position == Position::Latest { + self.hold_position(); + } + if let Position::Reading(anchor) = self.position { + if !cells.iter().any(|cell| EntryKey::cell(cell) == anchor.key) { + self.held_reading = Some(selection.snapshot); + return; + } + let index = self.resolve(cells, anchor); + let key = self.entry_key(cells, index); + let (offset, row_bias) = if key == anchor.key { + (anchor.offset, anchor.row_bias) + } else { + (0, 0) + }; + self.position = Position::Reading(Anchor { + key, + index, + offset, + row_bias, + }); + } + } + + pub(super) fn extend_selection(&mut self, column: u16, row: u16) { + let Some((end, layout)) = self.hit_test(column, row) else { + return; + }; + let Some(mut selection) = self.selection.take() else { + return; + }; + let cells = Arc::clone(&selection.snapshot.cells); + let range = selection.unit.range(&layout, end.offset); + let origin = selection.origin; + let backwards = (self.resolve(&cells, end), end.offset) + < (self.resolve(&cells, origin.0), origin.0.offset); + selection.start = if backwards { origin.1 } else { origin.0 }; + selection.end = Anchor { + offset: if backwards { range.start } else { range.end }, + ..end + }; + selection.snapshot.pinned.entry(end.key).or_insert(layout); + selection.moved = true; + selection.pointer = Some(ScreenPosition::new(column, row)); + selection.preferred_column = None; + self.pin_selection_range(&cells, &mut selection); + self.selection = Some(selection); + } + + /// A pointer-down anchor alone does not select text or need to hide decoration. + pub(crate) fn has_selection_range(&self) -> bool { + self.selection.as_ref().is_some_and(|selection| { + selection.start.key != selection.end.key + || selection.start.offset != selection.end.offset + }) + } + + pub(crate) fn selected_text(&mut self, cells: &[Arc]) -> Option { + if !self.has_selection_range() { + return None; + } + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); + let selection = self.selection.as_ref()?; + let mut start = selection.start; + let mut end = selection.end; + start.index = self.resolve(cells, start); + end.index = self.resolve(cells, end); + if (start.index, start.offset) > (end.index, end.offset) { + std::mem::swap(&mut start, &mut end); + } + if start == end { + return None; + } + let mut text = String::new(); + let mut previous: Option> = None; + for index in start.index..=end.index { + let layout = self.layout(cells, index)?; + if layout.row_count() == 0 { + continue; + } + if let Some(previous) = &previous { + text.push_str(previous.separator_after(&layout)); + } + let begin = if index == start.index { + start.offset + } else { + 0 + }; + let finish = if index == end.index { + end.offset + } else { + layout.text().len() + }; + text.push_str(layout.text().get(begin..finish)?); + previous = Some(layout); + } + text.retain(|ch| !ch.is_control() || matches!(ch, '\n' | '\t')); + Some(text) + } + + /// Release selection after confirmed delivery. Failed or unacknowledged terminal writes + /// retain the selected revision so the user can verify pasting and retry. + pub(crate) fn copy_selected_text_with( + &mut self, + cells: &[Arc], + text: &str, + copy: impl FnOnce(&str) -> Result, + ) -> Result { + let result = copy(text); + if matches!(result, Ok(crate::clipboard_copy::CopyStatus::Confirmed)) { + self.end_selection(cells); + } + result + } + + /// End the pointer gesture without discarding selected text when input ownership changes. + pub(crate) fn end_drag(&mut self) { + if !self.has_selection_range() + && self + .selection + .as_ref() + .is_some_and(|selection| selection.dragging && selection.resume_on_empty) + { + self.position = Position::Latest; + self.selection = None; + self.release_live_reading(); + } + if let Some(selection) = &mut self.selection { + selection.dragging = false; + } + } + + pub(super) fn selection_key(&mut self, cells: &[Arc], code: KeyCode) -> bool { + if !matches!( + code, + KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right + ) { + return false; + } + let Some(end) = self.selection.as_ref().map(|selection| selection.end) else { + return false; + }; + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); + let column = if matches!(code, KeyCode::Up | KeyCode::Down) { + let Some(layout) = self.layout(cells, self.resolve(cells, end)) else { + return true; + }; + Some( + self.selection + .as_ref() + .and_then(|selection| selection.preferred_column) + .unwrap_or_else(|| layout.column_for_offset(end.offset)), + ) + } else { + None + }; + let Some(next) = self.move_selection_endpoint(cells, end, code, column) else { + return true; + }; + if let Some(mut selection) = self.selection.take() { + selection.end = next; + selection.dragging = false; + selection.preferred_column = column; + self.pin_selection_range(cells, &mut selection); + self.selection = Some(selection); + } + let row = self + .layout(cells, next.index) + .map_or(/*default*/ 0, |layout| { + layout + .row_for_offset(next.offset) + .saturating_add_signed(next.row_bias.saturating_neg()) + }); + if !self + .visible + .iter() + .any(|visible| visible.index == next.index && visible.row == row) + { + self.position = Position::Reading(next); + } + true + } + + pub(crate) fn tick_selection(&mut self, cells: &[Arc]) -> bool { + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); + let Some(selection) = self + .selection + .as_ref() + .filter(|selection| selection.dragging && selection.moved) + else { + return false; + }; + let Some(pointer) = selection.pointer else { + return false; + }; + let direction = if pointer.y <= self.area.top() { + -1 + } else if pointer.y >= self.area.bottom().saturating_sub(/*rhs*/ 1) { + 1 + } else { + return false; + }; + let previous = self.position; + self.scroll(cells, direction); + previous != self.position + } + + pub(super) fn normalize_selection(&mut self, cells: &[Arc]) { + if let Some(mut selection) = self.selection.take() { + selection.start.index = self.resolve(cells, selection.start); + selection.end.index = self.resolve(cells, selection.end); + selection.origin.0.index = self.resolve(cells, selection.origin.0); + selection.origin.1.index = self.resolve(cells, selection.origin.1); + self.selection = Some(selection); + } + } + + pub(super) fn render_selection(&self, buf: &mut Buffer) { + let Some(selection) = &self.selection else { + return; + }; + let (start, end) = if (selection.start.index, selection.start.offset) + <= (selection.end.index, selection.end.offset) + { + (selection.start, selection.end) + } else { + (selection.end, selection.start) + }; + for (y, visible) in self.visible.iter().enumerate() { + if visible.index < start.index || visible.index > end.index { + continue; + } + let begin = if visible.key == start.key { + start.offset + } else { + 0 + }; + let finish = if visible.key == end.key { + end.offset + } else { + visible.layout.text().len() + }; + let area = Rect::new( + self.area.x, + self.area.y + y as u16, + self.area.width, + /*height*/ 1, + ); + visible + .layout + .highlight(begin..finish, area, buf, visible.row); + } + } + + pub(super) fn hit_test(&self, column: u16, row: u16) -> Option<(Anchor, Arc)> { + let row = row.saturating_sub(self.area.y) as usize; + let visible = self + .visible + .get(row.min(self.visible.len().saturating_sub(/*rhs*/ 1)))?; + Some(( + Anchor { + key: visible.key, + index: visible.index, + offset: visible + .layout + .position_at(visible.row, column.saturating_sub(self.area.x)), + row_bias: 0, + }, + Arc::clone(&visible.layout), + )) + } + + pub(super) fn hold_position(&mut self) { + if let Some(first) = self.visible.first() { + self.position = Position::Reading(Anchor { + key: first.key, + index: first.index, + offset: first.layout.position_at(first.row, /*column*/ 0), + row_bias: first + .layout + .row_for_offset(first.layout.position_at(first.row, /*column*/ 0)) + as isize + - first.row as isize, + }); + } + } + + fn pin_selection_range(&mut self, cells: &[Arc], selection: &mut Selection) { + selection.start.index = self.resolve(cells, selection.start); + selection.end.index = self.resolve(cells, selection.end); + for index in selection.start.index.min(selection.end.index) + ..=selection.start.index.max(selection.end.index) + { + let key = self.entry_key(cells, index); + if !selection.snapshot.pinned.contains_key(&key) + && let Some(layout) = self.layout(cells, index) + { + selection.snapshot.pinned.insert(key, layout); + } + } + } + + fn move_selection_endpoint( + &mut self, + cells: &[Arc], + end: Anchor, + code: KeyCode, + preferred_column: Option, + ) -> Option { + let mut index = self.resolve(cells, end); + let layout = self.layout(cells, index)?; + let offset = layout + .text() + .floor_char_boundary(end.offset.min(layout.text().len())); + let mut row_bias = 0; + let offset = match code { + KeyCode::Up | KeyCode::Down => { + let direction = if code == KeyCode::Up { -1 } else { 1 }; + let row = layout + .row_for_offset(offset) + .saturating_add_signed(end.row_bias.saturating_neg()); + let (next, row) = self.move_rows(cells, index, row, direction); + index = next; + let next_layout = self.layout(cells, index)?; + let offset = next_layout.position_at( + row, + preferred_column.unwrap_or_else(|| layout.column_for_offset(offset)), + ); + row_bias = next_layout.row_for_offset(offset) as isize - row as isize; + offset + } + KeyCode::Left if offset == 0 && index > 0 => { + index -= 1; + self.layout(cells, index)?.text().len() + } + KeyCode::Right if offset == layout.text().len() => { + if self.layout(cells, index + 1).is_some() { + index += 1; + 0 + } else { + offset + } + } + KeyCode::Left => layout.text()[..offset] + .grapheme_indices(/*is_extended*/ true) + .next_back() + .map_or(/*default*/ 0, |(offset, _)| offset), + KeyCode::Right => { + offset + + layout.text()[offset..] + .graphemes(/*is_extended*/ true) + .next() + .map_or(/*default*/ 0, str::len) + } + _ => return None, + }; + Some(Anchor { + key: self.entry_key(cells, index), + index, + offset, + row_bias, + }) + } +} + +#[cfg(test)] +#[path = "selection_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/transcript_view/selection_tests.rs b/codex-rs/tui/src/transcript_view/selection_tests.rs new file mode 100644 index 0000000000..16ae5373d2 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/selection_tests.rs @@ -0,0 +1,573 @@ +//! Selection keeps displayed stream revisions and extends by valid text units across interaction. + +use super::*; +use crate::exec_cell::CommandOutput; +use crate::exec_cell::ExecCall; +use crate::exec_cell::ExecCell; +use crate::history_cell::AgentMarkdownCell; +use crate::history_cell::AgentMessageCell; +use crate::history_cell::PlainHistoryCell; +use crate::history_cell::StreamingAgentTailCell; +use codex_app_server_protocol::CommandExecutionSource; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; +use pretty_assertions::assert_eq; +use std::time::Instant; + +#[test] +fn command_c_ignores_release_and_copies_only_an_active_selection() { + let cells: Vec> = vec![Arc::new(PlainHistoryCell::new(vec![ + "selected café".into(), + ]))]; + let mut view = TranscriptView::default(); + let command_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER); + render(&mut view, &cells, /*width*/ 24, /*height*/ 3); + assert!(view.handle_key(command_c, &cells).is_none()); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + assert!( + view.handle_key( + KeyEvent::new_with_kind( + KeyCode::Char('c'), + KeyModifiers::SUPER, + KeyEventKind::Release, + ), + &cells, + ) + .is_none() + ); + assert_eq!(view.selected_text(&cells).as_deref(), Some("selected café")); + let Some(ViewAction::Copy(text)) = view.handle_key(command_c, &cells) else { + panic!("Cmd+C must copy the transcript selection"); + }; + assert_eq!(text, "selected café"); + view.copy_selected_text_with(&cells, &text, |_| { + Ok(crate::clipboard_copy::CopyStatus::Confirmed) + }) + .unwrap(); + assert!(view.handle_key(command_c, &cells).is_none()); + assert!( + view.handle_key( + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + &cells + ) + .is_none() + ); +} + +#[test] +fn enter_on_an_empty_selection_does_not_copy_or_reach_the_composer() { + let cells: Vec> = + vec![Arc::new(PlainHistoryCell::new(vec!["select this".into()]))]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 32, /*height*/ 3); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 1); + assert!(matches!( + view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells), + Some(ViewAction::Changed) + )); + assert_eq!(view.selected_text(&cells), None); +} + +fn agent(lines: &[&str]) -> Arc { + Arc::new(AgentMessageCell::new( + lines + .iter() + .map(|line| Line::from((*line).to_owned())) + .collect(), + /*is_first_line*/ true, + )) +} + +fn render( + view: &mut TranscriptView, + cells: &[Arc], + width: u16, + height: u16, +) -> String { + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer, cells); + (0..height) + .map(|row| { + (0..width) + .map(|column| buffer[(column, row)].symbol()) + .collect::() + .trim_end() + .to_owned() + }) + .collect::>() + .join("\n") + .trim_end() + .to_owned() +} + +fn live(view: &mut TranscriptView, cell: &dyn HistoryCell, revision: u64) { + let key = ActiveCellTranscriptKey { + cacheable: true, + revision, + is_stream_continuation: cell.is_stream_continuation(), + animation_tick: None, + }; + view.sync_live_tail(/*width*/ 32, Some(key), |width| { + Some(cell.display_hyperlink_lines(width)) + }); +} + +fn release(view: &mut TranscriptView, cells: &[Arc], column: u16, row: u16) { + view.handle_mouse( + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column, + row, + modifiers: KeyModifiers::NONE, + }, + cells, + ); +} + +#[test] +fn clicking_and_clearing_selection_preserves_the_separator_row() { + let mut frames = Vec::new(); + for prepend in [false, true] { + let mut cells = vec![ + agent(&["first"]), + agent(&["alpha beta gamma", "second line", "third line"]), + agent(&["tail"]), + ]; + let mut view = TranscriptView::default(); + view.jump_to_entry(&cells, /*index*/ 1); + render(&mut view, &cells, /*width*/ 32, /*height*/ 3); + view.scroll(&cells, /*rows*/ -1); + let before = render(&mut view, &cells, /*width*/ 32, /*height*/ 3); + assert!(matches!( + view.position, + Position::Reading(Anchor { row_bias: 1, .. }) + )); + if prepend { + cells.insert(/*index*/ 0, agent(&["older"])); + view.history_loaded(&cells, 0..1); + assert_eq!( + render(&mut view, &cells, /*width*/ 32, /*height*/ 3), + before + ); + } + let position = view.position; + let down = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 9, + row: 1, + modifiers: KeyModifiers::NONE, + }; + view.handle_mouse(down, &cells); + release(&mut view, &cells, /*column*/ 9, /*row*/ 1); + assert_eq!( + ( + render(&mut view, &cells, /*width*/ 32, /*height*/ 3), + view.position, + view.selected_text(&cells), + ), + (before.clone(), position, None) + ); + + // Keep the real first click's coordinates/count, without depending on test scheduling. + view.last_click.as_mut().expect("first mouse down").0 = Instant::now(); + view.handle_mouse(down, &cells); + release(&mut view, &cells, /*column*/ 9, /*row*/ 1); + assert_eq!( + ( + render(&mut view, &cells, /*width*/ 32, /*height*/ 3), + view.position, + view.selected_text(&cells), + ), + (before.clone(), position, Some("beta".to_string())) + ); + view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &cells); + let after = render(&mut view, &cells, /*width*/ 32, /*height*/ 3); + assert_eq!( + (after.clone(), view.position, view.selected_text(&cells)), + (before, position, None) + ); + // Repeated clicks on padding or a separator must not select adjacent source text. + for (column, row) in [(31, down.row), (down.column, 0)] { + for clicks in [2, 3] { + view.last_click = Some((Instant::now(), column, row, clicks - 1)); + view.handle_mouse( + MouseEvent { + column, + row, + ..down + }, + &cells, + ); + assert_eq!(view.selected_text(&cells), None); + } + } + frames.push(format!("prepend={prepend}\n{after}")); + } + insta::assert_snapshot!(frames.join("\n\n"), @" + prepend=false + + • alpha beta gamma + second line + + prepend=true + + • alpha beta gamma + second line + "); +} + +#[test] +fn live_selection_survives_commits_finalization_new_live_content_and_resize() { + let mut cells = vec![agent(&["first"])]; + let mut view = TranscriptView::default(); + let tail = + StreamingAgentTailCell::new(vec!["tail selected".into()], /*is_first_line*/ false); + live(&mut view, &tail, /*revision*/ 1); + render(&mut view, &cells, /*width*/ 32, /*height*/ 4); + view.begin_selection(&cells, /*column*/ 4, /*row*/ 1, /*clicks*/ 3); + release(&mut view, &cells, /*column*/ 4, /*row*/ 1); + assert_eq!(view.selected_text(&cells), Some("tail selected".to_owned())); + + cells.push(Arc::new(AgentMessageCell::new( + vec!["tail selected".into()], + /*is_first_line*/ false, + ))); + let finalized: Arc = Arc::new(AgentMarkdownCell::new( + "first\ntail selected".to_owned(), + std::path::Path::new("."), + )); + view.replace_range(&cells, 0..2, &finalized); + cells.splice(0..2, [finalized]); + let unrelated = StreamingAgentTailCell::new( + vec!["unrelated next tail".into()], + /*is_first_line*/ true, + ); + live(&mut view, &unrelated, /*revision*/ 2); + insta::assert_snapshot!(render(&mut view, &cells, /*width*/ 32, /*height*/ 4), @" + • first + tail selected + "); + render(&mut view, &cells, /*width*/ 10, /*height*/ 6); + assert_eq!(view.selected_text(&cells), Some("tail selected".to_owned())); + + cells.insert(/*index*/ 0, agent(&["older page"])); + view.prepend_snapshot_history(&cells, 0..1); + render(&mut view, &cells, /*width*/ 32, /*height*/ 5); + assert_eq!(view.selected_text(&cells), Some("tail selected".to_owned())); + assert_eq!( + view.selection + .as_ref() + .expect("selection") + .snapshot + .cells + .len(), + 2 + ); + view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &cells); + assert!(!view.is_following()); + assert!(view.selection.is_none()); + view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &cells); + assert!(view.is_following()); + assert!(render(&mut view, &cells, /*width*/ 32, /*height*/ 5).contains("unrelated next tail")); +} + +#[test] +fn tool_selection_copies_its_displayed_revision_after_the_tool_commits() { + let tool = ExecCell::new( + ExecCall { + call_id: "tool".to_owned(), + command: vec!["printf".to_owned(), "selected tool output".to_owned()], + parsed: Vec::new(), + output: Some(CommandOutput::new( + /*exit_code*/ 0, + "selected tool output".to_owned(), + )), + source: CommandExecutionSource::Agent, + start_time: None, + duration: None, + interaction_input: None, + }, + /*animations_enabled*/ false, + ); + let mut cells = Vec::new(); + let mut view = TranscriptView::default(); + live(&mut view, &tool, /*revision*/ 1); + let visible = render(&mut view, &cells, /*width*/ 32, /*height*/ 8); + let row = visible + .lines() + .enumerate() + .filter_map(|(row, line)| line.contains("selected tool output").then_some(row)) + .last() + .expect("tool output") as u16; + view.begin_selection(&cells, /*column*/ 4, row, /*clicks*/ 3); + release(&mut view, &cells, /*column*/ 4, row); + let selected = view.selected_text(&cells).expect("selected tool output"); + cells.push(Arc::new(tool) as Arc); + live( + &mut view, + agent(&["another answer"]).as_ref(), + /*revision*/ 2, + ); + render(&mut view, &cells, /*width*/ 32, /*height*/ 8); + let Some(ViewAction::Copy(copied)) = view.handle_key( + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + &cells, + ) else { + panic!("copy action"); + }; + assert_eq!(copied, selected); +} + +#[test] +fn word_and_line_drags_keep_the_initial_unit_when_direction_changes() { + let cells = vec![agent(&["alpha beta gamma", "second line", "third"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 32, /*height*/ 5); + view.begin_selection(&cells, /*column*/ 9, /*row*/ 0, /*clicks*/ 2); + view.extend_selection(/*column*/ 16, /*row*/ 0); + assert_eq!(view.selected_text(&cells).as_deref(), Some("beta gamma")); + view.extend_selection(/*column*/ 3, /*row*/ 0); + assert_eq!(view.selected_text(&cells).as_deref(), Some("alpha beta")); + view.extend_selection(/*column*/ 16, /*row*/ 0); + assert_eq!(view.selected_text(&cells).as_deref(), Some("beta gamma")); + + view.begin_selection(&cells, /*column*/ 4, /*row*/ 1, /*clicks*/ 3); + view.extend_selection(/*column*/ 3, /*row*/ 0); + assert_eq!( + view.selected_text(&cells).as_deref(), + Some("alpha beta gamma\nsecond line\n") + ); + view.extend_selection(/*column*/ 4, /*row*/ 2); + assert_eq!( + view.selected_text(&cells).as_deref(), + Some("second line\nthird") + ); +} + +#[test] +fn keyboard_selection_moves_by_grapheme_and_keeps_its_column_across_short_lines() { + let cells = vec![ + agent(&["A👨‍👩‍👧‍👦界e\u{301}Z", "x", "1234567890"]), + agent(&["tail"]), + ]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 32, /*height*/ 5); + view.begin_selection(&cells, /*column*/ 2, /*row*/ 0, /*clicks*/ 1); + for _ in 0..4 { + view.selection_key(&cells, KeyCode::Right); + } + assert_eq!(view.selected_text(&cells).as_deref(), Some("A👨‍👩‍👧‍👦界e\u{301}")); + let original = view.selection.as_ref().expect("selection").end; + for key in [KeyCode::Down, KeyCode::Down, KeyCode::Up, KeyCode::Up] { + view.selection_key(&cells, key); + } + assert_eq!(view.selection.as_ref().expect("selection").end, original); + for _ in 0..10 { + view.selection_key(&cells, KeyCode::Down); + } + let end = view.selection.as_ref().expect("selection").end; + assert!(end.index < cells.len()); + let layout = view + .layout(&cells, end.index) + .expect("valid endpoint layout"); + assert!(layout.text().is_char_boundary(end.offset)); + for _ in 0..10 { + view.selection_key(&cells, KeyCode::Up); + } + assert_eq!(view.selection.as_ref().expect("selection").end, original); +} + +#[test] +fn plain_and_shift_arrows_move_selection_on_press_and_repeat() { + let cells = vec![agent(&["abc", "def", "ghi"])]; + for kind in [KeyEventKind::Press, KeyEventKind::Repeat] { + for modifiers in [KeyModifiers::NONE, KeyModifiers::SHIFT] { + for (code, expected) in [ + (KeyCode::Left, "d"), + (KeyCode::Right, "e"), + (KeyCode::Up, "bc\nd"), + (KeyCode::Down, "ef\ng"), + ] { + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 32, /*height*/ 5); + view.begin_selection(&cells, /*column*/ 3, /*row*/ 1, /*clicks*/ 1); + assert!(matches!( + view.handle_key(KeyEvent::new_with_kind(code, modifiers, kind), &cells), + Some(ViewAction::Changed) + )); + assert_eq!(view.selected_text(&cells).as_deref(), Some(expected)); + } + } + } +} + +#[test] +fn modified_arrows_end_selection_and_return_input_to_the_owner() { + let cells = vec![agent(&["alpha beta gamma"])]; + for kind in [KeyEventKind::Press, KeyEventKind::Repeat] { + for modifier in [ + KeyModifiers::CONTROL, + KeyModifiers::ALT, + KeyModifiers::CONTROL | KeyModifiers::ALT, + KeyModifiers::SUPER, + KeyModifiers::HYPER, + KeyModifiers::META, + ] { + for modifiers in [modifier, modifier | KeyModifiers::SHIFT] { + for code in [KeyCode::Left, KeyCode::Right, KeyCode::Up, KeyCode::Down] { + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 32, /*height*/ 5); + view.begin_selection( + &cells, /*column*/ 9, /*row*/ 0, /*clicks*/ 2, + ); + assert_eq!(view.selected_text(&cells).as_deref(), Some("beta")); + assert!( + view.handle_key(KeyEvent::new_with_kind(code, modifiers, kind), &cells) + .is_none() + ); + assert_eq!( + (view.has_active_interaction(), view.selected_text(&cells)), + (false, None) + ); + } + } + } + } +} + +#[test] +fn released_arrows_preserve_selection_and_reading_position() { + let cells = vec![agent(&["alpha beta gamma"])]; + for modifiers in [ + KeyModifiers::NONE, + KeyModifiers::SHIFT, + KeyModifiers::CONTROL, + KeyModifiers::ALT | KeyModifiers::SHIFT, + ] { + for code in [KeyCode::Left, KeyCode::Right, KeyCode::Up, KeyCode::Down] { + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 32, /*height*/ 5); + view.begin_selection(&cells, /*column*/ 9, /*row*/ 0, /*clicks*/ 2); + let position = view.position; + let selected = view.selected_text(&cells); + assert_eq!(selected.as_deref(), Some("beta")); + assert!( + view.handle_key( + KeyEvent::new_with_kind(code, modifiers, KeyEventKind::Release), + &cells, + ) + .is_none() + ); + assert_eq!( + (view.position, view.selected_text(&cells)), + (position, selected) + ); + } + } +} + +#[test] +fn loading_older_history_extends_the_snapshot_without_adopting_new_commits() { + let header = agent(&["header"]); + let selected = agent(&["selected"]); + let mut cells = vec![Arc::clone(&header), Arc::clone(&selected)]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 32, /*height*/ 5); + view.begin_selection(&cells, /*column*/ 4, /*row*/ 2, /*clicks*/ 2); + release(&mut view, &cells, /*column*/ 4, /*row*/ 2); + cells.push(agent(&["new commit"])); + let older = agent(&["older"]); + cells.insert(/*index*/ 1, Arc::clone(&older)); + view.prepend_snapshot_history(&cells, 1..2); + view.prepend_snapshot_history(&cells, 1..2); + assert_eq!( + view.snapshot_cells() + .expect("snapshot") + .iter() + .map(EntryKey::cell) + .collect::>(), + vec![ + EntryKey::cell(&header), + EntryKey::cell(&older), + EntryKey::cell(&selected) + ], + ); + render(&mut view, &cells, /*width*/ 32, /*height*/ 6); + assert_eq!(view.selected_text(&cells).as_deref(), Some("selected")); +} + +#[test] +fn copy_shortcuts_clear_selection_only_after_confirmed_delivery() { + use crate::clipboard_copy::CopyStatus; + + let cells: Vec> = vec![Arc::new(PlainHistoryCell::new(vec![ + "selected\tca\rfé\x1b\u{85}".into(), + ]))]; + for key in [ + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER), + KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ), + KeyEvent::new( + KeyCode::Char('C'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ), + KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL), + ] { + for status in [CopyStatus::Confirmed, CopyStatus::Unconfirmed] { + { + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 32, /*height*/ 3); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + let action = view.handle_key(key, &cells); + assert_eq!( + matches!(&action, Some(ViewAction::CopyAndFollow(_))), + key.code == KeyCode::Enter + ); + let Some(ViewAction::Copy(text) | ViewAction::CopyAndFollow(text)) = action else { + panic!("selection key must request a copy"); + }; + assert_eq!(text, "selected\tcafé"); + let failed = view.copy_selected_text_with(&cells, &text, |text| { + assert_eq!(text, "selected\tcafé"); + Err("clipboard unavailable".to_string()) + }); + assert_eq!(failed, Err("clipboard unavailable".to_string())); + assert_eq!( + view.selected_text(&cells).as_deref(), + Some("selected\tcafé") + ); + + let Some(ViewAction::Copy(text) | ViewAction::CopyAndFollow(text)) = + view.handle_key(key, &cells) + else { + panic!("failed copy must remain retryable"); + }; + let copied = view.copy_selected_text_with(&cells, &text, |text| { + assert_eq!(text, "selected\tcafé"); + Ok(status) + }); + assert_eq!(copied, Ok(status)); + assert_eq!( + view.selected_text(&cells), + (status == CopyStatus::Unconfirmed).then(|| text.clone()) + ); + assert_eq!(view.selection.is_none(), status == CopyStatus::Confirmed); + if status == CopyStatus::Confirmed { + assert!( + view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells) + .is_none() + ); + } + } + } + } +} diff --git a/codex-rs/tui/src/transcript_view/snapshot.rs b/codex-rs/tui/src/transcript_view/snapshot.rs new file mode 100644 index 0000000000..5bc30c129b --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshot.rs @@ -0,0 +1,92 @@ +//! Retain displayed revisions while selecting or reading a live tail or a replaced tool group. + +use std::collections::HashMap; + +use super::*; + +#[derive(Clone)] +pub(super) struct ViewSnapshot { + pub(super) cells: Arc<[Arc]>, + pub(super) pinned: HashMap>, +} + +impl TranscriptView { + pub(super) fn snapshot(&self) -> Option<&ViewSnapshot> { + self.selection + .as_ref() + .map(|selection| &selection.snapshot) + .or(self.held_reading.as_ref()) + } + + pub(super) fn snapshot_mut(&mut self) -> Option<&mut ViewSnapshot> { + self.selection + .as_mut() + .map(|selection| &mut selection.snapshot) + .or(self.held_reading.as_mut()) + } + + /// Clone the handle before mutating view state, without copying cells per frame. + pub(super) fn snapshot_cells(&self) -> Option]>> { + self.snapshot().map(|snapshot| Arc::clone(&snapshot.cells)) + } + + pub(super) fn capture_snapshot(&mut self, cells: &[Arc]) -> ViewSnapshot { + let cells = self.snapshot_cells().unwrap_or_else(|| Arc::from(cells)); + let mut pinned = self + .snapshot() + .map(|snapshot| snapshot.pinned.clone()) + .unwrap_or_default(); + pinned.extend( + self.visible + .iter() + .map(|visible| (visible.key, Arc::clone(&visible.layout))), + ); + if let Some(live) = self.layout(&cells, cells.len()) { + pinned.insert(EntryKey::Live, live); + } + ViewSnapshot { cells, pinned } + } + + /// Keep a live or replaced group's revision while reading inside it. Ordinary navigation + /// rejoins current history when it reaches a surviving cell. + pub(super) fn hold_live_reading( + &mut self, + cells: &[Arc], + layout: Arc, + ) { + if self.selection.is_some() { + return; + } + let Position::Reading(anchor) = self.position else { + self.release_live_reading(); + return; + }; + if anchor.key == EntryKey::Live { + if self.held_reading.is_none() { + let mut snapshot = self.capture_snapshot(cells); + snapshot.pinned.insert(EntryKey::Live, layout); + self.held_reading = Some(snapshot); + } + } else if self.held_reading.is_some() + && let Some(index) = cells + .iter() + .position(|cell| EntryKey::cell(cell) == anchor.key) + { + self.release_live_reading(); + self.position = Position::Reading(Anchor { index, ..anchor }); + } + } + + /// Search offsets belong to the held revision and must be refreshed when it is released. + pub(super) fn release_live_reading(&mut self) { + self.held_reading = None; + } + + pub(super) fn rewrap_snapshot(&mut self, width: u16) { + if let Some(snapshot) = self.snapshot_mut() { + for layout in snapshot.pinned.values_mut() { + *layout = Arc::new(layout.rewrap(width)); + } + } + } +} diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__compact_navigation_hints.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__compact_navigation_hints.snap new file mode 100644 index 0000000000..f92b0ef3fd --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__compact_navigation_hints.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/transcript_view/footer_tests.rs +assertion_line: 240 +expression: "snapshots.join(\"\\n\")" +--- +26 columns, LoadingOlder: ↑ Loading… · esc latest +26 columns, LoadingBeginning: ↑ Loading… · esc latest +26 columns, Failed: Retry: ⌥+> = vec![Arc::new(cell)]; + let mut view = crate::transcript_view::TranscriptView::default(); + + for width in [80, 32, 12, 80] { + // Ordinary resize renders the cell again; a selected revision rewraps its saved source. + assert_eq!( + retained.rewrap(width).text(), + expected_text, + "retained copy at {width} columns" + ); + view.end_selection(&cells); + let lines = cells[0].display_hyperlink_lines(width); + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 64); + let mut expected = Buffer::empty(area); + HyperlinkParagraph::new(&lines, Style::default()).render(area, &mut expected); + // The owned viewport inherits the line style on padding and hidden wide-character + // cells as well. The legacy paragraph only paints the visible spans there. + for (row, line) in lines.iter().enumerate() { + for column in 0..area.width { + let cell = &mut expected[(column, row as u16)]; + if cell.symbol() == " " { + cell.set_style(line.line.style); + } + } + } + let mut actual = Buffer::empty(area); + view.render(area, &mut actual, &cells); + // The viewport styles entire rows, including padding and hidden wide-character cells. + // Italic has no visible glyph there; retain every other attribute in the comparison. + for cell in actual.content.iter_mut().chain(expected.content.iter_mut()) { + if cell.symbol() == " " { + cell.modifier.remove(Modifier::ITALIC); + } + } + assert_eq!(actual, expected, "owned recap at {width} columns"); + let layout = view.layout(&cells, /*index*/ 0).expect("rendered recap"); + + // Select the body so the very narrow fallback's separate Recap label is not copied. + let start = layout.text().find("The café").expect("recap body"); + let end = layout.text().len(); + view.begin_selection( + &cells, + layout.column_for_offset(start), + layout.row_for_offset(start) as u16, + /*clicks*/ 1, + ); + view.extend_selection( + layout.column_for_offset(end), + layout.row_for_offset(end) as u16, + ); + assert_eq!( + view.selected_text(&cells), + Some(expected_text.clone()), + "selected copy at {width} columns" + ); + } +} + +#[test] +fn recap_tabs_preserve_url_wrapping_and_source_across_resize() { + let recap = "\thttps://example.com"; + let cell = ThreadRecapHistoryCell::new(recap.to_owned()); + let wide = TextLayout::new( + cell.display_hyperlink_lines(/*width*/ 80), + /*width*/ 80, + ); + let narrow = wide.rewrap(/*width*/ 32); + assert_eq!(narrow.text(), recap); + assert_eq!( + narrow + .rows + .iter() + .map(|row| row.line.line.to_string()) + .collect::>(), + [" ↳ Recap: ", " https://example.com"], + ); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 32, + narrow.row_count() as u16, + ); + let mut buffer = Buffer::empty(area); + narrow.render(area, &mut buffer, /*start_row*/ 0); + let restored = narrow.rewrap(/*width*/ 80).rewrap(/*width*/ 32); + let mut restored_buffer = Buffer::empty(area); + restored.render(area, &mut restored_buffer, /*start_row*/ 0); + assert_eq!(restored_buffer, buffer); + let link_start = recap.find("https://").expect("URL in recap"); + assert_eq!( + narrow.position_at( + narrow.row_for_offset(link_start), + narrow.column_for_offset(link_start) + ), + link_start, + ); +} + +#[test] +fn selected_recap_remains_visible_when_resize_cannot_fit_its_gutter() { + let mut frames = Vec::new(); + for recap in [ + "The café review is paused at https://example.com/review/42.", + "\thttps://example.com", + ] { + let cells: Vec> = + vec![Arc::new(ThreadRecapHistoryCell::new(recap.to_owned()))]; + let mut view = crate::transcript_view::TranscriptView::default(); + let wide_area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 64, + ); + view.render(wide_area, &mut Buffer::empty(wide_area), &cells); + let layout = view.layout(&cells, /*index*/ 0).expect("rendered recap"); + view.begin_selection( + &cells, + layout.column_for_offset(/*offset*/ 0), + /*row*/ 0, + /*clicks*/ 1, + ); + view.extend_selection( + layout.column_for_offset(recap.len()), + layout.row_for_offset(recap.len()) as u16, + ); + view.end_drag(); + let mut wide_buffer = Buffer::empty(wide_area); + view.render(wide_area, &mut wide_buffer, &cells); + + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 12, /*height*/ 64, + ); + let mut actual = Buffer::empty(area); + view.render(area, &mut actual, &cells); + // A held selection retains its source; the fresh narrow formatter adds a separate label. + let body_lines = cells[0] + .display_hyperlink_lines(area.width) + .into_iter() + .skip(/*n*/ 1) + .collect(); + let expected_layout = TextLayout::new(body_lines, area.width); + let mut expected = Buffer::empty(area); + expected_layout.render(area, &mut expected, /*start_row*/ 0); + expected_layout.highlight(0..recap.len(), area, &mut expected, /*start_row*/ 0); + assert_eq!(actual, expected, "selected recap {recap:?}"); + assert_eq!(view.selected_text(&cells), Some(recap.to_owned())); + let layout = view + .layout(&cells, /*index*/ 0) + .expect("selected narrow recap"); + frames.push( + layout + .rows + .iter() + .map(|row| row.line.line.to_string()) + .collect::>() + .join("\n"), + ); + + let mut restored = Buffer::empty(wide_area); + view.render(wide_area, &mut restored, &cells); + assert_eq!(restored, wide_buffer); + assert_eq!(view.selected_text(&cells), Some(recap.to_owned())); + } + insta::assert_snapshot!("owned_recap_selected_narrow", frames.join("\n---\n")); +} + +#[test] +fn resize_restores_the_exact_styles_of_whitespace_omitted_by_wrapping() { + let line = HyperlinkLine::new(Line::from(vec![ + "alpha".bold(), + " ".on_red(), + "beta".italic(), + " ".dim(), + "gamma".underlined(), + ])); + let wrapped = crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines( + std::slice::from_ref(&line), + RtOptions::new(/*width*/ 6), + ); + let layout = TextLayout::new(wrapped, /*width*/ 6).rewrap(/*width*/ 24); + assert_eq!(layout.text(), "alpha beta gamma"); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 2, + ); + let mut expected = Buffer::empty(area); + HyperlinkParagraph::new(&[line], Style::default()).render(area, &mut expected); + let mut actual = Buffer::empty(area); + layout.render(area, &mut actual, /*start_row*/ 0); + assert_eq!(actual, expected); +} + +fn assert_layout_paints_like_lines(lines: Vec, width: u16) { + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 30); + let mut expected = Buffer::empty(area); + HyperlinkParagraph::new(&lines, Style::default()).render(area, &mut expected); + let mut actual = Buffer::empty(area); + TextLayout::new(lines, width).render(area, &mut actual, /*start_row*/ 0); + assert_eq!(actual, expected); +} diff --git a/codex-rs/tui/src/transcript_view/text.rs b/codex-rs/tui/src/transcript_view/text.rs index 41f84567d0..e5cdcd840d 100644 --- a/codex-rs/tui/src/transcript_view/text.rs +++ b/codex-rs/tui/src/transcript_view/text.rs @@ -1,133 +1,418 @@ -//! Retain renderer output and row offsets, preserving Ratatui wrapping for overflow lines. +//! Wrapped transcript text with source positions shared by painting and selection. +//! +//! Input lines with shared source provenance belong to the same logical line. Other input +//! lines introduce hard breaks. Display wrapping and synthetic controls never enter `text`. +//! Layout offsets use `usize`; terminal coordinates are narrowed only for visible rows. +//! Disclosure controls follow their activity's source text without changing its indentation. + +use std::borrow::Cow; +use std::ops::Range; + +#[path = "text_logical.rs"] +mod logical; + +#[path = "text_tabs.rs"] +mod tabs; -use crate::terminal_hyperlinks::HyperlinkLine; -use crate::terminal_hyperlinks::HyperlinkParagraph; -use crate::width::display_width; use ratatui::buffer::Buffer; -use ratatui::buffer::CellWidth; use ratatui::layout::Alignment; use ratatui::layout::Rect; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::widgets::Widget; -use std::sync::Arc; +use unicode_segmentation::UnicodeSegmentation; -#[derive(Clone)] +use crate::line_truncation::line_width; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::HyperlinkParagraph; +use crate::terminal_hyperlinks::LineWrapPolicy; +use crate::terminal_hyperlinks::LogicalLineSource; +use crate::terminal_hyperlinks::remap_source_wrapped_line; +use crate::width::display_width; +use crate::wrapping::RtOptions; +use crate::wrapping::WrappedLine; +use crate::wrapping::adaptive_wrap_line_to_width; +use crate::wrapping::word_wrap_line_with_source; +use logical::LogicalLine; +use logical::logical_lines; +use ratatui::text::Line; + +/// One cell's logical text and its width-dependent display rows. pub(super) struct TextLayout { - pub(super) rows: Vec, - pub(super) byte_len: usize, + logical: Vec, + text: String, + rows: Vec, + width: u16, + pub(super) separated: bool, } -#[derive(Clone)] -pub(super) struct TextRow { - pub(super) line: Arc, - pub(super) offset: u16, +struct TextRow { + line: HyperlinkLine, + source: Range, + content_width: u16, + first_column: usize, + prefix_columns: usize, + tabs: tabs::TabStops, } impl TextLayout { pub(super) fn new(lines: Vec, width: u16) -> Self { - let byte_len = lines - .iter() - .flat_map(|line| &line.line.spans) - .map(|span| span.content.len()) - .sum(); - let rows = lines - .into_iter() - .flat_map(|line| { - // Most history renderers already wrap to width. Use the original paragraph for - // overflow so indentation, hyphens, and wide glyphs follow the same rules as paint. - let count = if crate::line_truncation::line_width(&line.line) - <= usize::from(width.max(/*other*/ 1)) - { - 1 - } else { - HyperlinkParagraph::new(std::slice::from_ref(&line), Style::default()) - .line_count(width.max(/*other*/ 1)) - }; - let line = Arc::new(line); - // Ratatui's paragraph scroll offset is u16, as in the existing pager. - (0..u16::try_from(count).unwrap_or(u16::MAX)).map(move |offset| TextRow { - line: Arc::clone(&line), - offset, - }) - }) - .collect(); - Self { rows, byte_len } + let logical = logical_lines(&lines); + Self::from_logical(logical, width) } + /// Reflow the same displayed content revision, retaining styles, links and disclosure label. + pub(super) fn rewrap(&self, width: u16) -> Self { + let layout = Self::from_logical(self.logical.clone(), width); + + if self.separated { + layout.with_leading_separator() + } else { + layout + } + } + + /// Add visual spacing between entries without changing any source position. pub(super) fn with_leading_separator(mut self) -> Self { - if !self.rows.is_empty() { + if !self.separated && !self.rows.is_empty() { self.rows.insert( /*index*/ 0, TextRow { - line: Arc::new(HyperlinkLine::from("")), - offset: 0, + line: HyperlinkLine::from(""), + source: 0..0, + content_width: self.width, + first_column: 0, + prefix_columns: 0, + tabs: tabs::TabStops::default(), }, ); + + self.separated = true; } self } + /// Source text between adjacent retained fragments; distinct logical lines have a hard break. + pub(super) fn separator_after(&self, next: &Self) -> &str { + if let Some(previous) = self.logical.last() + && let Some(next) = next.logical.first() + && std::sync::Arc::ptr_eq(&previous.origin.text, &next.origin.text) + && let Some(gap) = previous + .origin + .text + .get(previous.origin.range.end..next.origin.range.start) + { + gap + } else { + "\n" + } + } + + pub(super) fn text(&self) -> &str { + &self.text + } + pub(super) fn row_count(&self) -> usize { self.rows.len() } - pub(super) fn render(&self, area: Rect, buf: &mut Buffer, row: usize) { - let start = row.min(self.rows.len()); - let end = row - .saturating_add(usize::from(area.height)) - .min(self.rows.len()); - let mut y = area.y; - for rows in self.rows[start..end].chunk_by(|a, b| Arc::ptr_eq(&a.line, &b.line)) { - let first = &rows[0]; - let height = rows.len() as u16; - HyperlinkParagraph::new(std::slice::from_ref(first.line.as_ref()), Style::default()) - .scroll(first.offset) - .render(Rect::new(area.x, y, area.width, height), buf); - y += height; + /// Paint visible rows, including inherited styles on empty rows and trailing columns. + pub(super) fn render(&self, area: Rect, buf: &mut Buffer, start_row: usize) { + for (screen_row, row) in self.visible_rows(area, start_row) { + let mut row_area = + Rect::new(area.x, area.y + screen_row, area.width, /*height*/ 1); + buf.set_style(row_area, row.line.line.style); + row_area.width = row.content_width; + HyperlinkParagraph::new(std::slice::from_ref(&row.line), row.line.line.style) + .render(row_area, buf); } } - /// Highlight the entry's text while leaving synthetic gutters and padding untouched. - pub(super) fn highlight(&self, area: Rect, buf: &mut Buffer, row: usize) { - let row = &self.rows[row]; - let line = &row.line; - let text = line.line.to_string(); - let prefix = if row.offset == 0 { - line.source.as_ref().map_or( - /*default*/ 0, - |source| display_width(&text[..source.prefix_bytes]), - ) - } else { - 0 + /// Resolve a cell coordinate to a grapheme boundary, clamping padding to the row's text. + pub(super) fn position_at(&self, row: usize, column: u16) -> usize { + let Some(row) = self.rows.get(row) else { + return self.text.len(); }; - let width = display_width(&text); - let columns = usize::from(area.width); - let alignment = match line.line.alignment.unwrap_or(Alignment::Left) { - Alignment::Left => 0, - Alignment::Center => (columns / 2).saturating_sub(width / 2), - Alignment::Right => columns.saturating_sub(width), + let mut current_column = row.first_column; + for (offset, grapheme) in + self.text[row.source.clone()].grapheme_indices(/*is_extended*/ true) + { + current_column += row.tabs.grapheme_width(offset, grapheme); + if usize::from(column) < current_column { + return row.source.start + offset; + } + } + row.source.end + } + + /// Find the display row containing a source position, including omitted wrapping whitespace. + pub(super) fn row_for_offset(&self, offset: usize) -> usize { + self.rows + .partition_point(|row| row.source.start <= offset) + .saturating_sub(/*rhs*/ 1) + } + + pub(super) fn column_for_offset(&self, offset: usize) -> u16 { + let Some(row) = self.rows.get(self.row_for_offset(offset)) else { + return 0; }; - let (alignment, end) = if width > columns { - let end = (0..area.width) - .rfind(|x| !buf[(area.x + x, area.y)].symbol().trim().is_empty()) - .map_or(/*default*/ 0, |x| { - usize::from(x + buf[(area.x + x, area.y)].cell_width()) - }); - let start = if line.line.alignment.unwrap_or(Alignment::Left) == Alignment::Left { - 0 - } else { - (0..area.width) - .find(|x| !buf[(area.x + x, area.y)].symbol().trim().is_empty()) - .map_or(/*default*/ 0, usize::from) - }; - (start, end) - } else { - (alignment, alignment + width) - }; - for column in alignment + prefix..end.min(columns) { - buf[(area.x + column as u16, area.y)] - .set_style(Style::default().add_modifier(Modifier::REVERSED)); + let offset = self + .text + .floor_char_boundary(offset.clamp(row.source.start, row.source.end)); + let column = row.first_column + + row.tabs.column_for_offset( + display_width(&self.text[row.source.start..offset]), + offset - row.source.start, + ); + column.min(usize::from(self.width)) as u16 + } + + /// Highlight source graphemes only, leaving alignment and viewport padding untouched. + pub(super) fn highlight( + &self, + range: Range, + area: Rect, + buf: &mut Buffer, + start_row: usize, + ) { + for (screen_row, row) in self.visible_rows(area, start_row) { + let mut column = row.first_column; + for (offset, grapheme) in + self.text[row.source.clone()].grapheme_indices(/*is_extended*/ true) + { + let start = row.source.start + offset; + let width = row.tabs.grapheme_width(offset, grapheme); + if start < range.end && start + grapheme.len() > range.start { + for selected_column in + column..(column + width).min(usize::from(row.content_width)) + { + buf[(area.x + selected_column as u16, area.y + screen_row)] + .set_style(Style::default().add_modifier(Modifier::REVERSED)); + } + } + column += width; + } + } + } + + pub(super) fn word_range(&self, offset: usize) -> Range { + self.text + .split_word_bound_indices() + .map(|(start, word)| start..start + word.len()) + .find(|range| range.contains(&offset)) + .unwrap_or(self.text.len()..self.text.len()) + } + + /// Resolve the displayed link using the same destination policy as terminal OSC-8 output. + pub(super) fn link_at(&self, row: usize, column: u16) -> Option { + let row = self.rows.get(row)?; + let column = usize::from(column).checked_sub(row.first_column - row.prefix_columns)?; + row.line + .hyperlinks + .iter() + .find(|link| link.columns.contains(&column))? + .terminal_destination() + } + + /// Select a logical line, including its terminating hard newline when present. + pub(super) fn line_range(&self, offset: usize) -> Range { + let offset = self.text.floor_char_boundary(offset.min(self.text.len())); + let start = self.text[..offset] + .rfind('\n') + .map_or(/*default*/ 0, |newline| newline + 1); + let end = self.text[offset..] + .find('\n') + .map_or(self.text.len(), |newline| offset + newline + 1); + start..end + } + + fn visible_rows(&self, area: Rect, start_row: usize) -> impl Iterator { + let height = if area.width == 0 { 0 } else { area.height }; + debug_assert!(area.width == 0 || area.width == self.width); + self.rows + .iter() + .skip(start_row) + .take(usize::from(height)) + .enumerate() + .map(|(row, text)| (row as u16, text)) + } + + fn from_logical(logical: Vec, width: u16) -> Self { + let width = width.max(/*other*/ 1); + let mut text = String::new(); + let mut rows = Vec::new(); + for (index, line) in logical.iter().enumerate() { + if index > 0 { + text.push('\n'); + } + let start = text.len(); + text.extend( + line.line + .line + .spans + .iter() + .map(|span| span.content.as_ref()), + ); + rows.extend(layout_line(line, start, width)); + } + Self { + logical, + text, + rows, + width, + separated: false, } } } + +fn layout_line(logical: &LogicalLine, text_start: usize, width: u16) -> Vec { + let content_width = width.saturating_sub(logical.right_reserve).max(/*other*/ 1); + let mut logical = Cow::Borrowed(logical); + if line_width(&logical.initial_indent) >= usize::from(content_width) + || line_width(&logical.subsequent_indent) >= usize::from(content_width) + { + let logical = logical.to_mut(); + logical.initial_indent = Line::default(); + logical.subsequent_indent = Line::default(); + } + let mut line = logical.line.clone(); + line.source = Some(LogicalLineSource::new( + line.line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(), + )); + let options = RtOptions::new(usize::from(content_width)) + .initial_indent(logical.initial_indent.clone()) + .subsequent_indent(logical.subsequent_indent.clone()); + let (wrapped, tab_stops) = if line + .line + .spans + .iter() + .any(|span| span.content.contains('\t')) + { + tabs::wrap_line(&logical, usize::from(content_width)) + } else { + let wrapped = match logical.wrap_policy { + LineWrapPolicy::Word => word_wrap_line_with_source(&line.line, options), + LineWrapPolicy::UrlAware => adaptive_wrap_line_to_width(&line.line, options), + LineWrapPolicy::Hard => hard_wrap_line(&line.line, options), + }; + let stops = (0..wrapped.len()) + .map(|_| tabs::TabStops::default()) + .collect(); + (wrapped, stops) + }; + // This source covers the whole logical line, so wrapping already supplies final row offsets. + let positions = wrapped + .iter() + .map(|row| (row.range.clone(), row.prefix_bytes)) + .collect::>(); + remap_source_wrapped_line(&line, wrapped) + .into_iter() + .zip(positions) + .zip(tab_stops) + .map(|((mut wrapped, (source, prefix_bytes)), tabs)| { + wrapped.line.alignment = line.line.alignment; + for link in &mut wrapped.hyperlinks { + link.columns = tabs.project_link(link.columns.clone()); + } + let displayed: String = wrapped + .line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + let prefix_columns = display_width(&displayed[..prefix_bytes]); + let columns = usize::from(content_width); + let text_width = display_width(&displayed); + let first_column = match line.line.alignment.unwrap_or(Alignment::Left) { + Alignment::Left => 0, + Alignment::Center => (columns / 2).saturating_sub(text_width / 2), + Alignment::Right => columns.saturating_sub(text_width), + }; + let range = text_start + source.start..text_start + source.end; + TextRow { + line: wrapped, + source: range, + content_width, + first_column: first_column + prefix_columns, + prefix_columns, + tabs, + } + }) + .collect() +} + +/// Reuse the diff renderer's hard wrapping while preserving its distinct first/continuation gutters. +fn hard_wrap_line(line: &Line<'static>, options: RtOptions<'static>) -> Vec> { + let first_width = options + .width + .saturating_sub(options.initial_indent.width()) + .max(/*other*/ 1); + let first = crate::diff_render::wrap_styled_spans(&line.spans, first_width) + .into_iter() + .next() + .unwrap_or_default(); + let mut offset = first.iter().map(|span| span.content.len()).sum(); + let prefix_bytes = options + .initial_indent + .spans + .iter() + .map(|span| span.content.len()) + .sum(); + let mut initial = options.initial_indent.clone().style(line.style); + initial.spans.extend(first); + let mut out = vec![WrappedLine { + line: initial, + range: 0..offset, + prefix_bytes, + }]; + let total = line.spans.iter().map(|span| span.content.len()).sum(); + let remainder = logical::slice_line(line, offset..total); + let continuation_width = options + .width + .saturating_sub(options.subsequent_indent.width()) + .max(/*other*/ 1); + for chunk in crate::diff_render::wrap_styled_spans(&remainder.spans, continuation_width) { + let length: usize = chunk.iter().map(|span| span.content.len()).sum(); + if length == 0 { + continue; + } + let mut continuation = options.subsequent_indent.clone().style(line.style); + let prefix_bytes = continuation + .spans + .iter() + .map(|span| span.content.len()) + .sum(); + continuation.spans.extend(chunk); + out.push(WrappedLine { + line: continuation, + range: offset..offset + length, + prefix_bytes, + }); + offset += length; + } + out +} + +#[cfg(test)] +#[path = "text_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "diff_source_tests.rs"] +mod diff_source_tests; + +#[cfg(test)] +#[path = "source_cell_tests.rs"] +mod source_cell_tests; + +#[cfg(test)] +#[path = "prompt_style_tests.rs"] +mod prompt_style_tests; + +#[cfg(test)] +#[path = "text_tabs_tests.rs"] +mod tab_tests; diff --git a/codex-rs/tui/src/transcript_view/text_logical.rs b/codex-rs/tui/src/transcript_view/text_logical.rs new file mode 100644 index 0000000000..58a81011d0 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/text_logical.rs @@ -0,0 +1,103 @@ +//! Reconstruct logical styled lines from explicit wrapping provenance for resize and copy. + +use std::ops::Range; +use std::sync::Arc; + +use ratatui::text::Line; +use ratatui::text::Span; + +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::LineWrapPolicy; +use crate::terminal_hyperlinks::LogicalLineSource; +use crate::width::display_width; + +#[derive(Clone)] +pub(super) struct LogicalLine { + pub(super) line: HyperlinkLine, + pub(super) initial_indent: Line<'static>, + pub(super) subsequent_indent: Line<'static>, + pub(super) wrap_policy: LineWrapPolicy, + pub(super) right_reserve: u16, + pub(super) origin: LogicalLineSource, +} + +pub(super) fn logical_lines(lines: &[HyperlinkLine]) -> Vec { + let mut logical: Vec = Vec::new(); + for line in lines { + let source = line + .source + .clone() + .unwrap_or_else(|| LogicalLineSource::from_line(&line.line)); + let previous = logical.last_mut().filter(|previous| { + Arc::ptr_eq(&previous.origin.text, &source.text) + && previous.origin.range.end <= source.range.start + }); + match previous { + Some(previous) => previous.append(line, source), + None => { + let mut entry = LogicalLine { + line: HyperlinkLine::new(Line::default().style(line.line.style)), + initial_indent: slice_line(&line.line, 0..source.prefix_bytes), + subsequent_indent: source.continuation_indent.clone(), + wrap_policy: source.wrap_policy, + right_reserve: source.right_reserve, + origin: source.clone(), + }; + entry.origin.range.end = source.range.start; + entry.append(line, source); + logical.push(entry); + } + } + } + logical +} + +impl LogicalLine { + fn append(&mut self, line: &HyperlinkLine, source: LogicalLineSource) { + self.line.line.spans.extend( + source + .styled_range(self.origin.range.end..source.range.start) + .spans, + ); + let column = self.line.width(); + let body = slice_line( + &line.line, + source.prefix_bytes..source.prefix_bytes + source.range.len(), + ); + let prefix = slice_line(&line.line, 0..source.prefix_bytes); + let prefix_width = prefix.width(); + let content_width = display_width(&source.text[source.range.clone()]); + self.line.line.spans.extend(body.spans); + self.line + .hyperlinks + .extend(line.hyperlinks.iter().filter_map(|link| { + let start = link.columns.start.max(prefix_width); + let end = link.columns.end.min(prefix_width + content_width); + (start < end).then(|| { + link.with_columns(column + start - prefix_width..column + end - prefix_width) + }) + })); + self.line.line.alignment = line.line.alignment; + self.origin.range.end = source.range.end; + } +} + +pub(super) fn slice_line(line: &Line<'_>, range: Range) -> Line<'static> { + let mut offset = 0; + let spans = line + .spans + .iter() + .filter_map(|span| { + let start = range.start.saturating_sub(offset).min(span.content.len()); + let end = range.end.saturating_sub(offset).min(span.content.len()); + offset += span.content.len(); + (start < end).then(|| { + Span::styled( + span.content[start..end].to_owned(), + line.style.patch(span.style), + ) + }) + }) + .collect::>(); + Line::from(spans).style(line.style) +} diff --git a/codex-rs/tui/src/transcript_view/text_tabs.rs b/codex-rs/tui/src/transcript_view/text_tabs.rs new file mode 100644 index 0000000000..a400d5ea39 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/text_tabs.rs @@ -0,0 +1,303 @@ +//! Row-aware tab projection for owned transcript layout. Source bytes remain unchanged. +//! +//! Tab-free runs use the existing wrapping primitives. Each tab then advances to the next +//! eight-column stop in its actual display row, including the synthetic gutter. Sparse stops +//! are shared by source hit testing, highlighting, and hyperlink projection. + +use std::ops::Range; + +use ratatui::text::Line; +use ratatui::text::Span; + +use crate::line_truncation::line_width; +use crate::terminal_hyperlinks::LineWrapPolicy; +use crate::terminal_hyperlinks::LogicalLineSource; +use crate::width::display_width; +use crate::wrapping::RtOptions; +use crate::wrapping::WrappedLine; +use crate::wrapping::adaptive_wrap_line_to_width; +use crate::wrapping::adaptive_wrap_line_with_source; +use crate::wrapping::wrap_ranges_trim; + +use super::logical::LogicalLine; + +#[derive(Default)] +pub(super) struct TabStops(Vec); + +struct TabStop { + offset: usize, + raw_column: usize, + width: usize, + total_width: usize, +} + +impl TabStops { + pub(super) fn column_for_offset(&self, column: usize, offset: usize) -> usize { + let index = self.0.partition_point(|tab| tab.offset < offset); + self.project_column(column, index) + } + + pub(super) fn grapheme_width(&self, offset: usize, grapheme: &str) -> usize { + if grapheme != "\t" { + return display_width(grapheme); + } + self.0 + .binary_search_by_key(&offset, |tab| tab.offset) + .map_or(/*default*/ 0, |index| self.0[index].width) + } + + /// Link starts include tabs immediately before them; ends exclude tabs immediately after. + pub(super) fn project_link(&self, columns: Range) -> Range { + let start = self + .0 + .partition_point(|tab| tab.raw_column + display_width("\t") <= columns.start); + let end = self.0.partition_point(|tab| tab.raw_column < columns.end); + self.project_column(columns.start, start)..self.project_column(columns.end, end) + } + + fn project_column(&self, column: usize, tab_count: usize) -> usize { + let expanded = tab_count + .checked_sub(/*rhs*/ 1) + .map_or(/*default*/ 0, |index| self.0[index].total_width); + column.saturating_sub(tab_count * display_width("\t")) + expanded + } +} + +pub(super) fn wrap_line( + logical: &LogicalLine, + width: usize, +) -> (Vec>, Vec) { + let source = LogicalLineSource::from_line(&logical.line.line); + let continuation = Row::new( + &logical.subsequent_indent, + logical.line.line.style, + /*offset*/ 0, + width, + ); + let mut builder = Rows { + source: &source, + logical, + width, + continuation_width: width.saturating_sub(continuation.column).max(/*other*/ 1), + current: Row::new( + &logical.initial_indent, + logical.line.line.style, + /*offset*/ 0, + width, + ), + finished: Vec::new(), + }; + let mut offset = 0; + for part in source.text.split_inclusive('\t') { + let run = part.strip_suffix('\t').unwrap_or(part); + builder.append_run(offset..offset + run.len(), logical.wrap_policy); + offset += run.len(); + if part.ends_with('\t') { + // Ordinary wrapping trims trailing spaces. Before a tab they still affect its stop. + builder.append_run( + builder.current.wrapped.range.end..offset, + LineWrapPolicy::Hard, + ); + builder.append_tab(offset); + offset += 1; + } + } + builder.finished.push(builder.current); + builder + .finished + .into_iter() + .map(|row| (row.wrapped, row.tabs)) + .unzip() +} + +struct Row { + wrapped: WrappedLine<'static>, + tabs: TabStops, + column: usize, +} + +impl Row { + fn new( + indent: &Line<'static>, + style: ratatui::style::Style, + offset: usize, + width: usize, + ) -> Self { + let mut line = indent.clone().style(style); + // Gutters are display-only, so expanding their tabs never changes source offsets. + let mut column = 0; + for span in &mut line.spans { + let mut text = String::new(); + for run in span.content.split_inclusive('\t') { + let body = run.strip_suffix('\t').unwrap_or(run); + text.push_str(body); + column += display_width(body); + if run.ends_with('\t') { + let spaces = (8 - column % 8).min(width.saturating_sub(column)); + text.push_str(&" ".repeat(spaces)); + column += spaces; + } + } + span.content = text.into(); + } + let prefix_bytes = line.spans.iter().map(|span| span.content.len()).sum(); + Self { + wrapped: WrappedLine { + line, + range: offset..offset, + prefix_bytes, + }, + tabs: TabStops::default(), + column, + } + } +} + +struct Rows<'a> { + source: &'a LogicalLineSource, + logical: &'a LogicalLine, + width: usize, + continuation_width: usize, + current: Row, + finished: Vec, +} + +impl Rows<'_> { + fn next_row(&mut self, offset: usize) { + let next = Row::new( + &self.logical.subsequent_indent, + self.logical.line.line.style, + offset, + self.width, + ); + self.finished + .push(std::mem::replace(&mut self.current, next)); + } + + fn append_run(&mut self, range: Range, policy: LineWrapPolicy) { + if range.is_empty() { + return; + } + if self.current.column >= self.width { + self.next_row(range.start); + } + let text = &self.source.text[range.clone()]; + let mut first_width = self + .width + .saturating_sub(self.current.column) + .max(/*other*/ 1); + let following_width = self.continuation_width; + // A URL after a tab may fit the next row even when this row has little room left. + if policy == LineWrapPolicy::UrlAware + && first_width < following_width + && adaptive_wrap_line_with_source(&Line::from(text), RtOptions::new(first_width)) + .first() + .is_some_and(|row| { + line_width(&row.line) > first_width && line_width(&row.line) <= following_width + }) + { + self.next_row(range.start); + first_width = following_width; + } + let first = run_ranges(text, first_width, policy) + .into_iter() + .next() + .unwrap_or(0..0); + let base = match policy { + LineWrapPolicy::Word | LineWrapPolicy::UrlAware => { + first.end + + text[first.end..] + .bytes() + .take_while(|byte| *byte == b' ') + .count() + } + LineWrapPolicy::Hard => first.end, + }; + let mut ranges = vec![first]; + ranges.extend( + run_ranges(&text[base..], following_width, policy) + .into_iter() + .filter(|range| !range.is_empty()) + .map(|range| base + range.start..base + range.end), + ); + for (index, part) in ranges.into_iter().enumerate() { + let part = range.start + part.start..range.start + part.end; + if index > 0 { + self.next_row(part.start); + } + let line = self.styled_slice(part.clone()); + self.current.column += line.width(); + self.current.wrapped.line.spans.extend(line.spans); + self.current.wrapped.range.end = part.end; + } + } + + fn append_tab(&mut self, offset: usize) { + if self.current.column >= self.width { + self.next_row(offset); + } + let width = + (8 - self.current.column % 8).min(self.width.saturating_sub(self.current.column)); + let style = self.styled_slice(offset..offset + 1).spans[0].style; + let row = &mut self.current; + let previous_width = row + .tabs + .0 + .last() + .map_or(/*default*/ 0, |tab| tab.total_width); + row.tabs.0.push(TabStop { + offset: offset - row.wrapped.range.start, + raw_column: row.column - previous_width + row.tabs.0.len() * display_width("\t"), + width, + total_width: previous_width + width, + }); + row.wrapped + .line + .spans + .push(Span::styled(" ".repeat(width), style)); + row.wrapped.range.end = offset + 1; + row.column += width; + } + + /// Binary-search span boundaries so many runs never rescan the styled source prefix. + fn styled_slice(&self, range: Range) -> Line<'static> { + let first = self + .source + .styles + .partition_point(|(style_range, _)| style_range.end <= range.start); + Line::from( + self.source.styles[first..] + .iter() + .take_while(|(style_range, _)| style_range.start < range.end) + .map(|(style_range, style)| { + let start = style_range.start.max(range.start); + let end = style_range.end.min(range.end); + Span::styled(self.source.text[start..end].to_owned(), *style) + }) + .collect::>(), + ) + } +} + +fn run_ranges(text: &str, width: usize, policy: LineWrapPolicy) -> Vec> { + match policy { + LineWrapPolicy::Word => wrap_ranges_trim(text, width), + LineWrapPolicy::UrlAware => { + adaptive_wrap_line_to_width(&Line::from(text), RtOptions::new(width)) + .into_iter() + .map(|row| row.range) + .collect() + } + LineWrapPolicy::Hard => { + let mut offset = 0; + crate::diff_render::wrap_styled_spans(&[Span::raw(text.to_owned())], width) + .into_iter() + .map(|spans| { + let start = offset; + offset += spans.iter().map(|span| span.content.len()).sum::(); + start..offset + }) + .collect() + } + } +} diff --git a/codex-rs/tui/src/transcript_view/text_tabs_tests.rs b/codex-rs/tui/src/transcript_view/text_tabs_tests.rs new file mode 100644 index 0000000000..8d0168dee8 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/text_tabs_tests.rs @@ -0,0 +1,167 @@ +//! Tab display and source-coordinate regressions for owned transcript rows. + +use super::TextLayout; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::prefix_hyperlink_lines; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Stylize; +use ratatui::text::Line; + +#[test] +fn styled_tabs_use_row_stops_including_the_gutter() { + let mut line = HyperlinkLine::new(Line::from("\ttabs:\t".green())); + line.push_span("one".cyan(), Some("https://example.com/one")); + line.push_span("\t".magenta().underlined(), /*destination*/ None); + line.push_span("two".cyan(), Some("https://example.com/two")); + let lines = prefix_hyperlink_lines(vec![line], "› ".bold(), " ".into()); + let layout = TextLayout::new(lines, /*width*/ 32); + let area = Rect::new( + /*x*/ 3, /*y*/ 2, /*width*/ 32, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + layout.render(area, &mut buffer, /*start_row*/ 0); + assert_eq!(layout.text(), "\ttabs:\tone\ttwo"); + assert_eq!( + [0, 1, 6, 7, 10, 11].map(|offset| layout.column_for_offset(offset)), + [2, 8, 13, 16, 19, 24], + ); + assert_eq!( + [2, 7, 13, 15, 19, 23].map(|column| layout.position_at(/*row*/ 0, column)), + [0, 0, 6, 6, 10, 10], + ); + assert_eq!( + ( + layout.link_at(/*row*/ 0, /*column*/ 16).as_deref(), + layout.link_at(/*row*/ 0, /*column*/ 24).as_deref(), + layout.link_at(/*row*/ 0, /*column*/ 13).as_deref() + ), + ( + Some("https://example.com/one"), + Some("https://example.com/two"), + None + ), + ); + assert_eq!( + ( + buffer[(area.x + 3, area.y)].fg, + buffer[(area.x + 20, area.y)].fg + ), + (Color::Green, Color::Magenta), + ); + layout.highlight(6..7, area, &mut buffer, /*start_row*/ 0); + assert_eq!( + (0..32) + .filter(|column| buffer[(area.x + column, area.y)] + .modifier + .contains(Modifier::REVERSED)) + .collect::>(), + vec![13, 14, 15], + ); + assert_eq!(&layout.text()[6..7], "\t"); + insta::assert_snapshot!("styled_tab_stops_and_selection", format!("{buffer:?}")); +} + +#[test] +fn tab_stops_restart_after_wrapping_and_resize_preserves_source_and_links() { + let mut line = HyperlinkLine::new(Line::from("abcdefghi\t".green())); + line.push_span("X".cyan(), Some("https://example.com/x")); + line.push_span("\tYZ".magenta(), /*destination*/ None); + let lines = prefix_hyperlink_lines(vec![line], "› ".bold(), " ".into()); + let layout = TextLayout::new(lines, /*width*/ 10); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 10, /*height*/ 3, + ); + let mut buffer = Buffer::empty(area); + layout.render(area, &mut buffer, /*start_row*/ 0); + assert_eq!(layout.text(), "abcdefghi\tX\tYZ"); + assert_eq!(layout.row_count(), 3); + assert_eq!( + [9, 10, 12].map(|offset| ( + layout.row_for_offset(offset), + layout.column_for_offset(offset) + )), + [(1, 3), (1, 8), (2, 2)], + ); + assert_eq!( + layout.link_at(/*row*/ 1, /*column*/ 8).as_deref(), + Some("https://example.com/x") + ); + let wide = layout.rewrap(/*width*/ 40); + assert_eq!((wide.text(), wide.row_count()), (layout.text(), 1)); + let restored = wide.rewrap(/*width*/ 10); + let mut restored_buffer = Buffer::empty(area); + restored.render(area, &mut restored_buffer, /*start_row*/ 0); + assert_eq!(restored_buffer, buffer); +} + +#[test] +fn leading_and_repeated_tabs_remain_selectable_below_one_tab_stop() { + for width in 1..8 { + let layout = TextLayout::new(vec!["\t\t界e\u{301}\tX".into()], width); + assert_eq!(layout.text(), "\t\t界e\u{301}\tX"); + assert_eq!(layout.position_at(/*row*/ 0, width - 1), 0); + assert_eq!(layout.position_at(/*row*/ 1, width - 1), 1); + let offset = layout.text().find('X').unwrap(); + assert_eq!( + layout.position_at( + layout.row_for_offset(offset), + layout.column_for_offset(offset) + ), + offset + ); + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, layout.row_count() as u16); + let mut buffer = Buffer::empty(area); + layout.render(area, &mut buffer, /*start_row*/ 0); + let visible = buffer + .content + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert!(visible.contains('X'), "width {width}: {buffer:?}"); + layout.highlight(0..2, area, &mut buffer, /*start_row*/ 0); + assert!((0..width).all(|column| buffer[(column, 0)].modifier.contains(Modifier::REVERSED))); + } +} + +#[test] +fn ordinary_spaces_before_tabs_affect_the_stop_without_entering_the_gutter() { + let layout = TextLayout::new(vec!["ab \tX\t\tY".into()], /*width*/ 32); + assert_eq!(layout.text(), "ab \tX\t\tY"); + assert_eq!( + [5, 6, 7, 8, 9].map(|offset| layout.column_for_offset(offset)), + [5, 8, 9, 16, 24] + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + layout.render(area, &mut buffer, /*start_row*/ 0); + assert_eq!( + (buffer[(8, 0)].symbol(), buffer[(24, 0)].symbol()), + ("X", "Y") + ); +} + +#[test] +fn hard_wrapped_tabs_keep_source_characters_and_actual_row_stops() { + let mut line = HyperlinkLine::new(Line::from("abcde\tX\tY")); + let mut source = crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line); + source.wrap_policy = crate::terminal_hyperlinks::LineWrapPolicy::Hard; + line.source = Some(source); + let layout = TextLayout::new(vec![line], /*width*/ 4); + assert_eq!(layout.text(), "abcde\tX\tY"); + assert_eq!( + (layout.row_count(), layout.row_for_offset(/*offset*/ 8)), + (4, 3) + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 4, /*height*/ 4, + ); + let mut buffer = Buffer::empty(area); + layout.render(area, &mut buffer, /*start_row*/ 0); + insta::assert_snapshot!("hard_wrapped_tabs", format!("{buffer:?}")); +} diff --git a/codex-rs/tui/src/transcript_view/text_tests.rs b/codex-rs/tui/src/transcript_view/text_tests.rs new file mode 100644 index 0000000000..3ecd5bc177 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/text_tests.rs @@ -0,0 +1,338 @@ +//! Copy and painting regressions over the shared source-backed text layout. + +use super::TextLayout; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines; +use crate::terminal_hyperlinks::prefix_hyperlink_lines; +use crate::wrapping::RtOptions; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Stylize; +use ratatui::text::Line; + +#[test] +fn copying_wrapped_text_preserves_only_hard_newlines() { + let layout = TextLayout::new( + vec!["alpha beta gamma".into(), " next line".into()], + /*width*/ 10, + ); + assert_eq!(layout.text(), "alpha beta gamma\n next line"); + assert_eq!( + ( + layout.position_at(/*row*/ 1, /*column*/ 0), + layout.row_for_offset(/*offset*/ 11) + ), + (11, 1), + ); + assert_eq!( + &layout.text()[layout.line_range(/*offset*/ 12)], + "alpha beta gamma\n" + ); + + // A commit tick can divide one logical code line across several history cells. + let mut stream = crate::streaming::controller::StreamController::new( + Some(7), + std::path::Path::new("/"), + crate::history_cell::HistoryRenderMode::Rich, + ); + stream.push("```text\nalpha beta\n\n界界界界界\nnext\n```\n"); + let mut cells: Vec> = Vec::new(); + while let Some(cell) = stream.on_commit_tick_batch(/*max_lines*/ 1).0 { + cells.push(cell.into()); + } + let expected = TextLayout::new( + cells + .iter() + .flat_map(|cell| cell.transcript_hyperlink_lines(/*width*/ 9)) + .collect(), + /*width*/ 9, + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 9, /*height*/ 64, + ); + let mut view = crate::transcript_view::TranscriptView::default(); + view.render(area, &mut Buffer::empty(area), &cells); + view.begin_selection(&cells, /*column*/ 2, /*row*/ 0, /*clicks*/ 1); + view.extend_selection(/*column*/ 8, /*row*/ 63); + assert_eq!(view.selected_text(&cells).as_deref(), Some(expected.text())); +} + +#[test] +fn hit_testing_uses_graphemes_and_does_not_select_padding() { + let layout = TextLayout::new(vec!["a界e\u{301}👩‍💻".into()], /*width*/ 12); + assert_eq!( + (0..12) + .map(|column| layout.position_at(/*row*/ 0, column)) + .collect::>(), + vec![0, 1, 1, 4, 7, 7, 18, 18, 18, 18, 18, 18], + ); + assert_eq!(&layout.text()[layout.word_range(/*offset*/ 4)], "e\u{301}"); +} + +#[test] +fn visible_rows_preserve_styles_and_wrapped_hyperlinks() { + let mut line = HyperlinkLine::new(Line::from("read ".green())); + line.push_span("documentation".cyan(), Some("https://example.com/docs")); + let layout = TextLayout::new(vec![line], /*width*/ 8); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 8, /*height*/ 2, + ); + let mut buffer = Buffer::empty(area); + layout.render(area, &mut buffer, /*start_row*/ 1); + assert_eq!(buffer[(0, 0)].fg, Color::Cyan); + assert_eq!( + buffer[(0, 0)].symbol(), + "\x1b]8;;https://example.com/docs\x07d\x1b]8;;\x07", + ); + assert_eq!(layout.position_at(/*row*/ 1, /*column*/ 0), 5); +} + +#[test] +fn selection_highlights_wide_graphemes_without_trailing_padding() { + let layout = TextLayout::new(vec!["a界b".into()], /*width*/ 8); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 8, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + layout.render(area, &mut buffer, /*start_row*/ 0); + layout.highlight(1..4, area, &mut buffer, /*start_row*/ 0); + assert_eq!( + (0..8) + .map(|column| buffer[(column, 0)].modifier.contains(Modifier::REVERSED)) + .collect::>(), + vec![false, true, true, false, false, false, false, false], + ); +} + +#[test] +fn offsets_can_reach_rows_after_u16_limit() { + let layout = TextLayout::new(vec!["x".repeat(/*n*/ 65_538).into()], /*width*/ 1); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 1, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + layout.render(area, &mut buffer, /*start_row*/ 65_537); + assert_eq!( + ( + layout.row_count(), + layout.row_for_offset(/*offset*/ 65_537), + layout.position_at(/*row*/ 65_537, /*column*/ 0), + buffer[(0, 0)].symbol(), + ), + (65_538, 65_537, 65_537, "x"), + ); +} + +#[test] +fn aligned_text_excludes_synthetic_leading_columns() { + let mut line = HyperlinkLine::new(Line::default().right_aligned()); + line.push_span("wide".into(), Some("https://example.com")); + let layout = TextLayout::new(vec![line], /*width*/ 8); + assert_eq!(layout.link_at(/*row*/ 0, /*column*/ 3), None); + assert_eq!( + layout.link_at(/*row*/ 0, /*column*/ 4).as_deref(), + Some("https://example.com") + ); + assert_eq!( + (0..8) + .map(|column| layout.position_at(/*row*/ 0, column)) + .collect::>(), + vec![0, 0, 0, 0, 0, 1, 2, 3], + ); +} + +#[test] +fn empty_logical_lines_keep_their_hard_breaks() { + let layout = TextLayout::new(vec!["".into(), "text".into(), "".into()], /*width*/ 10); + assert_eq!( + ( + layout.text(), + layout.row_count(), + layout.line_range(/*offset*/ 0), + layout.line_range(/*offset*/ 5) + ), + ("\ntext\n", 3, 0..1, 1..6), + ); +} + +#[test] +fn source_provenance_preserves_spaces_and_excludes_gutters() { + let source = "alpha beta gamma"; + let lines = adaptive_wrap_hyperlink_lines(&[source.into()], RtOptions::new(/*width*/ 10)); + let lines = prefix_hyperlink_lines(lines, "› ".bold(), " ".into()); + let layout = TextLayout::new(lines, /*width*/ 12); + assert_eq!(layout.text(), source); + assert_eq!(layout.position_at(/*row*/ 0, /*column*/ 0), 0); + assert_eq!(layout.position_at(/*row*/ 0, /*column*/ 2), 0); + assert_eq!(layout.column_for_offset(/*offset*/ 0), 2); +} + +#[test] +fn selected_revision_rewraps_both_directions_with_styles_and_indents() { + let mut source = HyperlinkLine::new(Line::from("alpha ".green())); + source.push_span("beta gamma".cyan(), Some("https://example.com")); + let lines = adaptive_wrap_hyperlink_lines( + &[source], + RtOptions::new(/*width*/ 10) + .initial_indent("› ".into()) + .subsequent_indent(" ".into()), + ); + let narrow = TextLayout::new(lines, /*width*/ 10); + let wide = narrow.rewrap(/*width*/ 24); + let restored = wide.rewrap(/*width*/ 10); + assert_eq!( + ( + narrow.text(), + wide.text(), + restored.text(), + wide.row_count() + ), + ( + "alpha beta gamma", + "alpha beta gamma", + "alpha beta gamma", + 1 + ), + ); + let narrow_area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 10, /*height*/ 3, + ); + let mut original_buffer = Buffer::empty(narrow_area); + let mut restored_buffer = Buffer::empty(narrow_area); + narrow.render(narrow_area, &mut original_buffer, /*start_row*/ 0); + restored.render(narrow_area, &mut restored_buffer, /*start_row*/ 0); + assert_eq!(restored_buffer, original_buffer); + assert_eq!( + wide.link_at(/*row*/ 0, /*column*/ 8).as_deref(), + Some("https://example.com") + ); +} + +#[test] +fn partial_stream_fragment_does_not_copy_unseen_source_text() { + let lines = + adaptive_wrap_hyperlink_lines(&["alpha beta gamma".into()], RtOptions::new(/*width*/ 7)); + let first = TextLayout::new(vec![lines[0].clone()], /*width*/ 7); + let last = TextLayout::new(vec![lines[2].clone()], /*width*/ 7); + assert_eq!((first.text(), last.text()), ("alpha", "gamma")); +} + +#[test] +fn narrow_wrapping_keeps_joined_emoji_whole() { + let layout = TextLayout::new(vec!["👩‍💻x".into()], /*width*/ 2); + assert_eq!( + ( + layout.row_count(), + layout.position_at(/*row*/ 1, /*column*/ 0) + ), + (2, 11), + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 2, /*height*/ 2, + ); + let mut buffer = Buffer::empty(area); + layout.render(area, &mut buffer, /*start_row*/ 0); + assert_eq!(buffer[(0, 0)].symbol(), "👩‍💻"); +} + +#[test] +fn source_backed_markdown_copy_preserves_code_indentation() { + use crate::history_cell::AgentMarkdownCell; + use crate::history_cell::HistoryCell; + let cell = AgentMarkdownCell::new( + "first **paragraph** with words\n\n```rust\n let value = 12;\n```".into(), + std::path::Path::new("."), + ); + let layout = TextLayout::new( + cell.display_hyperlink_lines(/*width*/ 14), + /*width*/ 14, + ); + assert!(layout.text().contains("first paragraph with words")); + assert!(layout.text().contains(" let value = 12;")); + assert_eq!(layout.rewrap(/*width*/ 40).text(), layout.text()); +} + +#[test] +fn repeated_source_prefix_never_becomes_a_synthetic_indent() { + let source = "x x x x x"; + let wrapped = adaptive_wrap_hyperlink_lines( + &[HyperlinkLine::from(source)], + RtOptions::new(/*width*/ 4) + .initial_indent("x ".into()) + .subsequent_indent("x ".into()), + ); + let layout = TextLayout::new(wrapped, /*width*/ 4); + assert_eq!(layout.text(), source); + assert_eq!(layout.rewrap(/*width*/ 12).text(), source); +} + +#[test] +fn command_and_output_copy_preserve_hard_lines_across_resize() { + use crate::exec_cell::CommandOutput; + use crate::exec_cell::ExecCall; + use crate::exec_cell::ExecCell; + use crate::history_cell::HistoryCell; + let command = "printf 'alpha beta gamma delta'"; + let output = " output alpha beta gamma delta\nnext line"; + let cell = ExecCell::new( + ExecCall { + call_id: "copy-test".into(), + command: vec!["bash".into(), "-lc".into(), command.into()], + parsed: Vec::new(), + output: Some(CommandOutput::new(/*exit_code*/ 0, output.into())), + source: codex_app_server_protocol::CommandExecutionSource::Agent, + start_time: None, + duration: None, + interaction_input: None, + }, + /*animations_enabled*/ false, + ); + let rendered = crate::terminal_palette::with_test_default_colors( + crate::terminal_probe::DefaultColors { + fg: (220, 220, 220), + bg: (20, 20, 20), + }, + || { + [ + ( + "compact_command_copy", + cell.display_hyperlink_lines(/*width*/ 32), + ), + ( + "detailed_command_copy", + cell.transcript_hyperlink_lines(/*width*/ 32), + ), + ] + }, + ); + for (name, lines) in rendered { + let layout = TextLayout::new(lines, /*width*/ 32); + assert_eq!(layout.text(), format!("{command}\n{output}")); + let narrow = layout.rewrap(/*width*/ 24); + let wide = narrow.rewrap(/*width*/ 64); + assert_eq!((narrow.text(), wide.text()), (layout.text(), layout.text())); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 8, + ); + let mut buffer = Buffer::empty(area); + narrow.render(area, &mut buffer, /*start_row*/ 0); + insta::assert_snapshot!(name, format!("{buffer:?}")); + } +} + +#[test] +fn background_stdin_copy_preserves_indentation_and_newlines() { + use crate::history_cell::HistoryCell; + use crate::history_cell::UnifiedExecInteractionCell; + let stdin = " first long line of input\nnext"; + let cell = UnifiedExecInteractionCell::new(/*command_display*/ None, stdin.into()); + let layout = TextLayout::new( + cell.transcript_hyperlink_lines(/*width*/ 24), + /*width*/ 24, + ); + assert!(layout.text().ends_with(stdin)); + assert_eq!(layout.rewrap(/*width*/ 48).text(), layout.text()); +} diff --git a/codex-rs/tui/src/transcript_view_tests.rs b/codex-rs/tui/src/transcript_view_tests.rs index dee6cba27d..a6ec40f1d7 100644 --- a/codex-rs/tui/src/transcript_view_tests.rs +++ b/codex-rs/tui/src/transcript_view_tests.rs @@ -1,20 +1,44 @@ -//! Viewport behavior and cache lifetime across scrolling, pagination, and redraws. +//! Viewport behavior and cache lifetime across scrolling, selection, and redraws. use super::*; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; use pretty_assertions::assert_eq; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; -fn cell(text: impl Into) -> Arc { - Arc::new(crate::history_cell::PlainHistoryCell::new( - text.into() - .lines() - .map(|line| line.to_owned().into()) - .collect(), - )) +#[derive(Debug)] +struct TestCell { + text: String, + renders: Arc, } -fn render( +impl HistoryCell for TestCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.renders.fetch_add(/*val*/ 1, Ordering::Relaxed); + self.text + .lines() + .map(|line| line.to_owned().into()) + .collect() + } + + fn raw_lines(&self) -> Vec> { + self.display_lines(/*width*/ 80) + } +} + +pub(super) fn cell(text: impl Into) -> Arc { + Arc::new(TestCell { + text: text.into(), + renders: Arc::default(), + }) +} + +pub(super) fn render( view: &mut TranscriptView, cells: &[Arc], width: u16, @@ -22,11 +46,11 @@ fn render( ) -> Buffer { let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); let mut buffer = Buffer::empty(area); - view.render(area, &mut buffer, cells, /*highlight*/ None); + view.render(area, &mut buffer, cells); buffer } -fn text(buffer: &Buffer) -> String { +pub(super) fn text(buffer: &Buffer) -> String { (buffer.area.top()..buffer.area.bottom()) .map(|y| { (buffer.area.left()..buffer.area.right()) @@ -39,6 +63,411 @@ fn text(buffer: &Buffer) -> String { .join("\n") } +#[test] +fn reading_survives_prepend_and_new_output_then_returns_to_latest() { + let mut cells = vec![cell("older\nline two"), cell("current\nlast line")]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 20, /*height*/ 3); + view.scroll(&cells, /*rows*/ -2); + let before = text(&render( + &mut view, &cells, /*width*/ 20, /*height*/ 3, + )); + cells.insert(/*index*/ 0, cell("loaded from history")); + cells.push(cell("new output")); + assert_eq!( + text(&render( + &mut view, &cells, /*width*/ 20, /*height*/ 3 + )), + before + ); + view.jump_to_latest(); + insta::assert_snapshot!(text(&render(&mut view, &cells, /*width*/ 20, /*height*/ 3)), @" + last line + + new output + "); +} + +#[test] +fn prepending_history_updates_pagination_position_before_the_next_frame() { + let mut cells = vec![cell("current first"), cell("current last")]; + let mut view = TranscriptView::default(); + view.jump_to_entry(&cells, /*index*/ 0); + assert!(view.near_start(&cells)); + cells.remove(/*index*/ 0); + view.history_loaded(&cells, 0..0); + let earlier = (0..100) + .map(|index| cell(format!("earlier {index}"))) + .collect::>(); + cells.splice(0..0, earlier); + view.history_loaded(&cells, 0..100); + assert!(!view.needs_history(&cells)); + let Position::Reading(anchor) = view.position else { + panic!("reading anchor"); + }; + assert_eq!( + (anchor.key, anchor.index), + (EntryKey::cell(&cells[100]), 100) + ); +} + +#[test] +fn beginning_wait_keeps_live_content_through_new_output_prepend_and_resize() { + let mut cells = vec![cell("previous answer")]; + let live = cell("live zero\nlive one\nlive two\nlive three"); + let mut view = TranscriptView { + history: TranscriptHistoryState::Partial, + ..TranscriptView::default() + }; + view.sync_live_tail( + /*width*/ 20, + /*key*/ None, + |width| Some(live.display_hyperlink_lines(width)), + ); + let before = render(&mut view, &cells, /*width*/ 20, /*height*/ 2); + view.jump_to_beginning(&cells); + cells.insert(/*index*/ 0, cell("older page")); + view.history_loaded(&cells, 0..1); + view.sync_live_tail(/*width*/ 20, /*key*/ None, |_| { + Some(vec![HyperlinkLine::from("new streaming output")]) + }); + view.jump_to_beginning(&cells); + assert_eq!( + render(&mut view, &cells, /*width*/ 20, /*height*/ 2), + before + ); + let reading = view.position; + render(&mut view, &cells, /*width*/ 12, /*height*/ 3); + assert_eq!( + (view.position, view.history), + (reading, TranscriptHistoryState::LoadingBeginning) + ); + assert_eq!( + render(&mut view, &cells, /*width*/ 20, /*height*/ 2), + before + ); +} + +#[test] +fn returning_to_latest_cancels_beginning_intent_while_retaining_the_loaded_page() { + for (beginning, latest) in [ + ( + KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL), + ), + ( + KeyEvent::new(KeyCode::Char('<'), KeyModifiers::ALT), + KeyEvent::new(KeyCode::Char('>'), KeyModifiers::ALT), + ), + ( + KeyEvent::new(KeyCode::Char('<'), KeyModifiers::ALT | KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Char('>'), KeyModifiers::ALT | KeyModifiers::SHIFT), + ), + ( + KeyEvent::new(KeyCode::Char(','), KeyModifiers::ALT | KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT | KeyModifiers::SHIFT), + ), + ] { + let mut cells = vec![cell("newest answer\nlast line")]; + let mut view = TranscriptView { + history: TranscriptHistoryState::Partial, + ..TranscriptView::default() + }; + render(&mut view, &cells, /*width*/ 20, /*height*/ 2); + view.handle_key(beginning, &cells); + assert_eq!(view.history, TranscriptHistoryState::LoadingBeginning); + view.handle_key(latest, &cells); + assert_eq!( + (view.history, view.position), + (TranscriptHistoryState::LoadingOlder, Position::Latest), + ); + cells.insert( + /*index*/ 0, + cell("older page first line\nsecond line\nthird line"), + ); + view.history = TranscriptHistoryState::Partial; + view.history_loaded(&cells, 0..1); + assert!(!view.needs_history(&cells)); + insta::allow_duplicates! { + insta::assert_snapshot!(text(&render(&mut view, &cells, /*width*/ 20, /*height*/ 2)), @" + newest answer + last line + "); + } + view.handle_key(beginning, &cells); + assert_eq!(view.history, TranscriptHistoryState::LoadingBeginning); + } +} + +#[test] +fn transcript_jumps_leave_typing_and_effort_shortcuts_to_the_composer() { + let cells = vec![cell("current answer")]; + let mut view = TranscriptView::default(); + for key in [ + KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('>'), KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Char(','), KeyModifiers::ALT), + KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT), + KeyEvent::new( + KeyCode::Char('<'), + KeyModifiers::ALT | KeyModifiers::CONTROL, + ), + KeyEvent::new(KeyCode::Char('>'), KeyModifiers::ALT | KeyModifiers::SUPER), + KeyEvent::new_with_kind( + KeyCode::Char('<'), + KeyModifiers::ALT, + crossterm::event::KeyEventKind::Release, + ), + ] { + assert!(view.handle_key(key, &cells).is_none(), "{key:?}"); + assert_eq!(view.position, Position::Latest); + } +} + +#[test] +fn scrolling_to_latest_cancels_beginning_and_keeps_selection_when_the_final_page_arrives() { + for wheel in [false, true] { + for select in [false, true] { + let mut cells = vec![cell("older entry"), cell("latest answer")]; + let mut view = TranscriptView { + history: TranscriptHistoryState::Partial, + ..TranscriptView::default() + }; + view.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL), &cells); + render(&mut view, &cells, /*width*/ 20, /*height*/ 2); + if select { + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + } + let selected = view.selected_text(&cells); + if wheel { + view.handle_mouse( + MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }, + &cells, + ); + } else { + view.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), &cells); + } + assert_eq!( + (view.history, view.position, view.selected_text(&cells)), + ( + TranscriptHistoryState::LoadingOlder, + Position::Latest, + selected.clone() + ), + ); + cells.insert(/*index*/ 0, cell("final older page")); + cells[1] = cell("revised older entry"); + view.history = TranscriptHistoryState::Complete; + view.history_loaded(&cells, 0..1); + assert_eq!( + (view.position, view.selected_text(&cells)), + (Position::Latest, selected), + ); + assert!( + text(&render( + &mut view, &cells, /*width*/ 20, /*height*/ 2 + )) + .contains("latest answer") + ); + } + } +} + +#[test] +fn single_row_scrolling_crosses_an_entry_separator_in_both_directions() { + let cells = vec![cell("first"), cell("second")]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 20, /*height*/ 1); + let mut frames = Vec::new(); + for rows in [-1, -1, 1, 1] { + view.scroll(&cells, rows); + frames.push(text(&render( + &mut view, &cells, /*width*/ 20, /*height*/ 1, + ))); + } + assert_eq!(frames, ["", "first", "", "second"]); +} + +#[test] +fn clicking_an_edge_does_not_start_selection_autoscroll() { + let cells = vec![cell( + (0..40) + .map(|row| format!("row {row:02}\n")) + .collect::(), + )]; + for edge in [0, 5] { + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 20, /*height*/ 6); + view.scroll(&cells, /*rows*/ -12); + let before = render(&mut view, &cells, /*width*/ 20, /*height*/ 6); + view.handle_mouse( + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: edge, + modifiers: KeyModifiers::NONE, + }, + &cells, + ); + for _ in 0..10 { + assert!(!view.tick_selection(&cells)); + assert_eq!( + render(&mut view, &cells, /*width*/ 20, /*height*/ 6), + before + ); + } + } +} + +#[test] +fn wheel_scrolling_pauses_selection_autoscroll_until_the_next_drag() { + let cells = vec![cell( + (0..40) + .map(|row| format!("row {row:02}\n")) + .collect::(), + )]; + let mut frames = Vec::new(); + for (kind, edge) in [ + (MouseEventKind::ScrollUp, 0), + (MouseEventKind::ScrollDown, 5), + ] { + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 20, /*height*/ 6); + view.scroll(&cells, /*rows*/ -12); + render(&mut view, &cells, /*width*/ 20, /*height*/ 6); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 2, /*clicks*/ 1); + let drag = MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 3, + row: edge, + modifiers: KeyModifiers::NONE, + }; + view.handle_mouse(drag, &cells); + assert!(view.tick_selection(&cells)); + render(&mut view, &cells, /*width*/ 20, /*height*/ 6); + let selected = view.selected_text(&cells); + view.handle_mouse(MouseEvent { kind, ..drag }, &cells); + let after = render(&mut view, &cells, /*width*/ 20, /*height*/ 6); + for _ in 0..10 { + assert!(!view.tick_selection(&cells)); + assert_eq!(render(&mut view, &cells, /*width*/ 20, /*height*/ 6), after); + assert_eq!(view.selected_text(&cells), selected); + } + frames.push(text(&after)); + view.handle_mouse(drag, &cells); + assert!(view.tick_selection(&cells)); + render(&mut view, &cells, /*width*/ 20, /*height*/ 6); + let selected = view.selected_text(&cells); + view.handle_mouse(MouseEvent { kind, ..drag }, &cells); + render(&mut view, &cells, /*width*/ 20, /*height*/ 6); + view.handle_mouse( + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + ..drag + }, + &cells, + ); + assert!(!view.tick_selection(&cells)); + assert_eq!(view.selected_text(&cells), selected); + let click = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + row: 2, + ..drag + }; + view.handle_mouse(click, &cells); + view.handle_mouse(MouseEvent { kind, ..click }, &cells); + render(&mut view, &cells, /*width*/ 20, /*height*/ 6); + view.handle_mouse(click, &cells); + assert_eq!(view.selected_text(&cells), None); + } + insta::assert_snapshot!("wheel_stops_selection_autoscroll", frames.join("\n\n")); +} + +#[test] +fn only_visible_history_is_laid_out_and_repeated_frames_reuse_it() { + let renders = Arc::new(AtomicUsize::new(/*v*/ 0)); + let cells = (0..10_000) + .map(|index| { + Arc::new(TestCell { + text: format!("message {index}"), + renders: Arc::clone(&renders), + }) as Arc + }) + .collect::>(); + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 40, /*height*/ 20); + let initial = renders.load(Ordering::Relaxed); + assert!( + initial <= 12, + "laid out {initial} history entries for a 20-row viewport" + ); + render(&mut view, &cells, /*width*/ 40, /*height*/ 20); + assert_eq!(renders.load(Ordering::Relaxed), initial); + for highlight in [Some(9_999), Some(9_998), Some(9_998), None] { + view.set_highlight(highlight); + render(&mut view, &cells, /*width*/ 40, /*height*/ 20); + assert_eq!(renders.load(Ordering::Relaxed), initial); + } +} + +#[test] +fn selection_copies_source_across_wraps_and_survives_prepend() { + let mut cells = vec![cell("alpha beta gamma delta")]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 10, /*height*/ 5); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 1); + view.extend_selection(/*column*/ 5, /*row*/ 2); + assert_eq!( + view.selected_text(&cells), + Some("alpha beta gamma delta".to_string()) + ); + cells.insert(/*index*/ 0, cell("earlier page")); + render(&mut view, &cells, /*width*/ 20, /*height*/ 5); + assert_eq!( + view.selected_text(&cells), + Some("alpha beta gamma delta".to_string()) + ); +} + +#[test] +fn resize_keeps_the_logical_reading_anchor() { + let mut cells = vec![cell( + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda", + )]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 10, /*height*/ 2); + view.scroll(&cells, /*rows*/ -2); + let Position::Reading(anchor) = view.position else { + panic!("reading anchor"); + }; + render(&mut view, &cells, /*width*/ 20, /*height*/ 2); + assert_eq!(view.position, Position::Reading(anchor)); + let replacement = cell("final answer"); + view.replace_range(&cells, 0..1, &replacement); + cells.splice(0..1, [replacement]); + assert_eq!( + text(&render( + &mut view, &cells, /*width*/ 20, /*height*/ 2 + )), + "final answer\n" + ); + assert!(!view.is_following()); +} + +#[test] +fn zero_sized_viewport_is_safe_and_empty_history_accepts_navigation() { + let mut view = TranscriptView::default(); + render(&mut view, &[], /*width*/ 0, /*height*/ 0); + view.scroll(&[], /*rows*/ -10); + view.scroll(&[], /*rows*/ 10); + assert_eq!(view.selected_text(&[]), None); +} + #[derive(Debug)] struct MutableHistoryCell { version: Arc, @@ -63,115 +492,6 @@ impl HistoryCell for MutableHistoryCell { } } -#[test] -fn reading_survives_prepend_and_new_output_then_returns_to_latest() { - let mut cells = vec![cell("older\nline two"), cell("current\nlast line")]; - let mut view = TranscriptView::default(); - render(&mut view, &cells, /*width*/ 20, /*height*/ 3); - view.scroll(&cells, /*rows*/ -2); - let before = text(&render( - &mut view, &cells, /*width*/ 20, /*height*/ 3, - )); - cells.insert(/*index*/ 0, cell("loaded from history")); - view.history_changed(&cells); - cells.push(cell("new output")); - assert_eq!( - text(&render( - &mut view, &cells, /*width*/ 20, /*height*/ 3 - )), - before - ); - cells.remove(/*index*/ 0); - view.history_changed(&cells); - assert_eq!( - text(&render( - &mut view, &cells, /*width*/ 20, /*height*/ 3 - )), - before - ); - view.jump_to_latest(); - insta::assert_snapshot!(text(&render(&mut view, &cells, /*width*/ 20, /*height*/ 3)), @" - last line - - new output - "); -} - -#[test] -fn prepending_history_updates_pagination_position_before_the_next_frame() { - let mut cells = vec![cell("current first"), cell("current last")]; - let mut view = TranscriptView::default(); - view.jump_to_entry(&cells, /*index*/ 0); - assert!(view.near_start(&cells)); - render(&mut view, &cells, /*width*/ 20, /*height*/ 3); - cells.remove(/*index*/ 0); - view.history_changed(&cells); - cells.insert(/*index*/ 0, cell("earlier\n".repeat(100))); - view.history_changed(&cells); - assert!(!view.near_start(&cells)); - cells.remove(/*index*/ 0); - view.history_changed(&cells); - let earlier = (0..100) - .map(|index| cell(format!("earlier {index}"))) - .collect::>(); - cells.splice(0..0, earlier); - view.history_changed(&cells); - assert!(!view.near_start(&cells)); - let Position::Reading(anchor) = view.position else { - panic!("reading anchor"); - }; - assert_eq!( - (anchor.key, anchor.index), - (EntryKey::cell(&cells[100]), 100) - ); -} - -#[test] -fn single_row_scrolling_crosses_an_entry_separator_in_both_directions() { - let cells = vec![cell("first"), cell("second")]; - let mut view = TranscriptView::default(); - render(&mut view, &cells, /*width*/ 20, /*height*/ 1); - let mut frames = Vec::new(); - for rows in [-1, -1, 1, 1] { - view.scroll(&cells, rows); - frames.push(text(&render( - &mut view, &cells, /*width*/ 20, /*height*/ 1, - ))); - } - assert_eq!(frames, ["", "first", "", "second"]); -} - -#[test] -fn resize_and_consolidation_clamp_the_row_within_the_same_entry() { - let mut cells = vec![ - cell("older"), - cell("alpha beta gamma delta epsilon zeta eta theta"), - ]; - let mut view = TranscriptView::default(); - render(&mut view, &cells, /*width*/ 10, /*height*/ 2); - view.scroll(&cells, /*rows*/ -1); - let Position::Reading(anchor) = view.position else { - panic!("reading anchor"); - }; - render(&mut view, &cells, /*width*/ 8, /*height*/ 2); - assert_eq!(view.position, Position::Reading(anchor)); - let wide = render(&mut view, &cells, /*width*/ 60, /*height*/ 2); - assert_eq!( - text(&wide), - "alpha beta gamma delta epsilon zeta eta theta\n" - ); - - let replacement = cell("final answer"); - view.replace_range(&cells, 0..2, &replacement); - cells.splice(0..2, [replacement]); - let completed = render(&mut view, &cells, /*width*/ 60, /*height*/ 2); - assert!(!view.is_following()); - insta::assert_snapshot!(text(&completed), @" - final answer - - "); -} - #[test] fn mutable_history_formats_once_per_frame_and_refreshes_the_next_frame() { let version = Arc::new(AtomicUsize::new(/*v*/ 1)); @@ -195,3 +515,246 @@ fn mutable_history_formats_once_per_frame_and_refreshes_the_next_frame() { version 2 row 7 "); } + +#[test] +fn selected_latest_content_keeps_new_activity_visible() { + let mut cells = vec![cell("selected")]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 24, /*height*/ 3); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + view.scroll(&cells, /*rows*/ 10); + cells.push(cell("new output")); + render(&mut view, &cells, /*width*/ 24, /*height*/ 3); + assert!(!view.is_following()); + insta::assert_snapshot!(view.status_line_with_navigation("Enter/Ctrl+C copy · Esc clear selection", crate::motion::MotionMode::Reduced).to_string(), @"New activity · Enter/Ctrl+C copy · Esc clear selection"); +} + +#[test] +fn live_reader_survives_commit_new_tail_resize_and_selection_then_rejoins_latest() { + let mut cells = vec![cell("before")]; + let original = cell("live zero\nlive one\nlive two\nlive three\nlive four\nlive five"); + let mut view = TranscriptView::default(); + view.sync_live_tail(/*width*/ 20, /*key*/ None, |width| { + Some(original.display_hyperlink_lines(width)) + }); + render(&mut view, &cells, /*width*/ 20, /*height*/ 2); + view.scroll(&cells, /*rows*/ -2); + let before = text(&render( + &mut view, &cells, /*width*/ 20, /*height*/ 2, + )); + let reading = view.position; + cells.push(Arc::clone(&original)); + view.sync_live_tail(/*width*/ 20, /*key*/ None, |_| None); + assert_eq!( + text(&render( + &mut view, &cells, /*width*/ 20, /*height*/ 2 + )), + before + ); + view.sync_live_tail( + /*width*/ 20, + /*key*/ None, + |_| Some(vec![HyperlinkLine::from("new tail")]), + ); + assert_eq!( + text(&render( + &mut view, &cells, /*width*/ 20, /*height*/ 2 + )), + before + ); + render(&mut view, &cells, /*width*/ 9, /*height*/ 2); + assert_eq!(view.position, reading); + assert!( + view.status_line_with_navigation("", crate::motion::MotionMode::Reduced) + .to_string() + .contains("New activity") + ); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + assert_eq!(view.selected_text(&cells), Some("live two\n".to_string())); + view.jump_to_latest(); + assert!(view.snapshot().is_none()); + insta::assert_snapshot!(text(&render(&mut view, &cells, /*width*/ 20, /*height*/ 2)), @" + + new tail + "); +} + +#[test] +fn scrolling_out_of_held_live_content_releases_its_revision() { + let mut cells = vec![cell("before")]; + let original = cell("zero\none\ntwo\nthree\nfour\nfive"); + let mut view = TranscriptView::default(); + view.sync_live_tail(/*width*/ 20, /*key*/ None, |width| { + Some(original.display_hyperlink_lines(width)) + }); + render(&mut view, &cells, /*width*/ 20, /*height*/ 2); + view.scroll(&cells, /*rows*/ -2); + assert!(view.held_reading.is_some()); + cells.push(Arc::clone(&original)); + view.sync_live_tail(/*width*/ 20, /*key*/ None, |_| None); + view.scroll(&cells, /*rows*/ -100); + assert!(view.held_reading.is_none()); + view.scroll(&cells, /*rows*/ 100); + assert!(view.is_following()); + assert!( + text(&render( + &mut view, &cells, /*width*/ 20, /*height*/ 2 + )) + .contains("five") + ); +} + +#[test] +fn repeated_resize_preserves_plain_and_markdown_passages_before_the_next_draw() { + let passage = (0..40) + .map(|index| format!("café{index:02}")) + .collect::>() + .join(" "); + let entries = [ + cell(passage.clone()), + Arc::new(crate::history_cell::AgentMarkdownCell::new( + passage, + std::path::Path::new("/workspace"), + )) as Arc, + ]; + for entry in entries { + let cells = vec![cell("older entry"), entry]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 18, /*height*/ 3); + view.scroll(&cells, /*rows*/ -5); + // Resize can arrive before the first frame at this new reading position. + let before_resize = view.position; + let wide = text(&render( + &mut view, &cells, /*width*/ 34, /*height*/ 3, + )); + let original = render(&mut view, &cells, /*width*/ 18, /*height*/ 3); + assert_eq!(view.position, before_resize); + let original_text = text(&original); + let first_word = original_text + .split_whitespace() + .find(|word| word.starts_with("café")) + .expect("visible Unicode passage"); + assert!(wide.contains(first_word)); + let narrow = render(&mut view, &cells, /*width*/ 12, /*height*/ 3); + assert!(text(&narrow).contains(first_word)); + assert_eq!( + render(&mut view, &cells, /*width*/ 18, /*height*/ 3), + original + ); + assert!(!view.is_following()); + } +} + +#[test] +fn resizing_repeated_lines_keeps_the_same_occurrence() { + let duplicate = "alpha beta gamma delta"; + let mut cells = vec![cell(format!( + "{duplicate}\n{duplicate}\nsecond occurrence ends\ntrailing one\ntrailing two\ntrailing three" + ))]; + let mut view = TranscriptView::default(); + view.jump_to_entry(&cells, /*index*/ 0); + render(&mut view, &cells, /*width*/ 12, /*height*/ 2); + view.scroll(&cells, /*rows*/ 3); + let original = cells[0] + .raw_lines() + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + cells.insert(/*index*/ 0, cell("older report")); + view.history_loaded(&cells, 0..1); + let replacement = cell(format!("older report\n\n{original}")); + view.replace_group(&cells, 0..2, &replacement); + cells.splice(0..2, [replacement]); + let wide = render(&mut view, &cells, /*width*/ 24, /*height*/ 2); + insta::assert_snapshot!(text(&wide), @" + alpha beta gamma delta + second occurrence ends + "); + let narrow = render(&mut view, &cells, /*width*/ 12, /*height*/ 2); + insta::assert_snapshot!(text(&narrow), @" + gamma delta + second + "); +} + +#[test] +fn resize_uses_the_passage_from_the_current_live_or_replaced_entry() { + for entry_kind in ["live", "replaced"] { + let initial = (0..40) + .map(|index| format!("old{index:02}")) + .collect::>() + .join(" "); + let revised = (0..40) + .map(|index| format!("café{index:02}")) + .collect::>() + .join(" "); + let mut view = TranscriptView::default(); + let mut cells = Vec::new(); + if entry_kind == "live" { + view.sync_live_tail(/*width*/ 18, /*key*/ None, |_| { + Some(crate::terminal_hyperlinks::plain_hyperlink_lines(vec![ + initial.into(), + ])) + }); + } else { + cells.push(cell(initial)); + } + render(&mut view, &cells, /*width*/ 18, /*height*/ 3); + view.scroll(&cells, /*rows*/ -5); + if entry_kind == "live" { + view.sync_live_tail(/*width*/ 18, /*key*/ None, |_| { + Some(crate::terminal_hyperlinks::plain_hyperlink_lines(vec![ + revised.clone().into(), + ])) + }); + } else { + cells[0] = cell(revised.clone()); + } + let current = render(&mut view, &cells, /*width*/ 18, /*height*/ 3); + let current_text = text(¤t); + let first_word = current_text.split_whitespace().next().unwrap(); + if entry_kind == "live" { + view.sync_live_tail(/*width*/ 34, /*key*/ None, |_| { + Some(crate::terminal_hyperlinks::plain_hyperlink_lines(vec![ + revised.clone().into(), + ])) + }); + } + let wide = render(&mut view, &cells, /*width*/ 34, /*height*/ 3); + assert!(text(&wide).contains(first_word), "{entry_kind}"); + if entry_kind == "live" { + view.sync_live_tail(/*width*/ 18, /*key*/ None, |_| { + Some(crate::terminal_hyperlinks::plain_hyperlink_lines(vec![ + revised.into(), + ])) + }); + } + assert_eq!( + render(&mut view, &cells, /*width*/ 18, /*height*/ 3), + current, + "{entry_kind}" + ); + } +} + +#[test] +fn history_prefetch_counts_rows_in_preceding_entries() { + for preceding_rows in [1, 100] { + let cells = vec![ + cell( + (0..preceding_rows) + .map(|row| format!("older {row}\n")) + .collect::(), + ), + cell("current first\ncurrent last"), + ]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 24, /*height*/ 4); + assert_eq!(view.near_start(&cells), preceding_rows == 1); + view.jump_to_entry(&cells, /*index*/ 1); + assert_eq!(view.near_start(&cells), preceding_rows == 1); + view.jump_to_entry(&cells, /*index*/ 0); + assert!(view.near_start(&cells)); + } +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 3c4f6df198..f4ae0cb0a2 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -23,8 +23,7 @@ use crossterm::event::EnableBracketedPaste; #[cfg(not(windows))] use crossterm::event::EnableFocusChange; use crossterm::event::KeyEvent; -use crossterm::terminal::EnterAlternateScreen; -use crossterm::terminal::LeaveAlternateScreen; +use crossterm::event::MouseEvent; #[cfg(not(unix))] use crossterm::terminal::supports_keyboard_enhancement; use ratatui::backend::Backend; @@ -52,6 +51,7 @@ use crate::notifications::DesktopNotificationBackend; use crate::notifications::detect_backend; use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_hyperlinks::plain_hyperlink_lines; +use crate::tui::alternate_screen::ALTERNATE_SCREEN; use crate::tui::event_stream::EventBroker; use crate::tui::event_stream::TuiEventStream; #[cfg(unix)] @@ -61,6 +61,7 @@ use crate::tui::scrollback::ScrollbackStrategy; use codex_config::types::NotificationCondition; use codex_config::types::NotificationMethod; +mod alternate_screen; mod event_stream; mod frame_rate_limiter; mod frame_requester; @@ -69,11 +70,15 @@ mod input_boundary; #[cfg(unix)] mod job_control; mod keyboard_modes; +#[cfg(test)] +#[path = "tui/owned_screen_tests.rs"] +mod owned_screen_tests; #[cfg(all(test, unix))] #[path = "tui_panic_tests.rs"] mod panic_tests; mod screen_size; mod scrollback; +mod selection_clipboard; mod size_monitor; #[cfg(all(test, unix))] #[path = "tui_startup_tests.rs"] @@ -242,7 +247,7 @@ pub fn set_modes() -> Result<()> { // Some terminals (notably legacy Windows consoles) do not support // keyboard enhancement flags. Attempt to enable them, but continue // gracefully if unsupported. - keyboard_modes::enable_keyboard_enhancement(); + keyboard_modes::enable_keyboard_enhancement(&mut stdout()); #[cfg(not(windows))] let _ = execute!(stdout(), EnableFocusChange); @@ -311,9 +316,8 @@ fn restore_common( ) -> Result<()> { let mut first_error = ensure_virtual_terminal_processing().err(); - match keyboard_restore { - KeyboardRestore::PopStack => keyboard_modes::restore_keyboard_enhancement_stack(), - KeyboardRestore::ResetAfterExit => keyboard_modes::reset_keyboard_reporting_after_exit(), + if let Err(err) = ALTERNATE_SCREEN.restore(&mut stdout(), keyboard_restore) { + first_error.get_or_insert(err); } if let Err(err) = execute!(stdout(), DisableBracketedPaste) { @@ -568,6 +572,8 @@ pub enum TuiEvent { Key(KeyEvent), /// A bracketed paste payload normalized by the app layer before it reaches the composer. Paste(String), + /// A terminal mouse event for pointer interactions. + Mouse(MouseEvent), /// A terminal size notification and its reported dimensions. /// /// Resize is separate from `Draw` so the app can run feature-gated pre-render logic without @@ -586,6 +592,27 @@ pub enum TuiEvent { FocusLost, } +/// The overlay requesting pointer reports; ordinary pickers retain alternate-scroll input. +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum OverlayInput { + #[default] + Default, + Transcript, + StaticPager, + #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] + Usage, +} + +impl OverlayInput { + fn captures_mouse(self, owned_screen: bool) -> bool { + match self { + Self::Default => owned_screen, + Self::StaticPager => false, + Self::Transcript | Self::Usage => true, + } + } +} + pub struct Tui { frame_requester: FrameRequester, draw_tx: broadcast::Sender<()>, @@ -598,7 +625,7 @@ pub struct Tui { alt_saved_viewport: Option, #[cfg(unix)] suspend_context: SuspendContext, - // True when overlay alt-screen UI is active + // True when an overlay or the owned transcript uses the alternate screen. alt_screen_active: Arc, // True when terminal/tab is focused; updated internally from crossterm events terminal_focused: Arc, @@ -608,6 +635,11 @@ pub struct Tui { scrollback: ScrollbackStrategy, // When false, overlays stay on the inline screen. alt_screen_enabled: bool, + // Keep the alternate screen alive when an overlay closes. + owned_screen: bool, + overlay_input: OverlayInput, + // Selection copies survive closing a transcript overlay or startup session picker. + selection_clipboard_lease: Option, // Keeps unmanaged process stderr writes out of the inline viewport. _stderr_guard: terminal_stderr::TerminalStderrGuard, } @@ -669,15 +701,76 @@ impl Tui { notification_condition: NotificationCondition::default(), scrollback, alt_screen_enabled: true, + owned_screen: false, + overlay_input: OverlayInput::Default, + selection_clipboard_lease: None, _stderr_guard: stderr_guard, } } /// Set whether overlays switch to the alternate screen or stay inline. pub fn set_alt_screen_enabled(&mut self, enabled: bool) { + if !enabled && self.owned_screen { + let _ = self.set_owned_screen(/*owned*/ false); + } self.alt_screen_enabled = enabled; } + /// Own the terminal for the session, unless alternate-screen rendering is disabled. + /// + /// Closing an overlay retains the screen; external programs and suspension temporarily + /// restore the shell. Startup resolves ownership before the conversation begins. + pub fn set_owned_screen(&mut self, owned: bool) -> Result<()> { + let owned = owned && self.alt_screen_enabled; + if self.owned_screen == owned { + return Ok(()); + } + let transition = if owned { + self.owned_screen = true; + if self.is_alt_screen_active() { + ALTERNATE_SCREEN + .configure_input(self.terminal.backend_mut(), /*capture_mouse*/ true) + } else { + self.enter_alt_screen() + } + } else { + self.leave_alt_screen_for_handoff() + }; + if let Err(error) = transition { + self.owned_screen = self.is_alt_screen_active(); + self.terminal.invalidate_viewport(); + return Err(error); + } + self.owned_screen = owned; + self.frame_requester.schedule_frame(); + Ok(()) + } + + /// Whether the session owns the screen, including while temporarily yielding to an editor. + pub fn is_owned_screen(&self) -> bool { + self.transcript_mode().is_owned() + } + + pub(crate) fn transcript_mode(&self) -> crate::transcript_mode::TranscriptMode { + crate::transcript_mode::TranscriptMode::resolve(self.owned_screen, self.alt_screen_enabled) + } + + /// Retain the overlay input request across temporary editor, suspend and panic handoffs. + /// Actual terminal capture is tracked by AlternateScreen so partial writes remain cleanable. + pub(crate) fn set_overlay_input(&mut self, input: OverlayInput) -> Result<()> { + if self.alt_screen_enabled && self.is_alt_screen_active() { + self.overlay_input.apply( + &ALTERNATE_SCREEN, + self.terminal.backend_mut(), + input, + self.owned_screen, + ) + } else { + self.overlay_input = input; + Ok(()) + } + } + pub fn set_notification_settings( &mut self, method: NotificationMethod, @@ -728,6 +821,18 @@ impl Tui { set_modes()?; self._stderr_guard.recover_after_caught_panic()?; self.terminal.invalidate_cursor_state(); + if self.is_owned_screen() || self.overlay_input != OverlayInput::Default { + let saved = self.alt_saved_viewport; + self.alt_screen_active + .store(/*val*/ false, Ordering::Relaxed); + if let Some(saved) = saved { + // The panic hook returned to the main screen; clear only its inline viewport. + self.terminal.set_viewport_area(saved); + } + self.terminal.hide_cursor()?; + self.enter_alt_screen()?; + self.alt_saved_viewport = saved.or(self.alt_saved_viewport); + } self.terminal.invalidate_viewport(); self.frame_requester().schedule_frame(); Ok(()) @@ -762,7 +867,7 @@ impl Tui { // Leave alt screen if active to avoid conflicts with external program `f`. let was_alt_screen = self.is_alt_screen_active(); if was_alt_screen { - let _ = self.leave_alt_screen(); + let _ = self.leave_alt_screen_for_handoff(); } if let Err(err) = restore_keep_raw() { @@ -857,33 +962,60 @@ impl Tui { if !self.alt_screen_enabled { return Ok(()); } - let _ = execute!(self.terminal.backend_mut(), EnterAlternateScreen); + // Entry can change buffers before later input-mode configuration fails. self.terminal.invalidate_cursor_state(); - // Enable "alternate scroll" so terminals may translate wheel to arrows - let _ = execute!(self.terminal.backend_mut(), EnableAlternateScroll); + if self.owned_screen { + // Returning to the shell must not resurrect an editable inline composer. + self.terminal.hide_cursor()?; + self.terminal.clear()?; + } + let result = ALTERNATE_SCREEN.enter( + self.terminal.backend_mut(), + self.overlay_input.captures_mouse(self.owned_screen), + ); + self.alt_screen_active + .store(ALTERNATE_SCREEN.is_active(), Ordering::Relaxed); + if !self.is_alt_screen_active() { + return result; + } + if self.owned_screen { + // A newly entered screen may have its own visible cursor state. + self.terminal.hide_cursor()?; + } if let Ok(size) = self.terminal.size() { self.alt_saved_viewport = Some(self.terminal.viewport_area); self.terminal.resize(size)?; self.terminal.set_viewport_area(ratatui::layout::Rect::new( - 0, - 0, + /*x*/ 0, + /*y*/ 0, size.width, size.height, )); let _ = self.terminal.clear(); } - self.alt_screen_active.store(true, Ordering::Relaxed); - Ok(()) + result } - /// Leave alternate screen and restore the previously saved inline viewport, if any. + /// Close an overlay, preserving the alternate screen when the session owns it. pub fn leave_alt_screen(&mut self) -> Result<()> { - if !self.alt_screen_enabled { + let input_result = self.set_overlay_input(OverlayInput::Default); + if self.is_owned_screen() { + self.terminal.invalidate_viewport(); + return input_result; + } + let leave_result = self.leave_alt_screen_for_handoff(); + input_result.and(leave_result) + } + + /// Yield to the shell even when the session owns the alternate screen. + fn leave_alt_screen_for_handoff(&mut self) -> Result<()> { + if !self.alt_screen_enabled || !self.is_alt_screen_active() { return Ok(()); } - // Disable alternate scroll when leaving alt-screen - let _ = execute!(self.terminal.backend_mut(), DisableAlternateScroll); - let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); + let result = ALTERNATE_SCREEN.leave(self.terminal.backend_mut()); + if ALTERNATE_SCREEN.is_active() { + return result; + } self.terminal.invalidate_cursor_state(); if let Some(saved) = self.alt_saved_viewport.take() { self.terminal.set_viewport_area(saved); @@ -891,7 +1023,7 @@ impl Tui { // The restored main screen does not contain the alternate screen's diff baseline. self.terminal.invalidate_viewport(); self.alt_screen_active.store(false, Ordering::Relaxed); - Ok(()) + result } pub fn insert_history_lines(&mut self, lines: Vec>) { @@ -985,7 +1117,11 @@ impl Tui { return Ok(()); } - for batch in pending_history_lines.iter() { + // A failed write can already have emitted part of this batch. Never retry that batch + // automatically: replaying it would duplicate the successful prefix. Remaining batches + // stay queued, and the caller reports the I/O error. + while !pending_history_lines.is_empty() { + let batch = pending_history_lines.remove(/*index*/ 0); let mode = scrollback.history_insertion_mode(batch.wrap_policy); crate::insert_history::insert_history_hyperlink_lines_with_mode_and_wrap_policy( terminal, @@ -995,7 +1131,6 @@ impl Tui { screen_size, )?; } - pending_history_lines.clear(); Ok(()) } @@ -1022,7 +1157,12 @@ impl Tui { #[cfg(unix)] if let Some(prepared) = prepared_resume.take() { self.terminal.invalidate_cursor_state(); - prepared.apply(&mut self.terminal, screen_size)?; + prepared.apply( + &mut self.terminal, + screen_size, + self.owned_screen, + self.overlay_input.captures_mouse(self.owned_screen), + )?; } let terminal = &mut self.terminal; @@ -1075,7 +1215,8 @@ impl Tui { terminal.draw_with_size(screen_size, |frame| { draw_fn(frame); }) - })? + })??; + Ok(()) } pub fn draw_ambient_pet_image( @@ -1158,7 +1299,12 @@ impl Tui { #[cfg(unix)] if let Some(prepared) = prepared_resume.take() { self.terminal.invalidate_cursor_state(); - prepared.apply(&mut self.terminal, screen_size)?; + prepared.apply( + &mut self.terminal, + screen_size, + self.owned_screen, + self.overlay_input.captures_mouse(self.owned_screen), + )?; } let terminal = &mut self.terminal; @@ -1200,7 +1346,8 @@ impl Tui { terminal.draw_with_size(screen_size, |frame| { draw_fn(frame); }) - })? + })??; + Ok(()) } fn pending_viewport_area(&mut self, screen_size: Size) -> Result> { diff --git a/codex-rs/tui/src/tui/alternate_screen.rs b/codex-rs/tui/src/tui/alternate_screen.rs new file mode 100644 index 0000000000..4f45b44570 --- /dev/null +++ b/codex-rs/tui/src/tui/alternate_screen.rs @@ -0,0 +1,197 @@ +//! Pair alternate-screen transitions with that screen's independent keyboard-mode stack. +//! +//! Push only on entry and pop before leaving; the main screen keeps its own TUI mode until +//! terminal handoff. Transcript surfaces retain pointer reporting across overlays and disable it +//! before yielding to the shell. Promoting an overlay must not push another keyboard frame. + +use std::io::Result; +use std::io::Write; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +use crossterm::Command; +use crossterm::event::DisableMouseCapture; +#[cfg(windows)] +use crossterm::event::EnableMouseCapture; +use crossterm::event::PopKeyboardEnhancementFlags; +use crossterm::execute; +use crossterm::queue; +use crossterm::terminal::EnterAlternateScreen; +use crossterm::terminal::LeaveAlternateScreen; + +use super::DisableAlternateScroll; +use super::EnableAlternateScroll; +use super::KeyboardRestore; +use super::keyboard_modes; + +// Panic and exit cleanup cannot borrow Tui. Track the actual screen independently of its owner. +pub(super) static ALTERNATE_SCREEN: AlternateScreen = AlternateScreen { + active: AtomicBool::new(/*v*/ false), + mouse_active: AtomicBool::new(/*v*/ false), + input_configured: AtomicBool::new(/*v*/ false), +}; + +#[derive(Default)] +pub(super) struct AlternateScreen { + active: AtomicBool, + mouse_active: AtomicBool, + // A cleanup/setup error must not make the next identical request look already applied. + input_configured: AtomicBool, +} + +/// Report pointer motion so the owned transcript can update its return-to-bottom hover state. +struct EnablePointerCapture; + +impl Command for EnablePointerCapture { + fn write_ansi(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_str("\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1003h") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> Result<()> { + EnableMouseCapture.execute_winapi() + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + false + } +} + +impl AlternateScreen { + pub(super) fn is_active(&self) -> bool { + self.active.load(Ordering::Relaxed) + } + + pub(super) fn enter(&self, writer: &mut impl Write, capture_mouse: bool) -> Result<()> { + queue!(writer, EnterAlternateScreen)?; + // Stdout retains queued bytes on a flush error; cleanup must follow that pending entry. + self.active.store(/*val*/ true, Ordering::Relaxed); + writer.flush()?; + keyboard_modes::enable_keyboard_enhancement(writer); + self.configure_input(writer, capture_mouse) + } + + pub(super) fn configure_input( + &self, + writer: &mut impl Write, + capture_mouse: bool, + ) -> Result<()> { + self.input_configured + .store(/*val*/ false, Ordering::Relaxed); + let result = if capture_mouse { + // A partial write can already enable reporting; cleanup must still attempt to stop it. + self.mouse_active.store(/*val*/ true, Ordering::Relaxed); + execute!(writer, DisableAlternateScroll, EnablePointerCapture) + } else { + let mouse_result = self.disable_mouse(writer); + let scroll_result = execute!(writer, EnableAlternateScroll); + mouse_result.and(scroll_result) + }; + if result.is_ok() { + self.input_configured.store(/*val*/ true, Ordering::Relaxed); + } + result + } + + fn disable_mouse(&self, writer: &mut impl Write) -> Result<()> { + if self.mouse_active.load(Ordering::Relaxed) { + let result = execute!(writer, DisableMouseCapture); + if result.is_err() { + // A partial combined write must not skip the remaining mode resets. Keep the + // cleanup flag armed because delivery of these best-effort writes is uncertain. + #[cfg(not(windows))] + for sequence in [ + b"\x1b[?1003l", + b"\x1b[?1002l", + b"\x1b[?1000l", + b"\x1b[?1006l", + ] { + let _ = writer.write_all(sequence); + } + let _ = writer.flush(); + return result; + } + + self.mouse_active.store(/*val*/ false, Ordering::Relaxed); + } + Ok(()) + } + + pub(super) fn leave(&self, writer: &mut impl Write) -> Result<()> { + self.input_configured + .store(/*val*/ false, Ordering::Relaxed); + let mouse_result = self.disable_mouse(writer); + // Crossterm never pushes a keyboard stack on native Windows: its input-record API + // already reports enhanced keys, and its push/pop commands return Unsupported. + let keyboard_result = if cfg!(windows) || keyboard_modes::keyboard_enhancement_disabled() { + Ok(()) + } else { + // modifyOtherKeys is not stacked per screen; keep the main screen's fallback enabled + // until terminal handoff restores its keyboard modes too. + execute!(writer, PopKeyboardEnhancementFlags) + }; + let scroll_result = execute!(writer, DisableAlternateScroll); + // A failed earlier cleanup write must not prevent the actual screen transition. + let screen_result = execute!(writer, LeaveAlternateScreen); + if screen_result.is_ok() { + self.active.store(/*val*/ false, Ordering::Relaxed); + } + mouse_result + .and(keyboard_result) + .and(scroll_result) + .and(screen_result) + } + + pub(super) fn restore( + &self, + writer: &mut impl Write, + keyboard_restore: KeyboardRestore, + ) -> Result<()> { + let screen_result = if self.active.load(Ordering::Relaxed) { + self.leave(writer) + } else { + // A failed mouse cleanup can outlive a successful return to the main screen. + self.disable_mouse(writer) + }; + if self.active.load(Ordering::Relaxed) { + // Leave failed: another pop here would still target the alternate stack. + return screen_result; + } + // The alternate stack is restored before the main stack, even for legacy overlays. + match keyboard_restore { + KeyboardRestore::PopStack => keyboard_modes::restore_keyboard_enhancement_stack(writer), + KeyboardRestore::ResetAfterExit => { + keyboard_modes::reset_keyboard_reporting_after_exit(writer); + } + } + screen_result + } +} + +impl super::OverlayInput { + /// Roll back a partial overlay setup immediately; callers still receive the original error. + /// The fallback keeps capture when the session owns the screen, otherwise restores the picker. + pub(super) fn apply( + &mut self, + screen: &AlternateScreen, + writer: &mut impl Write, + next: Self, + owned: bool, + ) -> Result<()> { + if *self == next && screen.input_configured.load(Ordering::Relaxed) { + return Ok(()); + } + *self = next; + if let Err(error) = screen.configure_input(writer, next.captures_mouse(owned)) { + *self = Self::Default; + let _ = screen.configure_input(writer, owned); + return Err(error); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "alternate_screen_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/tui/alternate_screen_tests.rs b/codex-rs/tui/src/tui/alternate_screen_tests.rs new file mode 100644 index 0000000000..1b01e6caec --- /dev/null +++ b/codex-rs/tui/src/tui/alternate_screen_tests.rs @@ -0,0 +1,451 @@ +//! Replay emitted screen transitions against independent main/alternate keyboard stacks. + +#![cfg(unix)] + +use super::*; +use crossterm::event::KeyboardEnhancementFlags; +use crossterm::event::PushKeyboardEnhancementFlags; +use pretty_assertions::assert_eq; + +#[derive(Default)] +struct KeyboardScreens { + main: Vec, + alternate: Vec, + alternate_active: bool, +} + +impl KeyboardScreens { + fn process(&mut self, output: &[u8]) { + for sequence in std::str::from_utf8(output).unwrap().split('\x1b') { + match sequence { + "[?1049h" => self.alternate_active = true, + "[?1049l" => self.alternate_active = false, + _ => { + let stack = if self.alternate_active { + &mut self.alternate + } else { + &mut self.main + }; + if let Some(flags) = sequence + .strip_prefix("[>") + .and_then(|value| value.strip_suffix('u')) + { + stack.push(flags.parse().unwrap()); + } else if let Some(count) = sequence + .strip_prefix("[<") + .and_then(|value| value.strip_suffix('u')) + { + let count = count.parse().unwrap_or(/*default*/ 1); + stack.truncate(stack.len().saturating_sub(count)); + } + } + } + } + } + + fn state(&self) -> (bool, &[u8], &[u8]) { + (self.alternate_active, &self.main, &self.alternate) + } +} + +struct FailOnce { + output: Vec, + sequence: &'static [u8], + failed: bool, +} + +impl Write for FailOnce { + fn write(&mut self, bytes: &[u8]) -> Result { + if bytes == self.sequence && !self.failed { + self.failed = true; + return Err(std::io::Error::other("injected cleanup write failure")); + } + self.output.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> Result<()> { + Ok(()) + } +} + +#[test] +fn emergency_restore_cleans_up_alternate_screen_overlays() { + if keyboard_modes::keyboard_enhancement_disabled() { + return; + } + for close_before_restore in [false, true] { + for keyboard_restore in [KeyboardRestore::PopStack, KeyboardRestore::ResetAfterExit] { + let screen = AlternateScreen::default(); + let mut output = Vec::new(); + let mut terminal = KeyboardScreens::default(); + execute!( + output, + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ) + .unwrap(); + keyboard_modes::enable_keyboard_enhancement(&mut output); + // Exercise repeated overlays before the final exit, including the inline path + // where normal overlay closing already returned to the main screen. + screen.enter(&mut output, /*capture_mouse*/ false).unwrap(); + screen.leave(&mut output).unwrap(); + screen.enter(&mut output, /*capture_mouse*/ false).unwrap(); + if close_before_restore { + screen.leave(&mut output).unwrap(); + } + screen.restore(&mut output, keyboard_restore).unwrap(); + terminal.process(&output); + let parent_stack = match keyboard_restore { + KeyboardRestore::PopStack => &[1][..], + KeyboardRestore::ResetAfterExit => &[][..], + }; + assert_eq!( + (terminal.state(), screen.active.load(Ordering::Relaxed)), + ((false, parent_stack, &[][..]), false), + ); + } + } +} + +#[test] +fn cleanup_write_failure_does_not_pop_the_wrong_screen() { + if keyboard_modes::keyboard_enhancement_disabled() { + return; + } + for (sequence, stays_in_alternate) in [ + (b"\x1b[?1049h".as_slice(), false), + (b"\x1b[?1007l".as_slice(), false), + (b"\x1b[?1049l".as_slice(), true), + ] { + let screen = AlternateScreen::default(); + let mut output = Vec::new(); + let mut terminal = KeyboardScreens::default(); + execute!( + output, + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ) + .unwrap(); + keyboard_modes::enable_keyboard_enhancement(&mut output); + terminal.process(&std::mem::take(&mut output)); + let main_stack = terminal.main.clone(); + let mut writer = std::io::LineWriter::new(FailOnce { + output, + sequence, + failed: false, + }); + let enter_failed = sequence == b"\x1b[?1049h"; + assert_eq!( + screen.enter(&mut writer, /*capture_mouse*/ false).is_err(), + enter_failed + ); + let result = screen.restore(&mut writer, KeyboardRestore::PopStack); + terminal.process(&writer.get_ref().output); + let expected_main = if stays_in_alternate { + main_stack.as_slice() + } else { + &[1][..] + }; + assert_eq!( + ( + result.is_err(), + writer.get_ref().failed, + screen.active.load(Ordering::Relaxed), + terminal.state(), + ), + ( + !enter_failed, + true, + stays_in_alternate, + (stays_in_alternate, expected_main, &[][..]), + ), + ); + } +} + +#[test] +fn owned_overlay_promotion_and_handoff_balance_both_keyboard_stacks() { + if keyboard_modes::keyboard_enhancement_disabled() { + return; + } + let screen = AlternateScreen::default(); + let mut output = Vec::new(); + let mut keyboard = KeyboardScreens::default(); + let mut terminal = vt100::Parser::new( + /*rows*/ 24, /*cols*/ 80, /*scrollback_len*/ 0, + ); + keyboard_modes::enable_keyboard_enhancement(&mut output); + screen.enter(&mut output, /*capture_mouse*/ false).unwrap(); + keyboard.process(&output); + terminal.process(&std::mem::take(&mut output)); + let stacks = (keyboard.main.clone(), keyboard.alternate.clone()); + + for _ in 0..2 { + screen + .configure_input(&mut output, /*capture_mouse*/ true) + .unwrap(); + keyboard.process(&output); + terminal.process(&std::mem::take(&mut output)); + assert_eq!( + ( + keyboard.state(), + terminal.screen().mouse_protocol_mode(), + terminal.screen().mouse_protocol_encoding(), + ), + ( + (true, stacks.0.as_slice(), stacks.1.as_slice()), + vt100::MouseProtocolMode::AnyMotion, + vt100::MouseProtocolEncoding::Sgr, + ), + ); + screen.leave(&mut output).unwrap(); + keyboard.process(&output); + terminal.process(&std::mem::take(&mut output)); + assert_eq!( + ( + keyboard.state(), + terminal.screen().mouse_protocol_mode(), + terminal.screen().mouse_protocol_encoding(), + ), + ( + (false, stacks.0.as_slice(), &[][..]), + vt100::MouseProtocolMode::None, + vt100::MouseProtocolEncoding::Default, + ), + ); + screen.enter(&mut output, /*capture_mouse*/ true).unwrap(); + keyboard.process(&output); + terminal.process(&std::mem::take(&mut output)); + } + screen + .restore(&mut output, KeyboardRestore::ResetAfterExit) + .unwrap(); + keyboard.process(&output); + terminal.process(&output); + assert_eq!( + (keyboard.state(), terminal.screen().mouse_protocol_mode()), + ((false, &[][..], &[][..]), vt100::MouseProtocolMode::None), + ); +} + +#[test] +fn failed_mouse_cleanup_still_leaves_owned_screen_and_remains_retryable() { + let screen = AlternateScreen::default(); + let mut output = Vec::new(); + screen.enter(&mut output, /*capture_mouse*/ true).unwrap(); + let mut writer = FailOnce { + output, + sequence: b"\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l", + failed: false, + }; + assert!(screen.leave(&mut writer).is_err()); + for sequence in [ + b"\x1b[?1003l", + b"\x1b[?1002l", + b"\x1b[?1000l", + b"\x1b[?1006l", + ] { + assert!( + writer + .output + .windows(sequence.len()) + .any(|bytes| bytes == sequence) + ); + } + assert_eq!( + ( + screen.is_active(), + screen.mouse_active.load(Ordering::Relaxed) + ), + (false, true), + ); + screen + .restore(&mut writer, KeyboardRestore::ResetAfterExit) + .unwrap(); + let mut terminal = vt100::Parser::new( + /*rows*/ 24, /*cols*/ 80, /*scrollback_len*/ 0, + ); + terminal.process(&writer.output); + assert_eq!( + ( + screen.is_active(), + screen.mouse_active.load(Ordering::Relaxed), + terminal.screen().alternate_screen(), + terminal.screen().mouse_protocol_mode(), + ), + (false, false, false, vt100::MouseProtocolMode::None), + ); +} + +#[cfg(not(windows))] +#[test] +fn pager_capture_restores_picker_input_without_a_screen_transition() { + let screen = AlternateScreen::default(); + let mut output = Vec::new(); + let mut terminal = vt100::Parser::new( + /*rows*/ 12, /*cols*/ 40, /*scrollback_len*/ 0, + ); + screen.enter(&mut output, /*capture_mouse*/ false).unwrap(); + terminal.process(&std::mem::take(&mut output)); + assert_eq!( + terminal.screen().mouse_protocol_mode(), + vt100::MouseProtocolMode::None + ); + let mut input = super::super::OverlayInput::Default; + for requested in [ + super::super::OverlayInput::Transcript, + super::super::OverlayInput::StaticPager, + super::super::OverlayInput::Usage, + ] { + input + .apply(&screen, &mut output, requested, /*owned*/ false) + .unwrap(); + let capture = requested != super::super::OverlayInput::StaticPager; + assert_eq!( + output + .windows(b"\x1b[?1003h".len()) + .any(|bytes| bytes == b"\x1b[?1003h"), + capture + ); + terminal.process(&std::mem::take(&mut output)); + assert_eq!( + ( + screen.is_active(), + terminal.screen().mouse_protocol_mode(), + terminal.screen().mouse_protocol_encoding() + ), + ( + true, + if capture { + vt100::MouseProtocolMode::AnyMotion + } else { + vt100::MouseProtocolMode::None + }, + if capture { + vt100::MouseProtocolEncoding::Sgr + } else { + vt100::MouseProtocolEncoding::Default + } + ), + ); + input + .apply( + &screen, + &mut output, + super::super::OverlayInput::Default, + /*owned*/ false, + ) + .unwrap(); + terminal.process(&std::mem::take(&mut output)); + assert_eq!( + (screen.is_active(), terminal.screen().mouse_protocol_mode()), + (true, vt100::MouseProtocolMode::None) + ); + } + screen.leave(&mut output).unwrap(); +} + +#[cfg(not(windows))] +#[test] +fn failed_pager_capture_setup_restores_requested_and_actual_picker_policy() { + for requested in [ + super::super::OverlayInput::Transcript, + super::super::OverlayInput::Usage, + ] { + let screen = AlternateScreen::default(); + let mut output = Vec::new(); + screen.enter(&mut output, /*capture_mouse*/ false).unwrap(); + let mut writer = FailOnce { + output, + sequence: b"\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1003h", + failed: false, + }; + let mut input = super::super::OverlayInput::Default; + let error = input + .apply(&screen, &mut writer, requested, /*owned*/ false) + .unwrap_err(); + assert_eq!(error.to_string(), "injected cleanup write failure"); + assert!(input == super::super::OverlayInput::Default); + assert!(writer.failed); + assert!(!screen.mouse_active.load(Ordering::Relaxed)); + let mut terminal = vt100::Parser::new( + /*rows*/ 12, /*cols*/ 40, /*scrollback_len*/ 0, + ); + terminal.process(&writer.output); + assert_eq!( + ( + terminal.screen().alternate_screen(), + terminal.screen().mouse_protocol_mode() + ), + (true, vt100::MouseProtocolMode::None) + ); + assert!(writer.output.ends_with(b"\x1b[?1007h")); + screen.leave(&mut writer).unwrap(); + } +} + +#[cfg(not(windows))] +#[test] +fn identical_default_request_retries_after_failed_fallback_cleanup() { + struct PartialFailure { + output: Vec, + failed_setup: bool, + failed_cleanup: bool, + } + impl Write for PartialFailure { + fn write(&mut self, bytes: &[u8]) -> Result { + if bytes == b"\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1003h" && !self.failed_setup { + self.output.extend_from_slice(b"\x1b[?1000h\x1b[?1002h"); + self.failed_setup = true; + return Err(std::io::Error::other("partial setup")); + } + if bytes == b"\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l" + && !self.failed_cleanup + { + self.failed_cleanup = true; + return Err(std::io::Error::other("partial cleanup")); + } + self.output.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> Result<()> { + Ok(()) + } + } + let screen = AlternateScreen::default(); + let mut writer = PartialFailure { + output: Vec::new(), + failed_setup: false, + failed_cleanup: false, + }; + screen.enter(&mut writer, /*capture_mouse*/ false).unwrap(); + let mut input = super::super::OverlayInput::Default; + let result = input.apply( + &screen, + &mut writer, + super::super::OverlayInput::Transcript, + /*owned*/ false, + ); + assert_eq!(result.unwrap_err().to_string(), "partial setup"); + assert!(input == super::super::OverlayInput::Default); + assert!(screen.mouse_active.load(Ordering::Relaxed)); + assert!(!screen.input_configured.load(Ordering::Relaxed)); + input + .apply( + &screen, + &mut writer, + super::super::OverlayInput::Default, + /*owned*/ false, + ) + .unwrap(); + assert!(!screen.mouse_active.load(Ordering::Relaxed)); + assert!(screen.input_configured.load(Ordering::Relaxed)); + let mut terminal = vt100::Parser::new( + /*rows*/ 12, /*cols*/ 40, /*scrollback_len*/ 0, + ); + terminal.process(&writer.output); + assert_eq!( + terminal.screen().mouse_protocol_mode(), + vt100::MouseProtocolMode::None + ); + screen.leave(&mut writer).unwrap(); +} diff --git a/codex-rs/tui/src/tui/event_stream.rs b/codex-rs/tui/src/tui/event_stream.rs index 5f2ce7f5c3..35afa4bfbb 100644 --- a/codex-rs/tui/src/tui/event_stream.rs +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -196,7 +196,7 @@ impl TuiEventStream { /// Poll the shared crossterm stream for the next mapped `TuiEvent`. /// - /// This skips events we don't use (mouse events, etc.) and keeps polling until it yields + /// This skips events we don't use and keeps polling until it yields /// a mapped event, hits `Pending`, or sees EOF/error. When the broker is paused, it drops /// the underlying stream and returns `Pending` to fully release stdin. pub fn poll_crossterm_event(&mut self, cx: &mut Context<'_>) -> Poll> { @@ -263,7 +263,7 @@ impl TuiEventStream { } } - /// Map a crossterm event to a [`TuiEvent`], skipping events we don't use (mouse events, etc.). + /// Map a crossterm event to a [`TuiEvent`], preserving mouse coordinates and modifiers. fn map_crossterm_event(&mut self, event: Event) -> Option { match event { Event::Key(key_event) => { @@ -301,7 +301,7 @@ impl TuiEventStream { self.terminal_focused.store(false, Ordering::Relaxed); Some(TuiEvent::FocusLost) } - _ => None, + Event::Mouse(mouse) => Some(TuiEvent::Mouse(mouse)), } } } @@ -437,26 +437,24 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn key_event_skips_unmapped() { + async fn mouse_events_preserve_coordinates_and_do_not_consume_the_next_key() { let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); let mut stream = make_stream(broker, draw_rx, terminal_focused); - - handle.send(Ok(Event::Mouse(MouseEvent { - kind: MouseEventKind::Moved, - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - }))); - handle.send(Ok(Event::Key(KeyEvent::new( - KeyCode::Char('a'), - KeyModifiers::NONE, - )))); - - let next = stream.next().await.unwrap(); - match next { - TuiEvent::Key(key) => { - assert_eq!(key, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); - } + let expected = MouseEvent { + kind: MouseEventKind::Drag(crossterm::event::MouseButton::Left), + column: 123, + row: 42, + modifiers: KeyModifiers::SHIFT, + }; + handle.send(Ok(Event::Mouse(expected))); + match stream.next().await { + Some(TuiEvent::Mouse(actual)) => assert_eq!(actual, expected), + other => panic!("expected mouse event, got {other:?}"), + } + let expected = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE); + handle.send(Ok(Event::Key(expected))); + match stream.next().await { + Some(TuiEvent::Key(actual)) => assert_eq!(actual, expected), other => panic!("expected key event, got {other:?}"), } } diff --git a/codex-rs/tui/src/tui/job_control.rs b/codex-rs/tui/src/tui/job_control.rs index 2db1fc04f5..58a05b4147 100644 --- a/codex-rs/tui/src/tui/job_control.rs +++ b/codex-rs/tui/src/tui/job_control.rs @@ -1,3 +1,5 @@ +//! Restore terminal modes and screen placement across suspend/resume. + use std::io::Result; use std::io::stdout; use std::sync::Arc; @@ -10,16 +12,12 @@ use std::sync::atomic::Ordering; use crossterm::cursor::MoveTo; use crossterm::cursor::Show; use crossterm::event::KeyCode; -use crossterm::terminal::EnterAlternateScreen; -use crossterm::terminal::LeaveAlternateScreen; use ratatui::crossterm::execute; use ratatui::layout::Rect; use ratatui::layout::Size; use crate::key_hint; -use super::DisableAlternateScroll; -use super::EnableAlternateScroll; use super::Terminal; pub const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z')); @@ -64,8 +62,7 @@ impl SuspendContext { pub(crate) fn suspend(&self, alt_screen_active: &Arc) -> Result<()> { if alt_screen_active.load(Ordering::Relaxed) { // Leave alt-screen so the terminal returns to the normal buffer while suspended; also turn off alt-scroll. - let _ = execute!(stdout(), DisableAlternateScroll); - let _ = execute!(stdout(), LeaveAlternateScreen); + let _ = super::ALTERNATE_SCREEN.leave(&mut stdout()); self.set_resume_action(ResumeAction::RestoreAlt); } else { self.set_resume_action(ResumeAction::RealignInline); @@ -179,15 +176,22 @@ pub(crate) enum PreparedResumeAction { } impl PreparedResumeAction { - pub(crate) fn apply(self, terminal: &mut Terminal, screen_size: Size) -> Result<()> { + pub(crate) fn apply( + self, + terminal: &mut Terminal, + screen_size: Size, + owned: bool, + capture_mouse: bool, + ) -> Result<()> { match self { PreparedResumeAction::RealignViewport(area) => { terminal.set_viewport_area(area); } PreparedResumeAction::RestoreAltScreen => { - execute!(terminal.backend_mut(), EnterAlternateScreen)?; - // Enable "alternate scroll" so terminals may translate wheel to arrows - execute!(terminal.backend_mut(), EnableAlternateScroll)?; + super::ALTERNATE_SCREEN.enter(terminal.backend_mut(), capture_mouse)?; + if owned { + terminal.hide_cursor()?; + } terminal.set_viewport_area(Rect::from(screen_size)); terminal.clear()?; } diff --git a/codex-rs/tui/src/tui/keyboard_modes.rs b/codex-rs/tui/src/tui/keyboard_modes.rs index 83e409836c..f110b2bcbe 100644 --- a/codex-rs/tui/src/tui/keyboard_modes.rs +++ b/codex-rs/tui/src/tui/keyboard_modes.rs @@ -1,13 +1,13 @@ //! Terminal keyboard enhancement setup and teardown helpers. //! -//! The TUI uses crossterm's keyboard enhancement stack while it owns the terminal, but +//! The TUI pairs pushes and pops on each screen's keyboard enhancement stack, but //! process exit gets a stronger reset so the parent shell does not inherit enhanced key //! reporting if a terminal misses the normal stack pop. //! Windows terminal detection has a deadline, including WSL interop process launch. //! Inconclusive detection keeps keyboard enhancements disabled on WSL. use std::fmt; -use std::io::stdout; +use std::io::Write; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; @@ -206,7 +206,7 @@ fn read_windows_vscode_detection_with_timeout( #[path = "windows_term_program_tests.rs"] mod windows_term_program_tests; -pub(super) fn enable_keyboard_enhancement() { +pub(super) fn enable_keyboard_enhancement(writer: &mut impl Write) { if keyboard_enhancement_disabled() { return; } @@ -219,7 +219,7 @@ pub(super) fn enable_keyboard_enhancement() { }; let _ = execute!( - stdout(), + writer, DisableModifyOtherKeys, PushKeyboardEnhancementFlags(keyboard_enhancement_flags( terminal_info().name, @@ -232,7 +232,7 @@ pub(super) fn enable_keyboard_enhancement() { running_in_tmux_session, tmux_extended_keys_format.as_deref(), ) { - let _ = execute!(stdout(), EnableModifyOtherKeys); + let _ = execute!(writer, EnableModifyOtherKeys); } } @@ -309,17 +309,13 @@ fn read_tmux_extended_keys_format() -> Option { None } -pub(super) fn restore_keyboard_enhancement_stack() { - let _ = execute!( - stdout(), - PopKeyboardEnhancementFlags, - DisableModifyOtherKeys - ); +pub(super) fn restore_keyboard_enhancement_stack(writer: &mut impl Write) { + let _ = execute!(writer, PopKeyboardEnhancementFlags, DisableModifyOtherKeys); } -pub(super) fn reset_keyboard_reporting_after_exit() { +pub(super) fn reset_keyboard_reporting_after_exit(writer: &mut impl Write) { let _ = execute!( - stdout(), + writer, PopKeyboardEnhancementFlags, ResetKeyboardEnhancementFlags, DisableModifyOtherKeys diff --git a/codex-rs/tui/src/tui/owned_screen_tests.rs b/codex-rs/tui/src/tui/owned_screen_tests.rs new file mode 100644 index 0000000000..2405bc6254 --- /dev/null +++ b/codex-rs/tui/src/tui/owned_screen_tests.rs @@ -0,0 +1,208 @@ +//! Exercise session ownership in an isolated process so global terminal modes cannot race tests. + +#![cfg(unix)] + +use super::OverlayInput; +use super::TuiEvent; +use crate::pager_overlay::Overlay; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use ratatui::layout::Rect; +use ratatui::layout::Size; +use std::process::Command; + +const CHILD_ENV: &str = "CODEX_TUI_OWNED_SCREEN_CHILD"; + +#[test] +fn owned_screen_preserves_inline_viewport_across_overlay_handoff_and_resume() { + let output = Command::new(std::env::current_exe().expect("test binary")) + .args([ + "--exact", + "tui::owned_screen_tests::owned_screen_lifecycle_child", + "--ignored", + "--nocapture", + ]) + .env(CHILD_ENV, "1") + .output() + .expect("run isolated owned-screen child"); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let mut terminal = vt100::Parser::new( + /*rows*/ 24, /*cols*/ 80, /*scrollback_len*/ 0, + ); + terminal.process(&output.stdout); + assert_eq!( + ( + terminal.screen().alternate_screen(), + terminal.screen().mouse_protocol_mode() + ), + (false, vt100::MouseProtocolMode::None), + ); +} + +#[tokio::test(flavor = "current_thread")] +#[ignore = "spawned by the owned-screen lifecycle test"] +async fn owned_screen_lifecycle_child() { + if std::env::var_os(CHILD_ENV).is_none() { + return; + } + let mut tui = super::test_support::make_test_tui().expect("test tui"); + let inline = Rect::new( + /*x*/ 0, /*y*/ 4, /*width*/ 80, /*height*/ 5, + ); + tui.terminal.set_viewport_area(inline); + tui.set_alt_screen_enabled(/*enabled*/ false); + tui.set_owned_screen(/*owned*/ true).expect("honor no-alt"); + tui.set_overlay_input(OverlayInput::Transcript) + .expect("no-alt overlay"); + tui.set_overlay_input(OverlayInput::Default) + .expect("close no-alt overlay"); + assert_eq!( + ( + tui.is_owned_screen(), + tui.is_alt_screen_active(), + tui.terminal.viewport_area + ), + (false, false, inline), + ); + + let mut pager = Overlay::new_static_with_lines( + vec!["static pager".into()], + "DETAILS".to_string(), + crate::keymap::RuntimeKeymap::defaults().pager, + ); + pager + .handle_event(&mut tui, TuiEvent::FocusLost) + .expect("request no-alt static input"); + assert!(tui.overlay_input == OverlayInput::StaticPager); + assert!(!tui.is_alt_screen_active()); + pager + .handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), + ) + .expect("close no-alt static pager"); + assert!(pager.is_done()); + assert!(tui.overlay_input == OverlayInput::Default); + assert_eq!(tui.terminal.viewport_area, inline); + + tui.set_alt_screen_enabled(/*enabled*/ true); + // A startup overlay may already hold the alternate screen when App selects ownership. + tui.enter_alt_screen().expect("startup overlay"); + tui.set_overlay_input(OverlayInput::Transcript) + .expect("nested transcript input"); + tui.leave_alt_screen_for_handoff() + .expect("yield transcript to editor"); + assert!(tui.overlay_input == OverlayInput::Transcript); + tui.enter_alt_screen() + .expect("restore transcript after editor"); + tui.set_overlay_input(OverlayInput::Default) + .expect("return to picker input"); + assert!(tui.is_alt_screen_active()); + let mut pager = Overlay::new_static_with_lines( + vec!["static pager".into()], + "DETAILS".to_string(), + crate::keymap::RuntimeKeymap::defaults().pager, + ); + pager + .handle_event(&mut tui, TuiEvent::FocusLost) + .expect("request native static input"); + assert!(tui.overlay_input == OverlayInput::StaticPager); + tui.leave_alt_screen_for_handoff() + .expect("yield static pager to editor"); + assert!(tui.overlay_input == OverlayInput::StaticPager); + assert_eq!(tui.terminal.viewport_area, inline); + tui.enter_alt_screen().expect("restore static pager"); + super::ALTERNATE_SCREEN + .leave(tui.terminal.backend_mut()) + .expect("suspend static pager"); + super::job_control::PreparedResumeAction::RestoreAltScreen + .apply( + &mut tui.terminal, + Size::new(/*width*/ 80, /*height*/ 24), + /*owned*/ false, + tui.overlay_input.captures_mouse(/*owned_screen*/ false), + ) + .expect("resume static pager"); + pager + .handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), + ) + .expect("close static pager back to picker"); + assert!(pager.is_done()); + assert!(tui.overlay_input == OverlayInput::Default); + assert!(tui.is_alt_screen_active()); + tui.set_owned_screen(/*owned*/ true) + .expect("promote overlay"); + let owned = Rect::from(Size::new(/*width*/ 80, /*height*/ 24)); + for _ in 0..2 { + tui.enter_alt_screen().expect("open overlay"); + tui.leave_alt_screen().expect("close overlay"); + assert_eq!( + ( + tui.is_owned_screen(), + tui.is_alt_screen_active(), + tui.terminal.viewport_area, + tui.alt_saved_viewport, + ), + (true, true, owned, Some(inline)), + ); + } + + tui.leave_alt_screen_for_handoff().expect("yield to editor"); + assert_eq!( + ( + tui.is_owned_screen(), + tui.is_alt_screen_active(), + tui.terminal.viewport_area, + tui.alt_saved_viewport, + ), + (true, false, inline, None), + ); + tui.enter_alt_screen().expect("return from editor"); + // Suspension leaves the real screen while retaining the session's ownership and viewport. + super::ALTERNATE_SCREEN + .leave(tui.terminal.backend_mut()) + .expect("suspend screen"); + let resumed = Size::new(/*width*/ 100, /*height*/ 30); + super::job_control::PreparedResumeAction::RestoreAltScreen + .apply( + &mut tui.terminal, + resumed, + /*owned*/ true, + /*capture_mouse*/ true, + ) + .expect("resume owned screen"); + assert_eq!( + ( + tui.is_owned_screen(), + tui.terminal.viewport_area, + tui.alt_saved_viewport + ), + (true, Rect::from(resumed), Some(inline)), + ); + tui.set_alt_screen_enabled(/*enabled*/ false); + assert_eq!( + ( + tui.is_owned_screen(), + tui.is_alt_screen_active(), + tui.terminal.viewport_area, + tui.alt_saved_viewport, + ), + (false, false, inline, None), + ); + // Exercise direct owned entry as well as promotion of an existing overlay. + tui.set_alt_screen_enabled(/*enabled*/ true); + tui.set_owned_screen(/*owned*/ true) + .expect("own session screen"); + tui.set_owned_screen(/*owned*/ false) + .expect("release session screen"); + assert_eq!(tui.terminal.viewport_area, inline); +} diff --git a/codex-rs/tui/src/tui/screen_size.rs b/codex-rs/tui/src/tui/screen_size.rs index 77d5f5c406..fe315d4c28 100644 --- a/codex-rs/tui/src/tui/screen_size.rs +++ b/codex-rs/tui/src/tui/screen_size.rs @@ -47,7 +47,9 @@ impl Tui { TuiEvent::Draw | TuiEvent::FocusGained => { self.screen_size.deferred_size.take().unwrap_or(cached) } - TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost => cached, + TuiEvent::Key(_) | TuiEvent::Mouse(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost => { + cached + } }; if matches!(event, TuiEvent::Resize(_) | TuiEvent::Resume) && let Some(monitor) = &self.event_broker.size_monitor @@ -56,7 +58,7 @@ impl Tui { } self.screen_size.pending_draw_size = (!matches!( event, - TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost + TuiEvent::Key(_) | TuiEvent::Mouse(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost )) .then_some(size); Ok(size) @@ -72,6 +74,13 @@ impl Tui { self.screen_size.deferred_size = Some(size); } + /// Reserve the next draw's geometry so live layouts use the same width as their frame. + pub(crate) fn prepare_draw_size(&mut self) -> io::Result { + let size = self.take_event_screen_size()?; + self.screen_size.pending_draw_size = Some(size); + Ok(size) + } + pub(super) fn take_event_screen_size(&mut self) -> io::Result { if let Some(size) = self.screen_size.pending_draw_size.take() { return Ok(size); diff --git a/codex-rs/tui/src/tui/selection_clipboard.rs b/codex-rs/tui/src/tui/selection_clipboard.rs new file mode 100644 index 0000000000..1457bed675 --- /dev/null +++ b/codex-rs/tui/src/tui/selection_clipboard.rs @@ -0,0 +1,26 @@ +//! Keep native clipboard ownership after either transcript overlay consumer is closed. + +use super::Tui; +use crate::clipboard_copy::CopyFormat; +use crate::clipboard_copy::CopyOutcome; +use crate::clipboard_copy::CopyStatus; + +impl Tui { + pub(crate) fn copy_transcript_selection(&mut self, text: &str) -> Result { + self.copy_transcript_selection_with(text, |text| { + crate::clipboard_copy::copy_to_clipboard(text, CopyFormat::PlainText) + }) + } + + fn copy_transcript_selection_with( + &mut self, + text: &str, + copy: impl FnOnce(&str) -> Result, + ) -> Result { + Ok(copy(text)?.store(&mut self.selection_clipboard_lease)) + } +} + +#[cfg(test)] +#[path = "selection_clipboard_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/tui/selection_clipboard_tests.rs b/codex-rs/tui/src/tui/selection_clipboard_tests.rs new file mode 100644 index 0000000000..fc8af07e7c --- /dev/null +++ b/codex-rs/tui/src/tui/selection_clipboard_tests.rs @@ -0,0 +1,53 @@ +//! Closing nested or App transcript overlays must not release Linux native ownership. + +use super::*; +use crate::clipboard_copy::ClipboardLease; +use crate::history_cell::PlainHistoryCell; +use crate::keymap::RuntimeKeymap; +use crate::pager_overlay::Overlay; +use crate::tui::TuiEvent; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use std::sync::Arc; + +#[tokio::test] +async fn selection_clipboard_survives_overlay_close_and_later_terminal_requests() +-> std::io::Result<()> { + let mut tui = crate::tui::test_support::make_test_tui()?; + for nested in [false, true] { + let mut overlay = Overlay::new_transcript( + vec![Arc::new(PlainHistoryCell::new(vec!["selected".into()]))], + RuntimeKeymap::defaults().pager, + ); + assert_eq!( + tui.copy_transcript_selection_with("selected", |text| { + assert_eq!(text, "selected"); + Ok(CopyOutcome::Copied(Some(ClipboardLease::test()))) + }), + Ok(CopyStatus::Confirmed) + ); + overlay.handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)), + )?; + assert!(overlay.is_done()); + drop(overlay); + if !nested { + tui.leave_alt_screen()?; + } + assert!(tui.selection_clipboard_lease.is_some()); + for outcome in [CopyOutcome::Requested, CopyOutcome::Copied(None)] { + tui.copy_transcript_selection_with("retry", |_| Ok(outcome)) + .unwrap(); + assert!(tui.selection_clipboard_lease.is_some()); + } + assert_eq!( + tui.copy_transcript_selection_with("retry", |_| Err("blocked".into())), + Err("blocked".into()) + ); + assert!(tui.selection_clipboard_lease.is_some()); + } + Ok(()) +} diff --git a/codex-rs/tui/src/unarchive_prompt.rs b/codex-rs/tui/src/unarchive_prompt.rs index e5480a989b..96cac7dac5 100644 --- a/codex-rs/tui/src/unarchive_prompt.rs +++ b/codex-rs/tui/src/unarchive_prompt.rs @@ -65,7 +65,7 @@ pub(crate) async fn run_unarchive_prompt( | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {} - TuiEvent::FocusLost => continue, + TuiEvent::Mouse(_) | TuiEvent::FocusLost => continue, } guard.draw(&screen)?; } diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index 049aecc965..30e948da89 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -74,7 +74,7 @@ pub(crate) async fn run_update_prompt_if_needed( tui.screen_size_for_event(&event)?; match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) | TuiEvent::FocusLost => {} + TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Mouse(_) => {} TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area());