diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 67ba13f867..674b387702 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -96,6 +96,10 @@ pub struct TuiGlobalKeymap { pub open_agents: Option, /// Open the transcript overlay. pub open_transcript: Option, + /// Find text in the full transcript. + pub find_transcript: Option, + /// Focus activity groups in the owned transcript to inspect their details. + pub focus_activity: Option, /// Open the external editor for the current draft. pub open_external_editor: Option, /// Copy the last agent response to the clipboard. @@ -401,6 +405,8 @@ pub struct TuiPagerKeymap { pub close: Option, /// Close the transcript overlay via its dedicated toggle key. pub close_transcript: Option, + /// Find text in a transcript pager. + pub find: Option, } /// List selection context keybindings for popup-style selectable lists. @@ -769,6 +775,48 @@ mod tests { assert!(keymap.global.open_transcript.is_some()); } + #[test] + fn transcript_find_accepts_custom_chords_and_can_be_disabled() { + for (bindings, expected) in [ + ( + "'ctrl-x f'", + KeybindingsSpec::One(KeybindingSpec("ctrl-x f".to_owned())), + ), + ("[]", KeybindingsSpec::Many(Vec::new())), + ] { + for (context, action) in [("pager", "find"), ("global", "find_transcript")] { + let keymap: TuiKeymap = + toml::from_str(&format!("[{context}]\n{action} = {bindings}\n")) + .expect("Find binding"); + let mut expected_keymap = TuiKeymap::default(); + if context == "pager" { + expected_keymap.pager.find = Some(expected.clone()); + } else { + expected_keymap.global.find_transcript = Some(expected.clone()); + } + assert_eq!(keymap, expected_keymap); + } + } + } + + #[test] + fn activity_focus_accepts_custom_chords_and_can_be_disabled() { + for (bindings, expected) in [ + ( + "'ctrl-x t'", + KeybindingsSpec::One(KeybindingSpec("ctrl-x t".to_owned())), + ), + ("[]", KeybindingsSpec::Many(Vec::new())), + ] { + let keymap: TuiKeymap = + toml::from_str(&format!("[global]\nfocus_activity = {bindings}\n")) + .expect("activity focus binding"); + let mut expected_keymap = TuiKeymap::default(); + expected_keymap.global.focus_activity = Some(expected); + assert_eq!(keymap, expected_keymap); + } + } + #[test] fn minus_bindings_under_global_context_are_accepted() { for (spec, expected) in [ diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index d778c6aec4..db5bd7891d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4280,6 +4280,8 @@ "global": { "clear_terminal": null, "copy": null, + "find_transcript": null, + "focus_activity": null, "open_agents": null, "open_external_editor": null, "open_transcript": null, @@ -4306,6 +4308,7 @@ "pager": { "close": null, "close_transcript": null, + "find": null, "half_page_down": null, "half_page_up": null, "jump_bottom": null, @@ -4988,6 +4991,22 @@ ], "description": "Copy the last agent response to the clipboard." }, + "find_transcript": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Find text in the full transcript." + }, + "focus_activity": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Focus activity groups in the owned transcript to inspect their details." + }, "open_agents": { "allOf": [ { @@ -5179,6 +5198,8 @@ "default": { "clear_terminal": null, "copy": null, + "find_transcript": null, + "focus_activity": null, "open_agents": null, "open_external_editor": null, "open_transcript": null, @@ -5219,6 +5240,7 @@ "default": { "close": null, "close_transcript": null, + "find": null, "half_page_down": null, "half_page_up": null, "jump_bottom": null, @@ -5444,6 +5466,14 @@ ], "description": "Close the transcript overlay via its dedicated toggle key." }, + "find": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Find text in a transcript pager." + }, "half_page_down": { "allOf": [ { diff --git a/codex-rs/tui/src/app/history_pagination.rs b/codex-rs/tui/src/app/history_pagination.rs index fae90f0a78..ac53d3896f 100644 --- a/codex-rs/tui/src/app/history_pagination.rs +++ b/codex-rs/tui/src/app/history_pagination.rs @@ -198,6 +198,7 @@ impl App { self.transcript_cells.remove(index); } self.native_history.retain(&self.transcript_cells); + self.transcript_view.restart_search(); self.transcript_view .history_loaded(&self.transcript_cells, 0..0); if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index cf6b706d69..8cd2d58abc 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -16,6 +16,7 @@ impl App { cfg!(unix) && !self.enhanced_keys_supported && self.overlay.is_none() + && !self.transcript_view.is_search_active() && self.chat_widget.no_modal_or_popup_active() && matches!(key_event.code, KeyCode::Char(_)) && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) @@ -41,9 +42,7 @@ impl App { self.key_chord_matcher.clone().advance( key_event, &self.keymap.chords, - active_contexts, - tokio::time::Instant::now(), - ), + active_contexts), crate::keymap::KeyChordMatch::Pending(_) | crate::keymap::KeyChordMatch::Completed(_) ) @@ -54,18 +53,18 @@ 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); + self.transcript_view.set_keymap_bindings(&self.keymap); + if let Some(Overlay::Transcript(overlay)) = &mut self.overlay { + overlay.set_keymap_bindings(&self.keymap); } - if tui.is_owned_screen() - && self.overlay.is_none() - && self.chat_widget.no_modal_or_popup_active() - && self.transcript_view.owns_interaction_key(key_event) - && (self.transcript_view.has_active_interaction() - || self.backtrack.overlay_preview_active - || crate::transcript_view::JumpTarget::from_key(key_event).is_none()) + if matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key_event)) + || (tui.is_owned_screen() + && self.overlay.is_none() + && self.chat_widget.no_modal_or_popup_active() + && self.transcript_view.owns_interaction_key(key_event) + && (self.transcript_view.has_active_interaction() + || self.backtrack.overlay_preview_active + || crate::transcript_view::JumpTarget::from_key(key_event).is_none())) { let close_chord = tui.is_owned_screen() && self.overlay.is_none() @@ -76,7 +75,6 @@ impl App { key_event, &self.keymap.chords, self.active_keymap_contexts(), - tokio::time::Instant::now(), ) { crate::keymap::KeyChordMatch::Completed(event) => { self.keymap.pager.close_transcript.is_pressed(event) @@ -111,29 +109,24 @@ impl App { { return Some(key_event); } - match self.key_chord_matcher.advance( - key_event, - &self.keymap.chords, - contexts, - tokio::time::Instant::now(), - ) { + match self + .key_chord_matcher + .advance(key_event, &self.keymap.chords, contexts) + { crate::keymap::KeyChordMatch::PassThrough => { if was_pending && !self.key_chord_matcher.is_pending() { self.set_key_chord_hint_override(/*items*/ None); } Some(key_event) } - crate::keymap::KeyChordMatch::Pending(prefix) => { - if self.backtrack.primed { + crate::keymap::KeyChordMatch::Pending(_) => { + if self.backtrack.primed && !self.backtrack.overlay_preview_active { self.reset_backtrack_state(); } - self.set_key_chord_hint_override(Some(vec![ - ( - format!("{} …", prefix.display_label()), - "waiting for next key".to_string(), - ), - ("esc".to_string(), "cancel".to_string()), - ])); + self.set_key_chord_hint_override(self.key_chord_matcher.pending_hint_items( + &self.keymap.chords, + tui.terminal.last_known_screen_size.width, + )); tui.frame_requester() .schedule_frame_in(crate::keymap::KEY_CHORD_TIMEOUT); None @@ -152,10 +145,7 @@ impl App { pub(super) fn expire_pending_key_chord(&mut self) { let contexts = self.active_keymap_contexts(); - if self - .key_chord_matcher - .expire(contexts, tokio::time::Instant::now()) - { + if self.key_chord_matcher.expire(contexts) { self.set_key_chord_hint_override(/*items*/ None); } } @@ -167,6 +157,9 @@ impl App { } fn set_key_chord_hint_override(&mut self, items: Option>) { + if let Some(Overlay::Transcript(overlay)) = &mut self.overlay { + overlay.key_chord_hint = items.clone(); + } self.agents_overview .view_state .lock() @@ -179,8 +172,21 @@ impl App { use crate::keymap::KeymapContext; use crate::keymap::KeymapContextSet; - if self.overlay.is_some() { - return KeymapContextSet::new(KeymapContext::Pager); + if let Some(overlay) = &self.overlay { + let context = if matches!(overlay, Overlay::Transcript(view) if view.is_search_active()) + { + KeymapContext::Editor + } else { + KeymapContext::Pager + }; + return KeymapContextSet::new(context); + } + if self.transcript_view.is_search_active() && self.chat_widget.no_modal_or_popup_active() { + return KeymapContextSet::new(KeymapContext::Editor); + } + if self.transcript_view.is_activity_focused() && self.chat_widget.no_modal_or_popup_active() + { + return KeymapContextSet::activity(); } let voice_available = self.chat_widget.realtime_microphone_shortcut_available(); let contexts = self.chat_widget.keymap_contexts(); @@ -496,12 +502,19 @@ impl App { return; } - if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) { + let find_transcript = self.keymap.app.find_transcript.is_pressed(key_event); + if app_keymap_shortcuts_available + && (self.keymap.app.open_transcript.is_pressed(key_event) || find_transcript) + { self.scrollback_has_older_history = self .chat_widget .thread_id() .is_some_and(|thread_id| app_server.has_older_history(thread_id)); self.open_transcript_overlay(tui); + if find_transcript && let Some(Overlay::Transcript(overlay)) = &mut self.overlay { + overlay.set_keymap_bindings(&self.keymap); + overlay.begin_search(); + } return; } @@ -527,7 +540,9 @@ impl App { // handles it. if !tui.is_owned_screen() && self.should_handle_backtrack_esc(key_event) { self.chat_widget.prepare_composer_sparkle_key(key_event); - self.handle_backtrack_esc_key(tui); + if key_event.kind == KeyEventKind::Press { + self.handle_backtrack_esc_key(tui); + } } else if self.should_reject_side_backtrack_esc(key_event) { self.reject_side_backtrack_esc(); } else { @@ -559,7 +574,11 @@ impl App { // This avoids stale "Esc-primed" state after the user starts typing // (even if they later backspace to empty). if key_event.code != KeyCode::Esc && self.backtrack.primed { - self.reset_backtrack_state(); + if self.backtrack.overlay_preview_active { + self.close_transcript_overlay(tui); + } else { + self.reset_backtrack_state(); + } } self.chat_widget.handle_key_event(key_event); } @@ -654,6 +673,7 @@ impl App { pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool { !self.chat_widget.side_conversation_active() + && !self.chat_widget.shortcut_overlay_visible() && self.chat_widget.is_normal_backtrack_mode() && self.chat_widget.composer_is_empty() && !self.chat_widget.should_handle_vim_insert_escape(key_event) @@ -661,6 +681,7 @@ impl App { pub(super) fn should_reject_side_backtrack_esc(&self, key_event: KeyEvent) -> bool { self.chat_widget.side_conversation_active() + && !self.chat_widget.shortcut_overlay_visible() && self.chat_widget.is_normal_backtrack_mode() && self.chat_widget.composer_is_empty() && !self.chat_widget.should_handle_vim_insert_escape(key_event) @@ -681,6 +702,10 @@ impl App { } } +#[cfg(test)] +#[path = "input_ownership_tests.rs"] +mod ownership_tests; + #[cfg(test)] mod tests { use super::super::test_support::make_test_app; diff --git a/codex-rs/tui/src/app/input_ownership_tests.rs b/codex-rs/tui/src/app/input_ownership_tests.rs new file mode 100644 index 0000000000..497d9287b4 --- /dev/null +++ b/codex-rs/tui/src/app/input_ownership_tests.rs @@ -0,0 +1,56 @@ +//! Find and transcript selection retain control keys ahead of configurable chord prefixes. + +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn selection_copy_cancels_a_pending_find_editor_chord() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + app.keymap = RuntimeKeymap::from_config(&serde_json::from_value(serde_json::json!({ + "editor": {"move_line_start": ["ctrl-p h", "ctrl-x h"], "move_up": []} + }))?) + .expect("valid editor keymap"); + app.transcript_cells = vec![Arc::new(history_cell::PlainHistoryCell::new(vec![ + "needle text".into(), + ]))]; + app.transcript_view.begin_search(); + app.transcript_view.paste_search("needle"); + for key in [ + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), + KeyCode::Enter.into(), + KeyCode::Esc.into(), + ] { + assert_eq!(app.route_key_chord_event(&mut tui, key), Some(key)); + assert!(!app.key_chord_matcher.is_pending()); + } + app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?; + assert_eq!( + app.route_key_chord_event( + &mut tui, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL) + ), + None + ); + assert!(app.key_chord_matcher.is_pending()); + app.transcript_view.handle_key( + KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL), + &app.transcript_cells, + ); + for _ in "needle text".chars() { + app.transcript_view + .handle_key(KeyCode::Right.into(), &app.transcript_cells); + } + let copy = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(app.route_key_chord_event(&mut tui, copy), Some(copy)); + assert!(!app.key_chord_matcher.is_pending()); + assert_eq!( + app.transcript_view + .selected_text(&app.transcript_cells) + .as_deref(), + Some("needle text") + ); + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 918e299bc0..f6e9311aec 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -19,20 +19,22 @@ impl App { let chat_widget = &self.chat_widget; let transcript_width = chat_widget.history_wrap_width(width); let view = &mut self.transcript_view; + view.set_keymap_bindings(&self.keymap); view.set_presentation(view.is_detailed(), chat_widget.history_render_mode()); let active_key = chat_widget.active_cell_transcript_key(); let detailed = view.is_detailed(); - view.sync_live_tail(transcript_width, active_key, |width| { - chat_widget.active_cell_hyperlink_lines_with(width, |cell, width| { - if detailed { - cell.transcript_hyperlink_lines(width) - } else if chat_widget.history_render_mode() == HistoryRenderMode::Raw { - cell.display_hyperlink_lines_for_mode(width, HistoryRenderMode::Raw) - } else { - cell.compact_hyperlink_lines(width) - } + let active_ids = chat_widget.active_activity_ids(); + let expanded = view.sync_live_activity(&self.transcript_cells, active_ids) + && chat_widget.history_render_mode() == HistoryRenderMode::Rich; + if detailed { + view.sync_live_tail(transcript_width, active_key, |width| { + chat_widget.active_cell_transcript_hyperlink_lines(width) }) - }) + } else { + view.sync_live_activity_tail(transcript_width, active_key, expanded, |width| { + chat_widget.active_cell_owned_transcript_lines(width, expanded) + }) + } } pub(super) fn render_owned_transcript( @@ -52,6 +54,10 @@ impl App { let view = &mut self.transcript_view; let active_key = chat_widget.active_cell_transcript_key(); view.sync_history_tail(&self.transcript_cells); + view.prepare_width(transcript_width); + if view.advance_search(&self.transcript_cells) { + tui.frame_requester().schedule_frame(); + } let footer_area = crate::bottom_pane::inset_footer_hint_area(Rect::new( /*x*/ 0, /*y*/ 0, @@ -107,8 +113,16 @@ impl App { feedback_tick = view.render_composer_gap(follow_area, /*hint*/ None, frame.buffer); // Rendering resolves whether new activity is still hidden. Paint that result in // this frame so a revision change cannot flash a stale activity hint. - let footer = view.footer_with_navigation(footer_area.width, motion, latest_navigation); - + let mut footer = + view.footer_with_navigation(footer_area.width, motion, latest_navigation); + if let Some(footer) = footer.as_mut().filter(|footer| footer.is_interactive) + && let Some(items) = self + .key_chord_matcher + .pending_hint_items(&self.keymap.chords, footer_area.width) + && let Some(progress) = footer.text.lines.last_mut() + { + *progress = crate::bottom_pane::footer_hint_items_line(&items); + } let bottom = chat_widget.bottom_pane_renderable(footer.as_ref()); footer_height_changed = !dashboard_visible && bottom @@ -212,6 +226,7 @@ impl App { && !self.transcript_view.has_active_interaction() && keymap_action_ids().any(|action| { action.context != KeymapContext::Pager + && !matches!(action.action, "find_transcript" | "focus_activity") && self.active_keymap_contexts().contains_action(action) && bindings_for_action( &self.keymap, @@ -224,15 +239,27 @@ impl App { return Ok(false); } let action = match event { + TuiEvent::Key(key) + if !self.transcript_view.is_search_active() + && self.keymap.app.find_transcript.is_pressed(*key) => + { + self.transcript_view.begin_search(); + Some(ViewAction::Changed) + } TuiEvent::Key(key) => self .transcript_view .handle_key(*key, &self.transcript_cells), TuiEvent::Mouse(mouse) => self .transcript_view .handle_mouse(*mouse, &self.transcript_cells), - TuiEvent::Paste(_) => { + TuiEvent::Paste(text) => { self.transcript_view.end_selection(&self.transcript_cells); - None + if self.transcript_view.paste_search(text) { + Some(ViewAction::Changed) + } else { + self.transcript_view.clear_activity_focus(); + None + } } TuiEvent::Draw | TuiEvent::Resume @@ -323,10 +350,10 @@ impl App { #[path = "owned_transcript_tests.rs"] mod tests; -#[cfg(test)] -#[path = "owned_transcript_follow_tests.rs"] -mod follow_tests; - #[cfg(test)] #[path = "owned_transcript_input_tests.rs"] mod input_tests; + +#[cfg(test)] +#[path = "owned_transcript_follow_tests.rs"] +mod follow_tests; diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index a7be666386..c5545e9c04 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -2,6 +2,8 @@ use super::*; use crate::chatwidget::tests::helpers::normalize_snapshot_paths; +use crate::history_cell::HistoryCell; +use crate::history_cell::UserHistoryCell; use crate::session_state::ThreadSessionState; use crate::test_support::test_path_buf; use codex_app_server_protocol::AskForApproval; @@ -10,6 +12,16 @@ use codex_protocol::models::PermissionProfile; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; +fn user_cell(message: &str) -> Arc { + Arc::new(UserHistoryCell { + spoken: false, + message: message.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) +} + fn attach_thread(app: &mut App, thread_id: ThreadId) { app.chat_widget.handle_thread_session(ThreadSessionState { windows_sandbox_host: crate::app::WindowsSandboxHost::Local, @@ -307,3 +319,241 @@ async fn owned_transcript_keeps_text_out_of_the_pet_columns() -> Result<()> { tui.set_owned_screen(/*owned*/ false)?; Ok(()) } + +#[tokio::test] +async fn find_owns_editor_chords_without_changing_the_composer_draft() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + app.transcript_cells = vec![user_cell("alpha beta")]; + app.chat_widget + .apply_external_edit("draft remains intact".to_string()); + app.keymap = RuntimeKeymap::from_config(&serde_json::from_value(serde_json::json!({ + "global": {"find_transcript": "f4", "open_external_editor": "ctrl-g e"}, + "editor": {"move_line_start": "ctrl-x h"}, + "pager": {"find": "f4"} + }))?) + .expect("independent query bindings"); + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + for owned in [true, false] { + tui.set_owned_screen(owned)?; + if !owned { + app.open_transcript_overlay(&mut tui); + } + for event in [ + TuiEvent::Key(KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE)), + TuiEvent::Draw, + TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)), + TuiEvent::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)), + TuiEvent::Paste("alpha beta".to_string()), + // A global chord cannot own the next query character. + TuiEvent::Key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)), + TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + // Configured editor chords use the same query TextArea. + TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + TuiEvent::Key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)), + TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)), + TuiEvent::Key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)), + ] { + let pasted = matches!(event, TuiEvent::Paste(_)); + app.handle_tui_event(&mut tui, &mut app_server, event) + .await?; + if pasted { + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw) + .await?; + let rendered = buffer_text( + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal), + ); + assert!( + rendered.contains("enter next"), + "Find must resume after pasting over selection" + ); + } + if app.key_chord_matcher.is_pending() { + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw) + .await?; + let rendered = buffer_text( + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal), + ); + let cursor = tui.terminal.last_known_cursor_pos; + let query_row = rendered + .lines() + .nth(usize::from(cursor.y)) + .expect("visible query caret"); + assert_eq!(query_row.trim(), "Find: alpha betax"); + assert!(rendered.contains("h move line start")); + insta::assert_snapshot!( + "owned_find_pending_chord_footer", + rendered + .lines() + .skip(usize::from(cursor.y)) + .take(/*n*/ 2) + .map(str::trim) + .collect::>() + .join("\n") + ); + } + } + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw) + .await?; + let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer( + &tui.terminal, + )); + assert!( + rendered + .lines() + .any(|line| line.trim() == "Find: apha betax") + ); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "draft remains intact" + ); + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ) + .await?; + assert!(!match &app.overlay { + Some(Overlay::Transcript(overlay)) => overlay.is_search_active(), + _ => app.transcript_view.is_search_active(), + }); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "draft remains intact" + ); + app.close_transcript_overlay(&mut tui); + } + tui.set_owned_screen(/*owned*/ false)?; + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn offline_find_closes_before_the_next_ctrl_c_quits() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + app.chat_widget + .apply_external_edit("offline draft".to_string()); + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + app.open_transcript_overlay(&mut tui); + app.transcript_view.begin_search(); + app.transcript_view.paste_search("needle"); + app.reconnect.offline = true; + app.chat_widget.pause_for_disconnect(); + app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?; + let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer( + &tui.terminal, + )); + let cursor = tui.terminal.last_known_cursor_pos; + assert_eq!( + rendered.lines().nth(usize::from(cursor.y)).map(str::trim), + Some("Find: needle") + ); + assert!(!rendered.contains("ctrl+c quit")); + let close = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(matches!( + app.handle_tui_event(&mut tui, &mut app_server, close) + .await?, + AppRunControl::Continue + )); + assert!(!app.transcript_view.is_search_active()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "offline draft" + ); + assert!(app.transcript_view.is_detailed()); + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)), + ) + .await?; + assert!(!app.transcript_view.is_detailed()); + let quit = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(matches!( + app.handle_tui_event(&mut tui, &mut app_server, quit) + .await?, + AppRunControl::Exit(ExitReason::UserRequested) + )); + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} + +#[tokio::test] +async fn inline_transcript_search_draws_and_escape_closes_find() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + attach_thread(&mut app, ThreadId::new()); + app.transcript_cells = vec![user_cell("needle in history")]; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.open_transcript_overlay(&mut tui); + for event in [ + TuiEvent::Key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)), + TuiEvent::Paste("needle".to_string()), + TuiEvent::Draw, + ] { + app.handle_backtrack_overlay_event(&mut tui, &mut app_server, event) + .await?; + } + let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer( + &tui.terminal, + )); + assert!( + rendered.contains("enter next"), + "search advances on the inline overlay draw path" + ); + app.handle_backtrack_overlay_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ) + .await?; + assert!( + matches!(&app.overlay, Some(Overlay::Transcript(overlay)) if !overlay.has_active_interaction()) + ); + app.close_transcript_overlay(&mut tui); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn find_refreshes_live_details_before_searching_the_first_query() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + crate::chatwidget::tests::helpers::set_active_cell( + &mut app.chat_widget, + Box::new(crate::exec_cell::new_active_exec_command( + "live".into(), + vec!["printf visible\nprintf needle".into()], + Vec::new(), + codex_app_server_protocol::CommandExecutionSource::Agent, + /*interaction_input*/ None, + /*animations_enabled*/ false, + )), + ); + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?; + assert!( + !buffer_text(crate::custom_terminal::test_support::last_rendered_buffer( + &tui.terminal + )) + .contains("needle") + ); + for event in [ + TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)), + TuiEvent::Paste("needle".into()), + TuiEvent::Draw, + ] { + app.handle_tui_event(&mut tui, &mut app_server, event) + .await?; + } + let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer( + &tui.terminal, + )); + assert!(rendered.contains("enter next"), "{rendered}"); + tui.set_owned_screen(/*owned*/ false)?; + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_find_pending_chord_footer.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_find_pending_chord_footer.snap new file mode 100644 index 0000000000..32ccfda0b8 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_find_pending_chord_footer.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/owned_transcript_tests.rs +expression: "rendered.lines().skip(usize::from(cursor.y)).take(2).map(str::trim).collect::>().join(\"\\n\")" +--- +Find: alpha betax +ctrl+x then · h move line start · esc cancel diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 6f886b8a87..fc86ae0f5a 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -10,6 +10,8 @@ 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; +#[cfg(unix)] +use crate::pager_overlay::TranscriptHistoryState; use crate::test_support::test_path_display; use crate::tui::Tui; use codex_app_server_protocol::ToolRequestUserInputOption; @@ -94,20 +96,25 @@ async fn vim_buffer_jumps_route_default_chords_in_normal_and_operator_contexts() } #[tokio::test] -async fn completed_global_chord_reuses_the_existing_action_handler() -> Result<()> { +async fn global_chord_keeps_hints_and_completes_before_deadline() -> Result<()> { let (mut app, mut tui, mut app_server) = chord_app().await?; press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; assert!(app.key_chord_matcher.is_pending()); assert!(app.overlay.is_none()); + tokio::time::pause(); + tokio::time::advance(std::time::Duration::from_millis(/*millis*/ 500)).await; + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw) + .await?; + assert!(app.key_chord_matcher.is_pending()); insta::assert_snapshot!( render_bottom_popup(&app.chat_widget, /*width*/ 80) .replace(&test_path_display("/tmp/project"), "/tmp/project"), - @" - › Ask Codex to do anything + @r" + › Ask Codex to do anything - ctrl+x … waiting for next key · esc cancel - " + ctrl+x then · ctrl+t open transcript · ctrl+u interrupt turn · esc cancel + " ); press(&mut app, &mut tui, &mut app_server, ctrl('t')).await?; @@ -288,6 +295,65 @@ async fn legacy_terminal_preserves_active_global_alt_shortcuts() -> Result<()> { Ok(()) } +#[cfg(unix)] +#[tokio::test] +async fn owned_transcript_alt_jumps_preserve_legacy_vim_insert_and_draft() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + app.enhanced_keys_supported = false; + tui.set_owned_screen(/*owned*/ true)?; + app.transcript_cells = vec![std::sync::Arc::new( + crate::history_cell::PlainHistoryCell::new(vec!["Earlier transcript".into()]), + )]; + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("draft"); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('i').into(), + ) + .await?; + + let area = ratatui::layout::Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4, + ); + let mut buffer = ratatui::buffer::Buffer::empty(area); + app.transcript_view + .render(area, &mut buffer, &app.transcript_cells); + + for modifiers in [KeyModifiers::ALT, KeyModifiers::ALT | KeyModifiers::SHIFT] { + app.transcript_view.history = TranscriptHistoryState::Partial; + for (character, following, history) in [ + ('<', false, TranscriptHistoryState::LoadingBeginning), + ('>', true, TranscriptHistoryState::LoadingOlder), + ] { + press( + &mut app, + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Char(character), modifiers), + ) + .await?; + assert_eq!( + ( + app.transcript_view.is_following(), + app.transcript_view.history, + app.chat_widget.composer_text_with_pending(), + app.chat_widget + .should_handle_vim_insert_escape(KeyEvent::new( + KeyCode::Esc, + KeyModifiers::NONE, + )), + ), + (following, history, "draft".to_string(), true) + ); + } + } + tui.set_owned_screen(/*owned*/ false)?; + app_server.shutdown().await?; + Ok(()) +} + #[cfg(unix)] #[tokio::test] async fn legacy_terminal_preserves_configured_composer_alt_shortcuts() -> Result<()> { @@ -622,7 +688,7 @@ async fn dashboard_chord_hint_survives_refresh_and_clears_on_cancel() -> Result< let _ = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); insta::assert_snapshot!( render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), - @" ctrl+x … waiting for next key esc cancel" + @" ctrl+x then n new task esc cancel" ); assert_eq!( app.route_key_chord_event(&mut tui, KeyCode::Esc.into()), 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 34b9b1a425..00f077b706 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 @@ -13,4 +13,4 @@ expression: buffer_text(&buffer) Ctrl+Space select ↑/↓ to scroll · pgup/pgdn to page · home/end to jump - q close · esc edit previous + q close · f3 find · esc edit previous diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/vim_search_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/vim_search_tests.rs index be602bdb3b..f8152da54f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/vim_search_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/vim_search_tests.rs @@ -10,7 +10,6 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; -use tokio::time::Instant; #[test] fn fresh_vim_draft_records_typing_after_submission() { @@ -88,12 +87,8 @@ fn vim_search_query_edits_and_paste_preserve_the_draft() { KeyCode::Char(ch) }; let event = KeyEvent::new(code, KeyModifiers::NONE); - let event = match matcher.advance( - event, - &keymap.chords, - composer.keymap_contexts(), - Instant::now(), - ) { + let event = match matcher.advance(event, &keymap.chords, composer.keymap_contexts()) + { KeyChordMatch::PassThrough => event, KeyChordMatch::Completed(event) => event, KeyChordMatch::Pending(_) => continue, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7e9a26a6aa..75e92ba6dc 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -131,6 +131,7 @@ mod effort_status_line; mod experimental_features_view; mod file_search_popup; mod footer; +pub(crate) use footer::footer_hint_items_line; mod list_selection_view; mod memories_settings_view; mod mentions_v2; @@ -196,6 +197,8 @@ mod selection_row_layout; mod selection_tabs; mod startup; mod textarea; +pub(crate) use textarea::TextArea; +pub(crate) use textarea::TextAreaState; mod unified_exec_footer; pub(crate) use feedback_note_view::FeedbackNoteView; pub(crate) use hooks_browser_view::HooksBrowserView; diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs b/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs index a70afc7e02..ea67628a9c 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs @@ -7,7 +7,6 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; -use tokio::time::Instant; fn vim_textarea(text: &str, cursor: usize) -> TextArea { let mut textarea = TextArea::new(); @@ -28,12 +27,7 @@ fn keys(textarea: &mut TextArea, keys: &str) { ch => KeyCode::Char(ch), }; let event = KeyEvent::new(code, KeyModifiers::NONE); - match matcher.advance( - event, - &keymap.chords, - textarea.keymap_contexts(), - Instant::now(), - ) { + match matcher.advance(event, &keymap.chords, textarea.keymap_contexts()) { KeyChordMatch::PassThrough => textarea.input(event), KeyChordMatch::Completed(event) => textarea.input(event), KeyChordMatch::Pending(_) | KeyChordMatch::Cancelled | KeyChordMatch::Ignored => {} diff --git a/codex-rs/tui/src/chatwidget/activity_presentation.rs b/codex-rs/tui/src/chatwidget/activity_presentation.rs index 9aba668643..fab2bbafcf 100644 --- a/codex-rs/tui/src/chatwidget/activity_presentation.rs +++ b/codex-rs/tui/src/chatwidget/activity_presentation.rs @@ -1,7 +1,11 @@ -//! Render live conversation cells in compact or detailed form. +//! Expose live activity identity and local detail rendering to the owned transcript. +//! Canonical cells retain all available data; presentation does not modify tool execution. + use super::*; +use crate::transcript_view::ActivityTranscriptLines; + impl ChatWidget { - pub(crate) fn active_cell_hyperlink_lines_with( + pub(super) fn active_cell_hyperlink_lines_with( &self, width: u16, render: impl Fn(&dyn HistoryCell, u16) -> Vec, @@ -31,4 +35,59 @@ impl ChatWidget { } (!lines.is_empty()).then_some(lines) } + + pub(crate) fn active_activity_ids(&self) -> Vec { + self.transcript + .active_cell + .as_ref() + .map_or_else(Vec::new, |cell| cell.activity_ids()) + } + + /// Render only the chosen activity presentation, before any auxiliary live text. + pub(crate) fn active_cell_owned_transcript_lines( + &self, + width: u16, + expanded: bool, + ) -> Option { + let active = self.transcript.active_cell.as_deref(); + let mode = self.history_render_mode(); + let render_compact = |cell: &dyn HistoryCell, width| { + if mode == HistoryRenderMode::Raw { + cell.display_hyperlink_lines_for_mode(width, mode) + } else { + cell.compact_hyperlink_lines(width) + } + }; + let has_activity = mode == HistoryRenderMode::Rich + && active.is_some_and(|cell| !cell.activity_ids().is_empty()); + let activity = active.map_or_else(Vec::new, |cell| { + if expanded && has_activity { + cell.expanded_hyperlink_lines(width) + } else { + render_compact(cell, width) + } + }); + let has_hidden_details = + has_activity && active.is_some_and(|cell| cell.has_hidden_activity_details(width)); + let mut auxiliary = self + .active_cell_hyperlink_lines_with(width, |cell, width| { + if active.is_some_and(|active| std::ptr::eq(active, cell)) { + Vec::new() + } else { + render_compact(cell, width) + } + }) + .unwrap_or_default(); + if activity.is_empty() && auxiliary.is_empty() { + return None; + } + if !activity.is_empty() && !auxiliary.is_empty() { + auxiliary.insert(/*index*/ 0, HyperlinkLine::from("")); + } + Some(ActivityTranscriptLines { + activity, + auxiliary, + has_hidden_details, + }) + } } diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 6ec82096b3..1f044387a6 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -5,10 +5,6 @@ use crate::bottom_pane::BottomPaneView; use crate::clipboard_copy::CopyFormat; impl ChatWidget { - pub(crate) fn shortcut_overlay_visible(&self) -> bool { - self.bottom_pane.shortcut_overlay_visible() - } - pub(crate) fn set_agents_navigation_enabled(&mut self, enabled: bool) { self.bottom_pane.set_agents_navigation_enabled(enabled); } @@ -307,6 +303,11 @@ impl ChatWidget { .replace_selection_view_if_present(view_id, params) } + #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] + pub(crate) fn shortcut_overlay_visible(&self) -> bool { + self.bottom_pane.shortcut_overlay_visible() + } + pub(crate) fn no_modal_or_popup_active(&self) -> bool { self.bottom_pane.no_modal_or_popup_active() } @@ -360,6 +361,34 @@ impl ChatWidget { self.request_redraw(); } + /// Report a transcript copy without adding history and return its outcome to the viewport. + #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] + 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) + }) + } + + /// The owned viewport renders its own copy feedback above the composer. + #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] + pub(super) fn copy_transcript_selection_with( + &mut self, + text: &str, + copy_fn: impl FnOnce(&str) -> Result, + ) -> Result { + let result = self.write_clipboard(text, copy_fn); + let line = match &result { + Ok(status) => Line::from(status.message("selection").dim()), + Err(error) => Line::from(format!("Copy failed: {error}").red()), + }; + self.bottom_pane + .show_footer_flash(line, Duration::from_secs(/*secs*/ 3)); + result + } + pub(crate) fn copy_selection(&mut self, text: Arc, label: String, format: CopyFormat) { self.copy_selection_with(&text, &label, |text| { crate::clipboard_copy::copy_to_clipboard(text, format) diff --git a/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs b/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs index d924338602..b84074e952 100644 --- a/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs +++ b/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs @@ -721,6 +721,10 @@ async fn compact_voice_meters_keep_real_speaker_history_when_the_microphone_is_m #[tokio::test] async fn voice_toggle_shortcut_uses_the_slash_command_start_guard_and_preserves_draft() { let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await; + let config = + toml::from_str::("[chat]\ntoggle_voice = 'f8'").unwrap(); + let runtime = crate::keymap::RuntimeKeymap::from_config(&config).unwrap(); + chat.apply_keymap_update(config, &runtime); chat.set_side_conversation_active(/*active*/ true); chat.bottom_pane .set_composer_text("draft".to_string(), Vec::new(), Vec::new()); @@ -743,6 +747,10 @@ async fn voice_toggle_shortcut_uses_the_slash_command_start_guard_and_preserves_ #[tokio::test] async fn voice_toggle_shortcut_stops_only_on_press_outside_popups() { let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await; + let config = + toml::from_str::("[chat]\ntoggle_voice = 'f8'").unwrap(); + let runtime = crate::keymap::RuntimeKeymap::from_config(&config).unwrap(); + chat.apply_keymap_update(config, &runtime); let thread_id = activate_voice(&mut chat); let shortcut = KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE); for kind in [KeyEventKind::Repeat, KeyEventKind::Release] { diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 998c866077..585b5605d4 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -2280,9 +2280,9 @@ async fn slash_keymap_debug_opens_keypress_inspector() { let popup = render_bottom_popup(&chat, /*width*/ 80); assert!(popup.contains("Keypress Inspector")); assert!(popup.contains("Waiting for a keypress")); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + chat.handle_key_event(KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE)); let popup = render_bottom_popup(&chat, /*width*/ 100); - assert!(popup.contains("global.copy (Copy)")); + assert!(popup.contains("global.focus_activity")); assert!( drain_insert_history(&mut rx).is_empty(), "debug inspector should open without transcript messages" diff --git a/codex-rs/tui/src/footer_hint.rs b/codex-rs/tui/src/footer_hint.rs index f253fe60b3..bcf256d71b 100644 --- a/codex-rs/tui/src/footer_hint.rs +++ b/codex-rs/tui/src/footer_hint.rs @@ -12,7 +12,6 @@ pub(crate) fn shortcut(keys: &str, label: &str) -> Line<'static> { } /// Choose a complete hint variant so narrow surfaces never display half a shortcut. -#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] pub(crate) fn first_fitting_line( candidates: impl IntoIterator>, width: u16, diff --git a/codex-rs/tui/src/history_cell/mod.rs b/codex-rs/tui/src/history_cell/mod.rs index 2ff7163df5..d1fc5273bb 100644 --- a/codex-rs/tui/src/history_cell/mod.rs +++ b/codex-rs/tui/src/history_cell/mod.rs @@ -258,9 +258,8 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { self.transcript_hyperlink_lines(width) } - /// Whether differing activity presentations can contain genuinely hidden details. - /// The caller also compares compact and expanded content; overrides suppress differences - /// that only change headings, status decoration, or ordering of already-visible information. + /// Whether an activity offers details beyond its compact presentation. + /// Prefer source metadata so collapsed rendering does not materialize hidden content. #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] fn has_hidden_activity_details(&self, _width: u16) -> bool { true diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 15d3b32b03..5ee00fd7ab 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -45,6 +45,10 @@ mod conflict_tests; #[path = "keymap/voice_tests.rs"] mod voice_tests; +#[cfg(test)] +#[path = "keymap/global_find_tests.rs"] +mod global_find_tests; + pub(crate) use bindings::KeymapContext; pub(crate) use bindings::bindings_for_action; pub(crate) use bindings::keymap_action_id; @@ -91,6 +95,10 @@ pub(crate) struct AppKeymap { pub(crate) open_agents: Vec, /// Open transcript overlay. pub(crate) open_transcript: Vec, + /// Find text in the full transcript. + pub(crate) find_transcript: Vec, + /// Focus activity groups in the owned transcript to inspect their details. + pub(crate) focus_activity: Vec, /// Open external editor for the current draft. pub(crate) open_external_editor: Vec, /// Copy the last agent response to the clipboard. @@ -295,6 +303,7 @@ pub(crate) struct PagerKeymap { pub(crate) jump_bottom: Vec, pub(crate) close: Vec, pub(crate) close_transcript: Vec, + pub(crate) find: Vec, chord_hints: Arc, } @@ -629,13 +638,7 @@ impl RuntimeKeymap { || configured_context_alias_is_used(&keymap.list, alias) || configured_context_alias_is_used(&keymap.approval, alias) }); - let voice_toggle_default_is_shadowed = keymap.chat.toggle_voice.is_none() - && (configured_main_surface_alias_is_used(keymap, "f8") - || configured_context_alias_is_used(&keymap.vim_search, "f8") - || chords.bindings.iter().any(|binding| { - binding.action.context.overlaps(KeymapContext::Chat) - && binding.chord.prefix.parts() == key_hint::plain(KeyCode::F(8)).parts() - })); + // Preserve existing Ctrl+X shortcuts and chord prefixes when adding this default. let voice_mute_default_is_shadowed = keymap.chat.toggle_voice_mute.is_none() && (configured_main_surface_alias_is_used(keymap, "ctrl-x") @@ -645,6 +648,20 @@ impl RuntimeKeymap { == key_hint::ctrl(KeyCode::Char('x')).parts() })); + // New activity defaults yield to existing custom keys and chord prefixes. + let focus_activity_defaults: Vec<_> = defaults + .app + .focus_activity + .iter() + .copied() + .filter(|binding| { + !configured_context_binding_is_used(keymap, *binding) + && !chords.bindings.iter().any(|chord| { + chord.chord.prefix.normalized_parts() == binding.normalized_parts() + }) + }) + .collect(); + let app = AppKeymap { open_agents: resolve_bindings( keymap.global.open_agents.as_ref(), @@ -656,6 +673,16 @@ impl RuntimeKeymap { &defaults.app.open_transcript, "tui.keymap.global.open_transcript", )?, + find_transcript: resolve_bindings( + keymap.global.find_transcript.as_ref(), + &defaults.app.find_transcript, + "tui.keymap.global.find_transcript", + )?, + focus_activity: resolve_bindings( + keymap.global.focus_activity.as_ref(), + &focus_activity_defaults, + "tui.keymap.global.focus_activity", + )?, open_external_editor: resolve_bindings( keymap.global.open_external_editor.as_ref(), &defaults.app.open_external_editor, @@ -697,6 +724,15 @@ impl RuntimeKeymap { }, }; + // Voice yields to explicitly configured shortcuts and chord prefixes. + let voice_toggle_default_is_shadowed = keymap.chat.toggle_voice.is_none() + && (configured_main_surface_alias_is_used(keymap, "f8") + || configured_context_alias_is_used(&keymap.vim_search, "f8") + || chords.bindings.iter().any(|binding| { + binding.action.context.overlaps(KeymapContext::Chat) + && binding.chord.prefix.parts() == key_hint::plain(KeyCode::F(8)).parts() + })); + let mut chat = ChatKeymap { toggle_voice: if voice_toggle_default_is_shadowed { Vec::new() @@ -1290,6 +1326,7 @@ impl RuntimeKeymap { jump_bottom: resolve_local!(keymap, defaults, pager, jump_bottom), close: resolve_local!(keymap, defaults, pager, close), close_transcript: resolve_local!(keymap, defaults, pager, close_transcript), + find: resolve_local!(keymap, defaults, pager, find), chord_hints: Arc::clone(&chords), }; @@ -1366,6 +1403,14 @@ impl RuntimeKeymap { keymap.global.open_transcript.as_ref(), app.open_transcript.as_slice(), ), + ( + keymap.global.find_transcript.as_ref(), + app.find_transcript.as_slice(), + ), + ( + keymap.global.focus_activity.as_ref(), + app.focus_activity.as_slice(), + ), ( keymap.global.open_external_editor.as_ref(), app.open_external_editor.as_slice(), @@ -1550,7 +1595,7 @@ impl RuntimeKeymap { }); } } - resolved.configure_vim_search(keymap)?; + resolved.configure_search(keymap)?; resolved.validate_conflicts()?; chords::validate_chord_conflicts(&resolved)?; chords::install_dispatch_bindings(&mut resolved)?; @@ -1578,6 +1623,8 @@ impl RuntimeKeymap { app: AppKeymap { open_agents: default_bindings![], open_transcript: default_bindings![ctrl(KeyCode::Char('t'))], + find_transcript: default_bindings![plain(KeyCode::F(3))], + focus_activity: default_bindings![plain(KeyCode::F(4))], open_external_editor: default_bindings![ctrl(KeyCode::Char('g'))], copy: default_bindings![ctrl(KeyCode::Char('o'))], clear_terminal: default_bindings![ctrl(KeyCode::Char('l'))], @@ -1814,6 +1861,7 @@ impl RuntimeKeymap { jump_bottom: default_bindings![plain(KeyCode::End)], close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))], close_transcript: default_bindings![ctrl(KeyCode::Char('t'))], + find: default_bindings![plain(KeyCode::F(3)), plain(KeyCode::Char('/'))], chord_hints: Arc::default(), }, list: ListKeymap { @@ -1934,6 +1982,8 @@ impl RuntimeKeymap { let main_bindings = [ ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), + ("find_transcript", self.app.find_transcript.as_slice()), + ("focus_activity", self.app.focus_activity.as_slice()), ( "open_external_editor", self.app.open_external_editor.as_slice(), @@ -2037,6 +2087,8 @@ impl RuntimeKeymap { [ ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), + ("find_transcript", self.app.find_transcript.as_slice()), + ("focus_activity", self.app.focus_activity.as_slice()), ( "open_external_editor", self.app.open_external_editor.as_slice(), @@ -2093,6 +2145,8 @@ impl RuntimeKeymap { [ ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), + ("find_transcript", self.app.find_transcript.as_slice()), + ("focus_activity", self.app.focus_activity.as_slice()), ( "open_external_editor", self.app.open_external_editor.as_slice(), @@ -2216,6 +2270,16 @@ impl RuntimeKeymap { )?; validate_unique("list", context_bindings(KeymapContext::List))?; + validate_unique( + "activity", + context_bindings(KeymapContext::List).chain([ + ("global.focus_activity", self.app.focus_activity.as_slice()), + ( + "global.find_transcript", + self.app.find_transcript.as_slice(), + ), + ]), + )?; validate_unique("agents", context_bindings(KeymapContext::Agents))?; validate_no_reserved( @@ -2487,23 +2551,27 @@ fn configured_main_surface_alias_is_used(keymap: &TuiKeymap, alias: &str) -> boo } fn configured_context_alias_is_used(context: &impl Serialize, alias: &str) -> bool { + parse_keybinding(alias) + .is_some_and(|binding| configured_context_binding_is_used(context, binding)) +} + +fn configured_context_binding_is_used(context: &impl Serialize, binding: KeyBinding) -> bool { let Ok(value) = serde_json::to_value(context) else { return false; }; - keymap_value_contains_alias(&value, alias) + keymap_value_contains_binding(&value, binding) } -fn keymap_value_contains_alias(value: &serde_json::Value, alias: &str) -> bool { +fn keymap_value_contains_binding(value: &serde_json::Value, binding: KeyBinding) -> bool { match value { serde_json::Value::String(value) => parse_keybinding(value) - .zip(parse_keybinding(alias)) - .is_some_and(|(a, b)| a.normalized_parts() == b.normalized_parts()), + .is_some_and(|configured| configured.normalized_parts() == binding.normalized_parts()), serde_json::Value::Array(values) => values .iter() - .any(|value| keymap_value_contains_alias(value, alias)), + .any(|value| keymap_value_contains_binding(value, binding)), serde_json::Value::Object(values) => values .values() - .any(|value| keymap_value_contains_alias(value, alias)), + .any(|value| keymap_value_contains_binding(value, binding)), serde_json::Value::Bool(_) | serde_json::Value::Number(_) | serde_json::Value::Null => { false } @@ -2781,12 +2849,6 @@ mod tests { assert!(err.contains("tui.keymap.global.open_external_editor")); } - #[test] - fn default_copy_binding_is_ctrl_o() { - let runtime = RuntimeKeymap::defaults(); - assert_eq!(runtime.app.copy, vec![key_hint::ctrl(KeyCode::Char('o'))]); - } - #[test] fn permission_shortcuts_reserve_plain_text() { for binding in ["a", "shift-a", "2", "space"] { diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 99a6d97823..cff99b229b 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -103,6 +103,17 @@ pub(crate) struct KeymapActionId { } impl KeymapActionId { + /// Activity focus and Find remain active while transcript groups use list navigation. + pub(super) fn overlaps(self, other: Self) -> bool { + self.context.overlaps(other.context) + || (self.context == KeymapContext::Global + && matches!(self.action, "focus_activity" | "find_transcript") + && other.context == KeymapContext::List) + || (other.context == KeymapContext::Global + && matches!(other.action, "focus_activity" | "find_transcript") + && self.context == KeymapContext::List) + } + pub(crate) fn config_path(self) -> String { format!("tui.keymap.{}.{}", self.context.config_name(), self.action) } @@ -251,6 +262,8 @@ define_runtime_action_bindings! { "global" => Global, app, global [ open_agents, open_transcript, + find_transcript, + focus_activity, open_external_editor, copy, clear_terminal, @@ -380,6 +393,7 @@ define_runtime_action_bindings! { jump_bottom, close, close_transcript, + find, ], "list" => List, list, list [ move_up, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index f62e7d1cc2..ff758ff067 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -3,6 +3,7 @@ //! Single-event bindings remain in the ordinary runtime keymaps. A completed //! chord becomes an internal function-key token appended to the target action, //! so existing handlers remain the only action dispatch table. +//! Pending chords expire after one second or when their active context changes. use super::MAIN_RESERVED_BINDINGS; use super::RuntimeKeymap; @@ -25,6 +26,8 @@ use crossterm::event::KeyModifiers; use std::time::Duration; use tokio::time::Instant; +pub(crate) const KEY_CHORD_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1); + const FIRST_DISPATCH_FUNCTION_KEY: u8 = codex_config::types::MAX_FUNCTION_KEY + 1; const LAST_DISPATCH_FUNCTION_KEY: u8 = u8::MAX; const LIST_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ @@ -34,29 +37,38 @@ const LIST_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ ("resume_picker.toggle_density", ctrl(KeyCode::Char('o'))), ]; -/// Time allowed between the first and second strokes of a key chord. -pub(crate) const KEY_CHORD_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1); - /// Compact set of keymap contexts that share one active input path. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub(crate) struct KeymapContextSet(u16); +pub(crate) struct KeymapContextSet(u32); -const TRANSCRIPT_CLOSE: u16 = 1 << 15; +const ACTIVITY_FOCUS: u32 = 1 << 13; +const TRANSCRIPT_CLOSE: u32 = 1 << 15; impl KeymapContextSet { pub(crate) const fn new(context: KeymapContext) -> Self { Self(context_bit(context)) } - pub(crate) const fn with_transcript_close(self) -> Self { - Self(self.0 | TRANSCRIPT_CLOSE) + /// Activity navigation combines list actions with global focus and Find. + /// Other global actions remain inactive so existing list remaps keep priority. + pub(crate) const fn activity() -> Self { + Self(context_bit(KeymapContext::List) | context_bit(KeymapContext::Global) | ACTIVITY_FOCUS) } + /// Add only the detail-close action to an owned composer path. + pub(crate) const fn with_transcript_close(self) -> Self { + Self(self.0 | context_bit(KeymapContext::Pager) | TRANSCRIPT_CLOSE) + } + + /// Whether this input path can dispatch the action, including focus-specific exclusions. pub(crate) fn contains_action(self, action: KeymapActionId) -> bool { self.contains(action.context) - || (self.0 & TRANSCRIPT_CLOSE != 0 - && action.context == KeymapContext::Pager - && action.action == "close_transcript") + && (self.0 & ACTIVITY_FOCUS == 0 + || action.context != KeymapContext::Global + || matches!(action.action, "focus_activity" | "find_transcript")) + && (self.0 & TRANSCRIPT_CLOSE == 0 + || action.context != KeymapContext::Pager + || action.action == "close_transcript") } pub(crate) const fn with(self, context: KeymapContext) -> Self { @@ -68,7 +80,7 @@ impl KeymapContextSet { } } -const fn context_bit(context: KeymapContext) -> u16 { +const fn context_bit(context: KeymapContext) -> u32 { 1 << match context { KeymapContext::Global => 0, KeymapContext::Chat => 1, @@ -239,9 +251,9 @@ pub(crate) enum KeyChordMatch { #[derive(Clone, Copy, Debug)] struct PendingChord { + started_at: Instant, prefix: KeyBinding, contexts: KeymapContextSet, - started_at: Instant, } /// Tracks one pending two-stroke chord without buffering ordinary input. @@ -255,14 +267,65 @@ impl KeyChordMatcher { self.pending.is_some() } + /// Derive one presentation for the pending chord from the matcher that owns it. + pub(crate) fn pending_hint_items( + &self, + keymap: &RuntimeChordKeymap, + width: u16, + ) -> Option> { + let pending = self.pending?; + let mut items = vec![(pending.prefix.display_label(), "then".to_string())]; + for binding in keymap + .bindings + .iter() + .filter(|binding| { + binding.chord.prefix == pending.prefix + && pending.contexts.contains_action(binding.action) + }) + .take(/*n*/ 6) + { + let label = match binding.action.action { + "focus_activity" => "activity", + "find_transcript" => "find", + action => action, + }; + items.push(( + binding.chord.completion.display_label(), + label.replace('_', " "), + )); + } + items.push(("esc".to_string(), "cancel".to_string())); + if crate::bottom_pane::footer_hint_items_line(&items).width() > usize::from(width) { + let compact: Vec<_> = items[1..] + .iter() + .map(|(key, action)| { + let action = match action.as_str() { + "activity" => "inspect", + action => action, + }; + (key.clone(), action.to_string()) + }) + .collect(); + let mut fitting = Vec::new(); + for item in compact { + fitting.push(item); + if crate::bottom_pane::footer_hint_items_line(&fitting).width() > usize::from(width) + { + fitting.pop(); + } + } + return Some(fitting); + } + Some(items) + } + pub(crate) fn cancel(&mut self) -> bool { self.pending.take().is_some() } - pub(crate) fn expire(&mut self, contexts: KeymapContextSet, now: Instant) -> bool { + pub(crate) fn expire(&mut self, contexts: KeymapContextSet) -> bool { if self.pending.is_some_and(|pending| { - pending.contexts != contexts - || now.saturating_duration_since(pending.started_at) >= KEY_CHORD_TIMEOUT + pending.contexts != contexts || pending.started_at.elapsed() >= KEY_CHORD_TIMEOUT }) { self.pending = None; return true; @@ -275,13 +338,12 @@ impl KeyChordMatcher { key_event: KeyEvent, keymap: &RuntimeChordKeymap, contexts: KeymapContextSet, - now: Instant, ) -> KeyChordMatch { if is_dispatch_token_event(key_event) { return KeyChordMatch::Ignored; } - self.expire(contexts, now); + self.expire(contexts); if self.pending.is_some() && key_event.kind != KeyEventKind::Press { return KeyChordMatch::Ignored; @@ -325,9 +387,9 @@ impl KeyChordMatcher { .map(|binding| binding.chord.prefix) { self.pending = Some(PendingChord { + started_at: Instant::now(), prefix, contexts, - started_at: now, }); return KeyChordMatch::Pending(prefix); } @@ -436,7 +498,7 @@ pub(super) fn validate_chord_conflicts(keymap: &RuntimeKeymap) -> Result<(), Str validate_reserved_strokes(binding)?; if let Some(conflict) = runtime_action_bindings(keymap) - .filter(|candidate| binding.action.context.overlaps(candidate.id.context)) + .filter(|candidate| binding.action.overlaps(candidate.id)) .find(|candidate| { candidate.bindings.iter().any(|single| { normalize_chord_binding(*single).parts() == binding.chord.prefix.parts() @@ -454,7 +516,7 @@ Unbind or remap the existing shortcut before using it as a chord prefix.", for previous in &keymap.chords.bindings[..index] { if previous.action != binding.action - && previous.action.context.overlaps(binding.action.context) + && previous.action.overlaps(binding.action) && previous.chord == binding.chord { return Err(format!( diff --git a/codex-rs/tui/src/keymap/chords_tests.rs b/codex-rs/tui/src/keymap/chords_tests.rs index 588e7ee7d6..08adec1aa2 100644 --- a/codex-rs/tui/src/keymap/chords_tests.rs +++ b/codex-rs/tui/src/keymap/chords_tests.rs @@ -1,4 +1,3 @@ -use super::KEY_CHORD_TIMEOUT; use super::KeyChordMatch; use super::KeyChordMatcher; use super::KeymapContextSet; @@ -15,9 +14,9 @@ use codex_config::types::KeybindingsSpec; use codex_config::types::TuiKeymap; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; use std::collections::HashSet; -use tokio::time::Instant; fn binding(spec: &str) -> KeybindingsSpec { KeybindingsSpec::One(KeybindingSpec(spec.to_string())) @@ -122,12 +121,10 @@ fn default_vim_jump_top_uses_contextual_gg_chords() { let mut matcher = KeyChordMatcher::default(); let contexts = KeymapContextSet::new(context); assert!(matches!( - matcher.advance(g, &runtime.chords, contexts, Instant::now()), + matcher.advance(g, &runtime.chords, contexts), KeyChordMatch::Pending(_) )); - let KeyChordMatch::Completed(event) = - matcher.advance(g, &runtime.chords, contexts, Instant::now()) - else { + let KeyChordMatch::Completed(event) = matcher.advance(g, &runtime.chords, contexts) else { panic!("gg must dispatch the active Vim jump action"); }; assert!(target.is_pressed(event)); @@ -353,15 +350,13 @@ fn physical_dispatch_tokens_do_not_clear_pending_chords() { config.global.open_transcript = Some(binding("ctrl-x ctrl-t")); let keymap = RuntimeKeymap::from_config(&config).expect("valid chord keymap"); let contexts = KeymapContextSet::new(KeymapContext::Global); - let now = Instant::now(); let mut matcher = KeyChordMatcher::default(); assert!(matches!( matcher.advance( key_event(key_hint::ctrl(KeyCode::Char('x'))), &keymap.chords, - contexts, - now, + contexts ), KeyChordMatch::Pending(_) )); @@ -369,7 +364,7 @@ fn physical_dispatch_tokens_do_not_clear_pending_chords() { let action = keymap_action_id("global", "open_transcript").expect("known keymap action"); let token = dispatch_binding(action).expect("internal dispatch token"); assert_eq!( - matcher.advance(key_event(token), &keymap.chords, contexts, now), + matcher.advance(key_event(token), &keymap.chords, contexts), KeyChordMatch::Ignored ); assert!(matcher.is_pending()); @@ -377,8 +372,7 @@ fn physical_dispatch_tokens_do_not_clear_pending_chords() { matcher.advance( key_event(key_hint::ctrl(KeyCode::Char('t'))), &keymap.chords, - contexts, - now, + contexts ), KeyChordMatch::Completed(_) )); @@ -391,7 +385,6 @@ fn matching_chords_dispatches_the_active_context_action() { config.editor.move_line_start = Some(binding("ctrl-x home")); config.list.jump_top = Some(binding("ctrl-x home")); let runtime = RuntimeKeymap::from_config(&config).expect("non-overlapping chord contexts"); - let now = Instant::now(); for (context, prefix, completion, target, other) in [ ( @@ -418,15 +411,12 @@ fn matching_chords_dispatches_the_active_context_action() { ] { let mut matcher = KeyChordMatcher::default(); assert!(matches!( - matcher.advance(prefix, &runtime.chords, KeymapContextSet::new(context), now,), + matcher.advance(prefix, &runtime.chords, KeymapContextSet::new(context)), KeyChordMatch::Pending(_) )); - let KeyChordMatch::Completed(dispatch_event) = matcher.advance( - completion, - &runtime.chords, - KeymapContextSet::new(context), - now, - ) else { + let KeyChordMatch::Completed(dispatch_event) = + matcher.advance(completion, &runtime.chords, KeymapContextSet::new(context)) + else { panic!("second stroke must complete the active context chord"); }; assert!(target.is_pressed(dispatch_event)); @@ -434,45 +424,174 @@ fn matching_chords_dispatches_the_active_context_action() { } } -#[test] -fn matcher_cancels_on_escape_context_change_and_timeout() { +#[tokio::test(start_paused = true)] +async fn matcher_cancels_on_escape_context_change_and_timeout() { let mut config = TuiKeymap::default(); config.list.jump_top = Some(binding("ctrl-x ctrl-t")); let runtime = RuntimeKeymap::from_config(&config).expect("valid list chord"); let list = KeymapContextSet::new(KeymapContext::List); let pager = KeymapContextSet::new(KeymapContext::Pager); let prefix = key_event(key_hint::ctrl(KeyCode::Char('x'))); - let now = Instant::now(); let mut matcher = KeyChordMatcher::default(); assert!(matches!( - matcher.advance(prefix, &runtime.chords, list, now), + matcher.advance(prefix, &runtime.chords, list), KeyChordMatch::Pending(_) )); assert_eq!( matcher.advance( KeyEvent::new(KeyCode::Esc, crossterm::event::KeyModifiers::NONE), &runtime.chords, - list, - now, + list ), KeyChordMatch::Cancelled ); assert!(matches!( - matcher.advance(prefix, &runtime.chords, list, now), + matcher.advance(prefix, &runtime.chords, list), KeyChordMatch::Pending(_) )); - assert!(matcher.expire(pager, now)); + assert!(matcher.expire(pager)); + matcher.advance(prefix, &runtime.chords, list); + tokio::time::advance(super::KEY_CHORD_TIMEOUT).await; + assert_eq!( + matcher.advance( + key_event(key_hint::ctrl(KeyCode::Char('t'))), + &runtime.chords, + list + ), + KeyChordMatch::PassThrough, + ); +} +#[test] +fn context_transition_discards_prefix_before_routing_the_next_stroke() { + let mut config = TuiKeymap::default(); + config.list.jump_top = Some(binding("ctrl-x home")); + config.pager.jump_top = Some(binding("ctrl-x home")); + let runtime = + RuntimeKeymap::from_config(&config).expect("non-overlapping list and pager chords"); + let mut matcher = KeyChordMatcher::default(); assert!(matches!( - matcher.advance(prefix, &runtime.chords, list, now), + matcher.advance( + key_event(key_hint::ctrl(KeyCode::Char('x'))), + &runtime.chords, + KeymapContextSet::new(KeymapContext::List), + ), KeyChordMatch::Pending(_) )); - assert!(matcher.expire(list, now + KEY_CHORD_TIMEOUT)); + assert_eq!( + matcher.advance( + KeyCode::Home.into(), + &runtime.chords, + KeymapContextSet::new(KeymapContext::Pager), + ), + KeyChordMatch::PassThrough + ); + assert!(!matcher.is_pending()); } fn key_event(binding: crate::key_hint::KeyBinding) -> KeyEvent { let (code, modifiers) = binding.parts(); KeyEvent::new(code, modifiers) } + +#[test] +fn activity_context_preserves_list_chords_and_its_remapped_focus_toggle() { + let config = serde_json::from_value(serde_json::json!({ + "global": {"copy": "ctrl-x c", "focus_activity": "f12 t"}, + "list": {"jump_top": "ctrl-x c"}, + })) + .unwrap(); + let runtime = + RuntimeKeymap::from_config(&config).expect("existing disjoint contexts remain valid"); + for (contexts, prefix, completion, target, other) in [ + ( + KeymapContextSet::new(KeymapContext::Global), + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + KeyCode::Char('c'), + &runtime.app.copy, + &runtime.list.jump_top, + ), + ( + KeymapContextSet::activity(), + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + KeyCode::Char('c'), + &runtime.list.jump_top, + &runtime.app.copy, + ), + ( + KeymapContextSet::activity(), + KeyCode::F(12).into(), + KeyCode::Char('t'), + &runtime.app.focus_activity, + &runtime.list.jump_top, + ), + ] { + let mut matcher = KeyChordMatcher::default(); + assert!(matches!( + matcher.advance(prefix, &runtime.chords, contexts), + KeyChordMatch::Pending(_) + )); + let KeyChordMatch::Completed(event) = + matcher.advance(completion.into(), &runtime.chords, contexts) + else { + panic!("active action chord must complete"); + }; + assert!(target.is_pressed(event)); + assert!(!other.is_pressed(event)); + } + + let mut matcher = KeyChordMatcher::default(); + matcher.advance( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + &runtime.chords, + KeymapContextSet::activity(), + ); + assert_eq!( + matcher.pending_hint_items(&runtime.chords, /*width*/ 80), + Some(vec![ + ("ctrl+x".to_string(), "then".to_string()), + ("c".to_string(), "jump top".to_string()), + ("esc".to_string(), "cancel".to_string()), + ]), + ); + matcher.cancel(); + let combined = KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Global); + assert!(matches!( + matcher.advance(KeyCode::F(12).into(), &runtime.chords, combined), + KeyChordMatch::Pending(_) + )); + assert!(matcher.expire(KeymapContextSet::activity())); + assert!(!matcher.is_pending()); +} + +#[test] +fn pending_hint_skips_whole_oversized_configured_completions() { + let config = serde_json::from_value(serde_json::json!({"global": { + "copy": "ctrl-x ctrl-alt-shift-f12", "focus_activity": "ctrl-x alt-shift-f11" + }})) + .unwrap(); + let runtime = RuntimeKeymap::from_config(&config).unwrap(); + let mut matcher = KeyChordMatcher::default(); + matcher.advance( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + &runtime.chords, + KeymapContextSet::new(KeymapContext::Global), + ); + let mut scenes = Vec::new(); + for width in [80, 32, 12] { + let hints = matcher.pending_hint_items(&runtime.chords, width).unwrap(); + let line = crate::bottom_pane::footer_hint_items_line(&hints); + assert!(line.width() <= usize::from(width)); + scenes.push(format!("{width}: {line}")); + } + assert_eq!( + scenes, + [ + "80: ctrl+x then · shift+⌥+f11 activity · ctrl+shift+⌥+f12 copy · esc cancel", + "32: shift+⌥+f11 inspect · esc cancel", + "12: esc cancel", + ] + ); +} diff --git a/codex-rs/tui/src/keymap/conflict_tests.rs b/codex-rs/tui/src/keymap/conflict_tests.rs index 068d4a4300..b0962e57ae 100644 --- a/codex-rs/tui/src/keymap/conflict_tests.rs +++ b/codex-rs/tui/src/keymap/conflict_tests.rs @@ -80,3 +80,75 @@ fn new_agents_defaults_preserve_existing_custom_bindings() { } } } + +#[test] +fn activity_default_preserves_configured_keys_and_prefixes() { + use crate::key_hint; + use crossterm::event::KeyCode; + + for (context, action) in [ + ("global", "copy"), + ("editor", "move_left"), + ("list", "move_up"), + ] { + for configured in ["f4", "f4 f12"] { + let keymap: TuiKeymap = + serde_json::from_value(json!({context: {action: configured}})).unwrap(); + let runtime = RuntimeKeymap::from_config(&keymap) + .expect("a new activity default must preserve existing configuration"); + assert!( + !super::user_bindings(&runtime.app.focus_activity) + .contains(&key_hint::plain(KeyCode::F(4))) + ); + } + } +} + +#[test] +fn explicit_activity_remapping_and_unbinding_replace_all_defaults() { + use crate::key_hint; + use crossterm::event::KeyCode; + + for (configured, expected, hint) in [ + ( + json!("f12"), + vec![key_hint::plain(KeyCode::F(12))], + Some("f12"), + ), + (json!([]), Vec::new(), None), + (json!("ctrl-x t"), Vec::new(), Some("ctrl+x t")), + ] { + let keymap: TuiKeymap = + serde_json::from_value(json!({"global": {"focus_activity": configured}})).unwrap(); + let runtime = RuntimeKeymap::from_config(&keymap).expect("valid explicit activity binding"); + assert_eq!(super::user_bindings(&runtime.app.focus_activity), expected); + assert_eq!( + runtime + .primary_hint(super::KeymapContext::Global, "focus_activity") + .map(crate::key_hint::ShortcutHint::display_label), + hint.map(str::to_string), + ); + } +} + +#[test] +fn explicit_activity_bindings_cannot_shadow_list_navigation() { + for (focus, movement) in [ + ("f12", "f12"), + ("f12 t", "f12"), + ("f12", "f12 t"), + ("f12 t", "f12 t"), + ] { + let keymap: TuiKeymap = serde_json::from_value(json!({ + "global": {"focus_activity": focus}, + "list": {"move_up": movement}, + })) + .unwrap(); + let error = RuntimeKeymap::from_config(&keymap) + .expect_err("activity focus shares list navigation while inspecting groups"); + assert!( + error.contains("focus_activity") && error.contains("move_up"), + "{error}" + ); + } +} diff --git a/codex-rs/tui/src/keymap/global_find_tests.rs b/codex-rs/tui/src/keymap/global_find_tests.rs new file mode 100644 index 0000000000..14494e1322 --- /dev/null +++ b/codex-rs/tui/src/keymap/global_find_tests.rs @@ -0,0 +1,91 @@ +//! Global Find joins the existing chord inventory without replacing paging or user bindings. + +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn global_find_resolves_remaps_unbinding_and_dispatch_in_main_and_activity_contexts() { + for (configured, label) in [ + (json!("f12"), Some("f12")), + (json!([]), None), + (json!(["ctrl-x f", "f12"]), Some("ctrl+x f")), + ] { + let config = + serde_json::from_value(json!({"global":{"find_transcript":configured}})).unwrap(); + let runtime = RuntimeKeymap::from_config(&config).unwrap(); + assert_eq!( + runtime + .primary_hint(KeymapContext::Global, "find_transcript") + .map(crate::key_hint::ShortcutHint::display_label), + label.map(str::to_owned) + ); + assert!(!runtime.app.find_transcript.is_pressed(KeyCode::F(3).into())); + assert!(runtime.pager.find.is_pressed(KeyCode::F(3).into())); + assert!(runtime.pager.find.is_pressed(KeyCode::Char('/').into())); + assert!( + runtime + .pager + .page_down + .is_pressed(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)) + ); + } + let config = serde_json::from_value(json!({"global":{"find_transcript":"ctrl-x f"}})).unwrap(); + let runtime = RuntimeKeymap::from_config(&config).unwrap(); + for context in [ + KeymapContextSet::new(KeymapContext::Global), + KeymapContextSet::activity(), + ] { + let mut matcher = KeyChordMatcher::default(); + assert!(matches!( + matcher.advance( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + &runtime.chords, + context + ), + KeyChordMatch::Pending(_) + )); + let KeyChordMatch::Completed(key) = + matcher.advance(KeyCode::Char('f').into(), &runtime.chords, context) + else { + panic!("Find chord") + }; + assert!(runtime.app.find_transcript.is_pressed(key)); + } +} + +#[test] +fn new_find_defaults_yield_to_existing_keys_and_prefixes_but_explicit_conflicts_fail() { + for (context, action) in [ + ("global", "open_transcript"), + ("editor", "move_line_start"), + ("list", "move_up"), + ("approval", "approve"), + ] { + for binding in ["f3", "f3 x"] { + let config = serde_json::from_value(json!({context:{action:binding}})).unwrap(); + let runtime = RuntimeKeymap::from_config(&config) + .expect("existing binding wins over new default"); + assert!(!runtime.app.find_transcript.is_pressed(KeyCode::F(3).into())); + } + let config = serde_json::from_value( + json!({"global":{"find_transcript":"f12"},context:{action:"f12"}}), + ) + .unwrap(); + // Distinct JSON global keys would overwrite each other, so compose that case explicitly. + let config = if context == "global" { + serde_json::from_value( + json!({"global":{"find_transcript":"f12","open_transcript":"f12"}}), + ) + .unwrap() + } else { + config + }; + let error = RuntimeKeymap::from_config(&config) + .expect_err("explicit simultaneous bindings conflict"); + assert!( + error.contains("find_transcript") && error.contains(action), + "{error}" + ); + } +} diff --git a/codex-rs/tui/src/keymap/vim_search.rs b/codex-rs/tui/src/keymap/vim_search.rs index da9ca862cc..9386836153 100644 --- a/codex-rs/tui/src/keymap/vim_search.rs +++ b/codex-rs/tui/src/keymap/vim_search.rs @@ -1,5 +1,5 @@ -//! Search bindings shared by normal and operator-pending input. Existing explicit Vim -//! bindings win over new defaults; explicit search conflicts remain configuration errors. +//! Transcript and Vim search defaults yield to explicit bindings; explicit search conflicts +//! remain configuration errors. use super::bindings::configured_binding_for_action; use super::*; @@ -26,7 +26,51 @@ impl Default for VimSearchKeymap { } impl RuntimeKeymap { - pub(super) fn configure_vim_search(&mut self, config: &TuiKeymap) -> Result<(), String> { + pub(super) fn configure_search(&mut self, config: &TuiKeymap) -> Result<(), String> { + for context in [KeymapContext::Global, KeymapContext::Pager] { + let setting = match context { + KeymapContext::Global => &config.global.find_transcript, + _ => &config.pager.find, + }; + if setting.is_some() { + continue; + } + let find_action = bindings::KeymapActionId { + context, + action: if context == KeymapContext::Global { + "find_transcript" + } else { + "find" + }, + }; + let conflicts = |action: bindings::KeymapActionId| { + action.overlaps(find_action) + || context == KeymapContext::Global && action.context == KeymapContext::Approval + }; + let configured: Vec<_> = runtime_action_bindings(self) + .filter(|action| { + conflicts(action.id) + && bindings::configured_binding_for_action(config, action.id) + .is_some_and(Option::is_some) + }) + .flat_map(|action| action.bindings.iter().copied()) + .chain( + self.chords + .bindings + .iter() + .filter(|chord| conflicts(chord.action)) + .map(|chord| chord.chord.prefix), + ) + .collect(); + let bindings = match context { + KeymapContext::Global => &mut self.app.find_transcript, + _ => &mut self.pager.find, + }; + bindings.retain(|binding| { + let (code, modifiers) = binding.parts(); + !configured.is_pressed(KeyEvent::new(code, modifiers)) + }); + } let others: Vec<_> = runtime_action_bindings(self) .filter(|binding| { matches!( diff --git a/codex-rs/tui/src/keymap/voice_tests.rs b/codex-rs/tui/src/keymap/voice_tests.rs index 45797e3752..3fe76a9e9a 100644 --- a/codex-rs/tui/src/keymap/voice_tests.rs +++ b/codex-rs/tui/src/keymap/voice_tests.rs @@ -59,7 +59,6 @@ fn voice_mute_chord_dispatches_only_in_its_active_context() { let runtime = RuntimeKeymap::from_config(&config).unwrap(); let action = keymap_action_id("chat", "toggle_voice_mute").unwrap(); let mut matcher = KeyChordMatcher::default(); - let now = tokio::time::Instant::now(); let prefix = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL); let completion = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE); assert_eq!( @@ -67,17 +66,15 @@ fn voice_mute_chord_dispatches_only_in_its_active_context() { prefix, &runtime.chords, KeymapContextSet::new(KeymapContext::Pager), - now ), KeyChordMatch::PassThrough ); let contexts = KeymapContextSet::new(action.context); assert!(matches!( - matcher.advance(prefix, &runtime.chords, contexts, now), + matcher.advance(prefix, &runtime.chords, contexts), KeyChordMatch::Pending(_) )); - let KeyChordMatch::Completed(dispatch) = - matcher.advance(completion, &runtime.chords, contexts, now) + let KeyChordMatch::Completed(dispatch) = matcher.advance(completion, &runtime.chords, contexts) else { panic!("mute chord completes") }; @@ -140,27 +137,24 @@ fn voice_toggle_chord_dispatches_in_chat_without_an_active_voice_session() { let config = toml::from_str::("[chat]\ntoggle_voice = 'f8 v'").unwrap(); let runtime = RuntimeKeymap::from_config(&config).unwrap(); let mut matcher = KeyChordMatcher::default(); - let now = tokio::time::Instant::now(); let prefix = KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE); assert_eq!( matcher.advance( prefix, &runtime.chords, KeymapContextSet::new(KeymapContext::Pager), - now ), KeyChordMatch::PassThrough ); let contexts = KeymapContextSet::new(KeymapContext::Chat); assert!(matches!( - matcher.advance(prefix, &runtime.chords, contexts, now), + matcher.advance(prefix, &runtime.chords, contexts), KeyChordMatch::Pending(_) )); let KeyChordMatch::Completed(dispatch) = matcher.advance( KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE), &runtime.chords, contexts, - now, ) else { panic!("voice toggle chord completes") }; diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index adc4e946fd..d906ee6353 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -1493,7 +1493,7 @@ mod tests { let mut view = build_keymap_debug_view(&RuntimeKeymap::defaults(), &TuiKeymap::default()); view.show_delayed_hint_for_test(); - view.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + view.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); let rendered = render_debug(&view, /*width*/ 100); assert!(!rendered.contains("Still waiting?")); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 04a8590ed7..2579499e2a 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -86,6 +86,8 @@ impl KeymapActionDescriptor { pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("global", "Global", "open_agents", "Open the shared agent-session overview."), action("global", "Global", "open_transcript", "Open the transcript overlay."), + action("global", "Global", "find_transcript", "Find text in the full transcript."), + action("global", "Global", "focus_activity", "Focus transcript activity groups to expand or collapse their details."), action("global", "Global", "open_external_editor", "Open the current draft in an external editor."), action("global", "Global", "copy", "Copy the last agent response to the clipboard."), action("global", "Global", "clear_terminal", "Clear the terminal UI."), @@ -204,6 +206,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("pager", "Pager", "jump_bottom", "Jump to the end."), action("pager", "Pager", "close", "Close the pager overlay."), action("pager", "Pager", "close_transcript", "Close the transcript overlay."), + action("pager", "Pager", "find", "Find text in the transcript overlay."), action("list", "List", "move_up", "Move list selection up."), action("list", "List", "move_down", "Move list selection down."), action("list", "List", "move_left", "Move horizontally left in list pickers."), @@ -268,6 +271,8 @@ pub(super) fn binding_slot<'a>( match (context, action) { ("global", "open_agents") => Some(&mut keymap.global.open_agents), ("global", "open_transcript") => Some(&mut keymap.global.open_transcript), + ("global", "find_transcript") => Some(&mut keymap.global.find_transcript), + ("global", "focus_activity") => Some(&mut keymap.global.focus_activity), ("global", "open_external_editor") => Some(&mut keymap.global.open_external_editor), ("global", "copy") => Some(&mut keymap.global.copy), ("global", "clear_terminal") => Some(&mut keymap.global.clear_terminal), @@ -386,6 +391,7 @@ pub(super) fn binding_slot<'a>( ("pager", "jump_bottom") => Some(&mut keymap.pager.jump_bottom), ("pager", "close") => Some(&mut keymap.pager.close), ("pager", "close_transcript") => Some(&mut keymap.pager.close_transcript), + ("pager", "find") => Some(&mut keymap.pager.find), ("list", "move_up") => Some(&mut keymap.list.move_up), ("list", "move_down") => Some(&mut keymap.list.move_down), ("list", "move_left") => Some(&mut keymap.list.move_left), 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 index ea9c917d17..475176c2fd 100644 --- 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 @@ -11,4 +11,4 @@ last Ctrl+Space select ↑/↓ to scroll · pgup/pgdn to page · hom - q close · esc edit previous + q close · f3 find · 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 index 8fbbcb3744..03eb2fe078 100644 --- 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 @@ -23,7 +23,7 @@ recent transcript Earlier messages available. Ctrl+Space select ↑/↓ to scroll · pgup/pgdn to page · home/end to jump - q close · esc edit previous + q close · f3 find · esc edit previous --- failed --- / T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / @@ -45,4 +45,4 @@ recent transcript Ctrl+Space select ↑/↓ to scroll · pgup/pgdn to page · home/end to jump - q close · esc edit previous + q close · f3 find · esc edit previous diff --git a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_paginated_history_states_non_macos.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_paginated_history_states_non_macos.snap index 9d4aeebd19..472a7b267d 100644 --- a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_paginated_history_states_non_macos.snap +++ b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_paginated_history_states_non_macos.snap @@ -23,7 +23,7 @@ recent transcript Earlier messages available. Ctrl+Space select ↑/↓ to scroll · pgup/pgdn to page · home/end to jump - q close · esc edit previous + q close · f3 find · esc edit previous --- failed --- / T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / @@ -45,4 +45,4 @@ recent transcript Ctrl+Space select ↑/↓ to scroll · pgup/pgdn to page · home/end to jump - q close · esc edit previous + q close · f3 find · esc edit previous diff --git a/codex-rs/tui/src/pager_overlay/transcript.rs b/codex-rs/tui/src/pager_overlay/transcript.rs index cf990867ca..70b9733259 100644 --- a/codex-rs/tui/src/pager_overlay/transcript.rs +++ b/codex-rs/tui/src/pager_overlay/transcript.rs @@ -7,6 +7,7 @@ use super::*; use crate::history_cell::HistoryRenderMode; use crate::history_cell::SessionHeaderHistoryCell; use crate::history_cell::SessionInfoCell; +use crate::keymap::RuntimeKeymap; use crate::motion::MotionMode; use crate::transcript_view::TranscriptView; use crate::transcript_view::ViewAction; @@ -23,6 +24,7 @@ pub(crate) struct TranscriptOverlay { content_area: Rect, cursor: Option<(u16, u16)>, notice: Option, + pub(crate) key_chord_hint: Option>, is_done: bool, } @@ -40,6 +42,7 @@ impl TranscriptOverlay { content_area: Rect::default(), cursor: None, notice: None, + key_chord_hint: None, is_done: false, } } @@ -89,7 +92,12 @@ impl TranscriptOverlay { .map(|column| (status.x + column, status.y)); if let Some(notice) = &self.notice { let notice = Line::from(notice.clone()).dim(); - footer.text = notice.into(); + if self.view.is_search_active() { + footer.text.lines.truncate(/*len*/ 1); + footer.text.lines.push(notice); + } else { + footer.text = notice.into(); + } } Paragraph::new(footer.text).render( Rect::new(status.x, status.y, status.width, /*height*/ 3).intersection(area), @@ -105,17 +113,25 @@ impl TranscriptOverlay { } self.render_hints(hints, buf); } + if let Some(items) = &self.key_chord_hint { + let row = Rect::new(hints.x, hints.y, hints.width, 1).intersection(area); + Clear.render(row, buf); + crate::bottom_pane::footer_hint_items_line(items).render(row, buf); + } } pub(crate) fn draw(&mut self, tui: &mut tui::Tui) -> Result<()> { + let mut scanning = false; tui.draw(u16::MAX, |frame| { + self.view.prepare_width(frame.area().width); + scanning = self.view.advance_search(&self.cells); 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 { + if scanning || loading { tui.frame_requester() .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); } @@ -145,11 +161,14 @@ impl TranscriptOverlay { } self.view.handle_mouse(mouse, &self.cells) } - TuiEvent::Paste(_) => None, + TuiEvent::Paste(text) => { + if self.view.is_search_active() { + self.view.end_selection(&self.cells); + } + self.view.paste_search(&text).then_some(ViewAction::Changed) + } TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::FocusGained | TuiEvent::Resume => { - let dragging = - matches!(event, TuiEvent::Draw) && self.view.tick_selection(&self.cells); - if dragging { + if matches!(event, TuiEvent::Draw) && self.view.tick_selection(&self.cells) { tui.frame_requester() .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); } @@ -176,6 +195,18 @@ impl TranscriptOverlay { self.view.is_following() } + pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { + self.view.set_keymap_bindings(keymap); + } + + pub(crate) fn begin_search(&mut self) { + self.view.begin_search(); + } + + pub(crate) fn is_search_active(&self) -> bool { + self.view.is_search_active() + } + pub(crate) fn owns_interaction_key(&self, key: KeyEvent) -> bool { self.view.owns_interaction_key(key) } @@ -252,6 +283,7 @@ impl TranscriptOverlay { pub(crate) fn replace_cells(&mut self, cells: Vec>) { self.cells = cells; + self.view.restart_search(); self.view.history_loaded(&self.cells, 0..0); self.highlight_cell = self .highlight_cell @@ -326,6 +358,13 @@ impl TranscriptOverlay { if key.kind == KeyEventKind::Release { return None; } + if !self.view.is_search_active() + && (!self.view.has_active_interaction() || !self.view.owns_interaction_key(key)) + && self.keymap.find.is_pressed(key) + { + self.view.begin_search(); + return Some(ViewAction::Changed); + } if self.view.has_active_interaction() && let Some(action) = self.view.handle_key(key, &self.cells) { @@ -338,7 +377,6 @@ impl TranscriptOverlay { if self.navigate(key) { return Some(ViewAction::Changed); } - self.view.handle_key(key, &self.cells) } @@ -411,6 +449,12 @@ impl TranscriptOverlay { first_or_empty(&self.keymap, "close", &self.keymap.close), "close", )]; + if !self.keymap.find.is_empty() { + pairs.push(( + first_or_empty(&self.keymap, "find", &self.keymap.find), + "find", + )); + } pairs.push((vec![key_hint::plain(KeyCode::Esc).into()], "edit previous")); if self.highlight_cell.is_some() { pairs.push((vec![key_hint::plain(KeyCode::Right).into()], "to edit next")); diff --git a/codex-rs/tui/src/pager_overlay/transcript_tests.rs b/codex-rs/tui/src/pager_overlay/transcript_tests.rs index 855ae23bbe..97a0a6a700 100644 --- a/codex-rs/tui/src/pager_overlay/transcript_tests.rs +++ b/codex-rs/tui/src/pager_overlay/transcript_tests.rs @@ -246,6 +246,54 @@ async fn focus_loss_stops_edge_drag_and_preserves_selection_after_focus_returns( Ok(()) } +#[tokio::test] +async fn opening_find_retains_the_selected_mutable_revision() -> Result<()> { + let cell = history_cell::DynamicToolCallCell::from_item( + codex_app_server_protocol::ThreadItem::DynamicToolCall { + id: "tool".into(), + namespace: None, + tool: "inspect".into(), + arguments: serde_json::json!({}), + status: codex_app_server_protocol::DynamicToolCallStatus::InProgress, + content_items: None, + success: None, + duration_ms: None, + }, + ) + .unwrap(); + let mut overlay = transcript_overlay(vec![Arc::new(cell.clone())]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 12, + ); + let mut before = Buffer::empty(area); + overlay.render(area, &mut before); + let mut tui = crate::tui::test_support::make_test_tui()?; + overlay.handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)), + )?; + cell.mark_interrupted(); + overlay.handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)), + )?; + assert!(overlay.view.is_search_active()); + overlay.handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + )?; + let mut after = Buffer::empty(area); + overlay.render(area, &mut after); + let content = Rect::new( + /*x*/ 0, /*y*/ 1, /*width*/ 60, /*height*/ 7, + ); + assert_eq!( + buffer_to_text(&after, content), + buffer_to_text(&before, content) + ); + Ok(()) +} + fn buffer_to_text(buf: &Buffer, area: Rect) -> String { let mut out = String::new(); for y in area.y..area.bottom() { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 648938eefd..1dabd0128e 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -11,9 +11,6 @@ use crate::color::is_light; use crate::key_hint::KeyBindingListExt; use crate::key_hint::is_plain_text_key_event; use crate::keymap::ListAction; -use crate::keymap::ListKeymap; -use crate::keymap::PagerKeymap; -use crate::keymap::RuntimeChordKeymap; use crate::keymap::RuntimeKeymap; use crate::legacy_core::config::Config; use crate::legacy_core::config::edit::ConfigEditsBuilder; @@ -346,10 +343,8 @@ struct SessionPickerRunOptions { provider_filter: ProviderFilter, initial_density: SessionListDensity, view_persistence: Option, - pager_keymap: PagerKeymap, - list_keymap: ListKeymap, + keymap: RuntimeKeymap, initial_page_mode: PageLoadMode, - chord_keymap: Arc, } /// Interactive session picker that lists app-server threads with simple search, @@ -462,14 +457,12 @@ async fn run_resume_picker_with_launch_context( view_persistence: Some(SessionPickerViewPersistence { codex_home: local_settings.codex_home.to_path_buf(), }), - pager_keymap: runtime_keymap.pager, - list_keymap: runtime_keymap.list, + keymap: runtime_keymap, initial_page_mode: if uses_remote_workspace { PageLoadMode::StoreDefault } else { PageLoadMode::StateDbOnly }, - chord_keymap: runtime_keymap.chords, }; run_session_picker_with_loader( tui, @@ -523,14 +516,12 @@ pub async fn run_fork_picker_with_app_server( view_persistence: Some(SessionPickerViewPersistence { codex_home: local_settings.codex_home.to_path_buf(), }), - pager_keymap: runtime_keymap.pager, - list_keymap: runtime_keymap.list, + keymap: runtime_keymap, initial_page_mode: if uses_remote_workspace { PageLoadMode::StoreDefault } else { PageLoadMode::StateDbOnly }, - chord_keymap: runtime_keymap.chords, }; run_session_picker_with_loader( tui, @@ -569,9 +560,7 @@ async fn run_session_picker_with_loader( state.worktrees_enabled = options.worktrees_enabled; state.density = options.initial_density; state.view_persistence = options.view_persistence; - state.pager_keymap = options.pager_keymap; - state.list_keymap = options.list_keymap; - state.chord_keymap = options.chord_keymap; + state.keymap = options.keymap; state.launch_context = options.launch_context; state.initial_page_mode = options.initial_page_mode; state.start_initial_load(); @@ -877,10 +866,8 @@ struct PickerState { pending_transcript_cancellation: Option>, transcript_loading_frame_shown: bool, overlay: Option, - pager_keymap: PagerKeymap, - list_keymap: ListKeymap, + keymap: RuntimeKeymap, initial_page_mode: PageLoadMode, - chord_keymap: Arc, chord_matcher: crate::keymap::KeyChordMatcher, } @@ -1077,10 +1064,8 @@ impl PickerState { pending_transcript_cancellation: None, transcript_loading_frame_shown: false, overlay: None, - pager_keymap: RuntimeKeymap::defaults().pager, - list_keymap: RuntimeKeymap::defaults().list, + keymap: RuntimeKeymap::defaults(), initial_page_mode: PageLoadMode::StoreDefault, - chord_keymap: Arc::default(), chord_matcher: crate::keymap::KeyChordMatcher::default(), } } @@ -1091,16 +1076,18 @@ impl PickerState { self.chord_matcher.cancel(); return Some(key); } - let context = if self.overlay.is_some() { + let context = if matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.is_search_active()) + { + crate::keymap::KeymapContext::Editor + } else if self.overlay.is_some() { crate::keymap::KeymapContext::Pager } else { crate::keymap::KeymapContext::List }; match self.chord_matcher.advance( key, - &self.chord_keymap, + &self.keymap.chords, crate::keymap::KeymapContextSet::new(context), - tokio::time::Instant::now(), ) { crate::keymap::KeyChordMatch::PassThrough => Some(key), crate::keymap::KeyChordMatch::Completed(dispatch_event) => Some(dispatch_event), @@ -1138,10 +1125,11 @@ impl PickerState { else { return; }; - self.overlay = Some(Overlay::new_transcript( - cells.clone(), - self.pager_keymap.clone(), - )); + let mut overlay = Overlay::new_transcript(cells.clone(), self.keymap.pager.clone()); + if let Overlay::Transcript(view) = &mut overlay { + view.set_keymap_bindings(&self.keymap); + } + self.overlay = Some(overlay); self.pending_transcript_open = None; self.transcript_loading_frame_shown = false; self.request_frame(); @@ -1203,7 +1191,7 @@ impl PickerState { modifiers, .. } if modifiers.contains(KeyModifiers::CONTROL) => Some(SessionSelection::Exit), - key if self.list_keymap.cancel.is_pressed(key) => { + key if self.keymap.list.cancel.is_pressed(key) => { if let Some(thread_id) = self.pending_transcript_open.take() && matches!( self.transcript_cells.get(&thread_id), @@ -1228,7 +1216,7 @@ impl PickerState { if self.is_transcript_loading() { return Ok(self.handle_transcript_loading_key(key)); } - if !self.list_keymap.page_down.is_pressed(key) { + if !self.keymap.list.page_down.is_pressed(key) { self.pending_page_down_target = None; } // The session picker is always searchable, so plain text belongs to @@ -1243,7 +1231,7 @@ impl PickerState { } if modifiers.contains(KeyModifiers::CONTROL) => { return Ok(Some(SessionSelection::Exit)); } - _ if self.list_keymap.cancel.is_pressed(key) => { + _ if self.keymap.list.cancel.is_pressed(key) => { if self.query.is_empty() { return Ok(Some(SessionSelection::StartFresh)); } @@ -1291,9 +1279,9 @@ impl PickerState { } /* ^O */ => { self.toggle_density().await; } - _ if self.list_keymap.accept.is_pressed(key) + _ if self.keymap.list.accept.is_pressed(key) && !matches!(self.archive_state, archive::ArchiveState::Idle) => {} - _ if self.list_keymap.accept.is_pressed(key) => { + _ if self.keymap.list.accept.is_pressed(key) => { if let Some(row) = self.filtered_rows.get(self.selected) { let path = row.path.clone(); if let Some(thread_id) = row.thread_id { @@ -1319,14 +1307,14 @@ impl PickerState { self.request_frame(); } } - _ if allow_plain_char_navigation && self.list_keymap.move_up.is_pressed(key) => { + _ if allow_plain_char_navigation && self.keymap.list.move_up.is_pressed(key) => { if self.selected > 0 { self.selected -= 1; self.ensure_selected_visible(); } self.request_frame(); } - _ if allow_plain_char_navigation && self.list_keymap.move_down.is_pressed(key) => { + _ if allow_plain_char_navigation && self.keymap.list.move_down.is_pressed(key) => { if self.selected + 1 < self.filtered_rows.len() { self.selected += 1; self.ensure_selected_visible(); @@ -1334,7 +1322,7 @@ impl PickerState { self.maybe_load_more_for_scroll(); self.request_frame(); } - _ if allow_plain_char_navigation && self.list_keymap.page_up.is_pressed(key) => { + _ if allow_plain_char_navigation && self.keymap.list.page_up.is_pressed(key) => { let step = self.view_rows.unwrap_or(10).max(1); if self.selected > 0 { self.selected = self.selected.saturating_sub(step); @@ -1342,20 +1330,20 @@ impl PickerState { self.request_frame(); } } - _ if allow_plain_char_navigation && self.list_keymap.jump_top.is_pressed(key) + _ if allow_plain_char_navigation && self.keymap.list.jump_top.is_pressed(key) && !self.filtered_rows.is_empty() => { self.selected = 0; self.ensure_selected_visible(); self.request_frame(); } - _ if allow_plain_char_navigation && self.list_keymap.jump_bottom.is_pressed(key) + _ if allow_plain_char_navigation && self.keymap.list.jump_bottom.is_pressed(key) && !self.filtered_rows.is_empty() => { self.selected = self.filtered_rows.len().saturating_sub(1); self.ensure_selected_visible(); self.maybe_load_more_for_scroll(); self.request_frame(); } - _ if allow_plain_char_navigation && self.list_keymap.page_down.is_pressed(key) + _ if allow_plain_char_navigation && self.keymap.list.page_down.is_pressed(key) && !self.filtered_rows.is_empty() => { let step = self.view_rows.unwrap_or(10).max(1); let target = self.selected.saturating_add(step); @@ -1384,8 +1372,8 @@ impl PickerState { self.request_frame(); } _ if allow_plain_char_navigation - && (self.list_keymap.move_left.is_pressed(key) - || self.list_keymap.move_right.is_pressed(key)) => + && (self.keymap.list.move_left.is_pressed(key) + || self.keymap.list.move_right.is_pressed(key)) => { self.change_focused_toolbar_value(); self.request_frame(); @@ -2449,7 +2437,7 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { SessionListDensity::Dense => "comfy", }; let mut first_row_hints = Vec::new(); - if let Some(accept) = state.list_keymap.primary_hint(ListAction::Accept) { + if let Some(accept) = state.keymap.list.primary_hint(ListAction::Accept) { first_row_hints.push(PickerFooterHint { key: accept.display_label(), wide_label: action_label.to_string(), @@ -2465,7 +2453,7 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { priority: 2, }); } - if let Some(cancel) = state.list_keymap.primary_hint(ListAction::Cancel) { + if let Some(cancel) = state.keymap.list.primary_hint(ListAction::Cancel) { first_row_hints.push(PickerFooterHint { key: cancel.display_label(), wide_label: esc_label.to_string(), @@ -2489,7 +2477,7 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { ]); let option_keys = [ListAction::MoveLeft, ListAction::MoveRight] .into_iter() - .filter_map(|action| state.list_keymap.primary_hint(action)) + .filter_map(|action| state.keymap.list.primary_hint(action)) .map(super::key_hint::ShortcutHint::display_label) .collect::>() .join("/"); @@ -2523,7 +2511,7 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { ]; let browse_keys = [ListAction::MoveUp, ListAction::MoveDown] .into_iter() - .filter_map(|action| state.list_keymap.primary_hint(action)) + .filter_map(|action| state.keymap.list.primary_hint(action)) .map(super::key_hint::ShortcutHint::display_label) .collect::>() .join("/"); @@ -4547,15 +4535,15 @@ mod tests { assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+o dense view")); assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+t transcript")); assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+e expand")); - state.list_keymap.move_left = vec![crate::key_hint::ctrl(KeyCode::Char('h'))]; - state.list_keymap.move_right = vec![crate::key_hint::ctrl(KeyCode::Char('l'))]; + state.keymap.list.move_left = vec![crate::key_hint::ctrl(KeyCode::Char('h'))]; + state.keymap.list.move_right = vec![crate::key_hint::ctrl(KeyCode::Char('l'))]; let remapped_footer = footer_lines_text(&state, /*width*/ 220); assert!( remapped_footer.contains("ctrl+h/ctrl+l change option"), "{remapped_footer}" ); - state.list_keymap.move_left.clear(); - state.list_keymap.move_right.clear(); + state.keymap.list.move_left.clear(); + state.keymap.list.move_right.clear(); assert!(!footer_lines_text(&state, /*width*/ 220).contains("change option")); state.density = SessionListDensity::Dense; @@ -5156,6 +5144,14 @@ mod tests { /*filter_cwd*/ None, SessionPickerAction::Resume, ); + state.keymap = RuntimeKeymap::from_config( + &serde_json::from_value(serde_json::json!({ + "pager": {"scroll_up": ["ctrl-space x", "ctrl-x ctrl-space"]}, + "editor": {"move_line_start": ["alt-a", "ctrl-x h"]} + })) + .unwrap(), + ) + .unwrap(); state.pending_transcript_open = Some(thread_id); let cells: TranscriptCells = vec![Arc::new(PlainHistoryCell::new(vec!["transcript".into()]))]; @@ -5180,14 +5176,6 @@ 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!( @@ -5197,6 +5185,54 @@ mod tests { assert!(state.chord_matcher.is_pending()); assert_eq!(state.route_key_chord(select), Some(select)); assert!(!state.chord_matcher.is_pending()); + let mut tui = crate::tui::test_support::make_test_tui().expect("tui"); + state + .handle_overlay_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)), + ) + .unwrap(); + state + .handle_overlay_event(&mut tui, TuiEvent::Paste("tail".into())) + .unwrap(); + for (keys, insertion) in [ + ( + vec![KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT)], + "A", + ), + ( + vec![ + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + ], + "B", + ), + ] { + for key in keys { + if let Some(key) = state.route_key_chord(key) { + state + .handle_overlay_event(&mut tui, TuiEvent::Key(key)) + .unwrap(); + } + } + state + .handle_overlay_event(&mut tui, TuiEvent::Paste(insertion.into())) + .unwrap(); + } + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 12, + ); + let mut buffer = ratatui::buffer::Buffer::empty(area); + let Some(Overlay::Transcript(overlay)) = &mut state.overlay else { + panic!("transcript preview"); + }; + overlay.render(area, &mut buffer); + let text = buffer + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert!(text.contains("BAtail"), "Find query: {text}"); } #[tokio::test] @@ -6367,10 +6403,10 @@ session_picker_view = "dense" /*filter_cwd*/ None, SessionPickerAction::Resume, ); - state.list_keymap.page_down = vec![crate::key_hint::ctrl(KeyCode::Char('d'))]; - state.list_keymap.page_up = vec![crate::key_hint::ctrl(KeyCode::Char('u'))]; - state.list_keymap.jump_bottom = vec![crate::key_hint::ctrl(KeyCode::Char('y'))]; - state.list_keymap.jump_top = vec![crate::key_hint::ctrl(KeyCode::Char('a'))]; + state.keymap.list.page_down = vec![crate::key_hint::ctrl(KeyCode::Char('d'))]; + state.keymap.list.page_up = vec![crate::key_hint::ctrl(KeyCode::Char('u'))]; + state.keymap.list.jump_bottom = vec![crate::key_hint::ctrl(KeyCode::Char('y'))]; + state.keymap.list.jump_top = vec![crate::key_hint::ctrl(KeyCode::Char('a'))]; let mut items = Vec::new(); for idx in 0..20 { @@ -6429,7 +6465,7 @@ session_picker_view = "dense" /*filter_cwd*/ None, SessionPickerAction::Resume, ); - state.list_keymap.cancel = vec![crate::key_hint::ctrl(KeyCode::Char('c'))]; + state.keymap.list.cancel = vec![crate::key_hint::ctrl(KeyCode::Char('c'))]; let selection = state .handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)) diff --git a/codex-rs/tui/src/resume_picker/archive.rs b/codex-rs/tui/src/resume_picker/archive.rs index 41f0659290..875e74b192 100644 --- a/codex-rs/tui/src/resume_picker/archive.rs +++ b/codex-rs/tui/src/resume_picker/archive.rs @@ -36,8 +36,8 @@ impl PickerState { } let archive_key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL); - self.list_keymap.action_for(archive_key).is_none() - && !self.chord_keymap.bindings.iter().any(|binding| { + self.keymap.list.action_for(archive_key).is_none() + && !self.keymap.chords.bindings.iter().any(|binding| { binding.action.context == KeymapContext::List && binding.chord.prefix.is_press(archive_key) }) diff --git a/codex-rs/tui/src/resume_picker/archive_tests.rs b/codex-rs/tui/src/resume_picker/archive_tests.rs index 5923f50152..5593cb3db7 100644 --- a/codex-rs/tui/src/resume_picker/archive_tests.rs +++ b/codex-rs/tui/src/resume_picker/archive_tests.rs @@ -139,7 +139,7 @@ fn archive_failure_preserves_session_and_reports_server_error() { #[test] fn archive_shortcut_preserves_configured_list_binding() { let (mut state, _requests) = archive_picker_state(); - state.list_keymap.move_up.push(KeyBinding::new( + state.keymap.list.move_up.push(KeyBinding::new( KeyCode::Char('\u{0001}'), KeyModifiers::NONE, )); 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 102ed9f8c2..4e86df2a47 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 @@ -14,7 +14,7 @@ before: Ctrl+Space select ↑/↓ to scroll · pgup/pgdn to page · home/end to jump - q close · esc edit previous + q close · f3 find · esc edit previous after: @@ -28,4 +28,4 @@ after: Ctrl+Space select ↑/↓ to scroll · pgup/pgdn to page · home/end to jump - q close · esc edit previous + q close · f3 find · esc edit previous diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap index 476ec82427..4d9554e304 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap @@ -6,9 +6,9 @@ Keypress Inspector Press any key to see what Codex receives. Esc is inspected; Ctrl+C closes. Tip: Codex can only inspect keys your terminal sends. -Detected: ctrl+o -Config key: ctrl-o -Raw event: code=Char('o'), modifiers=ctrl, kind=Press +Detected: f8 +Config key: f8 +Raw event: code=F(8), modifiers=none, kind=Press Assigned actions: - - global.copy (Copy) - Copy the last agent response to the clipboard. [Default] + - chat.toggle_voice (Toggle Voice) - Start or stop a voice conversation. [Default] diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap index 0a6af7a34e..a755a71364 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -4,6 +4,8 @@ expression: snapshot --- Open Agents | unbound | Global open_agents Open Agents Open the shared agent-session overview. unbound Default Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default +Find Transcript | f3 | Global find_transcript Find Transcript Find text in the full transcript. f3 Default +Focus Activity | f4 | Global focus_activity Focus Activity Focus transcript activity groups to expand or collapse their details. f4 Default Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default @@ -12,5 +14,3 @@ Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle ra Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side Conversation Switch between a side conversation and its parent. ctrl-/ Default Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default -Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default -Previous Permission Mode | unbound | Chat previous_permission_mode Previous Permission Mode Switch to the previous available permission mode. unbound Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index 860169dfd9..6297777d7d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 147 actions, 1 customized, 5 unbound. + 150 actions, 1 customized, 5 unbound. All Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval › @@ -13,6 +13,8 @@ expression: "render_picker(params, 120)" › Global - Open Agents unbound Global Open Transcript ctrl-t + Global Find Transcript f3 + Global Focus Activity f4 Global Open External Editor ctrl-g Global Copy ctrl-o Global Clear Terminal ctrl-l @@ -33,7 +35,5 @@ expression: "render_picker(params, 120)" Composer Queue tab Composer Toggle Shortcuts ?, shift-? Composer History Search Previous ctrl-r - Composer History Search Next ctrl-s - Editor Insert Newline ctrl-j, ctrl-m, enter, shift-enter, alt-enter ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index edbf20a7cd..068133af8b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 148 actions, 0 customized, 6 unbound. + 151 actions, 0 customized, 6 unbound. All Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval › @@ -13,6 +13,8 @@ expression: "render_picker(params, 120)" › Global - Open Agents unbound Global Open Transcript ctrl-t + Global Find Transcript f3 + Global Focus Activity f4 Global Open External Editor ctrl-g Global Copy ctrl-o Global Clear Terminal ctrl-l @@ -33,7 +35,5 @@ expression: "render_picker(params, 120)" Composer Submit enter Composer Queue tab Composer Toggle Shortcuts ?, shift-? - Composer History Search Previous ctrl-r - Composer History Search Next ctrl-s ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 3f190d0405..9b9e267b4b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,20 +2,22 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (147 selectable) +tab: All (150 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (5) (5 selectable) -tab: App (18 selectable) +tab: App (20 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (69 selectable) -tab: Navigation (20 selectable) +tab: Navigation (21 selectable) tab: Agents (10 selectable) tab: Approval (8 selectable) tab: Debug (1 selectable) Open Agents | unbound | Global open_agents Open Agents Open the shared agent-session overview. unbound Default Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default +Find Transcript | f3 | Global find_transcript Find Transcript Find text in the full transcript. f3 Default +Focus Activity | f4 | Global focus_activity Focus Activity Focus transcript activity groups to expand or collapse their details. f4 Default Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default @@ -25,5 +27,3 @@ Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default -Previous Permission Mode | unbound | Chat previous_permission_mode Previous Permission Mode Switch to the previous available permission mode. unbound Default -Next Permission Mode | unbound | Chat next_permission_mode Next Permission Mode Switch to the next available permission mode. unbound Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap index 5fd043dec5..d994c06c26 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 147 actions, 1 customized, 6 unbound. + 150 actions, 1 customized, 6 unbound. All Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval › @@ -13,6 +13,8 @@ expression: "render_picker(params, 120)" › Global - Open Agents unbound Global * Open Transcript ctrl-t, ctrl-x ctrl-t + Global Find Transcript f3 + Global Focus Activity f4 Global Open External Editor ctrl-g Global Copy ctrl-o Global Clear Terminal ctrl-l @@ -33,7 +35,5 @@ expression: "render_picker(params, 120)" Composer Queue tab Composer Toggle Shortcuts ?, shift-? Composer History Search Previous ctrl-r - Composer History Search Next ctrl-s - Editor Insert Newline ctrl-j, ctrl-m, enter, shift-enter, alt-enter ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index df8f1966d4..13557c6377 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 147 actions, 0 customized, 5 unbound. + 150 actions, 0 customized, 5 unbound. All Common Customized (0) Unbound (5) App Composer Editor › @@ -13,6 +13,8 @@ expression: "render_picker(params, 78)" › Global - Open Agents unbound Global Open Transcript ctrl-t + Global Find Transcript f3 + Global Focus Activity f4 Global Open External Editor ctrl-g Global Copy ctrl-o Global Clear Terminal ctrl-l @@ -33,7 +35,5 @@ expression: "render_picker(params, 78)" Composer Queue tab Composer Toggle Shortcuts ?, shift-? Composer History Search Previous ctrl-r - Composer History Search Next ctrl-s - Editor Insert Newline ctrl-j, ctrl-m, enter, shift-ente… ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 39f9c50966..e6a30e77b5 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 147 actions, 0 customized, 5 unbound. + 150 actions, 0 customized, 5 unbound. All Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval › @@ -13,6 +13,8 @@ expression: "render_picker(params, 120)" › Global - Open Agents unbound Global Open Transcript ctrl-t + Global Find Transcript f3 + Global Focus Activity f4 Global Open External Editor ctrl-g Global Copy ctrl-o Global Clear Terminal ctrl-l @@ -33,7 +35,5 @@ expression: "render_picker(params, 120)" Composer Queue tab Composer Toggle Shortcuts ?, shift-? Composer History Search Previous ctrl-r - Composer History Search Next ctrl-s - Editor Insert Newline ctrl-j, ctrl-m, enter, shift-enter, alt-enter ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap index a1ee16cb55..d158d4a8f2 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 147 actions, 0 customized, 5 unbound. + 150 actions, 0 customized, 5 unbound. All Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval › @@ -13,6 +13,8 @@ expression: "render_picker(params, 120)" Global - Open Agents unbound Global Open Transcript ctrl-t + Global Find Transcript f3 + Global Focus Activity f4 Global Open External Editor ctrl-g Global Copy ctrl-o Global Clear Terminal ctrl-l @@ -33,7 +35,5 @@ expression: "render_picker(params, 120)" Composer Queue tab Composer Toggle Shortcuts ?, shift-? Composer History Search Previous ctrl-r - Composer History Search Next ctrl-s - Editor Insert Newline ctrl-j, ctrl-m, enter, shift-enter, alt-enter ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close 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 835faf8579..25f2ae317a 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,6 +1,6 @@ --- source: tui/src/pager_overlay.rs -assertion_line: 584 +assertion_line: 583 expression: term.backend() --- "/ S T A T I C / / / / / / / / / / / / / " 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 7619e85350..72ab591859 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,6 +1,6 @@ --- source: tui/src/pager_overlay.rs -assertion_line: 596 +assertion_line: 595 expression: term.backend() --- "/ S T A T I C / / / / / " diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index dcd20954b1..7b39a4de46 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -119,7 +119,6 @@ async fn run_startup_hooks_review_app( key_event, &keymap.chords, crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List), - tokio::time::Instant::now(), ) { crate::keymap::KeyChordMatch::PassThrough => key_event, crate::keymap::KeyChordMatch::Completed(dispatch_event) => dispatch_event, 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 406a11348e..cf3c75935b 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 @@ -32,7 +32,7 @@ before: Ctrl+Space select ↑/↓ to scroll · pgup/pgdn to page · home/end to jump - q close · esc edit previous + q close · f3 find · esc edit previous after: @@ -64,4 +64,4 @@ next message Ctrl+Space select ↑/↓ to scroll · pgup/pgdn to page · home/end to jump - q close · esc edit previous + q close · f3 find · esc edit previous diff --git a/codex-rs/tui/src/transcript_view.rs b/codex-rs/tui/src/transcript_view.rs index 6ea10e9b66..44b0d595be 100644 --- a/codex-rs/tui/src/transcript_view.rs +++ b/codex-rs/tui/src/transcript_view.rs @@ -6,11 +6,13 @@ mod activity; mod composer_gap; +mod disclosure; mod follow_control; mod footer; mod input; mod layout; mod mutations; +mod search; mod selection; mod snapshot; mod text; @@ -30,12 +32,14 @@ use ratatui::widgets::Clear; use ratatui::widgets::Widget; use layout::LayoutCache; +use search::Search; use selection::Selection; use snapshot::ViewSnapshot; use text::TextLayout; pub(crate) use input::JumpTarget; pub(crate) use input::ViewAction; +pub(crate) use layout::ActivityTranscriptLines; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] enum EntryKey { @@ -71,13 +75,14 @@ struct VisibleRow { row: usize, layout: Arc, key: EntryKey, + activity_ids: Arc<[String]>, } /// Shared scrolling and interaction state for compact and detailed transcript presentations. pub(crate) struct TranscriptView { + position: Position, follow_control: follow_control::FollowControl, copy_feedback: Option, - position: Position, cache: LayoutCache, live: Option>, live_separated: Option>, @@ -87,6 +92,7 @@ pub(crate) struct TranscriptView { visible: Vec, selection: Option, held_reading: Option, + search: Search, detailed: bool, mode: HistoryRenderMode, pub(crate) history: TranscriptHistoryState, @@ -96,6 +102,7 @@ pub(crate) struct TranscriptView { tail_visible: bool, last_tail: Option, last_click: Option<(std::time::Instant, u16, u16, u8)>, + disclosure: disclosure::Disclosure, } impl Default for TranscriptView { @@ -113,6 +120,7 @@ impl Default for TranscriptView { visible: Vec::new(), selection: None, held_reading: None, + search: Search::default(), detailed: false, mode: HistoryRenderMode::Rich, history: TranscriptHistoryState::Idle, @@ -122,15 +130,12 @@ impl Default for TranscriptView { tail_visible: true, last_tail: None, last_click: None, + disclosure: disclosure::Disclosure::default(), } } } impl TranscriptView { - pub(crate) fn is_detailed(&self) -> bool { - self.detailed - } - pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer, cells: &[Arc]) { self.cache.begin_frame(); self.sync_history_tail(cells); @@ -138,9 +143,7 @@ impl TranscriptView { 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.prepare_width(area.width); self.area = area; self.normalize_selection(cells); self.visible.clear(); @@ -150,6 +153,7 @@ impl TranscriptView { } let start = self.start(cells); let (mut index, mut row) = start; + let mut entry_activity_ids: Option<(usize, Arc<[String]>)> = None; for y in area.top()..area.bottom() { let Some(layout) = self.layout(cells, index) else { break; @@ -167,9 +171,19 @@ impl TranscriptView { break; }; let key = self.entry_key(cells, index); + let activity_ids = match &entry_activity_ids { + Some((previous, ids)) if *previous == index => Arc::clone(ids), + _ => { + let ids = self.displayed_activity_ids(cells, index); + entry_activity_ids = Some((index, Arc::clone(&ids))); + ids + } + }; let row_area = Rect::new(area.x, y, area.width, /*height*/ 1); layout.render(row_area, buf, row); - if self.highlight == Some(index) && self.selection.is_none() { + self.render_disclosure(&activity_ids, &layout, row, row_area, buf); + if self.highlight == Some(index) && self.selection.is_none() && !self.search.is_active() + { layout.highlight(0..layout.text().len(), row_area, buf, row); } self.visible.push(VisibleRow { @@ -177,6 +191,7 @@ impl TranscriptView { row, layout, key, + activity_ids, }); row += 1; } @@ -196,6 +211,7 @@ impl TranscriptView { if self.history == TranscriptHistoryState::LoadingBeginning && self.is_following() { self.scroll(current_cells, /*rows*/ 0); } + self.render_search(buf); } pub(crate) fn sync_live_tail( @@ -209,6 +225,18 @@ impl TranscriptView { }) } + pub(crate) fn sync_live_activity_tail( + &mut self, + width: u16, + key: Option, + expanded: bool, + lines: impl FnOnce(u16) -> Option, + ) -> bool { + self.sync_live_layout(width, key, |width| { + lines(width).map(|lines| layout::activity_layout(lines, width, expanded)) + }) + } + fn sync_live_layout( &mut self, width: u16, @@ -227,11 +255,11 @@ impl TranscriptView { 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; + if !self.is_following() && revision_changed && live.is_some() { + self.unseen_activity = true; + } + self.invalidate_live_search(); } let changed = self.live.is_some() || live.is_some(); self.live = live; @@ -247,17 +275,22 @@ impl TranscriptView { self.cache.clear(); self.live_key = None; // Search temporarily expands content without changing either presentation's position. - if self.detailed != detailed { + if self.detailed != detailed && !self.search.is_active() { let previous = self.position; self.position = self.saved_position.take().unwrap_or(previous); self.saved_position = Some(previous); } + self.restart_search(); self.detailed = detailed; self.mode = mode; } pub(crate) fn has_active_interaction(&self) -> bool { - self.selection.is_some() + self.selection.is_some() || self.search.is_active() || self.is_activity_focused() + } + + pub(crate) fn is_detailed(&self) -> bool { + self.detailed } pub(crate) fn is_following(&self) -> bool { @@ -278,11 +311,13 @@ impl TranscriptView { } pub(crate) fn jump_to_latest(&mut self) { + self.cancel_search(); self.cancel_beginning(); self.position = Position::Latest; self.selection = None; self.release_live_reading(); self.unseen_activity = false; + self.disclosure.focused = None; } pub(crate) fn scroll(&mut self, cells: &[Arc], rows: isize) { @@ -373,7 +408,8 @@ impl TranscriptView { } pub(crate) fn needs_history(&mut self, cells: &[Arc]) -> bool { - self.near_start(cells) + self.search.needs_history(self.history) + || (!self.search.is_active() && self.near_start(cells)) } pub(crate) fn near_start(&mut self, cells: &[Arc]) -> bool { diff --git a/codex-rs/tui/src/transcript_view/activity_tests.rs b/codex-rs/tui/src/transcript_view/activity_tests.rs index eeadf2641d..d88e3e8d9a 100644 --- a/codex-rs/tui/src/transcript_view/activity_tests.rs +++ b/codex-rs/tui/src/transcript_view/activity_tests.rs @@ -3,6 +3,9 @@ use super::*; use crate::history_cell::PlainHistoryCell; use crate::motion::MotionMode; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; fn cell(text: &str) -> Arc { @@ -63,3 +66,29 @@ fn unchanged_live_revisions_do_not_report_activity_but_hidden_text_does() { view.scroll(&cells, /*rows*/ -3); } } + +#[test] +fn escape_closes_selection_and_search_before_returning_to_latest() { + let mut cells = vec![cell("one\ntwo\nthree\nfour")]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*height*/ 3); + view.scroll(&cells, /*rows*/ -1); + cells.push(cell("new output")); + render(&mut view, &cells, /*height*/ 3); + view.begin_search(); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + view.handle_key(escape, &cells); + assert!(view.selection.is_none()); + assert!(view.is_search_active()); + view.handle_key(escape, &cells); + assert!(!view.is_search_active()); + assert!(!view.is_following()); + assert!(matches!( + view.handle_key(escape, &cells), + Some(ViewAction::Changed) + )); + assert!(view.is_following()); + assert!(view.footer(/*width*/ 80, MotionMode::Reduced).is_none()); + assert!(view.handle_key(escape, &cells).is_none()); +} diff --git a/codex-rs/tui/src/transcript_view/disclosure.rs b/codex-rs/tui/src/transcript_view/disclosure.rs new file mode 100644 index 0000000000..2fd967e880 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/disclosure.rs @@ -0,0 +1,435 @@ +//! Local activity disclosure keyed by retained tool identities, independent of text selection. +//! Controls follow the activity preview; copied text and full-transcript search remain source based. + +use crate::style::accent_color; +use std::collections::HashSet; + +use crate::bottom_pane::TranscriptFooter; +use crate::key_hint::KeyBindingListExt; +use crate::key_hint::key_label_spans; +use crate::keymap::KeymapContext; +use crate::keymap::ListAction; +use crate::keymap::RuntimeKeymap; +use crossterm::event::KeyEvent; +use ratatui::style::Modifier; +use ratatui::style::Style; + +use super::*; + +pub(super) struct Disclosure { + pub(super) expanded: HashSet, + pub(super) focused: Option>, + pub(super) live_ids: Vec, + pub(super) keymap: RuntimeKeymap, +} + +impl Default for Disclosure { + fn default() -> Self { + Self { + expanded: HashSet::new(), + focused: None, + live_ids: Vec::new(), + keymap: RuntimeKeymap::defaults(), + } + } +} + +impl Disclosure { + pub(super) fn is_expanded(&self, ids: &[String]) -> bool { + ids.iter().any(|id| self.expanded.contains(id)) + } +} + +impl TranscriptView { + pub(crate) fn is_activity_focused(&self) -> bool { + self.disclosure.focused.is_some() && !self.detailed && self.mode == HistoryRenderMode::Rich + } + + pub(crate) fn clear_activity_focus(&mut self) { + self.disclosure.focused = None; + } + + /// Live member identities survive the transition from active widget to committed history. + pub(crate) fn sync_live_activity( + &mut self, + cells: &[Arc], + ids: Vec, + ) -> bool { + // Disclosure follows its activity into history instead of an unrelated next live tail. + if self.snapshot().is_none() + && let Position::Reading(anchor) = self.position + && anchor.key == EntryKey::Live + && let Some(index) = self.canonical_activity_index(cells, &self.disclosure.live_ids) + && let Some(cell) = cells.get(index) + { + self.position = Position::Reading(Anchor { + key: EntryKey::cell(cell), + index, + offset: 0, + row_bias: 0, + }); + } + if self.disclosure.live_ids != ids { + self.live_key = None; + } + let expanded = self.disclosure.is_expanded(&ids); + if expanded { + self.disclosure.expanded.extend(ids.iter().cloned()); + } + self.disclosure.live_ids = ids; + expanded + } + + pub(super) fn activity_ids(&self, cells: &[Arc], index: usize) -> Vec { + cells.get(index).map_or_else( + || self.disclosure.live_ids.clone(), + |cell| cell.activity_ids(), + ) + } + + /// Retired live entries and replaced groups keep the identities of their displayed revision. + pub(super) fn displayed_activity_ids( + &self, + cells: &[Arc], + index: usize, + ) -> Arc<[String]> { + let key = cells.get(index).map_or(EntryKey::Live, EntryKey::cell); + self.snapshot() + .and_then(|snapshot| snapshot.activities.get(&key)) + .cloned() + .unwrap_or_else(|| self.activity_ids(cells, index).into()) + } + + pub(super) fn render_disclosure( + &self, + ids: &[String], + layout: &TextLayout, + row: usize, + area: Rect, + buf: &mut Buffer, + ) { + let focused = self + .disclosure + .focused + .as_ref() + .is_some_and(|focused| ids.iter().any(|id| focused.contains(id))); + if !focused || !layout.disclosure { + return; + } + if layout.disclosure_row() == Some(row) { + let columns = layout.disclosure_columns(); + buf.set_style( + Rect::new( + area.x + columns.start, + area.y, + columns.end - columns.start, + /*height*/ 1, + ), + Style::default() + .fg(accent_color()) + .bold() + .remove_modifier(Modifier::DIM), + ); + } else if row == usize::from(layout.separated) { + buf.set_style(area, Style::default().underlined()); + } + } + + pub(super) fn toggle_disclosure_at( + &mut self, + cells: &[Arc], + column: u16, + row: u16, + ) -> bool { + if self.detailed || self.mode != HistoryRenderMode::Rich { + return false; + } + let Some(visible) = row + .checked_sub(self.area.y) + .and_then(|row| self.visible.get(usize::from(row))) + else { + return false; + }; + if visible.layout.disclosure_row() != Some(visible.row) + || column + .checked_sub(self.area.x) + .is_none_or(|column| !visible.layout.disclosure_columns().contains(&column)) + { + return false; + } + let Some(index) = self.canonical_activity_index(cells, &visible.activity_ids) else { + return false; + }; + self.toggle_activity(cells, index); + true + } + + pub(super) fn handle_disclosure_key( + &mut self, + key: KeyEvent, + cells: &[Arc], + ) -> Option { + if self.detailed || self.mode != HistoryRenderMode::Rich || self.search.is_active() { + return None; + } + if self.disclosure.keymap.app.focus_activity.is_pressed(key) { + if self.is_activity_focused() { + self.disclosure.focused = None; + } else { + self.end_selection(cells); + let visible = self + .visible + .iter() + .rev() + .find_map(|row| self.canonical_activity_index(cells, &row.activity_ids)); + for index in visible.into_iter().chain((0..=cells.len()).rev()) { + if !self.activity_ids(cells, index).is_empty() + && self.focus_activity(cells, index) + { + break; + } + } + } + return Some(ViewAction::Changed); + } + let focused = self + .disclosure + .focused + .as_ref() + .and_then(|ids| self.canonical_activity_index(cells, ids)); + let Some(focused) = focused else { + self.disclosure.focused = None; + return None; + }; + let Some(action) = self.disclosure.keymap.list.action_for(key) else { + // Ordinary text resumes composing; activity navigation never becomes a text editor. + self.disclosure.focused = None; + return None; + }; + match action { + ListAction::Cancel => self.disclosure.focused = None, + ListAction::Accept => self.toggle_activity(cells, focused), + ListAction::MoveLeft | ListAction::MoveRight => { + let ids = self.activity_ids(cells, focused); + if self.disclosure.is_expanded(&ids) != (action == ListAction::MoveRight) { + self.toggle_activity(cells, focused); + } + } + ListAction::MoveUp + | ListAction::MoveDown + | ListAction::PageUp + | ListAction::PageDown + | ListAction::JumpTop + | ListAction::JumpBottom => { + let (mut range, backwards, steps) = match action { + ListAction::MoveUp => (0..focused, true, 1), + ListAction::PageUp => (0..focused, true, 5), + ListAction::JumpTop => (0..cells.len() + 1, false, 1), + ListAction::MoveDown => (focused + 1..cells.len() + 1, false, 1), + ListAction::PageDown => (focused + 1..cells.len() + 1, false, 5), + ListAction::JumpBottom => (0..cells.len() + 1, true, 1), + ListAction::MoveLeft + | ListAction::MoveRight + | ListAction::Accept + | ListAction::Cancel => unreachable!(), + }; + let mut next = focused; + for _ in 0..steps { + let candidate = if backwards { + range + .by_ref() + .rev() + .find(|index| !self.activity_ids(cells, *index).is_empty()) + } else { + range + .by_ref() + .find(|index| !self.activity_ids(cells, *index).is_empty()) + }; + let Some(index) = candidate else { break }; + next = index; + } + loop { + if self.focus_activity(cells, next) { + break; + } + let candidate = if backwards { + range.next_back() + } else { + range.next() + }; + let Some(index) = candidate else { break }; + next = index; + } + } + } + Some(ViewAction::Changed) + } + + pub(super) fn canonical_activity_index( + &self, + cells: &[Arc], + ids: &[String], + ) -> Option { + if ids.is_empty() { + return None; + } + // A visible immutable cell resolves directly; replaced groups fall back to member IDs. + if let Some(row) = self.visible.iter().find(|row| { + row.index <= cells.len() + && row.activity_ids.iter().any(|id| ids.contains(id)) + && cells.get(row.index).map_or(EntryKey::Live, EntryKey::cell) == row.key + && self + .activity_ids(cells, row.index) + .iter() + .any(|id| ids.contains(id)) + }) { + return Some(row.index); + } + (0..=cells.len()).find(|index| { + self.activity_ids(cells, *index) + .iter() + .any(|id| ids.contains(id)) + }) + } + + fn focus_activity(&mut self, cells: &[Arc], index: usize) -> bool { + if self + .current_layout(cells, index) + .is_none_or(|layout| !layout.disclosure) + { + return false; + } + let ids = self.activity_ids(cells, index); + let header_visible = self.visible.iter().any(|row| { + row.layout.disclosure + && row.activity_ids.iter().any(|id| ids.contains(id)) + && row.row == usize::from(row.layout.separated) + }); + self.disclosure.focused = Some(ids); + if !header_visible { + self.jump_to_entry(cells, index); + } + true + } + + fn toggle_activity(&mut self, cells: &[Arc], index: usize) { + let ids = self.activity_ids(cells, index); + if ids.is_empty() { + return; + } + let previous_top = self.visible.first().map(|visible| { + let offset = visible.layout.position_at(visible.row, /*column*/ 0); + ( + visible.key, + Arc::clone(&visible.activity_ids), + offset, + visible.layout.row_for_offset(offset) as isize - visible.row as isize, + ) + }); + self.end_selection(cells); + self.release_live_reading(); + // Source offsets only survive an unchanged cell. Regrouped/retired live entries resolve + // through their member IDs and anchor at their header, never a stale positional index. + let anchor = previous_top + .and_then(|(key, top_ids, offset, row_bias)| { + let unchanged = cells.iter().position(|cell| EntryKey::cell(cell) == key); + let top = unchanged.or_else(|| self.canonical_activity_index(cells, &top_ids))?; + let preserve_offset = unchanged.is_some() && top != index; + Some(Anchor { + key: cells.get(top).map_or(EntryKey::Live, EntryKey::cell), + index: top, + offset: if preserve_offset { offset } else { 0 }, + row_bias: if preserve_offset { row_bias } else { 0 }, + }) + }) + .unwrap_or(Anchor { + key: cells.get(index).map_or(EntryKey::Live, EntryKey::cell), + index, + offset: 0, + row_bias: 0, + }); + if self.disclosure.is_expanded(&ids) { + self.disclosure.expanded.retain(|id| !ids.contains(id)); + } else { + self.disclosure.expanded.extend(ids.iter().cloned()); + } + self.disclosure.focused = Some(ids); + self.position = Position::Reading(anchor); + self.cache.clear(); + self.live_key = None; + self.live_separated = None; + self.restart_search(); + } + + pub(super) fn disclosure_footer(&self, width: u16) -> Option { + if self.detailed || self.mode != HistoryRenderMode::Rich { + return None; + } + let keymap = &self.disclosure.keymap; + let text = if self.is_activity_focused() { + let hints: Vec<_> = [ + (ListAction::MoveUp, "previous"), + (ListAction::MoveDown, "next"), + (ListAction::Accept, "details"), + (ListAction::Cancel, "back"), + ] + .into_iter() + .filter_map(|(action, label)| { + keymap.list.primary_hint(action).map(|key| { + let mut spans = key.spans(); + spans.push(format!(" {label}").dim()); + Line::from(spans) + }) + }) + .collect(); + // Drop navigation before the details/back controls, retaining whole remapped chords. + crate::footer_hint::first_fitting_line( + (0..hints.len()).map(|start| { + let mut spans = Vec::new(); + for (index, hint) in hints[start..].iter().enumerate() { + if index > 0 { + spans.push(" · ".dim()); + } + spans.extend(hint.spans.clone()); + } + Line::from(spans) + }), + width, + ) + } else { + if !self.visible.iter().any(|row| row.layout.disclosure) { + return None; + } + let key = keymap.primary_hint(KeymapContext::Global, "focus_activity")?; + let primary = key.display_label(); + let alternatives = std::iter::once(primary.clone()) + .chain( + crate::keymap::user_bindings(&keymap.app.focus_activity) + .iter() + .map(crate::key_hint::KeyBinding::display_label) + .filter(|label| *label != primary), + ) + .take(/*n*/ 2) + .collect::>() + .join("/"); + crate::footer_hint::first_fitting_line( + [ + (alternatives.as_str(), " inspect activity"), + (alternatives.as_str(), " activity"), + (primary.as_str(), " activity"), + ] + .map(|(keys, action)| { + let mut spans = key_label_spans(keys); + spans.push(action.dim()); + Line::from(spans) + }), + width, + ) + }; + Some(TranscriptFooter { + text: text.into(), + cursor_column: None, + is_interactive: self.is_activity_focused(), + }) + } +} diff --git a/codex-rs/tui/src/transcript_view/follow_control.rs b/codex-rs/tui/src/transcript_view/follow_control.rs index f7128d6e9f..ef88d95c12 100644 --- a/codex-rs/tui/src/transcript_view/follow_control.rs +++ b/codex-rs/tui/src/transcript_view/follow_control.rs @@ -23,6 +23,7 @@ impl TranscriptView { && self.can_return_to_latest() && self.highlight.is_none() && self.selection.is_none() + && !self.is_search_active() }); let Some(area) = area else { self.follow_control = FollowControl::default(); diff --git a/codex-rs/tui/src/transcript_view/footer.rs b/codex-rs/tui/src/transcript_view/footer.rs index ca846fe21c..c3db6f4145 100644 --- a/codex-rs/tui/src/transcript_view/footer.rs +++ b/codex-rs/tui/src/transcript_view/footer.rs @@ -26,7 +26,21 @@ impl TranscriptView { (selection.start.key, selection.start.offset) != (selection.end.key, selection.end.offset) }); - + if let Some((query, cursor)) = self.search_footer(width) { + return Some(TranscriptFooter { + text: vec![ + query, + if has_selected_text { + selection_hint(width) + } else { + self.search.status_line(width, self.history) + }, + ] + .into(), + cursor_column: self.selection.is_none().then_some(cursor), + is_interactive: true, + }); + } if has_selected_text { return Some(TranscriptFooter { text: first_fitting_line( @@ -44,68 +58,72 @@ impl TranscriptView { is_interactive: true, }); } - + if self.is_activity_focused() { + return self.disclosure_footer(width); + } 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) + (pending || self.unseen_activity || can_return) + .then(|| { + let navigation = if self.can_return_to_latest() { + latest_navigation } 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, - is_interactive: false, - } - }) + 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, + is_interactive: false, + } + }) + .or_else(|| self.disclosure_footer(width)) } pub(crate) fn status_line_with_navigation( diff --git a/codex-rs/tui/src/transcript_view/footer_tests.rs b/codex-rs/tui/src/transcript_view/footer_tests.rs index 0bf3dc9515..fbbf2c2565 100644 --- a/codex-rs/tui/src/transcript_view/footer_tests.rs +++ b/codex-rs/tui/src/transcript_view/footer_tests.rs @@ -6,7 +6,12 @@ use crate::style::accent_color; 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 render(view: &mut TranscriptView, cells: &[Arc]) -> Buffer { let area = Rect::new( @@ -17,6 +22,53 @@ fn render(view: &mut TranscriptView, cells: &[Arc]) -> Buffer { buffer } +#[test] +fn selection_hints_follow_selected_text_instead_of_mouse_down() { + let cells: Vec> = + vec![Arc::new(PlainHistoryCell::new(vec!["é selection".into()]))]; + let mut snapshots = Vec::new(); + for searching in [false, true] { + let mut view = TranscriptView::default(); + render(&mut view, &cells); + if searching { + view.begin_search(); + } + for (label, kind, column) in [ + ("mouse down", MouseEventKind::Down(MouseButton::Left), 0), + ("one character", MouseEventKind::Drag(MouseButton::Left), 1), + ("back to anchor", MouseEventKind::Drag(MouseButton::Left), 0), + ("released empty", MouseEventKind::Up(MouseButton::Left), 0), + ] { + view.handle_mouse( + MouseEvent { + kind, + column, + row: 0, + modifiers: KeyModifiers::NONE, + }, + &cells, + ); + render(&mut view, &cells); + let footer = view.footer(/*width*/ 80, MotionMode::Reduced); + if label == "one character" { + assert_eq!(view.selected_text(&cells).as_deref(), Some("é")); + } + let text = footer.map_or_else( + || "".to_string(), + |footer| footer.text.to_string(), + ); + snapshots.push(format!("searching={searching} · {label}\n{text}")); + } + } + let joined = snapshots.join("\n\n"); + let snapshot = joined + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + insta::assert_snapshot!("selection_hint_threshold", snapshot); +} + #[test] fn loading_preserves_selected_content_and_copy_action() { let cells: Vec> = vec![Arc::new(PlainHistoryCell::new(vec![ @@ -37,7 +89,7 @@ fn loading_preserves_selected_content_and_copy_action() { let footer = view .footer(/*width*/ 80, MotionMode::Reduced) .expect("loading selection footer"); - assert_eq!(footer.cursor_column, None); + assert_eq!((footer.cursor_column, footer.is_interactive), (None, true)); insta::allow_duplicates! { insta::assert_snapshot!(footer.text.to_string(), @"↑ Loading earlier messages… · ctrl+c copy · enter copy & follow · esc clear"); } @@ -67,6 +119,50 @@ fn loading_preserves_selected_content_and_copy_action() { @"enter copy & follow · esc clear"); } +#[test] +fn loading_and_failure_preserve_search_query_and_caret() { + let cells: Vec> = vec![Arc::new(PlainHistoryCell::new(vec![ + "visible history".into(), + ]))]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + view.history = TranscriptHistoryState::Partial; + view.begin_search(); + view.paste_search("missing needle"); + assert!(!view.advance_search(&cells)); + assert!(view.needs_history(&cells)); + + let mut hints = Vec::new(); + for width in [32, 12] { + let editor = view.search_footer(width).expect("search editor"); + for state in [ + TranscriptHistoryState::LoadingOlder, + TranscriptHistoryState::LoadingBeginning, + TranscriptHistoryState::Failed, + ] { + view.history = state; + view.advance_search(&cells); + let footer = view + .footer(width, MotionMode::Reduced) + .expect("search footer"); + assert_eq!( + (footer.text.lines[0].clone(), footer.cursor_column), + (editor.0.clone(), Some(editor.1)), + ); + assert!(footer.is_interactive); + let hint = &footer.text.lines[1]; + assert!(hint.width() <= usize::from(width)); + hints.push(format!("{width} columns, {state:?}: {hint}")); + } + } + insta::assert_snapshot!(hints.join("\n")); + view.history = TranscriptHistoryState::Complete; + view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &cells); + assert!(!view.is_search_active()); + assert!(!view.is_detailed()); + assert!(view.footer(/*width*/ 32, MotionMode::Reduced).is_none()); +} + #[test] fn loading_completion_restores_unseen_activity_and_failure_stops_motion() { let mut cells: Vec> = vec![Arc::new(PlainHistoryCell::new(vec![ @@ -136,8 +232,38 @@ fn passive_footer_keeps_jump_actions_in_one_row() { insta::assert_snapshot!("compact_navigation_hints", snapshots.join("\n")); } -#[derive(Debug)] -struct ActivityCell; +#[test] +fn narrow_selection_and_search_keep_complete_exit_hints() { + 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 mut snapshots = Vec::new(); + for searching in [false, true] { + if searching { + view.end_selection(&cells); + view.begin_search(); + view.paste_search("selected"); + view.advance_search(&cells); + } + for width in [12, 20, 32, 80] { + let footer = view.footer(width, MotionMode::Reduced).unwrap(); + let hint = footer.text.lines.last().unwrap(); + assert!(hint.width() <= usize::from(width)); + assert!(hint.to_string().contains("esc")); + snapshots.push(format!("search={searching}, {width} columns: {hint}")); + } + } + insta::assert_snapshot!(snapshots.join("\n")); +} + +#[derive(Debug, Default)] +struct ActivityCell { + expanded_renders: AtomicUsize, +} impl HistoryCell for ActivityCell { fn display_lines(&self, _width: u16) -> Vec> { @@ -152,11 +278,110 @@ impl HistoryCell for ActivityCell { vec!["Activity summary".into(), "Hidden details".into()] } + fn expanded_hyperlink_lines(&self, width: u16) -> Vec { + self.expanded_renders + .fetch_add(/*val*/ 1, Ordering::Relaxed); + self.transcript_hyperlink_lines(width) + } + fn activity_ids(&self) -> Vec { vec!["sample-activity".into()] } } +#[test] +fn activity_alternative_focuses_details_and_hints_follow_configured_chords() { + let mut cells: Vec> = Vec::new(); + let cell = Arc::new(ActivityCell::default()); + let mut view = TranscriptView::default(); + let render_live = |view: &mut TranscriptView| { + let expanded = view.sync_live_activity(&cells, cell.activity_ids()); + view.sync_live_activity_tail(/*width*/ 32, /*key*/ None, expanded, |width| { + Some(ActivityTranscriptLines { + activity: if expanded { + cell.expanded_hyperlink_lines(width) + } else { + cell.compact_hyperlink_lines(width) + }, + auxiliary: Vec::new(), + has_hidden_details: true, + }) + }); + render(view, &[]) + }; + let collapsed = render_live(&mut view); + let mut hints = vec![format!("collapsed\n{collapsed:?}")]; + for width in [20, 32, 80] { + hints.push(format!( + "{width} columns: {}", + view.footer(width, MotionMode::Reduced).unwrap().text, + )); + } + view.handle_key(KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE), &cells); + assert!(view.is_activity_focused()); + hints.push(format!("focused\n{:?}", render_live(&mut view))); + view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + assert!(view.disclosure.is_expanded(&["sample-activity".to_owned()])); + let expanded = render_live(&mut view); + hints.push(format!("expanded\n{expanded:?}")); + let hidden = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 0, + ); + view.render(hidden, &mut Buffer::empty(hidden), &cells); + cells.push(cell.clone()); + view.sync_live_activity(&cells, Vec::new()); + view.sync_live_tail(/*width*/ 32, /*key*/ None, |_| None); + assert_eq!(render(&mut view, &cells), expanded); + assert!(cell.expanded_renders.swap(/*val*/ 0, Ordering::Relaxed) > 0); + view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + render(&mut view, &cells); + assert_eq!(cell.expanded_renders.load(Ordering::Relaxed), 0); + view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &cells); + assert!(!view.is_activity_focused()); + + for configured in [ + serde_json::json!("ctrl-x t"), + serde_json::json!(["ctrl-x t", "f4"]), + serde_json::json!([]), + ] { + let config = serde_json::from_value(serde_json::json!({ + "global": {"focus_activity": configured} + })) + .unwrap(); + let keymap = crate::keymap::RuntimeKeymap::from_config(&config).unwrap(); + view.set_keymap_bindings(&keymap); + view.jump_to_latest(); + render(&mut view, &cells); + let hint = view + .footer(/*width*/ 80, MotionMode::Reduced) + .map_or_else(|| "unbound".to_owned(), |footer| footer.text.to_string()); + hints.push(format!("{configured}: {hint}")); + } + insta::assert_snapshot!(hints.join("\n")); +} + +#[test] +fn transcript_footer_keys_remain_distinct_from_prose_and_query_text() { + use ratatui::widgets::Paragraph; + + let cells: Vec> = vec![Arc::new(ActivityCell::default())]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + view.begin_search(); + view.paste_search("Activity"); + view.advance_search(&cells); + let footer = view.footer(/*width*/ 80, MotionMode::Reduced).unwrap(); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 80, + footer.text.height() as u16, + ); + let mut buffer = Buffer::empty(area); + Paragraph::new(footer.text).render(area, &mut buffer); + insta::assert_snapshot!(format!("{buffer:?}")); +} + 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 index 2ad117bb2b..66090cc38d 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -102,7 +102,10 @@ impl TranscriptView { || (modifiers == KeyModifiers::NONE && matches!(code, KeyCode::PageUp | KeyCode::PageDown)); } - false + self.is_search_active() + && (matches!(code, KeyCode::Esc | KeyCode::Enter) + || (modifiers == KeyModifiers::CONTROL + && matches!(code, KeyCode::Char('c' | 'n' | 'p')))) } pub(crate) fn handle_key( @@ -113,7 +116,9 @@ impl TranscriptView { if key.kind == KeyEventKind::Release { return None; } - + if let Some(action) = self.handle_disclosure_key(key, cells) { + return Some(action); + } if self.selection.is_some() { if let Some(action) = self.handle_selection_key(key, cells) { return Some(action); @@ -129,7 +134,9 @@ impl TranscriptView { } return Some(ViewAction::Changed); } - + if self.handle_search_key(key, cells) { + return Some(ViewAction::Changed); + } self.handle_scroll_key(key, cells) } @@ -259,6 +266,10 @@ impl TranscriptView { event: MouseEvent, cells: &[Arc], ) -> Option { + if event.modifiers.is_empty() && self.toggle_disclosure_at(cells, event.column, event.row) { + return Some(ViewAction::Changed); + } + self.disclosure.focused = None; let visible = self .visible .get(usize::from(event.row.checked_sub(self.area.y)?))?; diff --git a/codex-rs/tui/src/transcript_view/layout.rs b/codex-rs/tui/src/transcript_view/layout.rs index b869314fac..afefd6caef 100644 --- a/codex-rs/tui/src/transcript_view/layout.rs +++ b/codex-rs/tui/src/transcript_view/layout.rs @@ -10,6 +10,39 @@ use super::*; const MAX_CACHED_ENTRIES: usize = 64; const MAX_CACHED_TEXT_BYTES: usize = 8 * 1024 * 1024; +/// The selected activity presentation and any live content that follows it. +pub(crate) struct ActivityTranscriptLines { + pub(crate) activity: Vec, + pub(crate) auxiliary: Vec, + pub(crate) has_hidden_details: bool, +} + +/// Keep disclosure controls outside selectable source without rendering hidden details. +pub(super) fn activity_layout( + mut lines: ActivityTranscriptLines, + width: u16, + expanded: bool, +) -> TextLayout { + let has_details = !lines.activity.is_empty() && (expanded || lines.has_hidden_details); + let source_offset = (!lines.auxiliary.is_empty()) + .then(|| TextLayout::new(lines.activity.clone(), width).text().len()); + lines.activity.extend(lines.auxiliary); + let layout = TextLayout::new(lines.activity, width); + if !has_details { + return layout; + } + let label = if expanded { + "− Show less" + } else { + "+ Show details" + } + .to_owned(); + match source_offset { + Some(offset) => layout.with_disclosure_control_at(label, offset), + None => layout.with_disclosure_control(label), + } +} + #[derive(Default)] pub(super) struct LayoutCache { entries: Vec, @@ -37,6 +70,8 @@ struct CachedLayout { #[derive(Clone, Copy, PartialEq, Eq)] pub(super) struct CellPresentation { separated: bool, + expanded: bool, + disclosure: bool, } impl TranscriptView { @@ -92,10 +127,34 @@ impl TranscriptView { } let detailed = self.detailed; let mode = self.mode; + let ids = cell.activity_ids(); + let disclosure = !detailed && mode == HistoryRenderMode::Rich && !ids.is_empty(); + let expanded = disclosure && self.disclosure.is_expanded(&ids); + if expanded { + self.disclosure.expanded.extend(ids); + } let separated = index > 0 && !cell.is_stream_continuation(); - let presentation = CellPresentation { separated }; + let presentation = CellPresentation { + separated, + expanded, + disclosure, + }; Some(self.cache.get(cell, width, presentation, || { - if detailed { + if disclosure { + activity_layout( + ActivityTranscriptLines { + activity: if expanded { + cell.expanded_hyperlink_lines(width) + } else { + cell.compact_hyperlink_lines(width) + }, + auxiliary: Vec::new(), + has_hidden_details: cell.has_hidden_activity_details(width), + }, + width, + expanded, + ) + } else if detailed { TextLayout::new(cell.transcript_hyperlink_lines(width), width) } else if mode == HistoryRenderMode::Rich { TextLayout::new(cell.compact_hyperlink_lines(width), width) diff --git a/codex-rs/tui/src/transcript_view/layout_tests.rs b/codex-rs/tui/src/transcript_view/layout_tests.rs index dedbbba0bc..8cdc12352b 100644 --- a/codex-rs/tui/src/transcript_view/layout_tests.rs +++ b/codex-rs/tui/src/transcript_view/layout_tests.rs @@ -45,7 +45,11 @@ fn layouts_refresh_for_width_animation_and_mutable_frames() { cache.get( &history, width, - CellPresentation { separated: false }, + CellPresentation { + separated: false, + expanded: false, + disclosure: false, + }, || TextLayout::new(history.transcript_hyperlink_lines(width), width), ) }; @@ -95,7 +99,11 @@ fn recent_entries_are_reused_and_old_entries_are_evicted() { cache.get( &history, /*width*/ 20, - CellPresentation { separated: false }, + CellPresentation { + separated: false, + expanded: false, + disclosure: false, + }, || { TextLayout::new( history.transcript_hyperlink_lines(/*width*/ 20), @@ -109,7 +117,11 @@ fn recent_entries_are_reused_and_old_entries_are_evicted() { cache.get( &history, /*width*/ 20, - CellPresentation { separated: false }, + CellPresentation { + separated: false, + expanded: false, + disclosure: false, + }, || { TextLayout::new( history.transcript_hyperlink_lines(/*width*/ 20), diff --git a/codex-rs/tui/src/transcript_view/mutations.rs b/codex-rs/tui/src/transcript_view/mutations.rs index 36f7aa1fb4..aab70bd635 100644 --- a/codex-rs/tui/src/transcript_view/mutations.rs +++ b/codex-rs/tui/src/transcript_view/mutations.rs @@ -5,29 +5,6 @@ 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); @@ -58,12 +35,14 @@ impl TranscriptView { if range.is_empty() { return; } + self.retain_search_origin(cells, range.clone()); if range.end == cells.len() && self.last_tail == cells.last().map(EntryKey::cell) { self.last_tail = Some(EntryKey::cell(replacement)); } if self.snapshot().is_some() { return; } + self.restart_search(); let reading = match self.position { Position::Reading(anchor) if range.contains(&self.resolve(cells, anchor)) => { Some(anchor) @@ -103,10 +82,12 @@ impl TranscriptView { if range.is_empty() { return; } + self.retain_search_origin(cells, range.clone()); // 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 searching = self.search.has_active_query(); let reading = match self.position { Position::Reading(anchor) => Some(anchor.key), Position::Latest => None, @@ -119,7 +100,8 @@ impl TranscriptView { 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 + if searching + || visible || replaced.iter().any(|cell| { let key = EntryKey::cell(cell); snapshot.pinned.contains_key(&key) || reading == Some(key) @@ -140,7 +122,8 @@ impl TranscriptView { snapshot.cells = joined.into(); return; } - if matches!(self.position, Position::Reading(anchor) + if searching + || 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)); @@ -160,6 +143,7 @@ impl TranscriptView { let Some((tail, preceding)) = cells.split_last() else { return; }; + self.retain_search_origin(cells, preceding.len()..cells.len()); let tail_key = EntryKey::cell(tail); if self.last_tail == Some(tail_key) { self.last_tail = preceding.last().map(EntryKey::cell); @@ -176,6 +160,7 @@ impl TranscriptView { if self.snapshot().is_some() { return; } + self.restart_search(); if let Position::Reading(anchor) = self.position && (anchor.key == tail_key || anchor.key == EntryKey::Live) { diff --git a/codex-rs/tui/src/transcript_view/mutations_tests.rs b/codex-rs/tui/src/transcript_view/mutations_tests.rs index 4b9daa7a70..961ac5f90e 100644 --- a/codex-rs/tui/src/transcript_view/mutations_tests.rs +++ b/codex-rs/tui/src/transcript_view/mutations_tests.rs @@ -354,3 +354,98 @@ fn absorbing_older_history_excludes_only_the_hydration_revision_from_new_activit ); } } + +#[test] +fn folding_a_reasoning_only_page_retains_its_find_and_copy_revision() { + use crate::thread_transcript::RawReasoningVisibility; + use crate::thread_transcript::fold_trailing_activity_details; + use crate::thread_transcript::thread_items_to_transcript_cells; + use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::Turn; + use codex_app_server_protocol::TurnItemsView; + use codex_app_server_protocol::TurnStatus; + + let call = serde_json::from_value(serde_json::json!({ + "type": "mcpToolCall", "id": "call", "server": "cua_repl", "tool": "js", + "status": "completed", "arguments": {"title": "Inspect"}, + "result": {"content": [{"type": "text", "text": "retained call output"}]}, "durationMs": 1 + })) + .expect("computer call"); + let reasoning = ThreadItem::Reasoning { + id: "reasoning".to_owned(), + summary: vec!["retained needle text with exact spacing".to_owned()], + content: Vec::new(), + }; + let current = Turn { + id: "turn".to_owned(), + items: vec![call, reasoning], + items_view: TurnItemsView::Full, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }; + let project = |items: &[ThreadItem]| { + thread_items_to_transcript_cells( + /*thread_id*/ None, + &codex_utils_absolute_path::AbsolutePathBuf::current_dir().unwrap(), + items.iter().cloned(), + RawReasoningVisibility::Hidden, + /*config*/ None, + ) + }; + for selecting in [false, true] { + let mut cells = project(¤t.items[1..]); + let mut view = TranscriptView::default(); + view.set_presentation(/*detailed*/ true, HistoryRenderMode::Rich); + view.jump_to_entry(&cells, /*index*/ 0); + render(&mut view, &cells, /*width*/ 30); + if selecting { + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 1); + for _ in 0..8 { + view.selection_key(&cells, crossterm::event::KeyCode::Right); + } + } else { + view.begin_search(); + view.paste_search("needle"); + for _ in 0..8 { + if !view.advance_search(&cells) { + break; + } + } + assert!( + view.search + .status_line(/*width*/ 80, view.history) + .to_string() + .starts_with("enter next") + ); + } + let before = render(&mut view, &cells, /*width*/ 30); + let copied = view.selected_text(&cells); + if selecting { + assert!(copied.as_ref().is_some_and(|text| !text.is_empty())); + } + cells.insert( + /*index*/ 0, + project(¤t.items[..1]).remove(/*index*/ 0), + ); + view.history_loaded(&cells, 0..1); + let joined = + fold_trailing_activity_details(&cells[0], &cells[1..], std::slice::from_ref(¤t)) + .unwrap(); + view.replace_group(&cells, 0..2, &joined); + cells.splice(0..2, [joined]); + assert_eq!(render(&mut view, &cells, /*width*/ 30), before); + render(&mut view, &cells, /*width*/ 16); + assert_eq!(view.selected_text(&cells), copied); + if !selecting { + assert!( + view.search + .status_line(/*width*/ 80, view.history) + .to_string() + .starts_with("enter next") + ); + } + } +} diff --git a/codex-rs/tui/src/transcript_view/search.rs b/codex-rs/tui/src/transcript_view/search.rs new file mode 100644 index 0000000000..3c9503482a --- /dev/null +++ b/codex-rs/tui/src/transcript_view/search.rs @@ -0,0 +1,732 @@ +//! Incremental literal search over the current transcript presentation. +//! +//! Search retains one match and one entry being scanned. Each frame examines a bounded text +//! chunk; reaching the oldest loaded entry asks the app's existing history pager to continue. +//! The pager owns loading and failure status; search only remembers that it needs another page. + +use crate::bottom_pane::TextArea; +use crate::bottom_pane::TextAreaState; +use crate::keymap::RuntimeKeymap; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::text::Span; +use ratatui::widgets::StatefulWidgetRef; +use std::ops::Range; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use super::*; + +const QUERY_BYTES: usize = 4096; +const SCAN_BYTES: usize = 16 * 1024; +const ENTRIES_PER_FRAME: usize = 8; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Direction { + Older, + Newer, +} + +#[derive(Clone, Copy)] +struct Cursor { + anchor: Anchor, + direction: Direction, +} + +#[derive(Clone, Copy, Default)] +enum Progress { + #[default] + Idle, + Restart, + Scanning(Cursor), + AwaitingHistory, + Found, + Exhausted, +} + +struct Match { + anchor: Anchor, + end: usize, +} + +pub(super) struct Search { + active: bool, + editor: TextArea, + folded_query: String, + saved_position: Position, + saved_snapshot: Option, + saved_detailed: bool, + current: Option, + progress: Progress, + scanning_layout: Option<(EntryKey, Arc)>, + page_start: Option, + query_truncated: bool, +} + +impl Default for Search { + fn default() -> Self { + Self { + active: false, + editor: TextArea::new(), + folded_query: String::new(), + saved_position: Position::default(), + saved_snapshot: None, + saved_detailed: false, + current: None, + progress: Progress::Idle, + scanning_layout: None, + page_start: None, + query_truncated: false, + } + } +} + +impl TranscriptView { + pub(crate) fn begin_search(&mut self) { + self.cancel_beginning(); + if self.search.active { + return; + } + let saved_position = self.position; + let saved_snapshot = self + .selection + .take() + .map(|selection| selection.snapshot) + .or(self.held_reading.take()); + let saved_detailed = self.detailed; + self.search.active = true; + self.disclosure.focused = None; + self.set_presentation(/*detailed*/ true, self.mode); + self.search.saved_position = saved_position; + self.search.saved_snapshot = saved_snapshot; + self.search.saved_detailed = saved_detailed; + self.search.progress = Progress::Restart; + } + + pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { + // Resolved keymaps are immutable; config changes replace their shared chord map. + if Arc::ptr_eq(&self.disclosure.keymap.chords, &keymap.chords) { + return; + } + self.search.editor.set_keymap_bindings(keymap); + self.disclosure.keymap = keymap.clone(); + } + + pub(crate) fn is_search_active(&self) -> bool { + self.search.active + } + + /// Project the editor and its caret from the same one-row viewport into the existing footer. + pub(crate) fn search_footer(&self, width: u16) -> Option<(Line<'static>, u16)> { + if !self.search.active || width == 0 { + return None; + } + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1); + let mut buffer = Buffer::empty(area); + let prefix_width = width.saturating_sub(/*rhs*/ 1).min(/*other*/ 6); + Line::from("Find: ").dim().render( + Rect::new(/*x*/ 0, /*y*/ 0, prefix_width, /*height*/ 1), + &mut buffer, + ); + let query_area = Rect::new( + prefix_width, + /*y*/ 0, + width - prefix_width, + /*height*/ 1, + ); + let mut state = TextAreaState::default(); + StatefulWidgetRef::render_ref(&&self.search.editor, query_area, &mut buffer, &mut state); + let (cursor_column, _) = self + .search + .editor + .cursor_pos_with_state(query_area, state)?; + let mut spans = Vec::new(); + let mut column = 0; + while column < width { + let cell = &buffer[(column, 0)]; + spans.push(Span::styled(cell.symbol().to_string(), cell.style())); + column += cell.symbol().width().max(/*other*/ 1) as u16; + } + Some((Line::from(spans), cursor_column)) + } + + pub(super) fn handle_search_key( + &mut self, + key: KeyEvent, + cells: &[Arc], + ) -> bool { + if !self.search.active { + return false; + } + let (code, modifiers) = crate::key_hint::normalize_key_parts(key.code, key.modifiers); + match (code, modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + self.cancel_search(); + } + // Ctrl+N/P stay distinct when a legacy terminal encodes Shift+Enter as Enter. + (KeyCode::Enter, KeyModifiers::NONE) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { + self.next_search_match(cells, Direction::Newer); + } + (KeyCode::Enter, KeyModifiers::SHIFT) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { + self.next_search_match(cells, Direction::Older); + } + _ => { + let before = self.search.editor.text().to_string(); + let cursor = self.search.editor.cursor(); + self.search.editor.input(key); + if self.search.editor.text().len() > QUERY_BYTES { + self.search.editor.set_text_clearing_elements(&before); + self.search.editor.set_cursor(cursor); + self.search.query_truncated = true; + } else if self.search.editor.text() != before { + if self.held_reading.take().is_some() { + self.visible.clear(); + self.position = Position::Latest; + } + self.search.query_changed(); + } + } + } + // Find owns every key until it closes, including unbound keys and editor chords. + true + } + + /// Retain the pre-search revision when canonical history retires the cell anchoring it. + pub(super) fn retain_search_origin( + &mut self, + cells: &[Arc], + retiring: std::ops::Range, + ) { + if self.search.active + && self.search.saved_snapshot.is_none() + && let Position::Reading(anchor) = self.search.saved_position + && cells[retiring] + .iter() + .any(|cell| EntryKey::cell(cell) == anchor.key) + { + let mut snapshot = self.capture_snapshot(cells); + if self.detailed != self.search.saved_detailed { + snapshot.pinned.clear(); + snapshot.activities.clear(); + } + self.search.saved_snapshot = Some(snapshot); + } + } + + /// Restore the pre-search presentation before an explicit return or cancellation. + pub(super) fn cancel_search(&mut self) { + if !self.search.active { + return; + } + let position = self.search.saved_position; + let snapshot = self.search.saved_snapshot.take().or_else(|| { + let Position::Reading(anchor) = position else { + return None; + }; + let mut snapshot = self.held_reading.take().filter(|snapshot| { + snapshot + .cells + .iter() + .any(|cell| EntryKey::cell(cell) == anchor.key) + })?; + if self.detailed != self.search.saved_detailed { + snapshot.pinned.clear(); + snapshot.activities.clear(); + } + Some(snapshot) + }); + let detailed = self.search.saved_detailed; + self.set_presentation(detailed, self.mode); + self.search.active = false; + self.search.editor.set_text_clearing_elements(""); + self.search.query_changed(); + self.search.progress = Progress::Idle; + self.position = position; + self.held_reading = snapshot; + self.rewrap_snapshot(self.area.width); + } + + pub(crate) fn paste_search(&mut self, text: &str) -> bool { + if !self.search.active { + return false; + } + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + let remaining = QUERY_BYTES.saturating_sub(self.search.editor.text().len()); + let end = normalized + .grapheme_indices(/*is_extended*/ true) + .map(|(offset, grapheme)| offset + grapheme.len()) + .take_while(|end| *end <= remaining) + .last() + .unwrap_or_default(); + if end > 0 { + if self.held_reading.take().is_some() { + self.visible.clear(); + self.position = Position::Latest; + } + self.search.editor.insert_str(&normalized[..end]); + self.search.query_changed(); + } + self.search.query_truncated = end < normalized.len(); + true + } + + /// Restart after a presentation change invalidates the searched text and its source offsets. + pub(crate) fn restart_search(&mut self) { + if self.search.active { + self.search.query_changed(); + } + } + + /// Drop an in-progress scan of a live revision when the visible source changes. A match + /// already being read has its own snapshot and remains valid until navigation leaves it. + pub(super) fn invalidate_live_search(&mut self) { + if self.held_reading.is_none() + && matches!( + self.search.progress, + Progress::Scanning(Cursor { + anchor: Anchor { + key: EntryKey::Live, + .. + }, + .. + }) + ) + { + self.restart_search(); + } + } + + pub(super) fn invalidate_held_search(&mut self) { + if self.search.has_active_query() { + self.restart_search(); + } + } + + /// Prepare geometry before scanning: offsets belong to a particular source layout. + pub(crate) fn prepare_width(&mut self, width: u16) { + if width == self.area.width { + return; + } + self.rewrap_snapshot(width); + self.area.width = width; + if self.held_reading.is_none() || !matches!(self.search.progress, Progress::Found) { + self.restart_search(); + } + } + + /// Return true only while another local scanning frame can make progress. + pub(crate) fn advance_search(&mut self, cells: &[Arc]) -> bool { + if self.selection.is_some() { + return false; + } + let current_cells = cells; + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); + if matches!(self.search.progress, Progress::Restart) { + self.search.progress = Progress::Idle; + // A refined query must not leave an obsolete match at the top while history loads. + self.position = self.search.saved_position; + if self.search.editor.is_empty() { + self.held_reading = self.search.saved_snapshot.clone(); + self.rewrap_snapshot(self.area.width); + return false; + } + self.start_search_scan(cells, Direction::Older); + } + if matches!(self.search.progress, Progress::AwaitingHistory) + && matches!( + self.history, + TranscriptHistoryState::Complete | TranscriptHistoryState::Idle + ) + { + self.search.progress = Progress::Exhausted; + } + let mut scanned_bytes = 0; + for _ in 0..ENTRIES_PER_FRAME { + let Progress::Scanning(mut cursor) = self.search.progress else { + return false; + }; + cursor.anchor.index = self.resolve(cells, cursor.anchor); + let Some(layout) = self.search_layout(cells, cursor.anchor) else { + self.advance_search_cursor(cells, cursor, 0..0, /*text_len*/ 0); + return false; + }; + let text = layout.text(); + cursor.anchor.offset = text.floor_char_boundary(cursor.anchor.offset.min(text.len())); + let window = scan_window(text, cursor, self.search.folded_query.len()); + if let Some(range) = find_literal( + &text[window.clone()], + &self.search.folded_query, + cursor.direction, + ) { + let anchor = Anchor { + offset: window.start + range.start, + ..cursor.anchor + }; + self.position = Position::Reading(anchor); + self.hold_live_reading(current_cells, Arc::clone(&layout)); + self.search.current = Some(Match { + anchor, + end: window.start + range.end, + }); + self.search.progress = Progress::Found; + self.search.scanning_layout = None; + return false; + } + scanned_bytes += window.len(); + self.advance_search_cursor(cells, cursor, window, text.len()); + // Batch short entries without letting a frame scan an unbounded transcript. + // The last window can overshoot the budget; its size and query overlap are bounded. + if scanned_bytes >= SCAN_BYTES { + return matches!(self.search.progress, Progress::Scanning(_)); + } + } + matches!(self.search.progress, Progress::Scanning(_)) + } + + /// Scan only the inserted page; retained session headers may precede its splice location. + 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.clone()); + 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 + }); + } + if !matches!(self.search.progress, Progress::AwaitingHistory) || inserted.is_empty() { + return; + } + let index = inserted.end - 1; + self.search.progress = Progress::Scanning(Cursor { + anchor: Anchor { + key: self.entry_key(cells, index), + index, + offset: usize::MAX, + row_bias: 0, + }, + direction: Direction::Older, + }); + self.search.scanning_layout = None; + self.search.page_start = Some(self.entry_key(cells, inserted.start)); + } + + pub(super) fn render_search(&self, buf: &mut Buffer) { + // The copy range takes precedence until selection ends. + if self.selection.is_some() { + return; + } + let Some(found) = &self.search.current else { + return; + }; + for (y, visible) in self.visible.iter().enumerate() { + if visible.key == found.anchor.key { + let area = Rect::new( + self.area.x, + self.area.y + y as u16, + self.area.width, + /*height*/ 1, + ); + visible + .layout + .highlight(found.anchor.offset..found.end, area, buf, visible.row); + } + } + } + + /// Navigate from a hit; confirmation must not reverse the initial scan of older history. + fn next_search_match(&mut self, cells: &[Arc], direction: Direction) { + if self.search.editor.is_empty() { + return; + } + if matches!(self.search.progress, Progress::AwaitingHistory) + && self.history == TranscriptHistoryState::Failed + && direction == Direction::Older + { + self.history = TranscriptHistoryState::Partial; + } else if let Some(found) = &self.search.current { + self.search.scanning_layout = None; + let offset = match direction { + Direction::Older => found.anchor.offset, + Direction::Newer => found.end, + }; + let match_start = found.anchor.offset; + let match_end = found.end; + let mut anchor = Anchor { + offset, + ..found.anchor + }; + if direction == Direction::Newer + && let Some(snapshot) = self.held_reading.take() + { + // Continue through newly committed output, or rejoin a surviving cell. + let index = if anchor.key == EntryKey::Live { + snapshot + .cells + .iter() + .rev() + .find_map(|previous| { + cells.iter().rposition(|cell| Arc::ptr_eq(cell, previous)) + }) + .map_or(0, |index| index + 1) + } else if let Some(index) = cells + .iter() + .position(|cell| EntryKey::cell(cell) == anchor.key) + { + index + } else { + self.restart_search(); + self.visible.clear(); + return; + }; + let prefix = snapshot + .pinned + .get(&anchor.key) + .and_then(|layout| layout.text().get(..offset)); + let same_prefix = self.current_layout(cells, index).is_some_and(|layout| { + prefix.is_some_and(|prefix| layout.text().starts_with(prefix)) + }); + anchor = Anchor { + key: cells.get(index).map_or(EntryKey::Live, EntryKey::cell), + index, + offset: if same_prefix { offset } else { 0 }, + row_bias: 0, + }; + self.search.current = same_prefix.then_some(Match { + anchor: Anchor { + offset: match_start, + ..anchor + }, + end: match_end, + }); + self.visible.clear(); + self.position = Position::Reading( + self.search + .current + .as_ref() + .map_or(anchor, |found| found.anchor), + ); + if same_prefix && let Some(layout) = self.current_layout(cells, index) { + self.hold_live_reading(cells, layout); + } + } + self.search.progress = Progress::Scanning(Cursor { anchor, direction }); + } else if matches!(self.search.progress, Progress::Idle | Progress::Exhausted) { + self.start_search_scan(cells, Direction::Older); + } + } + + fn start_search_scan(&mut self, cells: &[Arc], direction: Direction) { + self.search.page_start = None; + let count = cells.len() + usize::from(self.live.is_some()); + let index = match direction { + Direction::Older => count.saturating_sub(/*rhs*/ 1), + Direction::Newer => 0, + }; + let offset = match direction { + Direction::Older => usize::MAX, + Direction::Newer => 0, + }; + self.search.progress = Progress::Scanning(Cursor { + anchor: Anchor { + key: self.entry_key(cells, index), + index, + offset, + row_bias: 0, + }, + direction, + }); + } + + fn search_layout( + &mut self, + cells: &[Arc], + anchor: Anchor, + ) -> Option> { + if let Some((key, layout)) = &self.search.scanning_layout + && *key == anchor.key + { + return Some(Arc::clone(layout)); + } + let layout = self.layout(cells, anchor.index)?; + self.search.scanning_layout = Some((anchor.key, Arc::clone(&layout))); + Some(layout) + } + + fn advance_search_cursor( + &mut self, + cells: &[Arc], + cursor: Cursor, + window: Range, + text_len: usize, + ) { + let remaining = match cursor.direction { + Direction::Older => window.start > 0, + Direction::Newer => window.end < text_len, + }; + if remaining { + let offset = match cursor.direction { + Direction::Older => cursor.anchor.offset.saturating_sub(SCAN_BYTES), + Direction::Newer => cursor.anchor.offset.saturating_add(SCAN_BYTES), + }; + self.search.progress = Progress::Scanning(Cursor { + anchor: Anchor { + offset, + ..cursor.anchor + }, + ..cursor + }); + return; + } + self.search.scanning_layout = None; + let next = match cursor.direction { + Direction::Older if self.search.page_start == Some(cursor.anchor.key) => None, + Direction::Older => cursor.anchor.index.checked_sub(/*rhs*/ 1), + Direction::Newer => (cursor.anchor.index + 1 + < cells.len() + usize::from(self.live.is_some())) + .then_some(cursor.anchor.index + 1), + }; + self.search.progress = match next { + Some(index) => Progress::Scanning(Cursor { + anchor: Anchor { + key: self.entry_key(cells, index), + index, + offset: if cursor.direction == Direction::Older { + usize::MAX + } else { + 0 + }, + row_bias: 0, + }, + direction: cursor.direction, + }), + None if cursor.direction == Direction::Older + && matches!( + self.history, + TranscriptHistoryState::LoadingOlder + | TranscriptHistoryState::LoadingBeginning + | TranscriptHistoryState::Partial + | TranscriptHistoryState::Failed + ) => + { + Progress::AwaitingHistory + } + None => Progress::Exhausted, + }; + } +} + +impl Search { + pub(super) fn is_active(&self) -> bool { + self.active + } + + pub(super) fn has_active_query(&self) -> bool { + self.active && !self.editor.is_empty() + } + + pub(super) fn needs_history(&self, history: TranscriptHistoryState) -> bool { + matches!(self.progress, Progress::AwaitingHistory) + && history == TranscriptHistoryState::Partial + } + + pub(super) fn status_line(&self, width: u16, history: TranscriptHistoryState) -> Line<'static> { + let (status, compact) = match self.progress { + Progress::Idle => ("Type to find", "Type to find"), + Progress::Restart | Progress::Scanning(_) => ("Searching…", "Searching…"), + Progress::AwaitingHistory if history == TranscriptHistoryState::Failed => { + ("History unavailable · ctrl+p retry", "ctrl+p retry") + } + Progress::AwaitingHistory => ("Searching earlier history…", "Loading…"), + Progress::Found => ("enter next · ctrl+p previous", "enter next"), + Progress::Exhausted if self.current.is_some() => ( + "No more matches · enter next · ctrl+p previous", + "enter next", + ), + Progress::Exhausted => ("No matches", "No matches"), + }; + let limit = if self.query_truncated { + " · query limited to 4 KiB" + } else { + "" + }; + crate::footer_hint::first_fitting_line( + [ + format!("{status} · full transcript · esc close{limit}"), + format!("{status} · esc close{limit}"), + format!("{compact} · esc close"), + format!("{compact} · esc"), + "esc close".to_owned(), + ] + .map(|hint| super::footer::navigation_line(&hint)), + width, + ) + } + + fn query_changed(&mut self) { + self.folded_query = self + .editor + .text() + .chars() + .flat_map(char::to_lowercase) + .collect(); + self.current = None; + self.scanning_layout = None; + self.page_start = None; + self.progress = Progress::Restart; + self.query_truncated = false; + } +} + +fn scan_window(text: &str, cursor: Cursor, query_len: usize) -> Range { + // A source character can occupy four bytes while its lowercase form occupies only one. + let overlap = query_len.saturating_mul(/*rhs*/ 4); + let extent = SCAN_BYTES.saturating_add(overlap); + match cursor.direction { + Direction::Older => { + text.floor_char_boundary(cursor.anchor.offset.saturating_sub(extent)) + ..cursor.anchor.offset + } + Direction::Newer => { + cursor.anchor.offset + ..text.floor_char_boundary( + cursor.anchor.offset.saturating_add(extent).min(text.len()), + ) + } + } +} + +fn find_literal(text: &str, query: &str, direction: Direction) -> Option> { + let mut folded = String::new(); + let mut spans = Vec::new(); + for (start, character) in text.char_indices() { + for lowercase in character.to_lowercase() { + folded.push(lowercase); + spans.push((folded.len(), start..start + character.len_utf8())); + } + } + let start = match direction { + Direction::Older => folded.rfind(query), + Direction::Newer => folded.find(query), + }?; + let first = spans.partition_point(|(end, _)| *end <= start); + let last = spans.partition_point(|(end, _)| *end < start + query.len()); + Some(spans.get(first)?.1.start..spans.get(last)?.1.end) +} + +#[cfg(test)] +#[path = "search_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/transcript_view/search_tests.rs b/codex-rs/tui/src/transcript_view/search_tests.rs new file mode 100644 index 0000000000..70c34fb158 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/search_tests.rs @@ -0,0 +1,1093 @@ +//! Literal search, history continuation, and presentation restoration share source offsets. + +use super::*; +use pretty_assertions::assert_eq; +use ratatui::style::Modifier; + +#[derive(Debug)] +struct TestCell(String); + +impl HistoryCell for TestCell { + fn display_lines(&self, _width: u16) -> Vec> { + vec![self.0.clone().into()] + } + + fn raw_lines(&self) -> Vec> { + self.display_lines(/*width*/ 80) + } +} + +fn cell(text: impl Into) -> Arc { + Arc::new(TestCell(text.into())) +} + +fn finish_scan(view: &mut TranscriptView, cells: &[Arc]) { + for _ in 0..512 { + if !view.advance_search(cells) { + return; + } + } + panic!("search did not yield after bounded scanning frames"); +} + +#[test] +fn selection_pauses_find_and_normalized_navigation_resumes_the_same_query() { + let cells = vec![cell("older needle"), cell("newer needle")]; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 4, + ); + let mut view = TranscriptView { + area, + ..TranscriptView::default() + }; + view.begin_search(); + view.paste_search("needle"); + finish_scan(&mut view, &cells); + view.render(area, &mut Buffer::empty(area), &cells); + assert_eq!(view.search.current.as_ref().unwrap().anchor.index, 1); + // A pending previous-match scan must not move the viewport beneath a copy gesture. + view.handle_key( + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), + &cells, + ); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + let selected = view.selected_text(&cells); + assert!(!view.advance_search(&cells)); + assert_eq!(view.selected_text(&cells), selected); + assert_eq!(view.search.current.as_ref().unwrap().anchor.index, 1); + let mut selected_frame = Buffer::empty(area); + view.render(area, &mut selected_frame, &cells); + view.handle_key(KeyCode::Esc.into(), &cells); + finish_scan(&mut view, &cells); + assert_eq!(view.search.current.as_ref().unwrap().anchor.index, 0); + view.handle_key( + KeyEvent::new(KeyCode::Char('\u{e}'), KeyModifiers::NONE), + &cells, + ); + finish_scan(&mut view, &cells); + assert_eq!(view.search.current.as_ref().unwrap().anchor.index, 1); + assert_eq!(view.search.editor.text(), "needle"); + view.handle_key( + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + &cells, + ); + assert!(!view.is_search_active()); + insta::assert_snapshot!( + "selection_owns_find_highlight", + format!("{selected_frame:?}") + ); +} + +#[test] +fn literal_search_preserves_unicode_source_offsets() { + let text = "界 CAFÉ [x].* İ"; + assert_eq!( + [ + find_literal(text, "café", Direction::Newer), + find_literal(text, "[x].*", Direction::Older), + find_literal(text, "i\u{307}", Direction::Newer), + find_literal(text, "missing", Direction::Older), + ], + [Some(4..9), Some(10..15), Some(16..18), None], + ); +} + +#[test] +fn scanning_yields_and_finds_a_match_crossing_a_chunk_boundary() { + let text = format!( + "{}nEeDlE{}", + "a".repeat(SCAN_BYTES - 2), + "z".repeat(SCAN_BYTES * 2) + ); + let cells = vec![cell(text)]; + let mut view = TranscriptView { + area: Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4, + ), + ..TranscriptView::default() + }; + view.begin_search(); + view.paste_search("needle"); + assert!(view.advance_search(&cells)); + assert!(view.search.current.is_none()); + view.prepare_width(/*width*/ 40); + finish_scan(&mut view, &cells); + let found = view + .search + .current + .as_ref() + .expect("match across chunk boundary"); + assert_eq!( + (found.anchor.offset, found.end), + (SCAN_BYTES - 2, SCAN_BYTES + 4) + ); +} + +#[test] +fn short_history_entries_share_a_frame_without_starving_input() { + let mut cells = vec![cell("needle in the oldest message")]; + cells.extend((0..64).map(|index| cell(format!("newer message {index}")))); + let mut view = TranscriptView::default(); + view.begin_search(); + view.paste_search("needle"); + + let mut frames = 1; + while view.advance_search(&cells) { + frames += 1; + assert!(frames < 16, "short entries must not cost one frame each"); + } + assert!(frames > 1, "large histories must still yield to input"); + let found = view.search.current.as_ref().expect("oldest match"); + assert_eq!( + (found.anchor.index, found.anchor.offset..found.end), + (0, 0..6) + ); +} + +#[test] +fn replacing_scanned_content_restarts_search_while_reading_another_entry() { + let retired = format!("retired needle{}", "z".repeat(SCAN_BYTES * 2)); + let mut cells = vec![cell("reader stays here"), cell(retired)]; + let mut view = TranscriptView { + area: Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4, + ), + ..TranscriptView::default() + }; + view.jump_to_entry(&cells, /*index*/ 0); + let reading = view.position; + view.begin_search(); + view.paste_search("needle"); + assert!(view.advance_search(&cells)); + + let replacement = cell("replacement needle"); + view.replace_range(&cells, 1..2, &replacement); + cells.splice(1..2, [Arc::clone(&replacement)]); + assert_eq!(view.position, reading); + finish_scan(&mut view, &cells); + let found = view.search.current.as_ref().expect("match in replacement"); + assert_eq!( + ( + found.anchor.key, + found.anchor.index, + found.anchor.offset..found.end + ), + (EntryKey::cell(&replacement), 1, 12..18), + ); +} + +#[test] +fn search_resumes_in_prepended_history_without_rescanning_the_newer_entry() { + let newest = cell("newer messages"); + let mut cells = vec![Arc::clone(&newest)]; + let mut view = TranscriptView { + history: TranscriptHistoryState::Partial, + ..TranscriptView::default() + }; + view.begin_search(); + view.paste_search("needle"); + finish_scan(&mut view, &cells); + assert!(view.search.needs_history(view.history)); + view.history = TranscriptHistoryState::LoadingOlder; + assert!(!view.advance_search(&cells)); + cells.insert(/*index*/ 0, cell("old NEEDLE")); + view.history = TranscriptHistoryState::Complete; + view.history_loaded(&cells, 0..1); + finish_scan(&mut view, &cells); + let found = view.search.current.as_ref().expect("match in earlier page"); + assert_eq!( + ( + found.anchor.key, + found.anchor.index, + found.anchor.offset, + found.end + ), + (EntryKey::cell(&cells[0]), 0, 4, 10) + ); + assert!(!view.search.needs_history(view.history)); +} + +#[test] +fn empty_find_keeps_following_new_output_after_a_page_join() { + let mut cells = vec![cell("previous computer action"), cell("latest answer")]; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 8, + ); + let mut view = TranscriptView { + area, + history: TranscriptHistoryState::LoadingOlder, + ..TranscriptView::default() + }; + view.begin_search(); + view.sync_live_tail(/*width*/ 40, /*key*/ None, |_| { + Some(vec![HyperlinkLine::from("stream before page")]) + }); + finish_scan(&mut view, &cells); + view.render(area, &mut Buffer::empty(area), &cells); + + cells.insert(/*index*/ 0, cell("older computer action")); + view.history_loaded(&cells, 0..1); + let group = cell("joined computer actions"); + view.replace_group(&cells, 0..2, &group); + cells.splice(0..2, [group]); + view.history = TranscriptHistoryState::Complete; + cells.push(cell("new committed answer")); + view.sync_live_tail(/*width*/ 40, /*key*/ None, |_| { + Some(vec![HyperlinkLine::from("stream after page")]) + }); + finish_scan(&mut view, &cells); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer, &cells); + assert_eq!( + ( + view.is_following(), + view.is_search_active(), + view.held_reading.is_none() + ), + (true, true, true), + "{buffer:?}", + ); + insta::assert_snapshot!( + "empty_find_follows_output_after_page_join", + format!("{buffer:?}") + ); +} + +#[test] +fn empty_find_preserves_reading_inside_a_joined_group() { + let mut cells = vec![cell("displayed computer action"), cell("latest answer")]; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 4, + ); + let mut view = TranscriptView { + area, + ..TranscriptView::default() + }; + view.jump_to_entry(&cells, /*index*/ 0); + view.begin_search(); + finish_scan(&mut view, &cells); + let mut before = Buffer::empty(area); + view.render(area, &mut before, &cells); + + cells.insert(/*index*/ 0, cell("older computer action")); + view.history_loaded(&cells, 0..1); + let group = cell("joined computer actions"); + view.replace_group(&cells, 0..2, &group); + cells.splice(0..2, [group]); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + &cells, + ); + view.handle_search_key( + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + &cells, + ); + finish_scan(&mut view, &cells); + let mut after = Buffer::empty(area); + view.render(area, &mut after, &cells); + assert_eq!(after, before); + view.cancel_search(); + view.render(area, &mut after, &cells); + assert_eq!(after, before); +} + +#[test] +fn previous_match_continues_through_regrouped_pages_without_revisiting_newer_hits() { + let middle = cell("needle middle"); + let newest = cell("needle newest"); + let mut cells = vec![ + cell("newer group"), + Arc::clone(&middle), + Arc::clone(&newest), + ]; + let mut view = TranscriptView { + history: TranscriptHistoryState::Partial, + ..TranscriptView::default() + }; + view.begin_search(); + view.paste_search("needle"); + finish_scan(&mut view, &cells); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), + &cells, + ); + finish_scan(&mut view, &cells); + assert_eq!( + view.search.current.as_ref().unwrap().anchor.key, + EntryKey::cell(&middle) + ); + + view.handle_search_key( + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), + &cells, + ); + finish_scan(&mut view, &cells); + assert!(view.search.needs_history(view.history)); + cells.splice(0..0, [cell("earlier context"), cell("older group")]); + view.history_loaded(&cells, 0..2); + let group = cell("joined computer group"); + view.replace_group(&cells, 1..3, &group); + cells.splice(1..3, [group]); + finish_scan(&mut view, &cells); + assert_eq!( + ( + view.search.current.as_ref().unwrap().anchor.key, + view.search.needs_history(view.history) + ), + (EntryKey::cell(&middle), true), + ); + insta::assert_snapshot!(view.search.status_line(/*width*/ 80, view.history).to_string(), @"Searching earlier history… · full transcript · esc close"); + + let oldest = cell("needle oldest"); + cells.insert(/*index*/ 0, Arc::clone(&oldest)); + view.history = TranscriptHistoryState::Complete; + view.history_loaded(&cells, 0..1); + finish_scan(&mut view, &cells); + let mut hits = vec![view.search.current.as_ref().unwrap().anchor.key]; + for character in ['n', 'n'] { + view.handle_search_key( + KeyEvent::new(KeyCode::Char(character), KeyModifiers::CONTROL), + &cells, + ); + finish_scan(&mut view, &cells); + hits.push(view.search.current.as_ref().unwrap().anchor.key); + } + assert_eq!( + hits, + vec![ + EntryKey::cell(&oldest), + EntryKey::cell(&middle), + EntryKey::cell(&newest) + ] + ); +} + +#[test] +fn previous_match_finds_the_prepended_part_of_a_regrouped_entry() { + let newest = cell("needle newest"); + let mut cells = vec![cell("newer group"), Arc::clone(&newest)]; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 30, /*height*/ 3, + ); + let mut view = TranscriptView { + area, + history: TranscriptHistoryState::Partial, + ..TranscriptView::default() + }; + view.begin_search(); + view.paste_search("needle"); + finish_scan(&mut view, &cells); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), + &cells, + ); + finish_scan(&mut view, &cells); + assert!(view.search.needs_history(view.history)); + + let older = cell("needle older prefix"); + cells.insert(/*index*/ 0, Arc::clone(&older)); + view.history = TranscriptHistoryState::Complete; + view.history_loaded(&cells, 0..1); + let group = cell("needle older prefix and newer group"); + view.replace_group(&cells, 0..2, &group); + cells.splice(0..2, [group]); + finish_scan(&mut view, &cells); + assert_eq!( + view.search.current.as_ref().unwrap().anchor.key, + EntryKey::cell(&older) + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer, &cells); + assert_eq!(view.visible[0].key, EntryKey::cell(&older)); + assert!(buffer[(0, 0)].modifier.contains(Modifier::REVERSED)); + + view.handle_search_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + &cells, + ); + finish_scan(&mut view, &cells); + assert_eq!( + ( + view.search.current.as_ref().unwrap().anchor.key, + view.held_reading.is_none() + ), + (EntryKey::cell(&newest), true) + ); +} + +#[test] +fn enter_preserves_the_initial_scan_and_each_older_page_until_the_first_match() { + let mut cells = vec![cell("recent text ".repeat(SCAN_BYTES))]; + let mut view = TranscriptView { + area: Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4, + ), + history: TranscriptHistoryState::Partial, + ..TranscriptView::default() + }; + view.begin_search(); + view.paste_search("LONG T0000 USER"); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + insta::assert_snapshot!(view.search.status_line(/*width*/ 80, view.history).to_string(), @"Searching… · full transcript · esc close"); + assert!(view.advance_search(&cells)); + let Progress::Scanning(before) = view.search.progress else { + panic!("initial scan should still have unread text"); + }; + let layout = Arc::clone( + &view + .search + .scanning_layout + .as_ref() + .expect("scanning layout") + .1, + ); + for modifiers in [KeyModifiers::NONE, KeyModifiers::SHIFT] { + view.handle_search_key(KeyEvent::new(KeyCode::Enter, modifiers), &cells); + } + let Progress::Scanning(after) = view.search.progress else { + panic!("confirmation must retain the scan cursor"); + }; + assert_eq!( + (after.anchor, after.direction), + (before.anchor, Direction::Older) + ); + assert!(Arc::ptr_eq( + &layout, + &view + .search + .scanning_layout + .as_ref() + .expect("retained layout") + .1 + )); + finish_scan(&mut view, &cells); + assert!(view.search.needs_history(view.history)); + insta::assert_snapshot!(view.search.status_line(/*width*/ 80, view.history).to_string(), @"Searching earlier history… · full transcript · esc close"); + + for page in ["middle page without a hit", "LONG T0000 USER"] { + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + assert!(view.search.needs_history(view.history)); + view.history = TranscriptHistoryState::LoadingOlder; + assert!(!view.advance_search(&cells)); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + assert!(!view.search.needs_history(view.history)); + cells.insert(/*index*/ 0, cell(page)); + view.history = TranscriptHistoryState::Partial; + view.history_loaded(&cells, 0..1); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + assert_eq!(view.search.page_start, Some(EntryKey::cell(&cells[0]))); + finish_scan(&mut view, &cells); + } + let found = view + .search + .current + .as_ref() + .expect("first match in oldest page"); + assert_eq!( + ( + found.anchor.key, + found.anchor.index, + found.anchor.offset..found.end + ), + (EntryKey::cell(&cells[0]), 0, 0..15), + ); + assert!(!view.search.needs_history(view.history)); +} + +#[test] +fn search_scans_a_page_inserted_after_the_retained_session_header() { + let header: Arc = + Arc::new(crate::history_cell::SessionHeaderHistoryCell::new( + "test model".to_string(), + /*reasoning_effort*/ None, + /*show_fast_status*/ false, + std::path::PathBuf::from("/project"), + "test", + )); + let mut cells = vec![Arc::clone(&header), cell("recent messages")]; + let mut view = TranscriptView { + area: Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4, + ), + history: TranscriptHistoryState::Partial, + ..TranscriptView::default() + }; + view.begin_search(); + view.paste_search("needle"); + finish_scan(&mut view, &cells); + assert!(view.search.needs_history(view.history)); + cells.insert(/*index*/ 1, cell("older NEEDLE")); + view.history = TranscriptHistoryState::Complete; + view.history_loaded(&cells, 1..2); + finish_scan(&mut view, &cells); + let found = view + .search + .current + .as_ref() + .expect("match in page after header"); + assert_eq!( + ( + found.anchor.key, + found.anchor.index, + found.anchor.offset, + found.end + ), + (EntryKey::cell(&cells[1]), 1, 6, 12) + ); + assert!(Arc::ptr_eq(&cells[0], &header)); +} + +#[test] +fn next_previous_highlight_and_cancel_share_the_transcript_position() { + let cells = vec![cell("Needle one"), cell("Needle two")]; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 3, + ); + let mut view = TranscriptView { + area, + history: TranscriptHistoryState::Complete, + ..TranscriptView::default() + }; + view.jump_to_entry(&cells, /*index*/ 0); + let original = view.position; + view.begin_search(); + view.paste_search("needle"); + finish_scan(&mut view, &cells); + assert_eq!( + view.search.current.as_ref().map(|found| found.anchor.index), + Some(1) + ); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), &cells); + finish_scan(&mut view, &cells); + assert_eq!( + view.search.current.as_ref().map(|found| found.anchor.index), + Some(0) + ); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + finish_scan(&mut view, &cells); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer, &cells); + assert_eq!( + (0..10) + .map(|column| buffer[(column, 0)].modifier.contains(Modifier::REVERSED)) + .collect::>(), + vec![ + true, true, true, true, true, true, false, false, false, false + ] + ); + insta::assert_snapshot!(view.search.status_line(/*width*/ 80, view.history).to_string(), @"enter next · ctrl+p previous · full transcript · esc close"); + view.handle_search_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &cells); + assert!(matches!(view.search.progress, Progress::Found)); + view.handle_search_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &cells); + assert_eq!((view.position, view.search.is_active()), (original, false)); +} + +#[test] +fn legacy_search_shortcuts_navigate_in_both_directions_without_wrapping() { + let cells = vec![ + cell("SHORT T0000 USER]"), + cell("SHORT T0001 USER]"), + cell("SHORT T0002 USER]"), + ]; + let mut view = TranscriptView { + history: TranscriptHistoryState::Complete, + ..TranscriptView::default() + }; + view.begin_search(); + view.paste_search("USER]"); + finish_scan(&mut view, &cells); + let mut positions = Vec::new(); + for character in ['p', 'p', 'p', 'n', 'n', 'n'] { + view.handle_key( + KeyEvent::new(KeyCode::Char(character), KeyModifiers::CONTROL), + &cells, + ); + finish_scan(&mut view, &cells); + positions.push(( + view.search.current.as_ref().unwrap().anchor.index, + matches!(view.search.progress, Progress::Exhausted), + )); + } + assert_eq!( + positions, + vec![ + (1, false), + (0, false), + (0, true), + (1, false), + (2, false), + (2, true) + ], + ); + insta::assert_snapshot!(view.search.status_line(/*width*/ 80, view.history).to_string(), @"No more matches · enter next · ctrl+p previous · full transcript · esc close"); +} + +#[test] +fn find_reveals_hidden_command_output_and_restores_compact_presentation() { + let output = format!( + "{}hidden needle\n{}", + "head\n".repeat(/*n*/ 20), + "tail\n".repeat(/*n*/ 20) + ); + let command = crate::exec_cell::ExecCell::new( + crate::exec_cell::ExecCall { + call_id: "command".to_string(), + command: vec!["echo".to_string()], + parsed: Vec::new(), + output: Some(crate::exec_cell::CommandOutput::new( + /*exit_code*/ 0, output, + )), + source: codex_app_server_protocol::CommandExecutionSource::Agent, + start_time: None, + duration: None, + interaction_input: None, + }, + /*animations_enabled*/ false, + ); + assert!( + !command + .display_lines(/*width*/ 80) + .iter() + .any(|line| line.to_string().contains("hidden needle")) + ); + let mut cells: Vec> = vec![Arc::new(command)]; + let mut view = TranscriptView { + area: Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4, + ), + ..TranscriptView::default() + }; + view.begin_search(); + view.paste_search("hidden needle"); + finish_scan(&mut view, &cells); + assert!(view.search.current.is_some()); + assert!(view.is_detailed()); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL), + &cells, + ); + assert!(view.is_detailed()); + view.handle_search_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &cells); + assert_eq!( + (view.is_detailed(), view.position), + (false, Position::Latest) + ); + view.jump_to_entry(&cells, /*index*/ 0); + let mut compact = Buffer::empty(view.area); + view.render(view.area, &mut compact, &cells); + view.begin_search(); + view.render(view.area, &mut Buffer::empty(view.area), &cells); + let joined = cell("regrouped command output"); + view.replace_group(&cells, 0..1, &joined); + cells[0] = joined; + view.cancel_search(); + let mut restored = Buffer::empty(view.area); + view.render(view.area, &mut restored, &cells); + assert_eq!(restored, compact); +} + +#[test] +fn search_preserves_both_presentation_positions() { + let cells = vec![ + cell("old needle"), + cell("saved detailed position"), + cell("saved compact position"), + ]; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 30, /*height*/ 2, + ); + for detailed in [false, true] { + let mut view = TranscriptView::default(); + view.render(area, &mut Buffer::empty(area), &cells); + view.set_presentation(/*detailed*/ true, HistoryRenderMode::Rich); + view.jump_to_entry(&cells, /*index*/ 1); + let detailed_position = view.position; + view.set_presentation(/*detailed*/ false, HistoryRenderMode::Rich); + view.jump_to_entry(&cells, /*index*/ 2); + let compact_position = view.position; + view.set_presentation(detailed, HistoryRenderMode::Rich); + let positions = (view.position, view.saved_position); + + view.begin_search(); + finish_scan(&mut view, &cells); + assert_eq!((view.position, view.saved_position), positions); + view.render(area, &mut Buffer::empty(area), &cells); + + view.paste_search("needle"); + finish_scan(&mut view, &cells); + assert_eq!(view.search.current.as_ref().unwrap().anchor.index, 0); + view.handle_search_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &cells); + assert_eq!((view.position, view.saved_position), positions); + view.render(area, &mut Buffer::empty(area), &cells); + + view.set_presentation(!detailed, HistoryRenderMode::Rich); + assert_eq!( + view.position, + if detailed { + compact_position + } else { + detailed_position + }, + ); + view.render(area, &mut Buffer::empty(area), &cells); + } +} + +#[test] +fn failed_history_waits_for_explicit_retry_and_empty_query_cancels_loading() { + let cells = vec![cell("nothing here")]; + let mut view = TranscriptView { + history: TranscriptHistoryState::Partial, + ..TranscriptView::default() + }; + view.begin_search(); + view.paste_search("needle"); + finish_scan(&mut view, &cells); + view.history = TranscriptHistoryState::LoadingOlder; + assert!(!view.search.needs_history(view.history)); + // Loader transitions immediately determine requests and hints, without another search frame. + view.history = TranscriptHistoryState::Failed; + assert!(!view.search.needs_history(view.history)); + assert_eq!( + view.search + .status_line(/*width*/ 32, view.history) + .to_string(), + "ctrl+p retry · esc close", + ); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + assert_eq!(view.history, TranscriptHistoryState::Failed); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), + &cells, + ); + view.advance_search(&cells); + assert!(view.search.needs_history(view.history)); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + &cells, + ); + assert!(!view.advance_search(&cells)); + assert!(!view.search.needs_history(view.history)); + assert_eq!(view.position, Position::Latest); +} + +#[test] +fn live_scanning_restarts_when_its_source_changes() { + let mut view = TranscriptView { + area: Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4, + ), + ..TranscriptView::default() + }; + view.begin_search(); + let retired = format!("needle{}", "z".repeat(SCAN_BYTES * 2)); + view.sync_live_tail( + /*width*/ 80, + /*key*/ None, + |_| Some(vec![HyperlinkLine::from(retired)]), + ); + view.paste_search("needle"); + assert!(view.advance_search(&[])); + view.sync_live_tail( + /*width*/ 80, + /*key*/ None, + |_| Some(vec![HyperlinkLine::from("new needle")]), + ); + finish_scan(&mut view, &[]); + let found = view + .search + .current + .as_ref() + .expect("current live source match"); + assert_eq!(found.anchor.offset..found.end, 4..10); +} + +#[test] +fn live_match_stays_displayed_after_commit_and_a_fresh_query_searches_current_content() { + let mut cells = vec![cell("earlier needle")]; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 30, /*height*/ 3, + ); + let mut view = TranscriptView { + area, + ..TranscriptView::default() + }; + view.begin_search(); + view.sync_live_tail( + /*width*/ 30, + /*key*/ None, + |_| Some(vec![HyperlinkLine::from("live needle")]), + ); + view.paste_search("needle"); + finish_scan(&mut view, &cells); + view.sync_live_tail(/*width*/ 30, /*key*/ None, |_| { + Some(vec![HyperlinkLine::from("live needle again needle")]) + }); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer, &cells); + assert_eq!(view.visible[0].layout.text(), "live needle"); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + finish_scan(&mut view, &cells); + let found = view.search.current.as_ref().expect("new live match"); + assert_eq!( + (found.anchor.key, found.anchor.offset..found.end), + (EntryKey::Live, 18..24) + ); + cells.push(cell("live needle again needle committed needle")); + view.sync_live_tail(/*width*/ 30, /*key*/ None, |_| { + Some(vec![HyperlinkLine::from("current needle")]) + }); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer, &cells); + assert!(view.held_reading.is_some()); + assert_eq!(view.visible[0].layout.text(), "live needle again needle"); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + finish_scan(&mut view, &cells); + let found = view.search.current.as_ref().expect("new committed match"); + assert_eq!( + (found.anchor.key, found.anchor.offset..found.end), + (EntryKey::cell(&cells[1]), 35..41) + ); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + finish_scan(&mut view, &cells); + let found = view.search.current.as_ref().expect("current live match"); + assert_eq!( + (found.anchor.key, found.anchor.offset..found.end), + (EntryKey::Live, 8..14) + ); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), &cells); + finish_scan(&mut view, &cells); + assert!(view.held_reading.is_none()); + assert_eq!( + view.search + .current + .as_ref() + .expect("earlier match") + .anchor + .key, + EntryKey::cell(&cells[1]) + ); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + &cells, + ); + view.paste_search("current"); + finish_scan(&mut view, &cells); + let found = view.search.current.as_ref().expect("fresh current match"); + assert_eq!( + (found.anchor.key, found.anchor.offset..found.end), + (EntryKey::Live, 0..7) + ); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + finish_scan(&mut view, &cells); + let found = view + .search + .current + .as_ref() + .expect("exhausted search retains its match"); + assert_eq!( + (found.anchor.key, found.anchor.offset..found.end), + (EntryKey::Live, 0..7) + ); + cells.push(cell("new current")); + view.sync_live_tail( + /*width*/ 30, + /*key*/ None, + |_| Some(vec![HyperlinkLine::from("other")]), + ); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + finish_scan(&mut view, &cells); + let found = view + .search + .current + .as_ref() + .expect("commit after exhausting live matches"); + assert_eq!( + (found.anchor.key, found.anchor.offset..found.end), + (EntryKey::cell(&cells[2]), 4..11) + ); + view.jump_to_latest(); + assert!(view.held_reading.is_none()); + assert!(view.search.current.is_none()); +} + +#[test] +fn leaving_a_regrouped_search_match_restarts_from_current_history() { + let retired = cell("retired needle"); + let mut cells = vec![ + Arc::clone(&retired), + cell("trailing words ".repeat(/*n*/ 20)), + ]; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 30, /*height*/ 3, + ); + let mut view = TranscriptView { + area, + ..TranscriptView::default() + }; + view.begin_search(); + view.paste_search("needle"); + finish_scan(&mut view, &cells); + view.render(area, &mut Buffer::empty(area), &cells); + + cells.splice(0..0, [cell("surviving before"), cell("older group")]); + view.history_loaded(&cells, 0..2); + let replacement = cell("merged needle"); + view.replace_group(&cells, 1..3, &replacement); + cells.splice(1..3, [Arc::clone(&replacement)]); + finish_scan(&mut view, &cells); + assert_eq!( + view.search.current.as_ref().expect("held match").anchor.key, + EntryKey::cell(&retired) + ); + assert!(view.held_reading.is_some()); + + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + assert!(view.held_reading.is_none()); + assert!(view.search.current.is_none()); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + finish_scan(&mut view, &cells); + let found = view.search.current.as_ref().expect("current group match"); + assert_eq!( + ( + found.anchor.key, + found.anchor.index, + found.anchor.offset..found.end, + ), + (EntryKey::cell(&replacement), 1, 7..13), + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer, &cells); + assert_eq!(view.visible[0].key, EntryKey::cell(&replacement)); + assert!(buffer[(7, 0)].modifier.contains(Modifier::REVERSED)); +} + +#[test] +fn refining_a_query_restores_the_reading_position_while_history_loads() { + let cells = vec![ + cell("Earlier context"), + cell("Middle context"), + cell("Last answer"), + cell("Fixture link"), + ]; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 30, /*height*/ 6, + ); + let mut view = TranscriptView { + history: TranscriptHistoryState::Partial, + ..TranscriptView::default() + }; + for position in [ + Position::Latest, + Position::Reading(Anchor { + key: EntryKey::cell(&cells[0]), + index: 0, + offset: 0, + row_bias: 0, + }), + ] { + view.position = position; + view.begin_search(); + let mut before = Buffer::empty(area); + view.render(area, &mut before, &cells); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE), + &cells, + ); + finish_scan(&mut view, &cells); + assert_eq!( + view.search.current.as_ref().unwrap().anchor.key, + EntryKey::cell(&cells[3]) + ); + + view.paste_search("IRST ANSWER"); + finish_scan(&mut view, &cells); + assert!(view.search.needs_history(view.history)); + let mut loading = Buffer::empty(area); + view.render(area, &mut loading, &cells); + assert_eq!(loading, before); + view.handle_search_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &cells); + } +} + +#[test] +fn bounded_query_paste_keeps_the_suffix_and_whole_graphemes() { + let mut view = TranscriptView::default(); + view.begin_search(); + view.paste_search("X\r\nY\rZ"); + assert_eq!(view.search.editor.text(), "X\nY\nZ"); + view.cancel_search(); + view.begin_search(); + let original = format!("{}Z", "a".repeat(QUERY_BYTES - 2)); + view.paste_search(&original); + view.handle_search_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &[]); + view.paste_search("e\u{301}"); + assert_eq!( + (view.search.editor.text(), view.search.query_truncated), + (original.as_str(), true) + ); + view.paste_search("b"); + let full = format!("{}bZ", "a".repeat(QUERY_BYTES - 2)); + view.handle_search_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), &[]); + assert_eq!( + (view.search.editor.text(), view.search.query_truncated), + (full.as_str(), true) + ); + insta::assert_snapshot!(view.search.status_line(/*width*/ 100, view.history).to_string(), @"Searching… · full transcript · esc close · query limited to 4 KiB"); + view.handle_search_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE), &[]); + assert_eq!(view.search.editor.text(), &full[..full.len() - 1]); +} + +#[test] +fn query_projection_scrolls_the_existing_editor_and_tracks_its_caret() { + let mut view = TranscriptView::default(); + view.begin_search(); + view.paste_search("abcdefghijklmno"); + let (end, end_cursor) = view.search_footer(/*width*/ 14).expect("query footer"); + view.handle_search_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), &[]); + let (start, start_cursor) = view.search_footer(/*width*/ 14).expect("query footer"); + assert_eq!( + (end.to_string(), end_cursor, start.to_string(), start_cursor), + ( + "Find: ijklmno ".to_string(), + 13, + "Find: abcdefgh".to_string(), + 6 + ), + ); +} + +#[test] +fn selection_keys_precede_find_and_typing_returns_to_query() { + let cells = vec![cell("needle")]; + let mut view = TranscriptView::default(); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 3, + ); + view.render(area, &mut Buffer::empty(area), &cells); + view.begin_search(); + view.paste_search("needle"); + finish_scan(&mut view, &cells); + view.render(area, &mut Buffer::empty(area), &cells); + view.handle_key( + KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL), + &cells, + ); + view.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &cells); + assert!(matches!( + view.handle_key( + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + &cells + ), + Some(ViewAction::Copy(_)) + )); + assert!(view.is_search_active()); + view.handle_key( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + &cells, + ); + assert_eq!( + (view.selection.is_none(), view.search.editor.text()), + (true, "needlex") + ); +} diff --git a/codex-rs/tui/src/transcript_view/selection.rs b/codex-rs/tui/src/transcript_view/selection.rs index 0c9ca400f3..1cd6d3698e 100644 --- a/codex-rs/tui/src/transcript_view/selection.rs +++ b/codex-rs/tui/src/transcript_view/selection.rs @@ -83,7 +83,7 @@ impl TranscriptView { } } - /// Rejoin current history, retaining a replaced group until navigation leaves its revision. + /// Rejoin current history, retaining Find offsets or a replaced group until navigation. /// 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 { @@ -100,7 +100,9 @@ impl TranscriptView { self.hold_position(); } if let Position::Reading(anchor) = self.position { - if !cells.iter().any(|cell| EntryKey::cell(cell) == anchor.key) { + if self.search.has_active_query() + || !cells.iter().any(|cell| EntryKey::cell(cell) == anchor.key) + { self.held_reading = Some(selection.snapshot); return; } diff --git a/codex-rs/tui/src/transcript_view/selection_tests.rs b/codex-rs/tui/src/transcript_view/selection_tests.rs index 16ae5373d2..435966a390 100644 --- a/codex-rs/tui/src/transcript_view/selection_tests.rs +++ b/codex-rs/tui/src/transcript_view/selection_tests.rs @@ -18,6 +18,83 @@ use crossterm::event::MouseEventKind; use pretty_assertions::assert_eq; use std::time::Instant; +#[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] { + for searching in [false, true] { + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 32, /*height*/ 3); + if searching { + view.begin_search(); + } + 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), view.is_search_active()), + ( + (status == CopyStatus::Unconfirmed).then(|| text.clone()), + searching + ) + ); + assert_eq!(view.selection.is_none(), status == CopyStatus::Confirmed); + if !searching && status == CopyStatus::Confirmed { + assert!( + view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells) + .is_none() + ); + } + } + } + } +} + #[test] fn command_c_ignores_release_and_copies_only_an_active_selection() { let cells: Vec> = vec![Arc::new(PlainHistoryCell::new(vec![ @@ -500,74 +577,3 @@ fn loading_older_history_extends_the_snapshot_without_adopting_new_commits() { 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 index 5bc30c129b..0b6f55390b 100644 --- a/codex-rs/tui/src/transcript_view/snapshot.rs +++ b/codex-rs/tui/src/transcript_view/snapshot.rs @@ -8,6 +8,7 @@ use super::*; pub(super) struct ViewSnapshot { pub(super) cells: Arc<[Arc]>, pub(super) pinned: HashMap>, + pub(super) activities: HashMap>, } impl TranscriptView { @@ -36,18 +37,35 @@ impl TranscriptView { .snapshot() .map(|snapshot| snapshot.pinned.clone()) .unwrap_or_default(); + let mut activities = self + .snapshot() + .map(|snapshot| snapshot.activities.clone()) + .unwrap_or_default(); pinned.extend( self.visible .iter() .map(|visible| (visible.key, Arc::clone(&visible.layout))), ); + activities.extend( + self.visible + .iter() + .map(|visible| (visible.key, Arc::clone(&visible.activity_ids))), + ); if let Some(live) = self.layout(&cells, cells.len()) { pinned.insert(EntryKey::Live, live); + activities.insert( + EntryKey::Live, + self.displayed_activity_ids(&cells, cells.len()), + ); + } + ViewSnapshot { + cells, + pinned, + activities, } - ViewSnapshot { cells, pinned } } - /// Keep a live or replaced group's revision while reading inside it. Ordinary navigation + /// Keep a live, mutable, or replaced cell revision while reading inside it. Navigation /// rejoins current history when it reaches a surviving cell. pub(super) fn hold_live_reading( &mut self, @@ -61,10 +79,20 @@ impl TranscriptView { self.release_live_reading(); return; }; - if anchor.key == EntryKey::Live { - if self.held_reading.is_none() { + let mutable_cell = cells.get(anchor.index).is_some_and(|cell| { + EntryKey::cell(cell) == anchor.key + && (!cell.has_stable_transcript_height() + || cell.transcript_animation_tick().is_some()) + }); + if anchor.key == EntryKey::Live || mutable_cell { + if self + .held_reading + .as_ref() + .is_none_or(|snapshot| !snapshot.pinned.contains_key(&anchor.key)) + { + self.held_reading = None; let mut snapshot = self.capture_snapshot(cells); - snapshot.pinned.insert(EntryKey::Live, layout); + snapshot.pinned.insert(anchor.key, layout); self.held_reading = Some(snapshot); } } else if self.held_reading.is_some() @@ -79,7 +107,9 @@ impl TranscriptView { /// 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; + if self.held_reading.take().is_some() { + self.invalidate_held_search(); + } } pub(super) fn rewrap_snapshot(&mut self, width: u16) { diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__follow_control__tests__follow_control.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__follow_control__tests__follow_control.snap index 7a83407326..473e50cf4e 100644 --- a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__follow_control__tests__follow_control.snap +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__follow_control__tests__follow_control.snap @@ -1,6 +1,6 @@ --- source: tui/src/transcript_view/follow_control_tests.rs -assertion_line: 173 +assertion_line: 212 expression: "snapshots.join(\"\\n\\n\")" --- paused diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__activity_alternative_focuses_details_and_hints_follow_configured_chords.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__activity_alternative_focuses_details_and_hints_follow_configured_chords.snap new file mode 100644 index 0000000000..2c9d624e99 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__activity_alternative_focuses_details_and_hints_follow_configured_chords.snap @@ -0,0 +1,56 @@ +--- +source: tui/src/transcript_view/footer_tests.rs +expression: "hints.join(\"\\n\")" +--- +collapsed +Buffer { + area: Rect { x: 0, y: 0, width: 32, height: 3 }, + content: [ + "Activity summary ", + " + Show details ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} +20 columns: f4 inspect activity +32 columns: f4 inspect activity +80 columns: f4 inspect activity +focused +Buffer { + area: Rect { x: 0, y: 0, width: 32, height: 3 }, + content: [ + "Activity summary ", + " + Show details ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: UNDERLINED, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 18, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} +expanded +Buffer { + area: Rect { x: 0, y: 0, width: 32, height: 3 }, + content: [ + "Activity summary ", + "Hidden details ", + " − Show less ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: UNDERLINED, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 15, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +} +"ctrl-x t": ctrl+x t inspect activity +["ctrl-x t","f4"]: ctrl+x t/f4 inspect activity +[]: unbound diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__loading_and_failure_preserve_search_query_and_caret.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__loading_and_failure_preserve_search_query_and_caret.snap new file mode 100644 index 0000000000..a82e6b830d --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__loading_and_failure_preserve_search_query_and_caret.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/transcript_view/footer_tests.rs +assertion_line: 166 +expression: "hints.join(\"\\n\")" +--- +32 columns, LoadingOlder: Loading… · esc close +32 columns, LoadingBeginning: Loading… · esc close +32 columns, Failed: ctrl+p retry · esc close +12 columns, LoadingOlder: esc close +12 columns, LoadingBeginning: esc close +12 columns, Failed: esc close diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__narrow_selection_and_search_keep_complete_exit_hints.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__narrow_selection_and_search_keep_complete_exit_hints.snap new file mode 100644 index 0000000000..b601ab3ac7 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__narrow_selection_and_search_keep_complete_exit_hints.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/transcript_view/footer_tests.rs +expression: "snapshots.join(\"\\n\")" +--- +search=false, 12 columns: esc clear +search=false, 20 columns: enter copy+↓ · esc +search=false, 32 columns: enter copy & follow · esc clear +search=false, 80 columns: ctrl+c copy · enter copy & follow · esc clear +search=true, 12 columns: esc close +search=true, 20 columns: enter next · esc +search=true, 32 columns: enter next · esc close +search=true, 80 columns: enter next · ctrl+p previous · full transcript · esc close diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__selection_hint_threshold.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__selection_hint_threshold.snap new file mode 100644 index 0000000000..f8520a5163 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__selection_hint_threshold.snap @@ -0,0 +1,31 @@ +--- +source: tui/src/transcript_view/footer_tests.rs +expression: snapshot +--- +searching=false · mouse down + + +searching=false · one character +ctrl+c copy · enter copy & follow · esc clear + +searching=false · back to anchor + + +searching=false · released empty + + +searching=true · mouse down +Find: +Searching… · full transcript · esc close + +searching=true · one character +Find: +ctrl+c copy · enter copy & follow · esc clear + +searching=true · back to anchor +Find: +Searching… · full transcript · esc close + +searching=true · released empty +Find: +Searching… · full transcript · esc close diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__transcript_footer_keys_remain_distinct_from_prose_and_query_text.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__transcript_footer_keys_remain_distinct_from_prose_and_query_text.snap new file mode 100644 index 0000000000..857fde54bd --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__transcript_footer_keys_remain_distinct_from_prose_and_query_text.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/transcript_view/footer_tests.rs +expression: 'format!("{buffer:?}")' +--- +Buffer { + area: Rect { x: 0, y: 0, width: 80, height: 2 }, + content: [ + "Find: Activity ", + "enter next · ctrl+p previous · full transcript · esc close ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 6, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 5, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 19, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 49, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 52, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 58, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__search__tests__empty_find_follows_output_after_page_join.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__search__tests__empty_find_follows_output_after_page_join.snap new file mode 100644 index 0000000000..6824c2eeb1 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__search__tests__empty_find_follows_output_after_page_join.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/transcript_view/search_tests.rs +assertion_line: 203 +expression: "format!(\"{buffer:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 8 }, + content: [ + "joined computer actions ", + " ", + "latest answer ", + " ", + "new committed answer ", + " ", + "stream after page ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__search__tests__selection_owns_find_highlight.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__search__tests__selection_owns_find_highlight.snap new file mode 100644 index 0000000000..09c5557ff5 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__search__tests__selection_owns_find_highlight.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/transcript_view/search_tests.rs +assertion_line: 63 +expression: "format!(\"{selected_frame:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 32, height: 4 }, + content: [ + "newer needle ", + " ", + " ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED, + x: 12, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/transcript_view/text.rs b/codex-rs/tui/src/transcript_view/text.rs index e5cdcd840d..fb2ac357fb 100644 --- a/codex-rs/tui/src/transcript_view/text.rs +++ b/codex-rs/tui/src/transcript_view/text.rs @@ -19,10 +19,12 @@ use ratatui::layout::Alignment; use ratatui::layout::Rect; use ratatui::style::Modifier; use ratatui::style::Style; +use ratatui::style::Stylize; use ratatui::widgets::Widget; use unicode_segmentation::UnicodeSegmentation; use crate::line_truncation::line_width; +use crate::line_truncation::truncate_line_to_width; use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_hyperlinks::HyperlinkParagraph; use crate::terminal_hyperlinks::LineWrapPolicy; @@ -44,6 +46,14 @@ pub(super) struct TextLayout { rows: Vec, width: u16, pub(super) separated: bool, + pub(super) disclosure: bool, + disclosure_control: Option, +} + +struct DisclosureControl { + label: String, + source_offset: usize, + row: usize, } struct TextRow { @@ -63,8 +73,11 @@ impl TextLayout { /// 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); - + let mut layout = Self::from_logical(self.logical.clone(), width); + if let Some(control) = &self.disclosure_control { + layout = + layout.with_disclosure_control_at(control.label.clone(), control.source_offset); + } if self.separated { layout.with_leading_separator() } else { @@ -72,6 +85,57 @@ impl TextLayout { } } + /// Append a clipped interaction row outside the copied and searched source text. + pub(super) fn with_disclosure_control(self, label: String) -> Self { + let source_offset = self.text.len(); + self.with_disclosure_control_at(label, source_offset) + } + + /// Place a control after an activity, before any following auxiliary source text. + pub(super) fn with_disclosure_control_at( + mut self, + label: String, + source_offset: usize, + ) -> Self { + if self.text.is_empty() || label.is_empty() { + return self; + } + if let Some(control) = self.disclosure_control.take() { + self.rows.remove(control.row); + } + let source_offset = self + .text + .floor_char_boundary(source_offset.min(self.text.len())); + let control_row = self + .rows + .iter() + .rposition(|row| row.source.end <= source_offset) + .map_or(/*default*/ 0, |row| row + 1); + let indent = usize::from(self.width / 4).min(/*other*/ 4); + let line = truncate_line_to_width( + Line::from(format!("{}{label}", " ".repeat(indent))).dim(), + usize::from(self.width), + ); + self.rows.insert( + control_row, + TextRow { + line: HyperlinkLine::from(line), + source: source_offset..source_offset, + content_width: self.width, + first_column: indent, + prefix_columns: indent, + tabs: tabs::TabStops::default(), + }, + ); + self.disclosure = true; + self.disclosure_control = Some(DisclosureControl { + label, + source_offset, + row: control_row, + }); + self + } + /// Add visual spacing between entries without changing any source position. pub(super) fn with_leading_separator(mut self) -> Self { if !self.separated && !self.rows.is_empty() { @@ -86,7 +150,9 @@ impl TextLayout { tabs: tabs::TabStops::default(), }, ); - + if let Some(control) = &mut self.disclosure_control { + control.row += 1; + } self.separated = true; } self @@ -116,6 +182,17 @@ impl TextLayout { self.rows.len() } + pub(super) fn disclosure_row(&self) -> Option { + self.disclosure_control.as_ref().map(|control| control.row) + } + + pub(super) fn disclosure_columns(&self) -> Range { + let Some(row) = self.disclosure_row().and_then(|row| self.rows.get(row)) else { + return 0..0; + }; + row.first_column as u16..line_width(&row.line.line).min(usize::from(self.width)) as u16 + } + /// 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) { @@ -147,7 +224,17 @@ impl TextLayout { /// Find the display row containing a source position, including omitted wrapping whitespace. pub(super) fn row_for_offset(&self, offset: usize) -> usize { - self.rows + let before_control = if let Some(control_row) = self.disclosure_row() { + let after_control = &self.rows[control_row + 1..]; + let matching_rows = after_control.partition_point(|row| row.source.start <= offset); + if matching_rows > 0 { + return control_row + matching_rows; + } + control_row + } else { + self.rows.len() + }; + self.rows[..before_control] .partition_point(|row| row.source.start <= offset) .saturating_sub(/*rhs*/ 1) } @@ -261,6 +348,8 @@ impl TextLayout { rows, width, separated: false, + disclosure: false, + disclosure_control: None, } } } diff --git a/codex-rs/tui/src/transcript_view_tests.rs b/codex-rs/tui/src/transcript_view_tests.rs index fe295e67b1..5b2252094c 100644 --- a/codex-rs/tui/src/transcript_view_tests.rs +++ b/codex-rs/tui/src/transcript_view_tests.rs @@ -148,6 +148,52 @@ fn beginning_wait_keeps_live_content_through_new_output_prepend_and_resize() { ); } +#[test] +fn transcript_interaction_cancels_pending_beginning_without_losing_received_history() { + for key in [ + None, + Some(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)), + Some(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)), + Some(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)), + ] { + let mut cells = (0..20) + .map(|index| cell(format!("line {index}"))) + .collect::>(); + let mut view = TranscriptView { + history: TranscriptHistoryState::Partial, + ..TranscriptView::default() + }; + render(&mut view, &cells, /*width*/ 24, /*height*/ 3); + view.jump_to_beginning(&cells); + match key { + Some(key) if key.code == KeyCode::F(3) => view.begin_search(), + Some(key) => { + view.handle_key(key, &cells); + } + None => { + view.handle_mouse( + MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }, + &cells, + ); + } + } + assert_eq!(view.history, TranscriptHistoryState::LoadingOlder); + let before = render(&mut view, &cells, /*width*/ 24, /*height*/ 3); + cells.insert(/*index*/ 0, cell("received older history")); + view.history_loaded(&cells, 0..1); + view.history = TranscriptHistoryState::Complete; + assert_eq!( + render(&mut view, &cells, /*width*/ 24, /*height*/ 3), + before + ); + } +} + #[test] fn returning_to_latest_cancels_beginning_intent_while_retaining_the_loaded_page() { for (beginning, latest) in [ @@ -518,6 +564,29 @@ fn mutable_history_formats_once_per_frame_and_refreshes_the_next_frame() { version 2 row 6 version 2 row 7 "); + view.begin_search(); + view.paste_search("version 2"); + assert!(!view.advance_search(&cells)); + let matched = text(&render( + &mut view, &cells, /*width*/ 24, /*height*/ 5, + )); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + version.store(/*val*/ 3, Ordering::Relaxed); + view.end_selection(&cells); + assert_eq!( + text(&render( + &mut view, &cells, /*width*/ 24, /*height*/ 5 + )), + matched + ); + view.handle_key(KeyCode::Enter.into(), &cells); + assert!(!view.advance_search(&cells)); + assert!( + text(&render( + &mut view, &cells, /*width*/ 24, /*height*/ 5 + )) + .contains("version 3") + ); } #[test] @@ -575,6 +644,15 @@ fn live_reader_survives_commit_new_tail_resize_and_selection_then_rejoins_latest ); view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); assert_eq!(view.selected_text(&cells), Some("live two\n".to_string())); + view.begin_search(); + assert!(!view.advance_search(&cells)); + view.handle_key(KeyCode::Esc.into(), &cells); + assert_eq!( + text(&render( + &mut view, &cells, /*width*/ 20, /*height*/ 2 + )), + before + ); view.jump_to_latest(); assert!(view.snapshot().is_none()); insta::assert_snapshot!(text(&render(&mut view, &cells, /*width*/ 20, /*height*/ 2)), @"