feat(tui): refine transcript overlay navigation

This commit is contained in:
Felipe Coury
2026-05-18 14:18:59 -03:00
parent 3c36838dfb
commit afa78c9258
11 changed files with 549 additions and 412 deletions

View File

@@ -35,6 +35,7 @@ use crate::app_event::AppEvent;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::UserHistoryCell;
use crate::key_hint::KeyBindingListExt;
use crate::pager_overlay::Overlay;
use crate::tui;
use crate::tui::TuiEvent;
@@ -104,9 +105,9 @@ pub(crate) struct PendingBacktrackRollback {
impl App {
/// Route overlay events while the transcript overlay is active.
///
/// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter
/// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the
/// overlay.
/// If backtrack preview is active, Esc / previous-prompt steps selection, next-prompt steps
/// forward, Enter confirms. Otherwise, Esc or a prompt-selection key begins preview mode and
/// all other events are forwarded to the overlay.
pub(crate) async fn handle_backtrack_overlay_event(
&mut self,
tui: &mut tui::Tui,
@@ -122,19 +123,15 @@ impl App {
self.overlay_step_backtrack(tui, event)?;
Ok(true)
}
TuiEvent::Key(KeyEvent {
code: KeyCode::Left,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
TuiEvent::Key(key_event)
if self.keymap.pager.previous_user_prompt.is_pressed(key_event) =>
{
self.overlay_step_backtrack(tui, event)?;
Ok(true)
}
TuiEvent::Key(KeyEvent {
code: KeyCode::Right,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
TuiEvent::Key(key_event)
if self.keymap.pager.next_user_prompt.is_pressed(key_event) =>
{
self.overlay_step_backtrack_forward(tui, event)?;
Ok(true)
}
@@ -151,13 +148,19 @@ impl App {
Ok(true)
}
}
} else if let TuiEvent::Key(KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) = event
} else if let TuiEvent::Key(key_event) = event
&& (matches!(
key_event,
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}
) || self.keymap.pager.previous_user_prompt.is_pressed(key_event)
|| self.keymap.pager.next_user_prompt.is_pressed(key_event))
{
// First Esc in transcript overlay: begin backtrack preview at latest user message.
// First Esc / prompt-selection key in transcript overlay: begin backtrack preview at
// latest user message.
self.begin_overlay_backtrack_preview(tui);
Ok(true)
} else {
@@ -490,7 +493,8 @@ impl App {
Ok(())
}
/// Handle Right in overlay backtrack preview: step selection forward if armed, else forward.
/// Handle next-prompt in overlay backtrack preview: step selection forward if armed, else
/// forward.
fn overlay_step_backtrack_forward(
&mut self,
tui: &mut tui::Tui,

View File

@@ -0,0 +1,235 @@
use crate::color::is_light;
use crate::terminal_palette::default_bg;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Styled as _;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
const FOOTER_COMPACT_BREAKPOINT: u16 = 120;
const FOOTER_HINT_LEFT_PADDING: usize = 1;
const FOOTER_HINT_GAP: usize = 3;
#[derive(Clone, Debug)]
pub(crate) struct FooterHint {
key: String,
wide_label: String,
compact_label: String,
priority: u8,
}
impl FooterHint {
pub(crate) fn new(
key: impl Into<String>,
wide_label: impl Into<String>,
compact_label: impl Into<String>,
priority: u8,
) -> Self {
Self {
key: key.into(),
wide_label: wide_label.into(),
compact_label: compact_label.into(),
priority,
}
}
}
#[derive(Clone, Copy)]
enum FooterHintLabelMode {
Wide,
Compact,
KeyOnly,
}
pub(crate) fn footer_hint_line_for_row(hints: &[FooterHint], width: u16) -> Line<'static> {
if width >= FOOTER_COMPACT_BREAKPOINT
&& let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Wide, width)
{
return line;
}
if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Compact, width) {
return line;
}
if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::KeyOnly, width) {
return line;
}
let mut retained = (0..hints.len()).collect::<Vec<_>>();
retained.sort_by_key(|idx| hints[*idx].priority);
for retain_count in (1..=retained.len()).rev() {
let mut candidate_indices = retained[..retain_count].to_vec();
candidate_indices.sort_unstable();
let candidate = candidate_indices
.iter()
.map(|idx| &hints[*idx])
.collect::<Vec<_>>();
if let Some(line) = fit_footer_hint_refs(&candidate, FooterHintLabelMode::KeyOnly, width) {
return line;
}
}
Line::default()
}
pub(crate) fn render_footer_separator(area: Rect, buf: &mut Buffer, label: String) {
if area.width == 0 {
return;
}
Line::from("".repeat(area.width as usize).dim()).render_ref(area, buf);
if label.is_empty() {
return;
}
let label_width = UnicodeWidthStr::width(label.as_str()) as u16;
if label_width < area.width {
let label_area = Rect::new(
area.x + area.width - label_width - 1,
area.y,
label_width,
1,
);
Line::from(label.dim()).render_ref(label_area, buf);
}
}
fn fit_footer_hints(
hints: &[FooterHint],
mode: FooterHintLabelMode,
width: u16,
) -> Option<Line<'static>> {
let hint_refs = hints.iter().collect::<Vec<_>>();
fit_footer_hint_refs(&hint_refs, mode, width)
}
fn fit_footer_hint_refs(
hints: &[&FooterHint],
mode: FooterHintLabelMode,
width: u16,
) -> Option<Line<'static>> {
let gap_width = FOOTER_HINT_GAP;
if footer_hints_width(hints, mode, gap_width) > width as usize {
return None;
}
let mut spans = vec![
" ".repeat(FOOTER_HINT_LEFT_PADDING)
.set_style(footer_hint_label_style()),
];
for (idx, hint) in hints.iter().enumerate() {
if idx > 0 {
spans.push(" ".repeat(gap_width).set_style(footer_hint_label_style()));
}
spans.push(hint.key.clone().set_style(footer_hint_key_style()));
let label = match mode {
FooterHintLabelMode::Wide => Some(hint.wide_label.as_str()),
FooterHintLabelMode::Compact => Some(hint.compact_label.as_str()),
FooterHintLabelMode::KeyOnly => None,
};
if let Some(label) = label {
spans.push(" ".set_style(footer_hint_label_style()));
spans.push(label.to_string().set_style(footer_hint_label_style()));
}
}
Some(spans.into())
}
fn footer_hint_key_style() -> Style {
if default_bg().is_some_and(is_light) {
Style::default().fg(Color::Black)
} else {
Style::default()
}
}
fn footer_hint_label_style() -> Style {
if default_bg().is_some_and(is_light) {
Style::default().fg(Color::DarkGray)
} else {
Style::default().dim()
}
}
fn footer_hints_width(hints: &[&FooterHint], mode: FooterHintLabelMode, gap_width: usize) -> usize {
FOOTER_HINT_LEFT_PADDING
+ hints
.iter()
.enumerate()
.map(|(idx, hint)| {
let label_width = match mode {
FooterHintLabelMode::Wide => {
1 + UnicodeWidthStr::width(hint.wide_label.as_str())
}
FooterHintLabelMode::Compact => {
1 + UnicodeWidthStr::width(hint.compact_label.as_str())
}
FooterHintLabelMode::KeyOnly => 0,
};
let hint_width = UnicodeWidthStr::width(hint.key.as_str()) + label_width;
if idx == 0 {
hint_width
} else {
hint_width + gap_width
}
})
.sum::<usize>()
}
#[cfg(test)]
mod tests {
use super::*;
fn line_text(line: Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
#[test]
fn footer_hint_line_uses_wide_labels_when_width_allows() {
let hints = [FooterHint::new(
"enter",
"resume session",
"resume",
/*priority*/ 0,
)];
let rendered = line_text(footer_hint_line_for_row(&hints, /*width*/ 140));
assert!(rendered.contains("enter resume session"));
}
#[test]
fn footer_hint_line_compacts_below_breakpoint() {
let hints = [FooterHint::new(
"enter",
"resume session",
"resume",
/*priority*/ 0,
)];
let rendered = line_text(footer_hint_line_for_row(&hints, /*width*/ 80));
assert!(rendered.contains("enter resume"));
assert!(!rendered.contains("resume session"));
}
#[test]
fn footer_hint_line_drops_low_priority_hints_when_narrow() {
let hints = [
FooterHint::new("a", "alpha", "alpha", /*priority*/ 0),
FooterHint::new("b", "bravo", "bravo", /*priority*/ 9),
FooterHint::new("c", "charlie", "charlie", /*priority*/ 1),
];
let rendered = line_text(footer_hint_line_for_row(&hints, /*width*/ 6));
assert!(rendered.contains('a'));
assert!(rendered.contains('c'));
assert!(!rendered.contains('b'));
}
}

View File

@@ -832,8 +832,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)],
previous_user_prompt: default_bindings![plain(KeyCode::Left), alt(KeyCode::Up)],
next_user_prompt: default_bindings![plain(KeyCode::Right), alt(KeyCode::Down)],
},
list: ListKeymap {
move_up: default_bindings![
@@ -1478,14 +1478,6 @@ const TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
"fixed.transcript_edit_previous",
key_hint::plain(KeyCode::Esc),
),
(
"fixed.transcript_edit_previous",
key_hint::plain(KeyCode::Left),
),
(
"fixed.transcript_edit_next",
key_hint::plain(KeyCode::Right),
),
(
"fixed.transcript_confirm_edit",
key_hint::plain(KeyCode::Enter),
@@ -2183,9 +2175,26 @@ mod tests {
#[test]
fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() {
let mut keymap = TuiKeymap::default();
keymap.pager.close = Some(one("left"));
keymap.pager.close = Some(one("enter"));
expect_conflict(&keymap, "close", "fixed.transcript_edit_previous");
expect_conflict(&keymap, "close", "fixed.transcript_confirm_edit");
}
#[test]
fn pager_prompt_selection_defaults_to_left_and_right_arrows() {
let runtime = RuntimeKeymap::defaults();
assert_eq!(
runtime.pager.previous_user_prompt,
vec![key_hint::plain(KeyCode::Left), key_hint::alt(KeyCode::Up)]
);
assert_eq!(
runtime.pager.next_user_prompt,
vec![
key_hint::plain(KeyCode::Right),
key_hint::alt(KeyCode::Down)
]
);
}
#[test]

View File

@@ -126,6 +126,7 @@ mod external_agent_config_migration;
mod external_agent_config_migration_startup;
mod external_editor;
mod file_search;
mod footer_hints;
mod frames;
mod get_git_diff;
mod git_action_directives;

View File

@@ -19,6 +19,9 @@ use std::io::Result;
use std::sync::Arc;
use crate::chatwidget::ActiveCellTranscriptKey;
use crate::footer_hints::FooterHint;
use crate::footer_hints::footer_hint_line_for_row;
use crate::footer_hints::render_footer_separator;
use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryRenderMode;
use crate::history_cell::UserHistoryCell;
@@ -40,7 +43,6 @@ use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
@@ -122,25 +124,12 @@ fn first_or_empty(bindings: &[KeyBinding]) -> Vec<KeyBinding> {
bindings.first().copied().into_iter().collect()
}
// Render a single line of key hints from (key(s), description) pairs.
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec<KeyBinding>, &str)]) {
let mut spans: Vec<Span<'static>> = vec![" ".into()];
let mut first = true;
for (keys, desc) in pairs {
if !first {
spans.push(" ".into());
}
for (i, key) in keys.iter().enumerate() {
if i > 0 {
spans.push("/".into());
}
spans.push(Span::from(key));
}
spans.push(" ".into());
spans.push(Span::from(desc.to_string()));
first = false;
}
Paragraph::new(vec![Line::from(spans).dim()]).render_ref(area, buf);
fn key_label(bindings: &[KeyBinding]) -> String {
bindings
.iter()
.map(KeyBinding::display_label)
.collect::<Vec<_>>()
.join("/")
}
/// Generic widget for rendering a pager view.
@@ -220,11 +209,11 @@ impl PagerView {
fn render(&mut self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
self.render_header(area, buf);
let content_area = self.content_area(area);
self.update_last_content_height(content_area.height);
let content_height = self.content_height(content_area.width);
self.last_rendered_height = Some(content_height);
self.render_header(area, content_area, buf, content_height);
// If there is a pending request to scroll a specific chunk into view,
// satisfy it now that wrapping is up to date for this width.
if let Some(idx) = self.pending_scroll_chunk.take() {
@@ -239,12 +228,13 @@ impl PagerView {
self.render_bottom_bar(area, content_area, buf, content_height);
}
fn render_header(&self, area: Rect, buf: &mut Buffer) {
Span::from("/ ".repeat(area.width as usize / 2))
fn render_header(&self, area: Rect, content_area: Rect, buf: &mut Buffer, total_len: usize) {
let header = Rect::new(area.x, area.y, area.width, 1);
render_footer_separator(header, buf, String::new());
let percent = self.scroll_percent(content_area.height, total_len);
format!(" {} · {percent}% ", self.title)
.dim()
.render_ref(area, buf);
let header = format!("/ {}", self.title);
header.dim().render_ref(area, buf);
.render_ref(header, buf);
}
fn render_content(&mut self, area: Rect, buf: &mut Buffer) {
@@ -307,31 +297,23 @@ impl PagerView {
full_area: Rect,
content_area: Rect,
buf: &mut Buffer,
total_len: usize,
_total_len: usize,
) {
let sep_y = content_area.bottom();
let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1);
Span::from("".repeat(sep_rect.width as usize))
.dim()
.render_ref(sep_rect, buf);
let percent = if total_len == 0 {
100
} else {
let max_scroll = total_len.saturating_sub(content_area.height as usize);
if max_scroll == 0 {
100
} else {
(((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round()
as u8
}
};
let pct_text = format!(" {percent}% ");
let pct_w = pct_text.chars().count() as u16;
let pct_x = sep_rect.x + sep_rect.width - pct_w - 1;
Span::from(pct_text)
.dim()
.render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf);
render_footer_separator(sep_rect, buf, String::new());
}
fn scroll_percent(&self, content_height: u16, total_len: usize) -> u8 {
if total_len == 0 {
return 100;
}
let max_scroll = total_len.saturating_sub(content_height as usize);
if max_scroll == 0 {
return 100;
}
(((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() as u8
}
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> {
@@ -542,7 +524,7 @@ impl TranscriptOverlay {
Self {
view: PagerView::new(
Self::render_cells(&transcript_cells, state.highlight_cell, state.render_mode),
"T R A N S C R I P T".to_string(),
"Transcript".to_string(),
state.scroll_offset,
keymap,
),
@@ -567,7 +549,7 @@ impl TranscriptOverlay {
.enumerate()
.flat_map(|(i, c)| {
let mut v: Vec<Box<dyn Renderable>> = Vec::new();
let mut cell_renderable = if c.as_any().is::<UserHistoryCell>() {
let base_renderable = if c.as_any().is::<UserHistoryCell>() {
Box::new(CachedRenderable::new(CellRenderable {
cell: c.clone(),
style: if highlight_cell == Some(i) {
@@ -584,6 +566,7 @@ impl TranscriptOverlay {
render_mode,
})) as Box<dyn Renderable>
};
let mut cell_renderable = base_renderable;
if !c.is_stream_continuation() && i > 0 {
cell_renderable = Box::new(InsetRenderable::new(
cell_renderable,
@@ -828,6 +811,29 @@ impl TranscriptOverlay {
self.set_highlight_cell(Some(next_prompt));
}
fn header_title(&self) -> String {
let total = self
.cells
.iter()
.filter(|cell| cell.is_user_prompt())
.count();
let Some(highlight_cell) = self.highlight_cell else {
let noun = if total == 1 { "prompt" } else { "prompts" };
return format!("Transcript · {total} {noun}");
};
let selected = self
.cells
.iter()
.take(highlight_cell.saturating_add(1))
.filter(|cell| cell.is_user_prompt())
.count();
if selected == 0 || total == 0 {
let noun = if total == 1 { "prompt" } else { "prompts" };
return format!("Transcript · {total} {noun}");
}
format!("Transcript · {selected}/{total}")
}
fn rebuild_renderables(&mut self) {
let tail_renderable = self.take_live_tail_renderable();
self.view.renderables =
@@ -873,69 +879,108 @@ impl TranscriptOverlay {
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(
line1,
buf,
&[
(
first_or_empty(&self.view.keymap.scroll_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.scroll_down))
.collect(),
"to scroll",
),
(
first_or_empty(&self.view.keymap.page_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.page_down))
.collect(),
"to page",
),
(
first_or_empty(&self.view.keymap.jump_top)
.into_iter()
.chain(first_or_empty(&self.view.keymap.jump_bottom))
.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 scroll_keys = first_or_empty(&self.view.keymap.scroll_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.scroll_down))
.collect::<Vec<_>>();
let page_keys = first_or_empty(&self.view.keymap.page_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.page_down))
.collect::<Vec<_>>();
let jump_keys = first_or_empty(&self.view.keymap.jump_top)
.into_iter()
.chain(first_or_empty(&self.view.keymap.jump_bottom))
.collect::<Vec<_>>();
let prompt_keys = first_or_empty(&self.view.keymap.previous_user_prompt)
.into_iter()
.chain(first_or_empty(&self.view.keymap.next_user_prompt))
.collect::<Vec<_>>();
let navigation_hints = vec![
FooterHint::new(
key_label(&scroll_keys),
"scroll",
"scroll",
/*priority*/ 1,
),
FooterHint::new(
key_label(&prompt_keys),
"prompts",
"prompts",
/*priority*/ 2,
),
FooterHint::new(key_label(&page_keys), "page", "page", /*priority*/ 6),
FooterHint::new(key_label(&jump_keys), "jump", "jump", /*priority*/ 7),
];
footer_hint_line_for_row(&navigation_hints, area.width).render_ref(line1, buf);
let mut pairs: Vec<(Vec<KeyBinding>, &str)> = Vec::new();
let mut action_hints = Vec::new();
action_hints.push(FooterHint::new(
key_label(&first_or_empty(&self.view.keymap.close)),
"quit",
"quit",
/*priority*/ 0,
));
if !self.copy_keymap.is_empty() {
pairs.push((first_or_empty(&self.copy_keymap), "to copy"));
action_hints.push(FooterHint::new(
key_label(&first_or_empty(&self.copy_keymap)),
"copy",
"copy",
/*priority*/ 3,
));
}
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![
key_hint::plain(KeyCode::Esc),
key_hint::plain(KeyCode::Left),
],
"to edit prev",
let mode_label = match self.render_mode {
HistoryRenderMode::Rich => "raw",
HistoryRenderMode::Raw => "rich",
};
action_hints.push(FooterHint::new(
key_label(&first_or_empty(&self.toggle_raw_output_keymap)),
mode_label,
mode_label,
/*priority*/ 4,
));
pairs.push((vec![key_hint::plain(KeyCode::Right)], "to edit next"));
pairs.push((vec![key_hint::plain(KeyCode::Enter)], "to edit message"));
} else {
pairs.push((vec![key_hint::plain(KeyCode::Esc)], "to edit prev"));
}
render_key_hints(line2, buf, &pairs);
if self.highlight_cell.is_some() {
let previous_edit_keys = std::iter::once(key_hint::plain(KeyCode::Esc))
.chain(first_or_empty(&self.view.keymap.previous_user_prompt))
.collect::<Vec<_>>();
action_hints.push(FooterHint::new(
key_label(&previous_edit_keys),
"edit prev",
"prev",
/*priority*/ 8,
));
action_hints.push(FooterHint::new(
key_label(&first_or_empty(&self.view.keymap.next_user_prompt)),
"edit next",
"next",
/*priority*/ 9,
));
action_hints.push(FooterHint::new(
key_label(&[key_hint::plain(KeyCode::Enter)]),
"edit message",
"edit",
/*priority*/ 10,
));
} else {
let previous_edit_keys = std::iter::once(key_hint::plain(KeyCode::Esc))
.chain(first_or_empty(&self.view.keymap.previous_user_prompt))
.collect::<Vec<_>>();
action_hints.push(FooterHint::new(
key_label(&previous_edit_keys),
"edit prev",
"prev",
/*priority*/ 8,
));
}
footer_hint_line_for_row(&action_hints, area.width).render_ref(line2, buf);
}
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
let top_h = area.height.saturating_sub(3);
let top = Rect::new(area.x, area.y, area.width, top_h);
let bottom = Rect::new(area.x, area.y + top_h, area.width, 3);
self.view.title = self.header_title();
self.view.render(top, buf);
self.render_hints(bottom, buf);
}
@@ -1032,36 +1077,37 @@ impl StaticOverlay {
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(
line1,
buf,
&[
(
first_or_empty(&self.view.keymap.scroll_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.scroll_down))
.collect(),
"to scroll",
),
(
first_or_empty(&self.view.keymap.page_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.page_down))
.collect(),
"to page",
),
(
first_or_empty(&self.view.keymap.jump_top)
.into_iter()
.chain(first_or_empty(&self.view.keymap.jump_bottom))
.collect(),
"to jump",
),
],
);
let pairs: Vec<(Vec<KeyBinding>, &str)> =
vec![(first_or_empty(&self.view.keymap.close), "to quit")];
render_key_hints(line2, buf, &pairs);
let scroll_keys = first_or_empty(&self.view.keymap.scroll_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.scroll_down))
.collect::<Vec<_>>();
let page_keys = first_or_empty(&self.view.keymap.page_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.page_down))
.collect::<Vec<_>>();
let jump_keys = first_or_empty(&self.view.keymap.jump_top)
.into_iter()
.chain(first_or_empty(&self.view.keymap.jump_bottom))
.collect::<Vec<_>>();
let navigation_hints = vec![
FooterHint::new(
key_label(&scroll_keys),
"scroll",
"scroll",
/*priority*/ 1,
),
FooterHint::new(key_label(&page_keys), "page", "page", /*priority*/ 6),
FooterHint::new(key_label(&jump_keys), "jump", "jump", /*priority*/ 7),
];
footer_hint_line_for_row(&navigation_hints, area.width).render_ref(line1, buf);
let action_hints = vec![FooterHint::new(
key_label(&first_or_empty(&self.view.keymap.close)),
"quit",
"quit",
/*priority*/ 0,
)];
footer_hint_line_for_row(&action_hints, area.width).render_ref(line2, buf);
}
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
@@ -1139,11 +1185,13 @@ mod tests {
use crate::diff_model::FileChange;
use crate::exec_cell::CommandOutput;
use crate::history_cell;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::new_patch_event;
use codex_protocol::parse_command::ParsedCommand;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::style::Modifier;
use ratatui::text::Text;
#[derive(Debug)]
@@ -1332,6 +1380,52 @@ mod tests {
assert_eq!(overlay.selected_user_cell(), Some(2));
}
#[test]
fn transcript_header_counts_selected_user_prompt() {
let mut overlay = transcript_overlay(vec![
user_cell("first"),
Arc::new(AgentMessageCell::new(
vec![Line::from("assistant")],
/*is_first_line*/ true,
)),
user_cell("second"),
]);
assert_eq!(overlay.header_title(), "Transcript · 2 prompts");
overlay.move_prompt_selection(PromptSelectionDirection::Previous);
assert_eq!(overlay.header_title(), "Transcript · 2/2");
overlay.move_prompt_selection(PromptSelectionDirection::Previous);
assert_eq!(overlay.header_title(), "Transcript · 1/2");
}
#[test]
fn selected_user_prompt_keeps_reversed_style_without_role_gutter() {
let mut overlay = transcript_overlay(vec![user_cell("selected prompt")]);
overlay.set_highlight_cell(Some(0));
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 8,
);
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
let rendered = buffer_to_text(&buf, area);
assert!(!rendered.contains('▌'));
assert!(!rendered.contains('│'));
let prompt_marker = (area.y..area.bottom())
.flat_map(|y| (area.x..area.right()).map(move |x| (x, y)))
.find(|(x, y)| buf[(*x, *y)].symbol() == "")
.expect("expected selected prompt marker");
assert!(
buf[prompt_marker]
.style()
.add_modifier
.contains(Modifier::REVERSED)
);
}
#[test]
fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {

View File

@@ -9,6 +9,9 @@ mod transcript;
use crate::app_server_session::AppServerSession;
use crate::color::blend;
use crate::color::is_light;
use crate::footer_hints::FooterHint;
use crate::footer_hints::footer_hint_line_for_row;
use crate::footer_hints::render_footer_separator;
use crate::git_action_directives::parse_assistant_markdown;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_plain_text_key_event;
@@ -74,9 +77,6 @@ const SESSION_META_MIN_CWD_WIDTH: usize = 30;
const SESSION_META_MAX_CWD_WIDTH: usize = 72;
const SESSION_META_BRANCH_ICON: &str = "";
const SESSION_META_CWD_ICON: &str = "";
const FOOTER_COMPACT_BREAKPOINT: u16 = 120;
const FOOTER_HINT_LEFT_PADDING: usize = 1;
const FOOTER_HINT_GAP: usize = 3;
const PICKER_CHROME_HEIGHT: u16 = 8;
const PICKER_LIST_HORIZONTAL_INSET: u16 = 4;
@@ -2004,13 +2004,6 @@ fn filter_mode_label(filter_mode: SessionFilterMode) -> &'static str {
}
}
struct PickerFooterHint {
key: &'static str,
wide_label: String,
compact_label: String,
priority: u8,
}
fn render_picker_footer(
frame: &mut crate::custom_terminal::Frame,
area: Rect,
@@ -2022,9 +2015,9 @@ fn render_picker_footer(
}
let separator = Rect::new(area.x, area.y, area.width, 1);
render_picker_footer_separator(
frame,
render_footer_separator(
separator,
frame.buffer,
picker_footer_progress_label(state, list_height, area.width),
);
@@ -2038,30 +2031,6 @@ fn render_picker_footer(
}
}
fn render_picker_footer_separator(
frame: &mut crate::custom_terminal::Frame,
area: Rect,
progress_label: String,
) {
if area.width == 0 {
return;
}
let separator = "".repeat(area.width as usize);
frame.render_widget_ref(Line::from(separator.dim()), area);
let progress_width = UnicodeWidthStr::width(progress_label.as_str()) as u16;
if progress_width < area.width {
let percent_area = Rect::new(
area.x + area.width - progress_width - 1,
area.y,
progress_width,
1,
);
frame.render_widget_ref(Line::from(progress_label.dim()), percent_area);
}
}
fn picker_footer_progress_label(state: &PickerState, list_height: u16, width: u16) -> String {
let position = if state.filtered_rows.is_empty() {
0
@@ -2128,23 +2097,10 @@ fn picker_footer_scroll_percent(state: &PickerState, list_height: u16) -> u8 {
fn footer_hint_lines(state: &PickerState, width: u16) -> Vec<Line<'static>> {
if state.is_transcript_loading() {
let hints = [
PickerFooterHint {
key: "loading",
wide_label: String::from("transcript"),
compact_label: String::from("transcript"),
priority: 0,
},
PickerFooterHint {
key: "ctrl+c",
wide_label: String::from("quit"),
compact_label: String::from("quit"),
priority: 1,
},
FooterHint::new("loading", "transcript", "transcript", /*priority*/ 0),
FooterHint::new("ctrl+c", "quit", "quit", /*priority*/ 1),
];
let line = fit_footer_hints(&hints, FooterHintLabelMode::Wide, width)
.or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::Compact, width))
.or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::KeyOnly, width))
.unwrap_or_default();
let line = footer_hint_line_for_row(&hints, width);
return vec![line, Line::default()];
}
@@ -2170,99 +2126,30 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec<Line<'static>> {
SessionListDensity::Dense => "comfy",
};
let first_row_hints = vec![
PickerFooterHint {
key: "enter",
wide_label: action_label.to_string(),
compact_label: action_label.to_string(),
priority: 0,
},
PickerFooterHint {
key: "esc",
wide_label: esc_label.to_string(),
compact_label: esc_compact_label.to_string(),
priority: 1,
},
PickerFooterHint {
key: "ctrl+c",
wide_label: ctrl_c_label.to_string(),
compact_label: ctrl_c_label.to_string(),
priority: 2,
},
PickerFooterHint {
key: "tab",
wide_label: String::from("focus sort/filter"),
compact_label: String::from("focus"),
priority: 7,
},
PickerFooterHint {
key: "←/→",
wide_label: String::from("change option"),
compact_label: String::from("option"),
priority: 8,
},
FooterHint::new("enter", action_label, action_label, /*priority*/ 0),
FooterHint::new("esc", esc_label, esc_compact_label, /*priority*/ 1),
FooterHint::new("ctrl+c", ctrl_c_label, ctrl_c_label, /*priority*/ 2),
FooterHint::new("tab", "focus sort/filter", "focus", /*priority*/ 7),
FooterHint::new("←/→", "change option", "option", /*priority*/ 8),
];
let second_row_hints = vec![
PickerFooterHint {
key: "ctrl+o",
wide_label: density_label.to_string(),
compact_label: density_compact_label.to_string(),
priority: 3,
},
PickerFooterHint {
key: "ctrl+t",
wide_label: String::from("transcript"),
compact_label: String::from("preview"),
priority: 4,
},
PickerFooterHint {
key: "ctrl+e",
wide_label: String::from("expand"),
compact_label: String::from("exp"),
priority: 6,
},
PickerFooterHint {
key: "↑/↓",
wide_label: String::from("browse"),
compact_label: String::from("browse"),
priority: 5,
},
FooterHint::new(
"ctrl+o",
density_label,
density_compact_label,
/*priority*/ 3,
),
FooterHint::new("ctrl+t", "transcript", "preview", /*priority*/ 4),
FooterHint::new("ctrl+e", "expand", "exp", /*priority*/ 6),
FooterHint::new("↑/↓", "browse", "browse", /*priority*/ 5),
];
vec![
hint_line_for_row(&first_row_hints, width),
hint_line_for_row(&second_row_hints, width),
footer_hint_line_for_row(&first_row_hints, width),
footer_hint_line_for_row(&second_row_hints, width),
]
}
fn hint_line_for_row(hints: &[PickerFooterHint], width: u16) -> Line<'static> {
if width >= FOOTER_COMPACT_BREAKPOINT
&& let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Wide, width)
{
return line;
}
if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Compact, width) {
return line;
}
if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::KeyOnly, width) {
return line;
}
let mut retained = (0..hints.len()).collect::<Vec<_>>();
retained.sort_by_key(|idx| hints[*idx].priority);
for retain_count in (1..=retained.len()).rev() {
let mut candidate_indices = retained[..retain_count].to_vec();
candidate_indices.sort_unstable();
let candidate = candidate_indices
.iter()
.map(|idx| &hints[*idx])
.collect::<Vec<_>>();
if let Some(line) = fit_footer_hint_refs(&candidate, FooterHintLabelMode::KeyOnly, width) {
return line;
}
}
Line::default()
}
fn render_transcript_loading_overlay(frame: &mut crate::custom_terminal::Frame, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
@@ -2312,99 +2199,6 @@ fn transcript_loading_overlay_style() -> Style {
Style::default().bg(best_color(blend(overlay, bg, alpha)))
}
#[derive(Clone, Copy)]
enum FooterHintLabelMode {
Wide,
Compact,
KeyOnly,
}
fn fit_footer_hints(
hints: &[PickerFooterHint],
mode: FooterHintLabelMode,
width: u16,
) -> Option<Line<'static>> {
let hint_refs = hints.iter().collect::<Vec<_>>();
fit_footer_hint_refs(&hint_refs, mode, width)
}
fn fit_footer_hint_refs(
hints: &[&PickerFooterHint],
mode: FooterHintLabelMode,
width: u16,
) -> Option<Line<'static>> {
let gap_width = FOOTER_HINT_GAP;
if footer_hints_width(hints, mode, gap_width) > width as usize {
return None;
}
let mut spans = vec![
" ".repeat(FOOTER_HINT_LEFT_PADDING)
.set_style(footer_hint_label_style()),
];
for (idx, hint) in hints.iter().enumerate() {
if idx > 0 {
spans.push(" ".repeat(gap_width).set_style(footer_hint_label_style()));
}
spans.push(hint.key.set_style(footer_hint_key_style()));
let label = match mode {
FooterHintLabelMode::Wide => Some(hint.wide_label.as_str()),
FooterHintLabelMode::Compact => Some(hint.compact_label.as_str()),
FooterHintLabelMode::KeyOnly => None,
};
if let Some(label) = label {
spans.push(" ".set_style(footer_hint_label_style()));
spans.push(label.to_string().set_style(footer_hint_label_style()));
}
}
Some(spans.into())
}
fn footer_hint_key_style() -> Style {
if default_bg().is_some_and(is_light) {
Style::default().fg(Color::Black)
} else {
Style::default()
}
}
fn footer_hint_label_style() -> Style {
if default_bg().is_some_and(is_light) {
Style::default().fg(Color::DarkGray)
} else {
Style::default().dim()
}
}
fn footer_hints_width(
hints: &[&PickerFooterHint],
mode: FooterHintLabelMode,
gap_width: usize,
) -> usize {
FOOTER_HINT_LEFT_PADDING
+ hints
.iter()
.enumerate()
.map(|(idx, hint)| {
let label_width = match mode {
FooterHintLabelMode::Wide => {
1 + UnicodeWidthStr::width(hint.wide_label.as_str())
}
FooterHintLabelMode::Compact => {
1 + UnicodeWidthStr::width(hint.compact_label.as_str())
}
FooterHintLabelMode::KeyOnly => 0,
};
let hint_width = UnicodeWidthStr::width(hint.key) + label_width;
if idx == 0 {
hint_width
} else {
hint_width + gap_width
}
})
.sum::<usize>()
}
fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) {
if area.height == 0 {
return;

View File

@@ -2,13 +2,13 @@
source: tui/src/pager_overlay.rs
expression: term.backend()
---
"/ S T A T I C / / / / / / / / / / / / / "
" S T A T I C · 100% ────────────────────"
"one "
"two "
"three "
"~ "
"~ "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit "
"────────────────────────────────────────"
" ↑/↓ pgup/pgdn home/end "
" q quit "
" "

View File

@@ -2,11 +2,11 @@
source: tui/src/pager_overlay.rs
expression: term.backend()
---
"/ S T A T I C / / / / / "
" S T A T I C · 0% ──────"
"a very long line that "
"should wrap when "
"rendered within a narrow"
"─────────────────── 0% ─"
" ↑/↓ to scroll pgup/pg"
" q to quit "
"────────────────────────"
" ↑/↓ pgup/pgdn "
" q quit "
" "

View File

@@ -2,7 +2,7 @@
source: tui/src/pager_overlay.rs
expression: snapshot
---
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
Transcript · 0 prompts · 0% ───────────────────────────────────────────────────
• Added foo.txt (+2 -0)
1 +hello
2 +world
@@ -10,6 +10,6 @@ expression: snapshot
• Added foo.txt (+2 -0)
1 +hello
2 +world
─────────────────────────────────────────────────────────────────────────── 0%
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
───────────────────────────────────────────────────────────────────────────────
↑/↓ scroll ←/→ prompts pgup/pgdn page home/end jump
q quit ctrl + o copy ⌥ + r raw esc/← prev

View File

@@ -2,13 +2,13 @@
source: tui/src/pager_overlay.rs
expression: term.backend()
---
"/ T R A N S C R I P T / / / / / / / / / "
" Transcript · 0 prompts · 100% ─────────"
"alpha "
" "
"tail "
"~ "
"~ "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit esc to edit prev "
"────────────────────────────────────────"
" ↑/↓ ←/→ pgup/pgdn home/end "
" q ctrl + o ⌥ + r esc/← "
" "

View File

@@ -2,13 +2,13 @@
source: tui/src/pager_overlay.rs
expression: term.backend()
---
"/ T R A N S C R I P T / / / / / / / / / "
" Transcript · 0 prompts · 100% ─────────"
"alpha "
" "
"beta "
" "
"gamma "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" ctrl + o to copy ⌥ + r raw q to qui"
"────────────────────────────────────────"
" ↑/↓ ←/→ pgup/pgdn home/end "
" q ctrl + o ⌥ + r esc/← "
" "