[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
parent 2828549323
commit 53eb2e9f27
14 changed files with 286 additions and 48 deletions

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:?}"
);
}
}