diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 93392f1f7e..6e4b28feb0 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -335,6 +335,10 @@ pub struct TuiPagerKeymap { pub close: Option, /// Close the transcript overlay via its dedicated toggle key. pub close_transcript: Option, + /// Jump to the previous user prompt in the transcript overlay. + pub previous_user_prompt: Option, + /// Jump to the next user prompt in the transcript overlay. + pub next_user_prompt: Option, } /// List selection context keybindings for popup-style selectable lists. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 77d2c97d6e..b2b161a9f2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2842,8 +2842,10 @@ "half_page_up": null, "jump_bottom": null, "jump_top": null, + "next_user_prompt": null, "page_down": null, "page_up": null, + "previous_user_prompt": null, "scroll_down": null, "scroll_up": null }, @@ -3526,8 +3528,10 @@ "half_page_up": null, "jump_bottom": null, "jump_top": null, + "next_user_prompt": null, "page_down": null, "page_up": null, + "previous_user_prompt": null, "scroll_down": null, "scroll_up": null } @@ -3749,6 +3753,14 @@ ], "description": "Jump to the beginning." }, + "next_user_prompt": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Jump to the next user prompt in the transcript overlay." + }, "page_down": { "allOf": [ { @@ -3765,6 +3777,14 @@ ], "description": "Scroll up by one page." }, + "previous_user_prompt": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Jump to the previous user prompt in the transcript overlay." + }, "scroll_down": { "allOf": [ { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4a8ec3b907..e0fc437216 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -62,6 +62,7 @@ use crate::multi_agents::format_agent_picker_item_name; use crate::multi_agents::next_agent_shortcut_matches; use crate::multi_agents::previous_agent_shortcut_matches; use crate::pager_overlay::Overlay; +use crate::pager_overlay::TranscriptOverlayState; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; @@ -170,7 +171,6 @@ use crossterm::event::KeyModifiers; use ratatui::backend::Backend; use ratatui::layout::Rect; use ratatui::style::Stylize; -use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; @@ -498,6 +498,7 @@ pub(crate) struct App { // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, + pub(crate) transcript_overlay_state: TranscriptOverlayState, pub(crate) deferred_history_lines: Vec, has_emitted_history_lines: bool, transcript_reflow: TranscriptReflowState, @@ -982,6 +983,8 @@ Fix the config and retry.\n\ See the Codex keymap documentation for supported actions and examples." ) })?; + let transcript_overlay_state = + TranscriptOverlayState::new(chat_widget.history_render_mode()); #[cfg(not(debug_assertions))] let upgrade_version = crate::updates::get_upgrade_version(&config); @@ -1003,6 +1006,7 @@ See the Codex keymap documentation for supported actions and examples." keymap: runtime_keymap, transcript_cells: Vec::new(), overlay: None, + transcript_overlay_state, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, transcript_reflow: TranscriptReflowState::default(), diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index f6530cad26..872cac35dd 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -83,6 +83,7 @@ impl App { } else { self.chat_widget.set_raw_output_mode(enabled); } + self.transcript_overlay_state.render_mode = self.chat_widget.history_render_mode(); if let Err(err) = self.reflow_transcript_now(tui) { tracing::warn!(error = %err, "failed to reflow transcript after raw output mode toggle"); self.chat_widget @@ -166,14 +167,10 @@ impl App { return; } - if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) { - // Enter alternate screen and set viewport to full size. - let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript( - self.transcript_cells.clone(), - self.keymap.pager.clone(), - )); - tui.frame_requester().schedule_frame(); + if self.transcript_shortcut_available() + && self.keymap.app.open_transcript.is_pressed(key_event) + { + self.open_transcript_overlay(tui); return; } @@ -280,6 +277,10 @@ impl App { self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active() } + fn transcript_shortcut_available(&self) -> bool { + self.overlay.is_none() + } + pub(super) fn refresh_status_line(&mut self) { self.chat_widget.refresh_status_line(); } @@ -298,5 +299,6 @@ mod tests { app.chat_widget.open_keymap_debug(&keymap); assert!(!app.app_keymap_shortcuts_available()); + assert!(app.transcript_shortcut_available()); } } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 9bbe0c60b4..0f4e3e98f1 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -30,6 +30,9 @@ pub(super) async fn make_test_app() -> App { file_search, transcript_cells: Vec::new(), overlay: None, + transcript_overlay_state: crate::pager_overlay::TranscriptOverlayState::new( + crate::history_cell::HistoryRenderMode::Rich, + ), deferred_history_lines: Vec::new(), has_emitted_history_lines: false, transcript_reflow: TranscriptReflowState::default(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 1f16ea56a5..7316ef0b72 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3775,6 +3775,9 @@ async fn make_test_app() -> App { file_search, transcript_cells: Vec::new(), overlay: None, + transcript_overlay_state: crate::pager_overlay::TranscriptOverlayState::new( + crate::history_cell::HistoryRenderMode::Rich, + ), deferred_history_lines: Vec::new(), has_emitted_history_lines: false, transcript_reflow: TranscriptReflowState::default(), @@ -3838,6 +3841,9 @@ async fn make_test_app_with_channels() -> ( file_search, transcript_cells: Vec::new(), overlay: None, + transcript_overlay_state: crate::pager_overlay::TranscriptOverlayState::new( + crate::history_cell::HistoryRenderMode::Rich, + ), deferred_history_lines: Vec::new(), has_emitted_history_lines: false, transcript_reflow: TranscriptReflowState::default(), @@ -4960,6 +4966,9 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() { app.overlay = Some(Overlay::new_transcript( app.transcript_cells.clone(), app.keymap.pager.clone(), + app.keymap.app.copy.clone(), + app.keymap.app.toggle_raw_output.clone(), + app.transcript_overlay_state, )); app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.backtrack.overlay_preview_active = true; @@ -5495,6 +5504,11 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { app.overlay = Some(Overlay::new_transcript( app.transcript_cells.clone(), crate::keymap::RuntimeKeymap::defaults().pager, + Vec::new(), + Vec::new(), + crate::pager_overlay::TranscriptOverlayState::new( + crate::history_cell::HistoryRenderMode::Rich, + ), )); app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.has_emitted_history_lines = true; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 075a4a5ae2..0219fbfcbe 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -235,12 +235,18 @@ impl App { self.overlay = Some(Overlay::new_transcript( self.transcript_cells.clone(), self.keymap.pager.clone(), + self.keymap.app.copy.clone(), + self.keymap.app.toggle_raw_output.clone(), + self.transcript_overlay_state, )); tui.frame_requester().schedule_frame(); } /// Close transcript overlay and restore normal UI. pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) { + if let Some(Overlay::Transcript(transcript)) = &self.overlay { + self.transcript_overlay_state = transcript.state(); + } let _ = tui.leave_alt_screen(); let was_backtrack = self.backtrack.overlay_preview_active; if !self.deferred_history_lines.is_empty() { @@ -424,16 +430,51 @@ impl App { return Ok(()); } - if let Some(overlay) = &mut self.overlay { + let (copy_selection, copy_latest, close_overlay) = if let Some(overlay) = &mut self.overlay + { overlay.handle_event(tui, event)?; - if overlay.is_done() { - self.close_transcript_overlay(tui); - tui.frame_requester().schedule_frame(); - } + let (copy_selection, copy_latest) = match overlay { + Overlay::Transcript(transcript) => { + if transcript.take_copy_requested() { + match transcript.selected_user_cell() { + Some(user_cell_idx) => (Some(user_cell_idx), false), + None => (None, true), + } + } else { + (None, false) + } + } + Overlay::Static(_) => (None, false), + }; + (copy_selection, copy_latest, overlay.is_done()) + } else { + (None, false, false) + }; + if let Some(user_cell_idx) = copy_selection { + self.copy_transcript_turn(user_cell_idx); + } else if copy_latest { + self.chat_widget.copy_last_agent_markdown(); + } + if close_overlay { + self.close_transcript_overlay(tui); + tui.frame_requester().schedule_frame(); } Ok(()) } + fn copy_transcript_turn(&mut self, user_cell_idx: usize) { + let Some(user_cell) = self.transcript_cells.get(user_cell_idx).and_then(|cell| { + cell.as_any() + .downcast_ref::() + }) else { + self.chat_widget.copy_last_agent_markdown(); + return; + }; + let user_turn_count = user_count(&self.transcript_cells[..=user_cell_idx]); + self.chat_widget + .copy_agent_turn_markdown(user_turn_count, &user_cell.message); + } + /// Handle Enter in overlay backtrack preview: confirm selection and reset state. fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) { let nth_user_message = self.backtrack.nth_user_message; @@ -664,7 +705,6 @@ fn user_positions_iter( cells: &[Arc], ) -> impl Iterator + '_ { let session_start_type = TypeId::of::(); - let user_type = TypeId::of::(); let type_of = |cell: &Arc| cell.as_any().type_id(); let start = cells @@ -676,7 +716,7 @@ fn user_positions_iter( .iter() .enumerate() .skip(start) - .filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx)) + .filter_map(move |(idx, cell)| cell.is_user_prompt().then_some(idx)) } #[cfg(test)] diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index d9451fc9d5..ba5e533432 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -228,6 +228,14 @@ impl ChatWidget { self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard); } + pub(crate) fn copy_agent_turn_markdown(&mut self, user_turn_count: usize, user_prompt: &str) { + self.copy_agent_turn_markdown_with( + user_turn_count, + user_prompt, + crate::clipboard_copy::copy_to_clipboard, + ); + } + pub(crate) fn truncate_agent_copy_history_to_user_turn_count( &mut self, user_turn_count: usize, @@ -242,18 +250,11 @@ impl ChatWidget { copy_fn: impl FnOnce(&str) -> Result, String>, ) { match self.transcript.last_agent_markdown.clone() { - Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) { - Ok(lease) => { - self.clipboard_lease = lease; - self.add_to_history(history_cell::new_info_event( - "Copied last message to clipboard".into(), - /*hint*/ None, - )); - } - Err(error) => self.add_to_history(history_cell::new_error_event(format!( - "Copy failed: {error}" - ))), - }, + Some(markdown) if !markdown.is_empty() => self.copy_markdown_with_status( + &markdown, + "Copied last message to clipboard", + copy_fn, + ), _ if self.transcript.copy_history_evicted_by_rollback => { self.add_to_history(history_cell::new_error_event(format!( "Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy." @@ -266,6 +267,51 @@ impl ChatWidget { self.request_redraw(); } + pub(super) fn copy_agent_turn_markdown_with( + &mut self, + user_turn_count: usize, + user_prompt: &str, + copy_fn: impl FnOnce(&str) -> Result, String>, + ) { + match self + .transcript + .agent_markdown_for_user_turn(user_turn_count) + { + Some(markdown) if !markdown.is_empty() => { + let markdown = format!("## User\n\n{user_prompt}\n\n## Assistant\n\n{markdown}"); + self.copy_markdown_with_status( + &markdown, + "Copied selected turn to clipboard", + copy_fn, + ); + } + _ => self.add_to_history(history_cell::new_error_event( + "No agent response to copy for selected prompt".into(), + )), + } + self.request_redraw(); + } + + fn copy_markdown_with_status( + &mut self, + markdown: &str, + success_message: &str, + copy_fn: impl FnOnce(&str) -> Result, String>, + ) { + match copy_fn(markdown) { + Ok(lease) => { + self.clipboard_lease = lease; + self.add_to_history(history_cell::new_info_event( + success_message.into(), + /*hint*/ None, + )); + } + Err(error) => self.add_to_history(history_cell::new_error_event(format!( + "Copy failed: {error}" + ))), + } + } + #[cfg(test)] pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> { self.transcript.last_agent_markdown.as_deref() diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 4f425c25c9..9a278ccbe1 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1472,6 +1472,30 @@ async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() { ); } +#[tokio::test] +async fn transcript_turn_copy_includes_user_prompt_and_agent_markdown() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.transcript.record_visible_user_turn(); + chat.transcript + .record_agent_markdown("first response".to_string()); + + chat.copy_agent_turn_markdown_with(/*user_turn_count*/ 1, "first prompt", |markdown| { + assert_eq!( + markdown, + "## User\n\nfirst prompt\n\n## Assistant\n\nfirst response" + ); + Ok(Some(crate::clipboard_copy::ClipboardLease::test())) + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one success message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Copied selected turn to clipboard"), + "expected success message, got {rendered:?}" + ); +} + #[tokio::test] async fn slash_copy_state_is_preserved_during_running_task() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/transcript.rs b/codex-rs/tui/src/chatwidget/transcript.rs index 22b046728b..8f6ecd667f 100644 --- a/codex-rs/tui/src/chatwidget/transcript.rs +++ b/codex-rs/tui/src/chatwidget/transcript.rs @@ -106,6 +106,13 @@ impl TranscriptState { self.saw_copy_source_this_turn = false; } + pub(super) fn agent_markdown_for_user_turn(&self, user_turn_count: usize) -> Option<&str> { + self.agent_turn_markdowns + .iter() + .find(|entry| entry.user_turn_count == user_turn_count) + .map(|entry| entry.markdown.as_str()) + } + pub(super) fn reset_turn_flags(&mut self) { self.saw_copy_source_this_turn = false; self.saw_plan_update_this_turn = false; diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index c3214aeb16..22babcf37d 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -11,12 +11,15 @@ //! first drawn. //! 4. Completed runs only persist when they have output or a non-success status. use super::HistoryCell; +use super::plain_hyperlink_lines; use super::plain_lines; +use crate::history_cell::HistoryRenderMode; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; use crate::motion::activity_indicator; use crate::motion::shimmer_text; use crate::render::renderable::Renderable; +use crate::terminal_hyperlinks::HyperlinkLine; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookOutputEntry; use codex_app_server_protocol::HookOutputEntryKind; @@ -344,6 +347,21 @@ impl HistoryCell for HookCell { self.display_lines(width) } + fn transcript_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec> { + match mode { + HistoryRenderMode::Rich => self.rich_transcript_lines(width), + HistoryRenderMode::Raw => self.raw_lines(), + } + } + + fn transcript_hyperlink_lines_for_mode( + &self, + width: u16, + mode: HistoryRenderMode, + ) -> Vec { + plain_hyperlink_lines(self.transcript_lines_for_mode(width, mode)) + } + fn raw_lines(&self) -> Vec> { plain_lines(self.display_lines(u16::MAX)) } @@ -363,6 +381,45 @@ impl HistoryCell for HookCell { } } +impl HookCell { + fn rich_transcript_lines(&self, _width: u16) -> Vec> { + let mut lines = Vec::new(); + let mut running_group: Option = None; + for run in &self.runs { + if !run.state.should_render() { + continue; + } + + let Some(key) = run.running_group_key() else { + if let Some(group) = running_group.take() { + push_running_hook_group(&mut lines, &group, self.animations_enabled); + } + push_hook_line_separator(&mut lines); + run.push_rich_transcript_lines(&mut lines, self.animations_enabled); + continue; + }; + + if let Some(group) = running_group.as_mut() + && group.key == key + { + group.count += 1; + group.start_time = earliest_instant(group.start_time, run.state.start_time()); + continue; + } + + if let Some(group) = + running_group.replace(RunningHookGroup::new(key, run.state.start_time())) + { + push_running_hook_group(&mut lines, &group, self.animations_enabled); + } + } + if let Some(group) = running_group { + push_running_hook_group(&mut lines, &group, self.animations_enabled); + } + lines + } +} + impl Renderable for HookCell { fn render(&self, area: Rect, buf: &mut Buffer) { let lines = self.display_lines(area.width); @@ -463,6 +520,52 @@ impl HookRunCell { HookRunState::PendingReveal { .. } => {} } } + + fn push_rich_transcript_lines(&self, lines: &mut Vec>, animations_enabled: bool) { + let label = hook_event_label(self.event_name); + match &self.state { + HookRunState::VisibleRunning { start_time, .. } + | HookRunState::QuietLinger { start_time, .. } => { + let hook_text = format!("Running {label} hook"); + push_running_hook_header( + lines, + &hook_text, + Some(*start_time), + self.status_message.as_deref(), + animations_enabled, + ); + } + HookRunState::Completed { status, entries } => { + let status_text = format!("{status:?}").to_lowercase(); + let bullet = hook_completed_bullet(*status, entries); + lines.push( + vec![ + bullet, + " ".into(), + format!("{label} hook ({status_text})").into(), + ] + .into(), + ); + for entry in entries { + if entry.kind == HookOutputEntryKind::Context { + let line_count = entry.text.lines().count().max(1); + let label = if line_count == 1 { "line" } else { "lines" }; + lines.push( + format!( + " hook context: [collapsed in rich transcript; {line_count} {label}]" + ) + .into(), + ); + } else { + lines.push( + format!(" {}{}", hook_output_prefix(entry.kind), entry.text).into(), + ); + } + } + } + HookRunState::PendingReveal { .. } => {} + } + } } impl HookRunState { @@ -880,6 +983,58 @@ mod tests { .collect::() } + #[test] + fn rich_transcript_collapses_hook_context_but_raw_keeps_it() { + let mut run = hook_run_summary("hook-1"); + run.status = HookRunStatus::Completed; + run.status_message = None; + run.entries = vec![ + HookOutputEntry { + kind: HookOutputEntryKind::Context, + text: "line one\nline two".to_string(), + }, + HookOutputEntry { + kind: HookOutputEntryKind::Warning, + text: "stay visible".to_string(), + }, + ]; + let cell = HookCell::new_completed(run, /*animations_enabled*/ false); + + let rich = cell + .transcript_lines_for_mode(/*width*/ 80, HistoryRenderMode::Rich) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + let raw = cell + .transcript_lines_for_mode(/*width*/ 80, HistoryRenderMode::Raw) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + + assert!( + rich.iter() + .any(|line| line.contains("collapsed in rich transcript")) + ); + assert!( + rich.iter() + .any(|line| line.contains("warning: stay visible")) + ); + assert!( + raw.iter() + .any(|line| line.contains("hook context: line one")) + ); + } + fn hook_run_summary(id: &str) -> HookRunSummary { HookRunSummary { id: id.to_string(), diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index ed899f3b2e..c3e6f11ecd 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -191,6 +191,47 @@ impl HistoryCell for UserHistoryCell { } lines } + + fn transcript_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec> { + match mode { + HistoryRenderMode::Rich => self.injected_context_summary().map_or_else( + || self.display_lines(width), + |summary| vec![Line::from(summary)], + ), + HistoryRenderMode::Raw => self.raw_lines(), + } + } + + fn transcript_hyperlink_lines_for_mode( + &self, + width: u16, + mode: HistoryRenderMode, + ) -> Vec { + plain_hyperlink_lines(self.transcript_lines_for_mode(width, mode)) + } + + fn is_user_prompt(&self) -> bool { + self.injected_context_summary().is_none() + } +} + +impl UserHistoryCell { + fn injected_context_summary(&self) -> Option { + let label = if self.message.starts_with("# AGENTS.md instructions for ") { + "AGENTS.md instructions" + } else if self.message.starts_with("") { + "environment context" + } else if self.message.starts_with("") { + "permissions instructions" + } else { + return None; + }; + let line_count = self.message.lines().count().max(1); + let noun = if line_count == 1 { "line" } else { "lines" }; + Some(format!( + "{label}: [collapsed in rich transcript; {line_count} {noun}]" + )) + } } #[derive(Debug)] diff --git a/codex-rs/tui/src/history_cell/mod.rs b/codex-rs/tui/src/history_cell/mod.rs index 9cef3e4fa5..ba651d2b27 100644 --- a/codex-rs/tui/src/history_cell/mod.rs +++ b/codex-rs/tui/src/history_cell/mod.rs @@ -253,13 +253,38 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { plain_hyperlink_lines(self.transcript_lines(width)) } + /// Returns transcript overlay lines for the selected render mode. + fn transcript_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec> { + match mode { + HistoryRenderMode::Rich => self.transcript_lines(width), + HistoryRenderMode::Raw => self.raw_lines(), + } + } + + /// Returns transcript-overlay lines plus terminal hyperlink metadata for the selected mode. + fn transcript_hyperlink_lines_for_mode( + &self, + width: u16, + mode: HistoryRenderMode, + ) -> Vec { + match mode { + HistoryRenderMode::Rich => self.transcript_hyperlink_lines(width), + HistoryRenderMode::Raw => plain_hyperlink_lines(self.raw_lines()), + } + } + /// Returns the number of viewport rows for the transcript overlay. /// /// Uses the same `Paragraph::line_count` measurement as /// `desired_height`. Contains a workaround for a ratatui bug where /// a single whitespace-only line reports 2 rows instead of 1. + #[allow(dead_code)] fn desired_transcript_height(&self, width: u16) -> u16 { - let lines = visible_lines(self.transcript_hyperlink_lines(width)); + self.desired_transcript_height_for_mode(width, HistoryRenderMode::Rich) + } + + fn desired_transcript_height_for_mode(&self, width: u16, mode: HistoryRenderMode) -> u16 { + let lines = visible_lines(self.transcript_hyperlink_lines_for_mode(width, mode)); // Workaround: ratatui's line_count returns 2 for a single // whitespace-only line. Clamp to 1 in that case. if let [line] = &lines[..] @@ -282,6 +307,10 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { false } + fn is_user_prompt(&self) -> bool { + false + } + /// Returns a coarse "animation tick" when transcript output is time-dependent. /// /// The transcript overlay caches the rendered output of the in-flight active cell, so cells diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 63a04d19d4..5601924208 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -2004,6 +2004,33 @@ fn user_history_cell_height_matches_rendered_lines_with_remote_images() { assert_eq!(cell.desired_transcript_height(width), rendered_len); } +#[test] +fn injected_context_collapses_only_in_rich_transcript_mode() { + let cell = UserHistoryCell { + message: "# AGENTS.md instructions for /tmp/example\nline one\nline two".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }; + + let rich = render_lines(&cell.transcript_lines_for_mode(/*width*/ 80, HistoryRenderMode::Rich)); + let raw = render_lines(&cell.transcript_lines_for_mode(/*width*/ 80, HistoryRenderMode::Raw)); + + assert_eq!( + rich, + vec!["AGENTS.md instructions: [collapsed in rich transcript; 3 lines]"] + ); + assert_eq!( + raw, + vec![ + "# AGENTS.md instructions for /tmp/example", + "line one", + "line two", + ] + ); + assert!(!cell.is_user_prompt()); +} + #[test] fn user_history_cell_trims_trailing_blank_message_lines() { let cell = UserHistoryCell { diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index ac74632770..0bd4cb0b8c 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -221,6 +221,8 @@ pub(crate) struct PagerKeymap { pub(crate) jump_bottom: Vec, pub(crate) close: Vec, pub(crate) close_transcript: Vec, + pub(crate) previous_user_prompt: Vec, + pub(crate) next_user_prompt: Vec, } /// Generic list picker keybindings shared across popup list views. @@ -749,6 +751,8 @@ 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), + previous_user_prompt: resolve_local!(keymap, defaults, pager, previous_user_prompt), + next_user_prompt: resolve_local!(keymap, defaults, pager, next_user_prompt), }; let approval = ApprovalKeymap { @@ -1087,6 +1091,8 @@ 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'))], + previous_user_prompt: default_bindings![alt(KeyCode::Up)], + next_user_prompt: default_bindings![alt(KeyCode::Down)], }, list: ListKeymap { move_up: default_bindings![ @@ -1553,6 +1559,11 @@ impl RuntimeKeymap { ("jump_bottom", self.pager.jump_bottom.as_slice()), ("close", self.pager.close.as_slice()), ("close_transcript", self.pager.close_transcript.as_slice()), + ( + "previous_user_prompt", + self.pager.previous_user_prompt.as_slice(), + ), + ("next_user_prompt", self.pager.next_user_prompt.as_slice()), ], )?; @@ -1569,6 +1580,11 @@ impl RuntimeKeymap { ("jump_bottom", self.pager.jump_bottom.as_slice()), ("close", self.pager.close.as_slice()), ("close_transcript", self.pager.close_transcript.as_slice()), + ( + "previous_user_prompt", + self.pager.previous_user_prompt.as_slice(), + ), + ("next_user_prompt", self.pager.next_user_prompt.as_slice()), ], TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS, [], diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 9c5935fb4a..e75cb58420 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -20,6 +20,7 @@ use std::sync::Arc; use crate::chatwidget::ActiveCellTranscriptKey; use crate::history_cell::HistoryCell; +use crate::history_cell::HistoryRenderMode; use crate::history_cell::UserHistoryCell; use crate::key_hint; use crate::key_hint::KeyBinding; @@ -55,9 +56,38 @@ pub(crate) enum Overlay { Static(StaticOverlay), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct TranscriptOverlayState { + pub(crate) scroll_offset: usize, + pub(crate) highlight_cell: Option, + pub(crate) render_mode: HistoryRenderMode, +} + +impl TranscriptOverlayState { + pub(crate) fn new(render_mode: HistoryRenderMode) -> Self { + Self { + scroll_offset: usize::MAX, + highlight_cell: None, + render_mode, + } + } +} + impl Overlay { - pub(crate) fn new_transcript(cells: Vec>, keymap: PagerKeymap) -> Self { - Self::Transcript(TranscriptOverlay::new(cells, keymap)) + pub(crate) fn new_transcript( + cells: Vec>, + keymap: PagerKeymap, + copy_keymap: Vec, + toggle_raw_output_keymap: Vec, + state: TranscriptOverlayState, + ) -> Self { + Self::Transcript(TranscriptOverlay::new( + cells, + keymap, + copy_keymap, + toggle_raw_output_keymap, + state, + )) } pub(crate) fn new_static_with_lines( @@ -124,10 +154,19 @@ struct PagerView { keymap: PagerKeymap, last_content_height: Option, last_rendered_height: Option, + layout: Option, /// If set, on next render ensure this chunk is visible. pending_scroll_chunk: Option, } +#[derive(Debug)] +struct PagerLayout { + width: u16, + offsets: Arc<[usize]>, + heights: Arc<[usize]>, + total_height: usize, +} + impl PagerView { fn new( renderables: Vec>, @@ -142,15 +181,44 @@ impl PagerView { keymap, last_content_height: None, last_rendered_height: None, + layout: None, pending_scroll_chunk: None, } } - fn content_height(&self, width: u16) -> usize { - self.renderables - .iter() - .map(|c| c.desired_height(width) as usize) - .sum() + fn invalidate_layout(&mut self) { + self.layout = None; + } + + fn content_height(&mut self, width: u16) -> usize { + self.layout(width).total_height + } + + fn layout(&mut self, width: u16) -> &PagerLayout { + let needs_rebuild = self.layout.as_ref().is_none_or(|layout| { + layout.width != width || layout.heights.len() != self.renderables.len() + }); + if needs_rebuild { + let mut offsets = Vec::with_capacity(self.renderables.len()); + let mut heights = Vec::with_capacity(self.renderables.len()); + let mut total_height = 0usize; + for renderable in &self.renderables { + offsets.push(total_height); + let height = renderable.desired_height(width) as usize; + heights.push(height); + total_height = total_height.saturating_add(height); + } + self.layout = Some(PagerLayout { + width, + offsets: offsets.into(), + heights: heights.into(), + total_height, + }); + } + match self.layout.as_ref() { + Some(layout) => layout, + None => unreachable!("pager layout missing after rebuild"), + } } fn render(&mut self, area: Rect, buf: &mut Buffer) { @@ -182,12 +250,31 @@ impl PagerView { header.dim().render_ref(area, buf); } - fn render_content(&self, area: Rect, buf: &mut Buffer) { - let mut y = -(self.scroll_offset as isize); + fn render_content(&mut self, area: Rect, buf: &mut Buffer) { + let (offsets, heights) = { + let layout = self.layout(area.width); + (layout.offsets.clone(), layout.heights.clone()) + }; + if offsets.is_empty() { + for y in area.y..area.bottom() { + if area.width == 0 { + break; + } + buf[(area.x, y)] = Cell::from('~'); + for x in area.x + 1..area.right() { + buf[(x, y)] = Cell::from(' '); + } + } + return; + } + let first_visible = offsets + .partition_point(|offset| *offset <= self.scroll_offset) + .saturating_sub(1); + let mut y = offsets[first_visible] as isize - self.scroll_offset as isize; let mut drawn_bottom = area.y; - for renderable in &self.renderables { + for (idx, renderable) in self.renderables.iter().enumerate().skip(first_visible) { let top = y; - let height = renderable.desired_height(area.width) as isize; + let height = heights[idx] as isize; y += height; let bottom = y; if bottom < area.y as isize { @@ -343,13 +430,9 @@ impl PagerView { if area.height == 0 || idx >= self.renderables.len() { return; } - let first = self - .renderables - .iter() - .take(idx) - .map(|r| r.desired_height(area.width) as usize) - .sum(); - let last = first + self.renderables[idx].desired_height(area.width) as usize; + let layout = self.layout(area.width); + let first = layout.offsets[idx]; + let last = first.saturating_add(layout.heights[idx]); let current_top = self.scroll_offset; let current_bottom = current_top.saturating_add(area.height.saturating_sub(1) as usize); if first < current_top { @@ -394,11 +477,14 @@ impl Renderable for CachedRenderable { struct CellRenderable { cell: Arc, style: Style, + render_mode: HistoryRenderMode, } impl Renderable for CellRenderable { fn render(&self, area: Rect, buf: &mut Buffer) { - let hyperlink_lines = self.cell.transcript_hyperlink_lines(area.width); + let hyperlink_lines = self + .cell + .transcript_hyperlink_lines_for_mode(area.width, self.render_mode); let p = Paragraph::new(Text::from(visible_lines(hyperlink_lines.clone()))) .style(self.style) .wrap(Wrap { trim: false }); @@ -407,7 +493,8 @@ impl Renderable for CellRenderable { } fn desired_height(&self, width: u16) -> u16 { - self.cell.desired_transcript_height(width) + self.cell + .desired_transcript_height_for_mode(width, self.render_mode) } } @@ -441,6 +528,10 @@ pub(crate) struct TranscriptOverlay { /// Committed transcript cells (does not include the live tail). cells: Vec>, highlight_cell: Option, + render_mode: HistoryRenderMode, + copy_keymap: Vec, + toggle_raw_output_keymap: Vec, + copy_requested: bool, /// Cache key for the render-only live tail appended after committed cells. live_tail_key: Option, is_done: bool, @@ -466,16 +557,26 @@ impl TranscriptOverlay { /// /// This overlay does not own the "active cell"; callers may optionally append a live tail via /// `sync_live_tail` during draws to reflect in-flight activity. - pub(crate) fn new(transcript_cells: Vec>, keymap: PagerKeymap) -> Self { + pub(crate) fn new( + transcript_cells: Vec>, + keymap: PagerKeymap, + copy_keymap: Vec, + toggle_raw_output_keymap: Vec, + state: TranscriptOverlayState, + ) -> Self { Self { view: PagerView::new( - Self::render_cells(&transcript_cells, /*highlight_cell*/ None), + Self::render_cells(&transcript_cells, state.highlight_cell, state.render_mode), "T R A N S C R I P T".to_string(), - usize::MAX, + state.scroll_offset, keymap, ), cells: transcript_cells, - highlight_cell: None, + highlight_cell: state.highlight_cell, + render_mode: state.render_mode, + copy_keymap, + toggle_raw_output_keymap, + copy_requested: false, live_tail_key: None, is_done: false, } @@ -484,6 +585,7 @@ impl TranscriptOverlay { fn render_cells( cells: &[Arc], highlight_cell: Option, + render_mode: HistoryRenderMode, ) -> Vec> { cells .iter() @@ -498,11 +600,13 @@ impl TranscriptOverlay { } else { user_message_style() }, + render_mode, })) as Box } else { Box::new(CachedRenderable::new(CellRenderable { cell: c.clone(), style: Style::default(), + render_mode, })) as Box }; if !c.is_stream_continuation() && i > 0 { @@ -534,7 +638,9 @@ impl TranscriptOverlay { let had_prior_cells = !self.cells.is_empty(); let tail_renderable = self.take_live_tail_renderable(); self.cells.push(cell); - self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); + self.view.renderables = + Self::render_cells(&self.cells, self.highlight_cell, self.render_mode); + self.view.invalidate_layout(); if let Some(tail) = tail_renderable { let tail = if !had_prior_cells && self @@ -663,6 +769,7 @@ impl TranscriptOverlay { !self.cells.is_empty(), key.is_stream_continuation, )); + self.view.invalidate_layout(); } } if follow_bottom { @@ -686,12 +793,74 @@ impl TranscriptOverlay { self.view.is_scrolled_to_bottom() } + pub(crate) fn state(&self) -> TranscriptOverlayState { + TranscriptOverlayState { + scroll_offset: self.view.scroll_offset, + highlight_cell: self.highlight_cell, + render_mode: self.render_mode, + } + } + + pub(crate) fn take_copy_requested(&mut self) -> bool { + std::mem::take(&mut self.copy_requested) + } + + pub(crate) fn selected_user_cell(&self) -> Option { + self.highlight_cell.filter(|idx| { + self.cells + .get(*idx) + .is_some_and(|cell| cell.is_user_prompt()) + }) + } + + fn toggle_render_mode(&mut self) { + self.render_mode = match self.render_mode { + HistoryRenderMode::Rich => HistoryRenderMode::Raw, + HistoryRenderMode::Raw => HistoryRenderMode::Rich, + }; + self.rebuild_renderables(); + } + + fn move_prompt_selection(&mut self, direction: PromptSelectionDirection) { + let prompt_positions = self + .cells + .iter() + .enumerate() + .filter_map(|(idx, cell)| cell.is_user_prompt().then_some(idx)) + .collect::>(); + let Some(last_prompt) = prompt_positions.last().copied() else { + return; + }; + + let next_prompt = match self.highlight_cell { + Some(current) => { + let current_idx = prompt_positions + .iter() + .position(|idx| *idx == current) + .unwrap_or(prompt_positions.len().saturating_sub(1)); + match direction { + PromptSelectionDirection::Previous => { + prompt_positions[current_idx.saturating_sub(1)] + } + PromptSelectionDirection::Next => prompt_positions + .get(current_idx.saturating_add(1)) + .copied() + .unwrap_or(last_prompt), + } + } + None => last_prompt, + }; + self.set_highlight_cell(Some(next_prompt)); + } + fn rebuild_renderables(&mut self) { let tail_renderable = self.take_live_tail_renderable(); - self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); + self.view.renderables = + Self::render_cells(&self.cells, self.highlight_cell, self.render_mode); if let Some(tail) = tail_renderable { self.view.renderables.push(tail); } + self.view.invalidate_layout(); } /// Removes and returns the cached live-tail renderable, if present. @@ -700,7 +869,12 @@ impl TranscriptOverlay { /// cell renderables, so this relies on the live tail always being the final entry in /// `view.renderables` when present. fn take_live_tail_renderable(&mut self) -> Option> { - (self.view.renderables.len() > self.cells.len()).then(|| self.view.renderables.pop())? + let tail = (self.view.renderables.len() > self.cells.len()) + .then(|| self.view.renderables.pop())?; + if tail.is_some() { + self.view.invalidate_layout(); + } + tail } fn live_tail_renderable( @@ -749,11 +923,24 @@ impl TranscriptOverlay { .collect(), "to jump", ), + ( + first_or_empty(&self.view.keymap.previous_user_prompt) + .into_iter() + .chain(first_or_empty(&self.view.keymap.next_user_prompt)) + .collect(), + "to prompts", + ), ], ); - let mut pairs: Vec<(Vec, &str)> = - vec![(first_or_empty(&self.view.keymap.close), "to quit")]; + let mut pairs: Vec<(Vec, &str)> = Vec::new(); + if !self.copy_keymap.is_empty() { + pairs.push((first_or_empty(&self.copy_keymap), "to copy")); + } + if !self.toggle_raw_output_keymap.is_empty() { + pairs.push((first_or_empty(&self.toggle_raw_output_keymap), "raw")); + } + pairs.push((first_or_empty(&self.view.keymap.close), "to quit")); if self.highlight_cell.is_some() { pairs.push(( vec![ @@ -789,6 +976,28 @@ impl TranscriptOverlay { self.is_done = true; Ok(()) } + e if self.view.keymap.previous_user_prompt.is_pressed(e) => { + self.move_prompt_selection(PromptSelectionDirection::Previous); + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + Ok(()) + } + e if self.view.keymap.next_user_prompt.is_pressed(e) => { + self.move_prompt_selection(PromptSelectionDirection::Next); + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + Ok(()) + } + e if self.toggle_raw_output_keymap.is_pressed(e) => { + self.toggle_render_mode(); + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + Ok(()) + } + e if self.copy_keymap.is_pressed(e) => { + self.copy_requested = true; + Ok(()) + } other => self.view.handle_key_event(tui, other), }, TuiEvent::Draw | TuiEvent::Resize => { @@ -810,6 +1019,11 @@ impl TranscriptOverlay { } } +enum PromptSelectionDirection { + Previous, + Next, +} + pub(crate) struct StaticOverlay { view: PagerView, is_done: bool, @@ -990,7 +1204,23 @@ mod tests { } fn transcript_overlay(cells: Vec>) -> TranscriptOverlay { - TranscriptOverlay::new(cells, default_pager_keymap()) + let keymap = crate::keymap::RuntimeKeymap::defaults(); + TranscriptOverlay::new( + cells, + keymap.pager, + keymap.app.copy, + keymap.app.toggle_raw_output, + TranscriptOverlayState::new(HistoryRenderMode::Rich), + ) + } + + fn user_cell(message: &str) -> Arc { + Arc::new(UserHistoryCell { + message: message.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) } fn static_overlay(lines: Vec>, title: &str) -> StaticOverlay { @@ -1140,6 +1370,45 @@ mod tests { })); } + #[test] + fn transcript_overlay_state_round_trips_scroll_selection_and_mode() { + let keymap = crate::keymap::RuntimeKeymap::defaults(); + let state = TranscriptOverlayState { + scroll_offset: 3, + highlight_cell: Some(0), + render_mode: HistoryRenderMode::Raw, + }; + let overlay = TranscriptOverlay::new( + vec![user_cell("prompt")], + keymap.pager, + keymap.app.copy, + keymap.app.toggle_raw_output, + state, + ); + + assert_eq!(overlay.state(), state); + } + + #[test] + fn prompt_navigation_moves_between_user_prompts() { + let mut overlay = transcript_overlay(vec![ + user_cell("first"), + Arc::new(TestCell { + lines: vec![Line::from("assistant")], + }), + user_cell("second"), + ]); + + overlay.move_prompt_selection(PromptSelectionDirection::Previous); + assert_eq!(overlay.selected_user_cell(), Some(2)); + + overlay.move_prompt_selection(PromptSelectionDirection::Previous); + assert_eq!(overlay.selected_user_cell(), Some(0)); + + overlay.move_prompt_selection(PromptSelectionDirection::Next); + assert_eq!(overlay.selected_user_cell(), Some(2)); + } + #[test] fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() { let mut overlay = transcript_overlay(vec![Arc::new(TestCell { @@ -1476,7 +1745,7 @@ mod tests { #[test] fn pager_view_content_height_counts_renderables() { - let pv = pager_view( + let mut pv = pager_view( vec![ paragraph_block("a", /*lines*/ 2), paragraph_block("b", /*lines*/ 3), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 6ae9f37a2f..158d4b9a3b 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -980,6 +980,11 @@ impl PickerState { self.overlay = Some(Overlay::new_transcript( cells.clone(), self.pager_keymap.clone(), + Vec::new(), + Vec::new(), + crate::pager_overlay::TranscriptOverlayState::new( + crate::history_cell::HistoryRenderMode::Rich, + ), )); self.pending_transcript_open = None; self.transcript_loading_frame_shown = false; diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap index 61fcf7d2ea..05ab469a1b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap @@ -10,5 +10,5 @@ expression: term.backend() "gamma " "───────────────────────────────── 100% ─" " ↑/↓ to scroll pgup/pgdn to page hom" -" q to quit esc to edit prev " +" ctrl + o to copy ⌥ + r raw q to qui" " "