[tui] add optional details to TUI status header (#8293)

### What

Add optional `details` field to TUI's status indicator header. `details`
is shown under the header with text wrapping and a max height of 3
lines.

Duplicated changes to `tui2`.

### Why

Groundwork for displaying error details under `Reconnecting...` for
clarity with retryable errors.

Basic examples
<img width="1012" height="326" alt="image"
src="https://github.com/user-attachments/assets/dd751ceb-b179-4fb2-8fd1-e4784d6366fb"
/>

<img width="1526" height="358" alt="image"
src="https://github.com/user-attachments/assets/bbe466fc-faff-4a78-af7f-3073ccdd8e34"
/>

Truncation example
<img width="936" height="189" alt="image"
src="https://github.com/user-attachments/assets/f3f1b5dd-9050-438b-bb07-bd833c03e889"
/>

### Tests
Tested locally, added tests for truncation.
This commit is contained in:
sayan-oai authored and GitHub committed 2025-12-23 12:40:40 -08:00
1 parent 2828549323
commit 53eb2e9f27
14 files changed
+286 -48

No files matched your search

+5 -4
View File
@@ -288,12 +288,13 @@ impl BottomPane {
self.request_redraw(); self.request_redraw();
} }
/// Update the animated header shown to the left of the brackets in the /// Update the status indicator header (defaults to "Working") and details below it.
/// status indicator (defaults to "Working"). No-ops if the status ///
/// indicator is not active. /// Passing `None` clears any existing details. No-ops if the status indicator is not active.
pub(crate) fn update_status_header(&mut self, header: String) { pub(crate) fn update_status(&mut self, header: String, details: Option<String>) {
if let Some(status) = self.status.as_mut() { if let Some(status) = self.status.as_mut() {
status.update_header(header); status.update_header(header);
status.update_details(details);
self.request_redraw(); self.request_redraw();
} }
} }
+12 -5
View File
@@ -411,15 +411,22 @@ impl ChatWidget {
} }
} }
fn set_status_header(&mut self, header: String) { /// Update the status indicator header and details.
///
/// Passing `None` clears any existing details.
fn set_status(&mut self, header: String, details: Option<String>) {
self.current_status_header = header.clone(); self.current_status_header = header.clone();
self.bottom_pane.update_status_header(header); self.bottom_pane.update_status(header, details);
}
/// Convenience wrapper around [`Self::set_status`];
/// updates the status indicator header and clears any existing details.
fn set_status_header(&mut self, header: String) {
self.set_status(header, None);
} }
fn restore_retry_status_header_if_present(&mut self) { fn restore_retry_status_header_if_present(&mut self) {
if let Some(header) = self.retry_status_header.take() if let Some(header) = self.retry_status_header.take() {
&& self.current_status_header != header
{
self.set_status_header(header); self.set_status_header(header);
} }
} }
+1
View File
@@ -3292,6 +3292,7 @@ async fn stream_recovery_restores_previous_status_header() {
.status_widget() .status_widget()
.expect("status indicator should be visible"); .expect("status indicator should be visible");
assert_eq!(status.header(), "Working"); assert_eq!(status.header(), "Working");
assert_eq!(status.details(), None);
assert!(chat.retry_status_header.is_none()); assert!(chat.retry_status_header.is_none());
} }
@@ -0,0 +1,7 @@
---
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
"• Working (0s) "
" └ A man a plan a canal "
" panama "
+1 -12
View File
@@ -1,4 +1,5 @@
use crate::chatwidget::get_limits_duration; use crate::chatwidget::get_limits_duration;
use crate::text_formatting::capitalize_first;
use super::helpers::format_reset_timestamp; use super::helpers::format_reset_timestamp;
use chrono::DateTime; use chrono::DateTime;
@@ -221,15 +222,3 @@ fn format_credit_balance(raw: &str) -> Option<String> {
None None
} }
fn capitalize_first(label: &str) -> String {
let mut chars = label.chars();
match chars.next() {
Some(first) => {
let mut capitalized = first.to_uppercase().collect::<String>();
capitalized.push_str(chars.as_str());
capitalized
}
None => String::new(),
}
}
+105 -3
View File
@@ -10,7 +10,11 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Stylize; use ratatui::style::Stylize;
use ratatui::text::Line; use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef; use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
@@ -18,11 +22,18 @@ use crate::exec_cell::spinner;
use crate::key_hint; use crate::key_hint;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans; use crate::shimmer::shimmer_spans;
use crate::text_formatting::capitalize_first;
use crate::tui::FrameRequester; use crate::tui::FrameRequester;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
const DETAILS_MAX_LINES: usize = 3;
const DETAILS_PREFIX: &str = " └ ";
pub(crate) struct StatusIndicatorWidget { pub(crate) struct StatusIndicatorWidget {
/// Animated header text (defaults to "Working"). /// Animated header text (defaults to "Working").
header: String, header: String,
details: Option<String>,
show_interrupt_hint: bool, show_interrupt_hint: bool,
elapsed_running: Duration, elapsed_running: Duration,
@@ -58,6 +69,7 @@ impl StatusIndicatorWidget {
) -> Self { ) -> Self {
Self { Self {
header: String::from("Working"), header: String::from("Working"),
details: None,
show_interrupt_hint: true, show_interrupt_hint: true,
elapsed_running: Duration::ZERO, elapsed_running: Duration::ZERO,
last_resume_at: Instant::now(), last_resume_at: Instant::now(),
@@ -78,11 +90,23 @@ impl StatusIndicatorWidget {
self.header = header; self.header = header;
} }
/// Update the details text shown below the header.
pub(crate) fn update_details(&mut self, details: Option<String>) {
self.details = details
.filter(|details| !details.is_empty())
.map(|details| capitalize_first(details.trim_start()));
}
#[cfg(test)] #[cfg(test)]
pub(crate) fn header(&self) -> &str { pub(crate) fn header(&self) -> &str {
&self.header &self.header
} }
#[cfg(test)]
pub(crate) fn details(&self) -> Option<&str> {
self.details.as_deref()
}
pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) {
self.show_interrupt_hint = visible; self.show_interrupt_hint = visible;
} }
@@ -132,11 +156,43 @@ impl StatusIndicatorWidget {
pub fn elapsed_seconds(&self) -> u64 { pub fn elapsed_seconds(&self) -> u64 {
self.elapsed_seconds_at(Instant::now()) self.elapsed_seconds_at(Instant::now())
} }
/// Wrap the details text into a fixed width and return the lines, truncating if necessary.
fn wrapped_details_lines(&self, width: u16) -> Vec<Line<'static>> {
let Some(details) = self.details.as_deref() else {
return Vec::new();
};
if width == 0 {
return Vec::new();
}
let prefix_width = UnicodeWidthStr::width(DETAILS_PREFIX);
let opts = RtOptions::new(usize::from(width))
.initial_indent(Line::from(DETAILS_PREFIX.dim()))
.subsequent_indent(Line::from(Span::from(" ".repeat(prefix_width)).dim()))
.break_words(true);
let mut out = word_wrap_lines(details.lines().map(|line| vec![line.dim()]), opts);
if out.len() > DETAILS_MAX_LINES {
out.truncate(DETAILS_MAX_LINES);
let content_width = usize::from(width).saturating_sub(prefix_width).max(1);
let max_base_len = content_width.saturating_sub(1);
if let Some(last) = out.last_mut()
&& let Some(span) = last.spans.last_mut()
{
let trimmed: String = span.content.as_ref().chars().take(max_base_len).collect();
*span = format!("{trimmed}…").dim();
}
}
out
}
} }
impl Renderable for StatusIndicatorWidget { impl Renderable for StatusIndicatorWidget {
fn desired_height(&self, _width: u16) -> u16 { fn desired_height(&self, width: u16) -> u16 {
1 1 + u16::try_from(self.wrapped_details_lines(width).len()).unwrap_or(0)
} }
fn render(&self, area: Rect, buf: &mut Buffer) { fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -170,7 +226,16 @@ impl Renderable for StatusIndicatorWidget {
spans.push(format!("({pretty_elapsed})").dim()); spans.push(format!("({pretty_elapsed})").dim());
} }
Line::from(spans).render_ref(area, buf); let mut lines = Vec::new();
lines.push(Line::from(spans));
if area.height > 1 {
// If there is enough space, add the details lines below the header.
let details = self.wrapped_details_lines(area.width);
let max_details = usize::from(area.height.saturating_sub(1));
lines.extend(details.into_iter().take(max_details));
}
Paragraph::new(Text::from(lines)).render_ref(area, buf);
} }
} }
@@ -229,6 +294,27 @@ mod tests {
insta::assert_snapshot!(terminal.backend()); insta::assert_snapshot!(terminal.backend());
} }
#[test]
fn renders_wrapped_details_panama_two_lines() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false);
w.update_details(Some("A man a plan a canal panama".to_string()));
w.set_interrupt_hint_visible(false);
// Freeze time-dependent rendering (elapsed + spinner) to keep the snapshot stable.
w.is_paused = true;
w.elapsed_running = Duration::ZERO;
// Prefix is 4 columns, so a width of 30 yields a content width of 26: one column
// short of fitting the whole phrase (27 cols), forcing exactly one wrap without ellipsis.
let mut terminal = Terminal::new(TestBackend::new(30, 3)).expect("terminal");
terminal
.draw(|f| w.render(f.area(), f.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(terminal.backend());
}
#[test] #[test]
fn timer_pauses_when_requested() { fn timer_pauses_when_requested() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -250,4 +336,20 @@ mod tests {
let after_resume = widget.elapsed_seconds_at(baseline + Duration::from_secs(13)); let after_resume = widget.elapsed_seconds_at(baseline + Duration::from_secs(13));
assert_eq!(after_resume, before_pause + 3); assert_eq!(after_resume, before_pause + 3);
} }
#[test]
fn details_overflow_adds_ellipsis() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
w.update_details(Some("abcd abcd abcd abcd".to_string()));
let lines = w.wrapped_details_lines(6);
assert_eq!(lines.len(), DETAILS_MAX_LINES);
let last = lines.last().expect("expected last details line");
assert!(
last.spans[1].content.as_ref().ends_with("…"),
"expected ellipsis in last line: {last:?}"
);
}
} }
+12
View File
@@ -2,6 +2,18 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
pub(crate) fn capitalize_first(input: &str) -> String {
let mut chars = input.chars();
match chars.next() {
Some(first) => {
let mut capitalized = first.to_uppercase().collect::<String>();
capitalized.push_str(chars.as_str());
capitalized
}
None => String::new(),
}
}
/// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a compact way before truncating. /// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a compact way before truncating.
/// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells. /// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells.
pub(crate) fn format_and_truncate_tool_result( pub(crate) fn format_and_truncate_tool_result(
+5 -4
View File
@@ -266,12 +266,13 @@ impl BottomPane {
self.composer.current_text() self.composer.current_text()
} }
/// Update the animated header shown to the left of the brackets in the /// Update the status indicator header (defaults to "Working") and details below it.
/// status indicator (defaults to "Working"). No-ops if the status ///
/// indicator is not active. /// Passing `None` clears any existing details. No-ops if the status indicator is not active.
pub(crate) fn update_status_header(&mut self, header: String) { pub(crate) fn update_status(&mut self, header: String, details: Option<String>) {
if let Some(status) = self.status.as_mut() { if let Some(status) = self.status.as_mut() {
status.update_header(header); status.update_header(header);
status.update_details(details);
self.request_redraw(); self.request_redraw();
} }
} }
+12 -5
View File
@@ -377,15 +377,22 @@ impl ChatWidget {
} }
} }
fn set_status_header(&mut self, header: String) { /// Update the status indicator header and details.
///
/// Passing `None` clears any existing details.
fn set_status(&mut self, header: String, details: Option<String>) {
self.current_status_header = header.clone(); self.current_status_header = header.clone();
self.bottom_pane.update_status_header(header); self.bottom_pane.update_status(header, details);
}
/// Convenience wrapper around [`Self::set_status`];
/// updates the status indicator header and clears any existing details.
fn set_status_header(&mut self, header: String) {
self.set_status(header, None);
} }
fn restore_retry_status_header_if_present(&mut self) { fn restore_retry_status_header_if_present(&mut self) {
if let Some(header) = self.retry_status_header.take() if let Some(header) = self.retry_status_header.take() {
&& self.current_status_header != header
{
self.set_status_header(header); self.set_status_header(header);
} }
} }
+1
View File
@@ -2944,6 +2944,7 @@ async fn stream_recovery_restores_previous_status_header() {
.status_widget() .status_widget()
.expect("status indicator should be visible"); .expect("status indicator should be visible");
assert_eq!(status.header(), "Working"); assert_eq!(status.header(), "Working");
assert_eq!(status.details(), None);
assert!(chat.retry_status_header.is_none()); assert!(chat.retry_status_header.is_none());
} }
@@ -0,0 +1,7 @@
---
source: tui2/src/status_indicator_widget.rs
expression: terminal.backend()
---
"• Working (0s) "
" └ A man a plan a canal "
" panama "
+1 -12
View File
@@ -1,4 +1,5 @@
use crate::chatwidget::get_limits_duration; use crate::chatwidget::get_limits_duration;
use crate::text_formatting::capitalize_first;
use super::helpers::format_reset_timestamp; use super::helpers::format_reset_timestamp;
use chrono::DateTime; use chrono::DateTime;
@@ -221,15 +222,3 @@ fn format_credit_balance(raw: &str) -> Option<String> {
None None
} }
fn capitalize_first(label: &str) -> String {
let mut chars = label.chars();
match chars.next() {
Some(first) => {
let mut capitalized = first.to_uppercase().collect::<String>();
capitalized.push_str(chars.as_str());
capitalized
}
None => String::new(),
}
}
+105 -3
View File
@@ -10,7 +10,11 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Stylize; use ratatui::style::Stylize;
use ratatui::text::Line; use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef; use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
@@ -18,11 +22,18 @@ use crate::exec_cell::spinner;
use crate::key_hint; use crate::key_hint;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans; use crate::shimmer::shimmer_spans;
use crate::text_formatting::capitalize_first;
use crate::tui::FrameRequester; use crate::tui::FrameRequester;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
const DETAILS_MAX_LINES: usize = 3;
const DETAILS_PREFIX: &str = " └ ";
pub(crate) struct StatusIndicatorWidget { pub(crate) struct StatusIndicatorWidget {
/// Animated header text (defaults to "Working"). /// Animated header text (defaults to "Working").
header: String, header: String,
details: Option<String>,
show_interrupt_hint: bool, show_interrupt_hint: bool,
elapsed_running: Duration, elapsed_running: Duration,
@@ -58,6 +69,7 @@ impl StatusIndicatorWidget {
) -> Self { ) -> Self {
Self { Self {
header: String::from("Working"), header: String::from("Working"),
details: None,
show_interrupt_hint: true, show_interrupt_hint: true,
elapsed_running: Duration::ZERO, elapsed_running: Duration::ZERO,
last_resume_at: Instant::now(), last_resume_at: Instant::now(),
@@ -78,11 +90,23 @@ impl StatusIndicatorWidget {
self.header = header; self.header = header;
} }
/// Update the details text shown below the header.
pub(crate) fn update_details(&mut self, details: Option<String>) {
self.details = details
.filter(|details| !details.is_empty())
.map(|details| capitalize_first(details.trim_start()));
}
#[cfg(test)] #[cfg(test)]
pub(crate) fn header(&self) -> &str { pub(crate) fn header(&self) -> &str {
&self.header &self.header
} }
#[cfg(test)]
pub(crate) fn details(&self) -> Option<&str> {
self.details.as_deref()
}
pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) {
self.show_interrupt_hint = visible; self.show_interrupt_hint = visible;
} }
@@ -132,11 +156,43 @@ impl StatusIndicatorWidget {
pub fn elapsed_seconds(&self) -> u64 { pub fn elapsed_seconds(&self) -> u64 {
self.elapsed_seconds_at(Instant::now()) self.elapsed_seconds_at(Instant::now())
} }
/// Wrap the details text into a fixed width and return the lines, truncating if necessary.
fn wrapped_details_lines(&self, width: u16) -> Vec<Line<'static>> {
let Some(details) = self.details.as_deref() else {
return Vec::new();
};
if width == 0 {
return Vec::new();
}
let prefix_width = UnicodeWidthStr::width(DETAILS_PREFIX);
let opts = RtOptions::new(usize::from(width))
.initial_indent(Line::from(DETAILS_PREFIX.dim()))
.subsequent_indent(Line::from(Span::from(" ".repeat(prefix_width)).dim()))
.break_words(true);
let mut out = word_wrap_lines(details.lines().map(|line| vec![line.dim()]), opts);
if out.len() > DETAILS_MAX_LINES {
out.truncate(DETAILS_MAX_LINES);
let content_width = usize::from(width).saturating_sub(prefix_width).max(1);
let max_base_len = content_width.saturating_sub(1);
if let Some(last) = out.last_mut()
&& let Some(span) = last.spans.last_mut()
{
let trimmed: String = span.content.as_ref().chars().take(max_base_len).collect();
*span = format!("{trimmed}…").dim();
}
}
out
}
} }
impl Renderable for StatusIndicatorWidget { impl Renderable for StatusIndicatorWidget {
fn desired_height(&self, _width: u16) -> u16 { fn desired_height(&self, width: u16) -> u16 {
1 1 + u16::try_from(self.wrapped_details_lines(width).len()).unwrap_or(0)
} }
fn render(&self, area: Rect, buf: &mut Buffer) { fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -170,7 +226,16 @@ impl Renderable for StatusIndicatorWidget {
spans.push(format!("({pretty_elapsed})").dim()); spans.push(format!("({pretty_elapsed})").dim());
} }
Line::from(spans).render_ref(area, buf); let mut lines = Vec::new();
lines.push(Line::from(spans));
// If there is enough space, add the details lines below the header.
if area.height > 1 {
let details = self.wrapped_details_lines(area.width);
let max_details = usize::from(area.height.saturating_sub(1));
lines.extend(details.into_iter().take(max_details));
}
Paragraph::new(Text::from(lines)).render_ref(area, buf);
} }
} }
@@ -229,6 +294,27 @@ mod tests {
insta::assert_snapshot!(terminal.backend()); insta::assert_snapshot!(terminal.backend());
} }
#[test]
fn renders_wrapped_details_panama_two_lines() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false);
w.update_details(Some("A man a plan a canal panama".to_string()));
w.set_interrupt_hint_visible(false);
// Freeze time-dependent rendering (elapsed + spinner) to keep the snapshot stable.
w.is_paused = true;
w.elapsed_running = Duration::ZERO;
// Prefix is 4 columns, so a width of 30 yields a content width of 26: one column
// short of fitting the whole phrase (27 cols), forcing exactly one wrap without ellipsis.
let mut terminal = Terminal::new(TestBackend::new(30, 3)).expect("terminal");
terminal
.draw(|f| w.render(f.area(), f.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(terminal.backend());
}
#[test] #[test]
fn timer_pauses_when_requested() { fn timer_pauses_when_requested() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -250,4 +336,20 @@ mod tests {
let after_resume = widget.elapsed_seconds_at(baseline + Duration::from_secs(13)); let after_resume = widget.elapsed_seconds_at(baseline + Duration::from_secs(13));
assert_eq!(after_resume, before_pause + 3); assert_eq!(after_resume, before_pause + 3);
} }
#[test]
fn details_overflow_adds_ellipsis() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
w.update_details(Some("abcd abcd abcd abcd".to_string()));
let lines = w.wrapped_details_lines(6);
assert_eq!(lines.len(), DETAILS_MAX_LINES);
let last = lines.last().expect("expected last details line");
assert!(
last.spans[1].content.as_ref().ends_with("…"),
"expected ellipsis in last line: {last:?}"
);
}
} }
+12
View File
@@ -2,6 +2,18 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
pub(crate) fn capitalize_first(input: &str) -> String {
let mut chars = input.chars();
match chars.next() {
Some(first) => {
let mut capitalized = first.to_uppercase().collect::<String>();
capitalized.push_str(chars.as_str());
capitalized
}
None => String::new(),
}
}
/// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a compact way before truncating. /// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a compact way before truncating.
/// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells. /// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells.
pub(crate) fn format_and_truncate_tool_result( pub(crate) fn format_and_truncate_tool_result(