From 21191e17074655a989fe15a2b1580aea447d7d2b Mon Sep 17 00:00:00 2001 From: Tuan-Lung Wang Date: Mon, 16 Mar 2026 16:07:46 -0700 Subject: [PATCH] Add transcript details toggle --- codex-rs/tui/src/pager_overlay.rs | 277 +++++++++++++++++++++++++++--- 1 file changed, 257 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 26ec1470e4..901f51c7fe 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -20,6 +20,7 @@ use std::ops::Range; use std::sync::Arc; use crate::chatwidget::ActiveCellTranscriptKey; +use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::UserHistoryCell; use crate::key_hint; @@ -111,7 +112,9 @@ const KEY_CTRL_C: KeyBinding = key_hint::ctrl(KeyCode::Char('c')); const KEY_TAB: KeyBinding = key_hint::plain(KeyCode::Tab); const KEY_A: KeyBinding = key_hint::plain(KeyCode::Char('a')); const KEY_E: KeyBinding = key_hint::plain(KeyCode::Char('e')); +const KEY_D: KeyBinding = key_hint::plain(KeyCode::Char('d')); const MAX_ANCHOR_LINES: usize = 2; +const DETAILS_PLACEHOLDER_LABEL: &str = "Details..."; // Common pager navigation hints rendered on the first line const PAGER_KEY_HINTS: &[(&[KeyBinding], &str)] = &[ @@ -479,25 +482,30 @@ impl Renderable for CellRenderable { struct CollapsedTurnRenderable { prompt: String, + show_details_placeholder: bool, response: String, } impl CollapsedTurnRenderable { fn lines(&self, width: u16) -> Vec> { let budget = width.saturating_sub(4).max(8) as usize; - vec![ + let mut lines = vec![ Line::from(vec![ Span::styled("› ".to_string(), user_message_style().bold().dim()), Span::styled(truncate_preview_text(&self.prompt, budget), user_message_style()), ]), - Line::from(vec![ - Span::styled("• ".to_string(), Style::default().dim()), - Span::styled( - truncate_preview_text(&self.response, budget), - Style::default().dim(), - ), - ]), - ] + ]; + if self.show_details_placeholder { + lines.push(details_line(budget)); + } + lines.push(Line::from(vec![ + Span::styled("• ".to_string(), Style::default().dim()), + Span::styled( + truncate_preview_text(&self.response, budget), + Style::default().dim(), + ), + ])); + lines } } @@ -534,6 +542,55 @@ fn truncate_preview_text(text: &str, max_graphemes: usize) -> String { } } +fn details_line(budget: usize) -> Line<'static> { + Line::from(vec![ + Span::styled(" ".to_string(), Style::default().dim()), + Span::styled( + truncate_preview_text(DETAILS_PLACEHOLDER_LABEL, budget), + Style::default().dim().italic(), + ), + ]) +} + +struct DetailsPlaceholderRenderable; + +impl Renderable for DetailsPlaceholderRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new(Text::from(vec![details_line( + area.width.saturating_sub(2).max(8) as usize, + )])) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(Text::from(vec![details_line( + width.saturating_sub(2).max(8) as usize, + )])) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(0) + } +} + +#[derive(Clone, Debug)] +struct TurnDisplay { + prompt_idx: usize, + details_indices: Vec, + final_answer_indices: Vec, +} + +impl TurnDisplay { + fn has_details(&self) -> bool { + !self.details_indices.is_empty() + } + + fn has_final_answer(&self) -> bool { + !self.final_answer_indices.is_empty() + } +} + pub(crate) struct TranscriptOverlay { /// Pager UI state and the renderables currently displayed. /// @@ -550,6 +607,7 @@ pub(crate) struct TranscriptOverlay { selected_anchor: Option, anchors_visible: bool, expand_all: bool, + show_details: bool, mode: TranscriptBrowseMode, is_done: bool, } @@ -601,6 +659,7 @@ impl TranscriptOverlay { &anchors, selected_anchor, false, + false, selected_anchor.and_then(|idx| anchors.get(idx).map(|anchor| anchor.cell_idx)), ), "T R A N S C R I P T".to_string(), @@ -614,6 +673,7 @@ impl TranscriptOverlay { selected_anchor, anchors_visible: true, expand_all: false, + show_details: false, mode, is_done: false, }; @@ -736,6 +796,7 @@ impl TranscriptOverlay { anchors: &[TranscriptAnchor], selected_anchor: Option, expand_all: bool, + show_details: bool, highlight_cell: Option, ) -> Vec> { let mut renderables: Vec> = Vec::new(); @@ -750,16 +811,20 @@ impl TranscriptOverlay { } for (anchor_idx, turn_range) in turn_ranges.iter().enumerate() { + let turn_display = Self::turn_display(cells, turn_range.clone()); if expand_all || anchor_idx == selected_anchor { - for cell_idx in turn_range.clone() { - let cell = &cells[cell_idx]; - let renderable = Self::cell_renderable(cell.clone(), cell_idx, highlight_cell); - renderables.push(Self::with_spacing(renderable, !cell.is_stream_continuation(), renderables.len())); - } + Self::push_expanded_turn_renderables( + &mut renderables, + cells, + &turn_display, + show_details, + highlight_cell, + ); } else if let Some(anchor) = anchors.get(anchor_idx) { let collapsed = Box::new(CachedRenderable::new(CollapsedTurnRenderable { prompt: anchor.label.clone(), - response: Self::collapsed_response_summary(cells, turn_range.clone()), + show_details_placeholder: turn_display.has_details(), + response: Self::collapsed_final_answer_summary(cells, &turn_display), })) as Box; renderables.push(Self::with_spacing(collapsed, true, renderables.len())); } @@ -768,6 +833,52 @@ impl TranscriptOverlay { renderables } + fn push_expanded_turn_renderables( + renderables: &mut Vec>, + cells: &[Arc], + turn_display: &TurnDisplay, + show_details: bool, + highlight_cell: Option, + ) { + let prompt_cell = &cells[turn_display.prompt_idx]; + let prompt_renderable = Self::cell_renderable( + prompt_cell.clone(), + turn_display.prompt_idx, + highlight_cell, + ); + renderables.push(Self::with_spacing( + prompt_renderable, + !prompt_cell.is_stream_continuation(), + renderables.len(), + )); + + if show_details { + for &cell_idx in &turn_display.details_indices { + let cell = &cells[cell_idx]; + let renderable = Self::cell_renderable(cell.clone(), cell_idx, highlight_cell); + renderables.push(Self::with_spacing( + renderable, + !cell.is_stream_continuation(), + renderables.len(), + )); + } + } else if turn_display.has_details() { + let placeholder = Box::new(CachedRenderable::new(DetailsPlaceholderRenderable)) + as Box; + renderables.push(Self::with_spacing(placeholder, true, renderables.len())); + } + + for &cell_idx in &turn_display.final_answer_indices { + let cell = &cells[cell_idx]; + let renderable = Self::cell_renderable(cell.clone(), cell_idx, highlight_cell); + renderables.push(Self::with_spacing( + renderable, + !cell.is_stream_continuation(), + renderables.len(), + )); + } + } + fn cell_renderable( cell: Arc, cell_idx: usize, @@ -813,6 +924,82 @@ impl TranscriptOverlay { .collect() } + fn turn_display(cells: &[Arc], turn_range: Range) -> TurnDisplay { + let prompt_idx = turn_range.start; + let final_answer_indices = Self::final_answer_indices(cells, turn_range.clone()); + let detail_start = turn_range.start.saturating_add(1); + let mut details_indices = Vec::new(); + for cell_idx in detail_start..turn_range.end { + if final_answer_indices.contains(&cell_idx) { + continue; + } + details_indices.push(cell_idx); + } + TurnDisplay { + prompt_idx, + details_indices, + final_answer_indices, + } + } + + fn is_agent_message_cell(cell: &dyn HistoryCell) -> bool { + cell.as_any().is::() + } + + fn final_answer_indices(cells: &[Arc], turn_range: Range) -> Vec { + if let Some(range) = Self::latest_agent_message_range(cells, turn_range.clone()) { + return range.collect(); + } + Self::latest_meaningful_block_range(cells, turn_range) + .map(Iterator::collect) + .unwrap_or_default() + } + + fn latest_agent_message_range( + cells: &[Arc], + turn_range: Range, + ) -> Option> { + let response_start = turn_range.start.saturating_add(1); + for cell_idx in (response_start..turn_range.end).rev() { + if !Self::is_agent_message_cell(cells[cell_idx].as_ref()) { + continue; + } + let mut start = cell_idx; + while start > response_start + && Self::is_agent_message_cell(cells[start - 1].as_ref()) + && cells[start].is_stream_continuation() + { + start -= 1; + } + return Some(start..cell_idx + 1); + } + None + } + + fn latest_meaningful_block_range( + cells: &[Arc], + turn_range: Range, + ) -> Option> { + let response_start = turn_range.start.saturating_add(1); + for cell_idx in (response_start..turn_range.end).rev() { + if !Self::has_meaningful_transcript(cells[cell_idx].as_ref()) { + continue; + } + let mut start = cell_idx; + while start > response_start && cells[start].is_stream_continuation() { + start -= 1; + } + return Some(start..cell_idx + 1); + } + None + } + + fn has_meaningful_transcript(cell: &dyn HistoryCell) -> bool { + cell.transcript_lines(200) + .iter() + .any(|line| line.spans.iter().any(|span| !span.content.trim().is_empty())) + } + fn collapsed_response_summary( cells: &[Arc], turn_range: Range, @@ -844,6 +1031,23 @@ impl TranscriptOverlay { "(no response yet)".to_string() } + fn collapsed_final_answer_summary(cells: &[Arc], turn_display: &TurnDisplay) -> String { + if turn_display.has_final_answer() { + let start = *turn_display + .final_answer_indices + .first() + .unwrap_or(&turn_display.prompt_idx); + let end = turn_display + .final_answer_indices + .last() + .map(|idx| idx + 1) + .unwrap_or(start + 1); + Self::collapsed_response_summary(cells, start..end) + } else { + "(no final answer)".to_string() + } + } + fn chunk_index_for_cell(&self, cell_idx: usize) -> Option { let turn_ranges = Self::turn_ranges(&self.cells, &self.anchors); let selected_anchor = self @@ -861,16 +1065,32 @@ impl TranscriptOverlay { let mut chunk_idx = first_anchor_idx; for (anchor_idx, turn_range) in turn_ranges.iter().enumerate() { - if anchor_idx == selected_anchor || self.expand_all { - if turn_range.contains(&cell_idx) { - return Some(chunk_idx + cell_idx.saturating_sub(turn_range.start)); + let turn_display = Self::turn_display(&self.cells, turn_range.clone()); + if self.expand_all || anchor_idx == selected_anchor { + let mut visible_indices = vec![turn_display.prompt_idx]; + if self.show_details { + visible_indices.extend(turn_display.details_indices.iter().copied()); } - chunk_idx += turn_range.end.saturating_sub(turn_range.start); + visible_indices.extend(turn_display.final_answer_indices.iter().copied()); + if let Some(offset) = visible_indices.iter().position(|idx| *idx == cell_idx) { + return Some(chunk_idx + offset); + } + if !self.show_details && turn_display.has_details() && cell_idx == turn_display.prompt_idx { + return Some(chunk_idx); + } + chunk_idx += 1 + + usize::from(turn_display.has_details() || self.show_details && !turn_display.details_indices.is_empty()) + + turn_display.final_answer_indices.len() + + if self.show_details { + turn_display.details_indices.len() + } else { + 0 + }; } else { if turn_range.start == cell_idx { return Some(chunk_idx); } - chunk_idx += 1; + chunk_idx += 2 + usize::from(turn_display.has_details()); } } None @@ -897,6 +1117,7 @@ impl TranscriptOverlay { &self.anchors, self.selected_anchor, self.expand_all, + self.show_details, self.selected_transcript_cell(), ); if let Some(tail) = tail_renderable { @@ -1022,6 +1243,7 @@ impl TranscriptOverlay { &self.anchors, self.selected_anchor, self.expand_all, + self.show_details, self.selected_transcript_cell(), ); if let Some(tail) = tail_renderable { @@ -1073,6 +1295,14 @@ impl TranscriptOverlay { }, ), (&[KEY_E], if self.expand_all { "collapse all" } else { "expand all" }), + ( + &[KEY_D], + if self.show_details { + "hide details" + } else { + "show details" + }, + ), ]; if self.anchors_visible { pairs.push(( @@ -1263,6 +1493,13 @@ impl TranscriptOverlay { .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); Ok(()) } + e if KEY_D.is_press(e) => { + self.show_details = !self.show_details; + self.rebuild_renderables(); + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + Ok(()) + } other => match self.mode { TranscriptBrowseMode::FreeForm => self.view.handle_key_event(tui, other), TranscriptBrowseMode::ByPrompt => self.handle_anchor_key_event(tui, other),