mirror of
https://github.com/openai/codex.git
synced 2026-05-01 11:52:10 +03:00
[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:
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user