[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
2025-12-23 12:40:40 -08:00
committed by GitHub
Unverified
parent 2828549323
commit 53eb2e9f27
14 changed files with 286 additions and 48 deletions
+5 -4
View File
@@ -288,12 +288,13 @@ impl BottomPane {
self.request_redraw();
}
/// Update the animated header shown to the left of the brackets in the
/// status indicator (defaults to "Working"). No-ops if the status
/// indicator is not active.
pub(crate) fn update_status_header(&mut self, header: String) {
/// Update the status indicator header (defaults to "Working") and details below it.
///
/// Passing `None` clears any existing details. No-ops if the status indicator is not active.
pub(crate) fn update_status(&mut self, header: String, details: Option<String>) {
if let Some(status) = self.status.as_mut() {
status.update_header(header);
status.update_details(details);
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.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) {
if let Some(header) = self.retry_status_header.take()
&& self.current_status_header != header
{
if let Some(header) = self.retry_status_header.take() {
self.set_status_header(header);
}
}
+1
View File
@@ -3292,6 +3292,7 @@ async fn stream_recovery_restores_previous_status_header() {
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), "Working");
assert_eq!(status.details(), 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::text_formatting::capitalize_first;
use super::helpers::format_reset_timestamp;
use chrono::DateTime;
@@ -221,15 +222,3 @@ fn format_credit_balance(raw: &str) -> Option<String> {
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::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -18,11 +22,18 @@ use crate::exec_cell::spinner;
use crate::key_hint;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use crate::text_formatting::capitalize_first;
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 {
/// Animated header text (defaults to "Working").
header: String,
details: Option<String>,
show_interrupt_hint: bool,
elapsed_running: Duration,
@@ -58,6 +69,7 @@ impl StatusIndicatorWidget {
) -> Self {
Self {
header: String::from("Working"),
details: None,
show_interrupt_hint: true,
elapsed_running: Duration::ZERO,
last_resume_at: Instant::now(),
@@ -78,11 +90,23 @@ impl StatusIndicatorWidget {
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)]
pub(crate) fn header(&self) -> &str {
&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) {
self.show_interrupt_hint = visible;
}
@@ -132,11 +156,43 @@ impl StatusIndicatorWidget {
pub fn elapsed_seconds(&self) -> u64 {
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 {
fn desired_height(&self, _width: u16) -> u16 {
1
fn desired_height(&self, width: u16) -> u16 {
1 + u16::try_from(self.wrapped_details_lines(width).len()).unwrap_or(0)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -170,7 +226,16 @@ impl Renderable for StatusIndicatorWidget {
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());
}
#[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]
fn timer_pauses_when_requested() {
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));
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::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.
/// 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(
+5 -4
View File
@@ -266,12 +266,13 @@ impl BottomPane {
self.composer.current_text()
}
/// Update the animated header shown to the left of the brackets in the
/// status indicator (defaults to "Working"). No-ops if the status
/// indicator is not active.
pub(crate) fn update_status_header(&mut self, header: String) {
/// Update the status indicator header (defaults to "Working") and details below it.
///
/// Passing `None` clears any existing details. No-ops if the status indicator is not active.
pub(crate) fn update_status(&mut self, header: String, details: Option<String>) {
if let Some(status) = self.status.as_mut() {
status.update_header(header);
status.update_details(details);
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.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) {
if let Some(header) = self.retry_status_header.take()
&& self.current_status_header != header
{
if let Some(header) = self.retry_status_header.take() {
self.set_status_header(header);
}
}
+1
View File
@@ -2944,6 +2944,7 @@ async fn stream_recovery_restores_previous_status_header() {
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), "Working");
assert_eq!(status.details(), 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::text_formatting::capitalize_first;
use super::helpers::format_reset_timestamp;
use chrono::DateTime;
@@ -221,15 +222,3 @@ fn format_credit_balance(raw: &str) -> Option<String> {
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::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -18,11 +22,18 @@ use crate::exec_cell::spinner;
use crate::key_hint;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use crate::text_formatting::capitalize_first;
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 {
/// Animated header text (defaults to "Working").
header: String,
details: Option<String>,
show_interrupt_hint: bool,
elapsed_running: Duration,
@@ -58,6 +69,7 @@ impl StatusIndicatorWidget {
) -> Self {
Self {
header: String::from("Working"),
details: None,
show_interrupt_hint: true,
elapsed_running: Duration::ZERO,
last_resume_at: Instant::now(),
@@ -78,11 +90,23 @@ impl StatusIndicatorWidget {
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)]
pub(crate) fn header(&self) -> &str {
&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) {
self.show_interrupt_hint = visible;
}
@@ -132,11 +156,43 @@ impl StatusIndicatorWidget {
pub fn elapsed_seconds(&self) -> u64 {
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 {
fn desired_height(&self, _width: u16) -> u16 {
1
fn desired_height(&self, width: u16) -> u16 {
1 + u16::try_from(self.wrapped_details_lines(width).len()).unwrap_or(0)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -170,7 +226,16 @@ impl Renderable for StatusIndicatorWidget {
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());
}
#[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]
fn timer_pauses_when_requested() {
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));
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::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.
/// 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(