feat(tui): improve transcript overlay

This commit is contained in:
Felipe Coury
2026-06-01 10:59:57 -03:00
parent a8a6071279
commit 378db994c5
18 changed files with 767 additions and 61 deletions
+4
View File
@@ -335,6 +335,10 @@ pub struct TuiPagerKeymap {
pub close: Option<KeybindingsSpec>,
/// Close the transcript overlay via its dedicated toggle key.
pub close_transcript: Option<KeybindingsSpec>,
/// Jump to the previous user prompt in the transcript overlay.
pub previous_user_prompt: Option<KeybindingsSpec>,
/// Jump to the next user prompt in the transcript overlay.
pub next_user_prompt: Option<KeybindingsSpec>,
}
/// List selection context keybindings for popup-style selectable lists.
+20
View File
@@ -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": [
{
+5 -1
View File
@@ -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<Overlay>,
pub(crate) transcript_overlay_state: TranscriptOverlayState,
pub(crate) deferred_history_lines: Vec<crate::terminal_hyperlinks::HyperlinkLine>,
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(),
+10 -8
View File
@@ -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());
}
}
+3
View File
@@ -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(),
+14
View File
@@ -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;
+47 -7
View File
@@ -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::<crate::history_cell::UserHistoryCell>()
}) 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<dyn crate::history_cell::HistoryCell>],
) -> impl Iterator<Item = usize> + '_ {
let session_start_type = TypeId::of::<SessionInfoCell>();
let user_type = TypeId::of::<UserHistoryCell>();
let type_of = |cell: &Arc<dyn crate::history_cell::HistoryCell>| 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)]
+58 -12
View File
@@ -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<Option<crate::clipboard_copy::ClipboardLease>, 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<Option<crate::clipboard_copy::ClipboardLease>, 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<Option<crate::clipboard_copy::ClipboardLease>, 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()
@@ -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;
@@ -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;
+155
View File
@@ -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<Line<'static>> {
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<HyperlinkLine> {
plain_hyperlink_lines(self.transcript_lines_for_mode(width, mode))
}
fn raw_lines(&self) -> Vec<Line<'static>> {
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<Line<'static>> {
let mut lines = Vec::new();
let mut running_group: Option<RunningHookGroup> = 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<Line<'static>>, 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::<String>()
}
#[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::<String>()
})
.collect::<Vec<_>>();
let raw = cell
.transcript_lines_for_mode(/*width*/ 80, HistoryRenderMode::Raw)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>();
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(),
+41
View File
@@ -191,6 +191,47 @@ impl HistoryCell for UserHistoryCell {
}
lines
}
fn transcript_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec<Line<'static>> {
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<HyperlinkLine> {
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<String> {
let label = if self.message.starts_with("# AGENTS.md instructions for ") {
"AGENTS.md instructions"
} else if self.message.starts_with("<environment_context>") {
"environment context"
} else if self.message.starts_with("<permissions instructions>") {
"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)]
+30 -1
View File
@@ -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<Line<'static>> {
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<HyperlinkLine> {
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
+27
View File
@@ -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 {
+16
View File
@@ -221,6 +221,8 @@ pub(crate) struct PagerKeymap {
pub(crate) jump_bottom: Vec<KeyBinding>,
pub(crate) close: Vec<KeyBinding>,
pub(crate) close_transcript: Vec<KeyBinding>,
pub(crate) previous_user_prompt: Vec<KeyBinding>,
pub(crate) next_user_prompt: Vec<KeyBinding>,
}
/// 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,
[],
+300 -31
View File
@@ -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<usize>,
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<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
Self::Transcript(TranscriptOverlay::new(cells, keymap))
pub(crate) fn new_transcript(
cells: Vec<Arc<dyn HistoryCell>>,
keymap: PagerKeymap,
copy_keymap: Vec<KeyBinding>,
toggle_raw_output_keymap: Vec<KeyBinding>,
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<usize>,
last_rendered_height: Option<usize>,
layout: Option<PagerLayout>,
/// If set, on next render ensure this chunk is visible.
pending_scroll_chunk: Option<usize>,
}
#[derive(Debug)]
struct PagerLayout {
width: u16,
offsets: Arc<[usize]>,
heights: Arc<[usize]>,
total_height: usize,
}
impl PagerView {
fn new(
renderables: Vec<Box<dyn Renderable>>,
@@ -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<dyn HistoryCell>,
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<Arc<dyn HistoryCell>>,
highlight_cell: Option<usize>,
render_mode: HistoryRenderMode,
copy_keymap: Vec<KeyBinding>,
toggle_raw_output_keymap: Vec<KeyBinding>,
copy_requested: bool,
/// Cache key for the render-only live tail appended after committed cells.
live_tail_key: Option<LiveTailKey>,
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<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
pub(crate) fn new(
transcript_cells: Vec<Arc<dyn HistoryCell>>,
keymap: PagerKeymap,
copy_keymap: Vec<KeyBinding>,
toggle_raw_output_keymap: Vec<KeyBinding>,
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<dyn HistoryCell>],
highlight_cell: Option<usize>,
render_mode: HistoryRenderMode,
) -> Vec<Box<dyn Renderable>> {
cells
.iter()
@@ -498,11 +600,13 @@ impl TranscriptOverlay {
} else {
user_message_style()
},
render_mode,
})) as Box<dyn Renderable>
} else {
Box::new(CachedRenderable::new(CellRenderable {
cell: c.clone(),
style: Style::default(),
render_mode,
})) as Box<dyn Renderable>
};
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<usize> {
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::<Vec<_>>();
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<Box<dyn Renderable>> {
(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<KeyBinding>, &str)> =
vec![(first_or_empty(&self.view.keymap.close), "to quit")];
let mut pairs: Vec<(Vec<KeyBinding>, &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<Arc<dyn HistoryCell>>) -> 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<dyn HistoryCell> {
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<Line<'static>>, 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),
+5
View File
@@ -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;
@@ -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"
" "