feat(tui): align transcript footer stats with session picker

This commit is contained in:
Felipe Coury
2026-05-18 16:12:10 -03:00
parent 702c534301
commit 1144d4cc6f
8 changed files with 129 additions and 43 deletions

View File

@@ -99,6 +99,14 @@ pub(crate) fn render_footer_separator(area: Rect, buf: &mut Buffer, label: Strin
}
}
pub(crate) fn first_fitting_right_label(width: u16, labels: &[String]) -> String {
labels
.iter()
.find(|label| UnicodeWidthStr::width(label.as_str()) < width as usize)
.cloned()
.unwrap_or_default()
}
pub(crate) fn render_footer_line_with_optional_right(
area: Rect,
buf: &mut Buffer,
@@ -372,4 +380,25 @@ mod tests {
assert_eq!(buffer_text(&buf, area), "sta…");
}
#[test]
fn first_fitting_right_label_picks_first_that_fits() {
let labels = vec![
" 10 / 120 · 55% ".to_string(),
" 10/120 · 55% ".to_string(),
" 55% ".to_string(),
];
assert_eq!(
first_fitting_right_label(/*width*/ 15, &labels),
" 10/120 · 55% "
);
}
#[test]
fn first_fitting_right_label_returns_empty_when_nothing_fits() {
let labels = vec![" 100% ".to_string()];
assert_eq!(first_fitting_right_label(/*width*/ 4, &labels), "");
}
}

View File

