Wrap built-in TUI status lines within the footer row budget (#60682)

## Why

Long built-in status lines were truncated to a single row even when the footer had room for more content.

## What changed

- Wrap status text to the available width and row budget, preserving styles and hyperlink targets across rows.
- Show an unlinked ellipsis when content exceeds the available space.
- Refresh wrapped content when the status text, agent label, or width changes.
- Keep reasoning-effort transitions on one row and restore wrapping after the final animation frame.

## Testing

Add coverage for wrapping and hyperlink boundaries across footer layouts, content and agent-label updates, resizing, and wrapping restoration after an effort transition. Update transcript and startup snapshots for multiline status text.

GitOrigin-RevId: 30e0795c473efd62fc4d0dca4deb6029b625a9e8
This commit is contained in:
Joe Savona
2026-11-10 23:35:28 +00:00
committed by copyberry
parent 192e2ef3ce
commit 65dbdb5f03
17 changed files with 380 additions and 45 deletions
@@ -503,6 +503,8 @@ async fn owned_drag_stops_when_focus_or_input_ownership_is_lost() -> Result<()>
async fn owned_details_keep_the_composer_cursor_and_screen() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
attach_thread(&mut app, ThreadId::new());
app.chat_widget
.set_status_line(Some("gpt-test default · /tmp/project".into()));
app.transcript_cells = vec![user_cell("First prompt"), user_cell("Second prompt")];
app.chat_widget
.apply_external_edit("preserved draft".to_string());
@@ -647,8 +649,7 @@ async fn owned_details_keep_the_composer_cursor_and_screen() -> Result<()> {
app.transcript_view.link_at(column, row).as_deref(),
Some("https://example.com/docs")
);
let copied_cursor = tui.terminal.last_known_cursor_pos;
assert_eq!((copied_cursor.x, copied_cursor.y + 1), (caret.x, caret.y));
assert_eq!(tui.terminal.last_known_cursor_pos, caret);
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
for pos in Rect::from(outer)
.positions()
@@ -33,7 +33,6 @@ transcript row 39
tab to queue mes
6 columns, 18 rows
ript
row 38
transc
ript
@@ -49,7 +48,8 @@ row 39
her
e
GPT…
GPT-
5.6…
tab
80 columns, 6 rows
@@ -4,7 +4,6 @@ expression: normalize_snapshot_paths(screen)
---
····································
····································
····· ·····
·····› Second prompt ·····
····· ·····
····· ·····
@@ -17,6 +16,7 @@ expression: normalize_snapshot_paths(screen)
····· ·····
·····› preserved draft ·····
····· ·····
····· gpt-test default · /tmp…·····
····· gpt-test default · /tmp/·····
····· project ·····
····································
····································
@@ -40,8 +40,8 @@ expression: "normalize_snapshot_paths(normalize_agent_center_snapshot(snapshots.
› Ask Codex to do anything
GPT-5.6-Sol default · /tmp/pr…
GPT-5.6-Sol default · /tmp/
project
? for shortcuts
@@ -41,6 +41,8 @@ async fn owned_startup_hides_tip_in_transcript() -> Result<()> {
];
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
app.chat_widget
.set_status_line(Some("GPT-5.6-Sol default · /tmp/project".into()));
let mut snapshots = Vec::new();
for width in [80, 32] {
let size = Size::new(width, /*height*/ 20);
+2
View File
@@ -248,6 +248,8 @@ async fn turn_tip_placements_and_completion_barrier() -> Result<()> {
}
}
assert_eq!(saw_barrier, !working);
app.chat_widget
.set_status_line(Some("gpt-test default · /tmp/project".into()));
let frames = tui.frame_requester();
let mut painted = Default::default();
tui.draw(height, |frame| {
@@ -414,6 +414,7 @@ mod paste_input;
mod popup_state;
mod reconnect;
pub(crate) use reconnect::RestrictedInputMode;
mod native_status;
mod slash_input;
mod sparkle;
mod status_surface;
@@ -796,6 +797,7 @@ impl ChatComposer {
ide_context_active: false,
status_line_value: None,
measured_status_line: Default::default(),
wrapped_native_status: Default::default(),
status_line_enabled: false,
side_conversation_context_label: None,
active_agent_label: None,
@@ -413,6 +413,8 @@ pub(super) struct FooterState {
pub(super) status_line_value: Option<FooterContent>,
// Measurement bounds and occupied height, shared by layout and cursor placement.
pub(super) measured_status_line: std::cell::Cell<Option<(ratatui::layout::Size, u16)>>,
pub(super) wrapped_native_status:
std::cell::RefCell<Option<super::native_status::WrappedNativeStatus>>,
pub(super) status_line_enabled: bool,
pub(super) side_conversation_context_label: Option<String>,
pub(super) active_agent_label: Option<String>,
@@ -0,0 +1,133 @@
//! Wrap the built-in status line using its painted grapheme widths and native hyperlink ranges.
//! Measurement and paint share the visible rows at the current width.
use super::ChatComposer;
use super::FooterContent;
use super::composer_layout::MAX_FOOTER_ROWS;
use crate::render::line_utils::line_to_static;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::TerminalHyperlink;
use crate::terminal_hyperlinks::project_hyperlink_columns;
use crate::width::display_width;
use crate::wrapping::adaptive_wrap_line_to_width;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::text::Span;
use std::cell::Ref;
use unicode_segmentation::UnicodeSegmentation;
pub(super) struct WrappedNativeStatus {
width: u16,
suffix: Vec<Span<'static>>,
rows: Vec<HyperlinkLine>,
}
impl WrappedNativeStatus {
pub(super) fn height(&self) -> u16 {
self.rows.len().max(1) as u16
}
pub(super) fn render(&self, area: Rect, buf: &mut Buffer) {
let height = usize::from(area.height);
for (index, line) in self.rows.iter().take(height).enumerate() {
let row = Rect::new(area.x, area.y + index as u16, area.width, 1);
if (index + 1 == height && self.rows.len() > height)
|| line.width() > usize::from(area.width)
{
line.clone().render_with_ellipsis(row, buf);
} else {
line.render_fitted(row, buf);
}
}
}
}
impl ChatComposer {
pub(super) fn wrapped_native_status(
&self,
content: &FooterContent,
width: u16,
) -> Option<Ref<'_, WrappedNativeStatus>> {
let (line, links) = content.native_parts()?;
if width == 0 {
return None;
}
// The setter invalidates configured spans. The styled agent suffix can change independently.
let configured = self
.footer
.status_line_value
.as_ref()
.and_then(FooterContent::native_parts)
.map_or(0, |(line, _)| line.spans.len());
let suffix = &line.spans[configured..];
let cache = &self.footer.wrapped_native_status;
if cache
.borrow()
.as_ref()
.is_none_or(|entry| entry.width != width || entry.suffix != suffix)
{
if content.width() <= width {
return None;
}
cache.replace(Some(WrappedNativeStatus {
width,
suffix: suffix.to_vec(),
rows: wrap_rows(line, links, width),
}));
}
Ref::filter_map(cache.borrow(), Option::as_ref).ok()
}
}
fn wrap_rows(line: &Line<'_>, links: &[TerminalHyperlink], width: u16) -> Vec<HyperlinkLine> {
// One row of lookahead settles the last visible break. Whitespace discarded by
// wrapping cannot count toward that budget or it could hide a later word.
let limit = usize::from(MAX_FOOTER_ROWS) + 1;
let mut remaining = usize::from(width) * limit;
let mut prefix = Line::default().style(line.style);
for span in &line.spans {
let mut end = 0;
for grapheme in span.content.graphemes(true) {
end += grapheme.len();
if !grapheme.chars().all(char::is_whitespace) {
remaining =
remaining.saturating_sub(display_width(grapheme).min(usize::from(width)));
if remaining == 0 {
break;
}
}
}
prefix.push_span(Span::styled(&span.content[..end], span.style));
if remaining == 0 {
break;
}
}
// Native link columns are counted per grapheme within each painted span.
let mut graphemes = prefix
.spans
.iter()
.flat_map(|span| span.content.graphemes(true));
let (mut byte, mut column) = (0, 0);
let mut column_at = |target| {
while byte < target
&& let Some(grapheme) = graphemes.next()
{
byte += grapheme.len();
column += display_width(grapheme);
}
column
};
adaptive_wrap_line_to_width(&prefix, usize::from(width).into())
.into_iter()
.take(limit)
.map(|row| {
let source = column_at(row.range.start)..column_at(row.range.end);
HyperlinkLine {
line: line_to_static(&row.line),
hyperlinks: project_hyperlink_columns(links, source, /*prefix*/ 0),
source: None,
}
})
.collect()
}
@@ -0,0 +1,17 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
expression: "frames.join(\"\\n---\\n\")"
---
لا PR #42 build
green deploy
ready all unit
tests passed…
---
لا PR #42 build
green deploy
ready all unit…
---
لا PR #42 build
green deploy…
---
لا PR #42 build…
@@ -15,6 +15,7 @@ impl ChatComposer {
self.effort_status_line_transition = None;
}
self.footer.measured_status_line.set(None);
self.footer.wrapped_native_status.take();
self.footer.status_line_value = status_line;
true
}
@@ -66,6 +67,12 @@ impl ChatComposer {
cache.set(Some((bounds, height)));
height
}
Some(native) if bounds.height > 1 && self.active_status_transition().is_none() => self
.wrapped_native_status(native, bounds.width)
.map_or_else(
|| u16::from(native.width() > 0),
|rows| rows.height().min(bounds.height),
),
Some(line) => u16::from(line.width() > 0),
None => 0,
}
@@ -98,6 +105,12 @@ impl ChatComposer {
passive_footer_status_line(&props).map(Cow::Owned)
}
fn active_status_transition(&self) -> Option<&EffortStatusLineTransition> {
self.effort_status_line_transition
.as_ref()
.filter(|transition| !transition.is_finished())
}
pub(super) fn render_status_surface(
&self,
area: Rect,
@@ -119,11 +132,7 @@ impl ChatComposer {
}
let area = inset_footer_hint_area(area);
let line = self.status_surface_content();
if let Some(transition) = self
.effort_status_line_transition
.as_ref()
.filter(|transition| !transition.is_finished())
{
if let Some(transition) = self.active_status_transition() {
if let Some(frame) = transition.render_line(
line.map(|line| line.into_owned().into_hyperlink_line()),
area.width,
@@ -134,7 +143,13 @@ impl ChatComposer {
requester.schedule_frame_in(EFFORT_STATUS_LINE_FRAME_TICK);
}
} else if let Some(line) = line {
line.into_owned().render(area, buf);
if area.height > 1
&& let Some(wrapped) = self.wrapped_native_status(&line, area.width)
{
wrapped.render(area, buf);
} else {
line.into_owned().render(area, buf);
}
}
}
}
@@ -474,6 +474,129 @@ fn extension_status_keeps_its_rows_when_effort_changes() {
}
}
#[test]
fn native_status_wraps_links_within_the_host_budget() {
let mut composer = composer();
composer.set_collaboration_modes_enabled(/*enabled*/ true);
let url = "https://example.com/pr/42";
let mut status = HyperlinkLine::from("لا ");
status.push_span(
"PR #42 build green deploy ready all unit tests passed release queued".underlined(),
Some(url),
);
composer.set_status_line(Some(status.into()));
let mut frames = Vec::new();
composer.footer.toggle_shortcuts_key = None;
for (fullscreen, mode, status_height) in [
(true, None, 4),
(true, Some(CollaborationModeIndicator::Plan), 3),
(false, None, 2),
(false, Some(CollaborationModeIndicator::Plan), 1),
] {
composer.set_collaboration_mode_indicator(mode);
let (_, rows) = render_status(
&composer,
Rect::new(0, 0, 18, 20),
ComposerRenderOptions {
separate_status_line: fullscreen,
..Default::default()
},
);
assert_eq!(rows.len(), status_height);
for row in &rows {
let text = strip_osc8(row);
let (prefix, rest) = text
.strip_prefix("لا ")
.map_or(("", text.as_str()), |rest| ("لا ", rest));
let (link, end) = rest
.strip_suffix('…')
.map_or((rest, ""), |link| (link, "…"));
let visible_link: String = link
.chars()
.map(|ch| osc8_hyperlink(url, &ch.to_string()))
.collect();
assert_eq!(*row, format!("{prefix}{visible_link}{end}"));
}
frames.push(
rows.iter()
.map(|row| strip_osc8(row))
.collect::<Vec<_>>()
.join("\n"),
);
}
insta::assert_snapshot!(frames.join("\n---\n"));
}
#[test]
fn native_wrap_tracks_agent_label_and_content_changes_at_the_same_width() {
let mut composer = composer();
composer.set_status_line(Some(Line::from("build ready").into()));
let area = Rect::new(0, 0, 16, 14);
composer.footer.toggle_shortcuts_key = None;
for agent in ["frontend", "backend"] {
composer.set_active_agent_label(Some(agent.into()));
assert_eq!(
render_status(&composer, area, Default::default()).1,
["build ready ·", agent]
);
}
composer.set_status_line(Some(Line::from("tests green").into()));
assert_eq!(
render_status(&composer, area, Default::default()).1,
["tests green ·", "backend"]
);
let wide = Rect { width: 36, ..area };
assert_eq!(
render_status(&composer, wide, Default::default()).1,
["tests green · backend"]
);
assert_eq!(
render_status(&composer, area, Default::default()).1,
["tests green ·", "backend"]
);
}
#[tokio::test(start_paused = true)]
async fn native_effort_transition_restores_wrapping_after_its_final_frame() {
let mut composer = composer();
composer.set_status_line(Some(
Line::from("build ready tests passed deploy queued").into(),
));
composer.set_active_reasoning_effort_baseline(/*effort*/ None);
composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Max),
/*animations_enabled*/ true,
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 14, /*height*/ 20,
);
for separate_status_line in [false, true] {
let options = ComposerRenderOptions {
separate_status_line,
..Default::default()
};
assert_eq!(render_status(&composer, area, options).1.len(), 1);
}
let options = ComposerRenderOptions {
separate_status_line: true,
..Default::default()
};
let status = composer.layout_with_options(area, options).status;
assert_eq!(status.height, 1);
let (requester, mut frames) = FrameRequester::test_channel();
composer.set_frame_requester(requester);
tokio::time::advance(Duration::from_secs(/*secs*/ 10)).await;
composer.render_status_surface(status, &mut Buffer::empty(area), options);
frames.try_recv().expect("final paint requests a redraw");
let wrapped = composer.layout_with_options(area, options).status;
let mut buf = Buffer::empty(area);
composer.render_status_surface(wrapped, &mut buf, options);
assert!(wrapped.height > 1);
assert!(frame(&buf).contains("tests"));
assert!(frames.try_recv().is_err());
}
fn render(
composer: &ChatComposer,
width: u16,
@@ -13,13 +13,13 @@
use std::cell::Cell;
use std::time::Duration;
use std::time::Instant;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use tokio::time::Instant;
use crate::color::blend;
use crate::color::is_light;
@@ -63,7 +63,14 @@ impl EffortTier {
pub(crate) struct EffortStatusLineTransition {
tier: EffortTier,
previous: HyperlinkLine,
started_at: Cell<Option<Instant>>,
phase: Cell<TransitionPhase>,
}
#[derive(Clone, Copy)]
enum TransitionPhase {
Pending,
Running(Instant),
Finished,
}
impl EffortStatusLineTransition {
@@ -71,14 +78,12 @@ impl EffortStatusLineTransition {
Self {
tier,
previous,
started_at: Cell::new(None),
phase: Cell::new(TransitionPhase::Pending),
}
}
pub(crate) fn is_finished(&self) -> bool {
self.started_at
.get()
.is_some_and(|started| started.elapsed() >= TOTAL)
matches!(self.phase.get(), TransitionPhase::Finished)
}
pub(crate) fn render_line(
@@ -86,13 +91,18 @@ impl EffortStatusLineTransition {
current: Option<HyperlinkLine>,
width: u16,
) -> Option<HyperlinkLine> {
let elapsed = match self.started_at.get() {
Some(started) => started.elapsed(),
None => {
self.started_at.set(Some(Instant::now()));
let elapsed = match self.phase.get() {
TransitionPhase::Pending => {
self.phase.set(TransitionPhase::Running(Instant::now()));
Duration::ZERO
}
TransitionPhase::Running(started) => started.elapsed(),
TransitionPhase::Finished => return current,
};
// Paint the last single-row frame before the next layout restores wrapping.
if elapsed >= TOTAL {
self.phase.set(TransitionPhase::Finished);
}
transition_line_at(self.tier, &self.previous, current, elapsed, width)
}
}
@@ -19,14 +19,17 @@ fn clock_waits_for_the_first_visible_frame() {
let transition =
EffortStatusLineTransition::new(EffortTier::Ultra, "gpt-5.4 high · main".into());
assert!(!transition.is_finished());
assert_eq!(transition.started_at.get(), None);
assert!(matches!(transition.phase.get(), TransitionPhase::Pending));
let current = HyperlinkLine::from("gpt-5.4 ultra · main");
let line = transition
.render_line(Some(current), /*width*/ 40)
.expect("first frame should render the outgoing line");
assert_eq!(text(&line.line), "gpt-5.4 high · main");
assert!(transition.started_at.get().is_some());
assert!(matches!(
transition.phase.get(),
TransitionPhase::Running(_)
));
}
#[test]
@@ -6,9 +6,9 @@ use ratatui::layout::Rect;
use ratatui::text::Line;
use std::sync::Arc;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::highlight::with_syntax_theme;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::TerminalHyperlink;
use crate::ui_tree::PreparedUiTree;
use crate::ui_tree::UiTree;
@@ -29,6 +29,14 @@ impl From<UiTree> for FooterContent {
}
impl FooterContent {
pub(crate) fn native_parts(&self) -> Option<(&Line<'static>, &[TerminalHyperlink])> {
match self {
Self::Line(line) => Some((line, &[])),
Self::HyperlinkLine(line) => Some((&line.line, &line.hyperlinks)),
Self::Extension { .. } => None,
}
}
/// Natural display width in terminal cells.
pub(crate) fn width(&self) -> u16 {
let width = match self {
@@ -46,18 +54,17 @@ impl FooterContent {
with_syntax_theme(|theme| content.render(area, theme, buf));
}
native => {
let mut line = native.into_hyperlink_line();
let line = native.into_hyperlink_line();
let area = Rect {
height: area.height.min(1),
..area
};
let width = usize::from(area.width);
if line.width() > width {
line.line = truncate_line_with_ellipsis_if_overflow(line.line, width);
// The ellipsis is never part of the link.
line.clip_hyperlinks_to_width(line.width().saturating_sub(1));
line.render_with_ellipsis(area, buf);
} else {
line.render_fitted(area, buf);
}
line.render_fitted(area, buf);
}
}
}
+20 -13
View File
@@ -482,19 +482,8 @@ pub(crate) fn remap_source_wrapped_line(
let end = start + display_width(&text[range.clone()]);
source_byte = range.end;
source_column = end;
let hyperlinks = source
.hyperlinks
.iter()
.filter_map(|link| {
let first = link.columns.start.max(start);
let last = link.columns.end.min(end);
(first < last).then(|| {
link.with_columns(
prefix_columns + first - start..prefix_columns + last - start,
)
})
})
.collect();
let hyperlinks =
project_hyperlink_columns(&source.hyperlinks, start..end, prefix_columns);
HyperlinkLine {
line,
hyperlinks,
@@ -504,6 +493,24 @@ pub(crate) fn remap_source_wrapped_line(
.collect()
}
/// Translate links intersecting a source fragment into columns on the displayed row.
pub(crate) fn project_hyperlink_columns(
links: &[TerminalHyperlink],
source: Range<usize>,
prefix: usize,
) -> Vec<TerminalHyperlink> {
links
.iter()
.filter_map(|link| {
let first = link.columns.start.max(source.start);
let last = link.columns.end.min(source.end);
(first < last).then(|| {
link.with_columns(prefix + first - source.start..prefix + last - source.start)
})
})
.collect()
}
/// Re-attach source hyperlink ranges after visible-text wrapping has split a line.
///
/// Link text is matched in display order so a URL split across table rows retains the complete
@@ -113,6 +113,17 @@ impl HyperlinkLine {
self.mark_cells(area, buf);
}
/// Reserve the last visible grapheme for an unlinked overflow marker.
pub(crate) fn render_with_ellipsis(mut self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
self.line =
crate::line_truncation::truncate_line_with_ellipsis(self.line, usize::from(area.width));
self.clip_hyperlinks_to_width(self.width().saturating_sub(1));
self.render_fitted(area, buf);
}
fn mark_cells(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;