@@ -23,6 +23,7 @@ use std::time::Instant;
use crate::chatwidget::ActiveCellTranscriptKey;
use crate::chatwidget::CopyStatus;
use crate::footer_hints::FooterHint;
use crate::footer_hints::first_fitting_right_label;
use crate::footer_hints::footer_hint_line_for_row;
use crate::footer_hints::render_footer_line_with_optional_right;
use crate::footer_hints::render_footer_separator;
@@ -141,6 +142,8 @@ struct PagerView {
renderables: Vec<Box<dyn Renderable>>,
scroll_offset: usize,
title: String,
footer_separator_label: String,
show_header_progress: bool,
keymap: PagerKeymap,
last_content_height: Option<usize>,
last_rendered_height: Option<usize>,
@@ -168,6 +171,8 @@ impl PagerView {
renderables,
scroll_offset,
title,
footer_separator_label: String::new(),
show_header_progress: true,
keymap,
last_content_height: None,
last_rendered_height: None,
@@ -235,10 +240,13 @@ impl PagerView {
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(header, buf);
let title = if self.show_header_progress {
let percent = self.scroll_percent(content_area.height, total_len);
format!(" {} · {percent}% ", self.title)
} else {
format!(" {} ", self.title)
};
title.dim().render_ref(header, buf);
}
fn render_content(&mut self, area: Rect, buf: &mut Buffer) {
@@ -306,7 +314,7 @@ impl PagerView {
let sep_y = content_area.bottom();
let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1);
render_footer_separator(sep_rect, buf, String::new());
render_footer_separator(sep_rect, buf, self.footer_separator_label.clone());
}
fn scroll_percent(&self, content_height: u16, total_len: usize) -> u8 {
@@ -535,12 +543,16 @@ impl TranscriptOverlay {
state: TranscriptOverlayState,
) -> Self {
Self {
view: PagerView::new(
Self::render_cells(&transcript_cells, state.highlight_cell, state.render_mode),
"Transcript".to_string(),
state.scroll_offset,
keymap,
),
view: {
let mut view = PagerView::new(
Self::render_cells(&transcript_cells, state.highlight_cell, state.render_mode),
"Transcript".to_string(),
state.scroll_offset,
keymap,
);
view.show_header_progress = false;
view
},
cells: transcript_cells,
highlight_cell: state.highlight_cell,
render_mode: state.render_mode,
@@ -832,26 +844,34 @@ impl TranscriptOverlay {
}
fn header_title(&self) -> String {
"Transcript".to_string()
}
fn footer_progress_label(&self, content_height: u16, total_len: usize, width: u16) -> 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}")
.highlight_cell
.and_then(|highlight_cell| {
let selected = self
.cells
.iter()
.take(highlight_cell.saturating_add(1))
.filter(|cell| cell.is_user_prompt())
.count();
(selected > 0).then_some(selected)
})
.unwrap_or(total);
let percent = self.view.scroll_percent(content_height, total_len);
let labels = [
format!(" {selected} / {total} · {percent}% "),
format!(" {selected}/{total} · {percent}% "),
format!(" {percent}% "),
];
first_fitting_right_label(width, &labels)
}
fn rebuild_renderables(&mut self) {
@@ -1036,6 +1056,10 @@ impl TranscriptOverlay {
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();
let content_area = self.view.content_area(top);
let total_len = self.view.content_height(content_area.width);
self.view.footer_separator_label =
self.footer_progress_label(content_area.height, total_len, top.width);
self.view.render(top, buf);
self.render_hints(bottom, buf);
}
@@ -1440,7 +1464,7 @@ mod tests {
}
#[test]
fn transcript_header_counts_selected_user_prompt() {
fn transcript_header_title_is_stable() {
let mut overlay = transcript_overlay(vec![
user_cell("first"),
Arc::new(AgentMessageCell::new(
@@ -1450,13 +1474,48 @@ mod tests {
user_cell("second"),
]);
assert_eq!(overlay.header_title(), "Transcript · 2 prompts");
assert_eq!(overlay.header_title(), "Transcript");
overlay.move_prompt_selection(PromptSelectionDirection::Previous);
assert_eq!(overlay.header_title(), "Transcript · 2/2");
assert_eq!(overlay.header_title(), "Transcript");
overlay.move_prompt_selection(PromptSelectionDirection::Previous);
assert_eq!(overlay.header_title(), "Transcript · 1/2");
assert_eq!(overlay.header_title(), "Transcript");
}
#[test]
fn transcript_footer_progress_label_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.footer_progress_label(
/*content_height*/ 5, /*total_len*/ 12, /*width*/ 80
),
" 2 / 2 · 100% "
);
overlay.move_prompt_selection(PromptSelectionDirection::Previous);
assert_eq!(
overlay.footer_progress_label(
/*content_height*/ 5, /*total_len*/ 12, /*width*/ 80
),
" 2 / 2 · 100% "
);
overlay.move_prompt_selection(PromptSelectionDirection::Previous);
assert_eq!(
overlay.footer_progress_label(
/*content_height*/ 5, /*total_len*/ 12, /*width*/ 80
),
" 1 / 2 · 100% "
);
}
#[test]

View File

@@ -10,6 +10,7 @@ use crate::app_server_session::AppServerSession;
use crate::color::blend;
use crate::color::is_light;
use crate::footer_hints::FooterHint;
use crate::footer_hints::first_fitting_right_label;
use crate::footer_hints::footer_hint_line_for_row;
use crate::footer_hints::render_footer_separator;
use crate::git_action_directives::parse_assistant_markdown;
@@ -2048,10 +2049,7 @@ fn picker_footer_progress_label(state: &PickerState, list_height: u16, width: u1
format!(" {position}/{total} · {percent}% "),
format!(" {percent}% "),
];
labels
.into_iter()
.find(|label| UnicodeWidthStr::width(label.as_str()) < width as usize)
.unwrap_or_default()
first_fitting_right_label(width, &labels)
}
fn picker_footer_percent(state: &PickerState, list_height: u16) -> u8 {

View File

@@ -2,7 +2,7 @@
source: tui/src/pager_overlay.rs
expression: snapshot
---
Transcript · 0 prompts · 0% ───────────────────────────────────────────────────
Transcript ────────────────────────────────────────────────────────────────────
• 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 / 0 · 0%
↑/↓ scroll ←/→ prompts pgup/pgdn page home/end jump
q quit ctrl + o copy ⌥ + r raw esc/← prev

View File

@@ -2,10 +2,10 @@
source: tui/src/pager_overlay.rs
expression: "render_snapshot(&mut overlay, Rect::new(0, 0, 80, 8),)"
---
Transcript · 1 prompt · 100% ──────────────────────────────────────────────────
Transcript ────────────────────────────────────────────────────────────────────
prompt
───────────────────────────────────────────────────────────────────────────────
───────────────────────────────────────────────────────────────── 1 / 1 · 100%
↑/↓ scroll ←/→ prompts pgup/pgdn page … Copied selected turn to clipboard
q quit ctrl + o copy ⌥ + r raw esc/← prev

View File

@@ -2,10 +2,10 @@
source: tui/src/pager_overlay.rs
expression: "render_snapshot(&mut overlay, Rect::new(0, 0, 28, 8),)"
---
Transcript · 1 prompt · 100
Transcript ────────────────
prompt
───────────────────────────
───────────── 1 / 1 · 100%
No agent response to copy f…
q ctrl + o ⌥ + r

View File

@@ -2,13 +2,13 @@
source: tui/src/pager_overlay.rs
expression: term.backend()
---
" Transcript · 0 prompts · 100% ─────────"
" Transcript ────────────────────────────"
"alpha "
" "
"tail "
"~ "
"~ "
"────────────────────────────────────────"
"───────────────────────── 0 / 0 · 100% ─"
" ↑/↓ ←/→ pgup/pgdn home/end "
" q ctrl + o ⌥ + r esc/← "
" "

View File

@@ -2,13 +2,13 @@
source: tui/src/pager_overlay.rs
expression: term.backend()
---
" Transcript · 0 prompts · 100% ─────────"
" Transcript ────────────────────────────"
"alpha "
" "
"beta "
" "
"gamma "
"────────────────────────────────────────"
"───────────────────────── 0 / 0 · 100% ─"
" ↑/↓ ←/→ pgup/pgdn home/end "
" q ctrl + o ⌥ + r esc/← "
" "