Allow extension status content to use multiple footer rows (#60032)

## Why

Extension status content was limited to one row, clipping multiline text and content that wraps at narrow terminal widths.

## What changed

Measure extension status content at the available width and reserve up to four rows in both shared and separate status layouts. Account for mode indicators and warnings, and remeasure when content or available width changes.

Extra status rows yield to the draft and other composer surfaces when vertical space is limited. An unfocused composer keeps a single dim status row.

## Testing

Add regression coverage for resizing, wrapping beside mode indicators and warnings, hyperlink preservation, limited vertical space, cursor placement, footer overrides, focus changes, theme updates, and reasoning effort changes.

GitOrigin-RevId: 6a637b5cf6f34684639a92d920da4795c90abb6f
This commit is contained in:
Joe Savona
2026-11-07 02:01:00 +00:00
committed by copyberry
parent 5c7a1602c8
commit cfc11eb483
9 changed files with 357 additions and 63 deletions
+2 -11
View File
@@ -789,6 +789,7 @@ impl ChatComposer {
goal_status_indicator: None,
ide_context_active: false,
status_line_value: None,
measured_status_line: Default::default(),
status_line_enabled: false,
side_conversation_context_label: None,
active_agent_label: None,
@@ -4679,17 +4680,7 @@ impl ChatComposer {
.render(transcript_hint_area, buf);
}
None => {
let hint_rect = if let Some(warning_area) = warning_area {
Rect {
width: warning_area
.x
.saturating_sub(footer_rect.x)
.saturating_sub(/*rhs*/ 2),
..footer_rect
}
} else {
footer_rect
};
let hint_rect = warning_notice::before_warning(footer_rect, warning_area);
if let Some(input) = self.draft.textarea.vim_query() {
input.render(inset_footer_hint_area(hint_rect), buf);
} else if let Some(line) = self.history_search_footer_line() {
@@ -41,9 +41,23 @@ impl ChatComposer {
area: Rect,
options: ComposerRenderOptions<'_>,
) -> ComposerLayout {
let footer_hint_height = self.footer_hint_height(area.width, options);
// Preserve input and hints before allocating the persistent status row.
let status_height = self.status_surface_height(options).min(
let mut footer_hint_height = self.footer_hint_height(area.width, options);
let mut status_height = self.status_surface_height(area.width, options);
let shared = footer_hint_height > 1 && self.shows_shared_status(options);
if shared || status_height > 1 {
// Extra status rows yield to the draft, voice strip and other composer surfaces.
let desired =
self.height_with_rows(area.width, options, footer_hint_height, status_height);
let rows = if shared {
&mut footer_hint_height
} else {
&mut status_height
};
*rows = rows
.saturating_sub(desired.saturating_sub(area.height))
.max(1);
}
let status_height = status_height.min(
area.height
.saturating_sub(footer_hint_height.saturating_add(/*rhs*/ 3)),
);
@@ -195,7 +209,21 @@ impl ChatComposer {
width: u16,
options: ComposerRenderOptions<'_>,
) -> u16 {
let footer_hint_height = self.footer_hint_height(width, options);
self.height_with_rows(
width,
options,
self.footer_hint_height(width, options),
self.status_surface_height(width, options),
)
}
fn height_with_rows(
&self,
width: u16,
options: ComposerRenderOptions<'_>,
footer_hint_height: u16,
status_height: u16,
) -> u16 {
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
let inner_width =
width.saturating_sub(COLS_WITH_MARGIN.saturating_add(options.textarea_right_reserve));
@@ -206,22 +234,22 @@ impl ChatComposer {
.try_into()
.unwrap_or(u16::MAX);
let remote_images_separator = u16::from(remote_images_height > 0);
self.draft.textarea.desired_height(inner_width)
+ remote_images_height
+ self.thumbnail_height(inner_width, options)
+ remote_images_separator
+ self.status_surface_height(options)
+ if self.shortcuts_above_composer(options) {
[
self.draft.textarea.desired_height(inner_width),
remote_images_height,
self.thumbnail_height(inner_width, options),
remote_images_separator,
status_height,
if self.shortcuts_above_composer(options) {
footer_height(&self.hint_footer_props(options), width)
} else {
0
}
+ 2
+ self
.voice_strip
.as_ref()
.map_or(0, |strip| strip.desired_height(width) + 1)
+ if self.popups.active.is_above_composer()
},
2,
self.voice_strip.as_ref().map_or(/*default*/ 0, |strip| {
strip.desired_height(width).saturating_add(1)
}),
if self.popups.active.is_above_composer()
&& options.command_popup_placement != CommandPopupPlacement::AboveComposer
{
footer_hint_height
@@ -229,6 +257,9 @@ impl ChatComposer {
self.popups
.active
.required_height(width, footer_hint_height)
}
},
]
.into_iter()
.fold(0, u16::saturating_add)
}
}
@@ -17,6 +17,7 @@ use std::time::Instant;
use super::super::footer::footer_height;
use super::super::footer::reset_mode_after_activity;
use super::ActivePopup;
use super::warning_notice;
use crate::bottom_pane::footer::FooterKeyHints;
use crate::bottom_pane::footer::FooterProps;
use crate::bottom_pane::footer_content::FooterContent;
@@ -283,6 +284,9 @@ impl super::ChatComposer {
}
pub(super) fn footer_hint_height(&self, width: u16, options: ComposerRenderOptions<'_>) -> u16 {
if self.shows_shared_status(options) {
return self.status_line_height(width, options);
}
if self.show_warning_notice(options) || self.shortcuts_above_composer(options) {
return 1;
}
@@ -300,11 +304,12 @@ impl super::ChatComposer {
pub(super) fn render_transcript_footer(
&self,
mut hint_area: Rect,
hint_area: Rect,
buf: &mut Buffer,
footer: &TranscriptFooter,
warning_area: Option<Rect>,
) {
let hint_area = warning_notice::before_warning(hint_area, warning_area);
let mut text = footer.text.clone();
if footer.cursor_column.is_none()
&& self.footer.flash_visible()
@@ -313,12 +318,6 @@ impl super::ChatComposer {
{
*line = flash.line.clone();
}
if let Some(warning_area) = warning_area {
hint_area.width = warning_area
.x
.saturating_sub(hint_area.x)
.saturating_sub(/*rhs*/ 2);
}
if !footer.is_interactive
&& !self.footer.flash_visible()
&& self.footer_mode() == FooterMode::ComposerEmpty
@@ -366,6 +365,8 @@ pub(super) struct FooterState {
pub(super) goal_status_indicator: Option<GoalStatusIndicator>,
pub(super) ide_context_active: bool,
pub(super) status_line_value: Option<FooterContent>,
// Available width and occupied height, shared by layout and cursor placement.
pub(super) measured_status_line: std::cell::Cell<Option<(u16, u16)>>,
pub(super) status_line_enabled: bool,
pub(super) side_conversation_context_label: Option<String>,
pub(super) active_agent_label: Option<String>,
@@ -1,6 +1,10 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
expression: "format!(\"{wide}\\n{narrow}\")"
expression: shared
---
branch PR #42 Plan mode (⇧tab to cycle)
branch… Plan mode
16 | Ready PR 12345
15 | Ready
15 | PR 12345
16 | Ready PR 12345
25 | Ready
25 | PR 12345 Plan mode
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
expression: rows
---
[
" ",
"› a ",
" b ",
" c ",
" ",
" one… ",
" ",
]
@@ -3,6 +3,9 @@
//! Shortcut help grows above the input, preserving status and close rows below it even when clipped.
use super::*;
use ratatui::layout::Size;
const MAX_FOOTER_ROWS: u16 = 4;
impl ChatComposer {
pub(crate) fn set_status_line(&mut self, status_line: Option<FooterContent>) -> bool {
@@ -12,6 +15,7 @@ impl ChatComposer {
if matches!(status_line, Some(FooterContent::Extension { .. })) {
self.effort_status_line_transition = None;
}
self.footer.measured_status_line.set(None);
self.footer.status_line_value = status_line;
true
}
@@ -44,8 +48,44 @@ impl ChatComposer {
&& self.footer_mode().shows_passive_line(self.is_task_running)
}
pub(super) fn status_surface_height(&self, options: ComposerRenderOptions<'_>) -> u16 {
u16::from(self.has_separate_status(options))
pub(super) fn status_surface_height(
&self,
width: u16,
options: ComposerRenderOptions<'_>,
) -> u16 {
if self.has_separate_status(options) {
self.status_line_height(width, options)
} else {
0
}
}
pub(super) fn status_line_height(&self, width: u16, options: ComposerRenderOptions<'_>) -> u16 {
// The unfocused composer paints one dim status line.
if options.unfocused {
return 1;
}
let Some(FooterContent::Extension { content }) = &self.footer.status_line_value else {
return 1;
};
let mut area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1);
if !options.separate_status_line {
let warning = self.warning_notice_layout(inset_footer_hint_area(area), options);
area = warning_notice::before_warning(area, warning.map(|(rect, _)| rect));
}
let (_, width) = self.status_line_layout(area, options, content.measure_natural().width);
let cache = &self.footer.measured_status_line;
if let Some((previous_width, height)) = cache.get()
&& previous_width == width
{
return height;
}
let height = content
.measure(Size::new(width, MAX_FOOTER_ROWS))
.height
.max(1);
cache.set(Some((width, height)));
height
}
pub(super) fn hint_footer_props(&self, options: ComposerRenderOptions<'_>) -> FooterProps {
@@ -2,6 +2,7 @@
use super::*;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::osc8_hyperlink;
use crate::terminal_hyperlinks::strip_osc8;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
@@ -59,7 +60,15 @@ fn extension_status_line_clips_links_to_footer_layout() {
.iter()
.filter(|cell| cell.symbol().contains("https://example.com/pr/42"))
.collect::<Vec<_>>();
assert_eq!(linked.len(), if width == 12 { 1 } else { 6 }, "{buffer:?}");
let linked_text: String = linked
.iter()
.map(|cell| strip_osc8(cell.symbol()))
.collect();
assert_eq!(
linked_text,
if width == 12 { "PR#" } else { "PR #42" },
"{buffer:?}"
);
assert!(linked.iter().all(|cell| !cell.symbol().contains('…')));
assert!(buffer.content.iter().any(|cell| cell.symbol() == "p"));
}
@@ -71,38 +80,237 @@ fn extension_status_line_clips_links_to_footer_layout() {
assert!(!output.contains("bot"), "{output}");
}
fn status_rows(
composer: &ChatComposer,
area: Rect,
options: ComposerRenderOptions<'_>,
) -> Vec<String> {
let layout = composer.layout_with_options(area, options);
let status = if options.separate_status_line {
layout.status
} else {
layout.footer
};
let mut buf = Buffer::empty(area);
composer.render_with_options(area, &mut buf, /*mask_char*/ None, options);
(status.y..status.bottom())
.map(|y| {
(status.x..status.right())
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
.trim()
.to_owned()
})
.collect()
}
#[test]
fn extension_layout_uses_the_width_left_by_the_mode_indicator_after_resize() {
let mut composer = composer();
composer.set_collaboration_modes_enabled(/*enabled*/ true);
composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan));
composer.footer.show_warnings_key = None;
let tree: crate::ui_tree::UiTree =
r#"{"type":"row","props":{"gap":1,"wrap":"wrap"},"children":[
"branch", {"type":"link","props":{"href":"https://example.com/pr/42"},"children":["PR #42"]}
]}"#
.parse()
.unwrap();
r#"{"type":"row","props":{"wrap":"wrap","gap":1},"children":[
"Ready", {"type":"row","props":{"gap":1},"children":["PR",
{"type":"link","props":{"href":"https://example.com/pr/12345"},"children":["12345"]}]}]}"#
.parse()
.unwrap();
composer.set_status_line(Some(FooterContent::from(tree)));
composer.set_collaboration_modes_enabled(/*enabled*/ true);
let linked_number: String = "12345"
.chars()
.map(|digit| osc8_hyperlink("https://example.com/pr/12345", &digit.to_string()))
.collect();
let [shared, separate] = [false, true].map(|separate_status_line| {
let mut frames = Vec::new();
for (width, mode, warning_count) in [
(16, None, 0),
(15, None, 0),
(16, None, 0),
(25, Some(CollaborationModeIndicator::Plan), 0),
(19, None, 1),
] {
composer.set_collaboration_mode_indicator(mode);
let options = ComposerRenderOptions {
separate_status_line,
warning_count,
..Default::default()
};
let height = composer.desired_height_with_options(width, options);
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let rows = status_rows(&composer, area, options);
for row in &rows {
let text = strip_osc8(row);
assert_eq!(*row, text.replace("12345", &linked_number));
if warning_count == 0 {
frames.push(format!("{width} | {text}"));
}
}
if warning_count > 0 {
assert_eq!(
composer.footer.warning_notice_area.get().unwrap().bottom(),
height
);
assert_eq!(rows.len(), if separate_status_line { 1 } else { 2 });
let last = strip_osc8(rows.last().unwrap());
assert!(last.contains("PR 12345"));
assert_eq!(last.contains("⚠ 1"), !separate_status_line);
}
}
frames.join("\n")
});
assert_eq!(shared, separate);
insta::assert_snapshot!(shared);
}
let hosts = [false, true].map(|separate_status_line| {
#[test]
fn status_rows_yield_to_the_composer_and_shared_footer_overrides() {
let mut composer = composer();
let tree: crate::ui_tree::UiTree = r#""one\ntwo\nthree\nfour\nfive""#.parse().unwrap();
composer.set_status_line(Some(FooterContent::from(tree)));
composer.set_text_content("a\nb\nc".into(), Vec::new(), Vec::new());
for separate_status_line in [false, true] {
let options = ComposerRenderOptions {
separate_status_line,
..Default::default()
};
[55, 24, 55].map(|width| {
let height = composer.desired_height_with_options(width, options);
let area = Rect::new(0, 0, width, height);
let short = if separate_status_line {
"one…"
} else {
"one\ntwo…"
};
for (height, expected) in [(12, "one\ntwo\nthree\nfour…"), (7, short)] {
let area = Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 7, height);
assert_eq!(status_rows(&composer, area, options).join("\n"), expected);
let input = composer.layout_with_options(area, options).textarea;
let mut buffer = Buffer::empty(area);
composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options);
let y = height - 1 - u16::from(separate_status_line);
let row: String = (0..width).map(|x| buffer[(x, y)].symbol()).collect();
strip_osc8(&row).trim().to_owned()
})
assert!(input.height >= 3);
let draft = [0, 1, 2].map(|dy| buffer[(input.x, input.y + dy)].symbol());
assert_eq!(draft, ["a", "b", "c"]);
let (x, y) = composer
.cursor_pos_with_options(area, options)
.expect("visible draft cursor");
assert!(input.contains((x, y).into()));
if separate_status_line && height == 7 {
let rows: Vec<String> = buffer
.content
.chunks(usize::from(area.width))
.map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect())
.collect();
insta::assert_debug_snapshot!(rows);
}
}
}
let tree: crate::ui_tree::UiTree = r#""x\ny""#.parse().unwrap();
composer.set_status_line(Some(FooterContent::from(tree)));
assert_eq!(
composer.footer_hint_height(/*width*/ 7, ComposerRenderOptions::default()),
2
);
composer.show_footer_flash("Copied".into(), Duration::from_secs(/*secs*/ 3));
assert_eq!(
composer.footer_hint_height(/*width*/ 7, ComposerRenderOptions::default()),
1
);
}
#[test]
fn sidebar_focus_collapses_extension_status_to_its_dim_line() {
let mut composer = composer();
composer.insert_str("draft");
let tree: crate::ui_tree::UiTree = r#""one\ntwo\nthree\nfour""#.parse().unwrap();
composer.set_status_line(Some(tree.into()));
let frames = [false, true, false].map(|unfocused| {
let options = ComposerRenderOptions {
separate_status_line: true,
unfocused,
..Default::default()
};
let height = composer.desired_height_with_options(/*width*/ 40, options);
status_rows(&composer, Rect::new(0, 0, 40, height), options).join("\n")
});
let [wide, narrow, restored] = &hosts[0];
assert_eq!(hosts[0], hosts[1]);
assert_eq!(wide, restored);
insta::assert_snapshot!(format!("{wide}\n{narrow}"));
insta::assert_snapshot!(frames.join("\n---\n"), @"
one
two
three
four
---
one…
---
one
two
three
four
");
}
#[test]
fn extension_status_paints_with_the_current_theme_at_the_same_width() {
use crate::render::highlight::current_syntax_theme;
use crate::render::highlight::set_syntax_theme;
use ratatui::style::Color;
use syntect::highlighting::Color as ThemeColor;
let mut composer = composer();
let content: crate::ui_tree::UiTree =
r#"{"type":"text","props":{"color":"primary"},"children":["λ"]}"#
.parse()
.unwrap();
composer.set_status_line(Some(FooterContent::from(content)));
let previous = current_syntax_theme();
let colors = [1, 2].map(|index| {
let mut theme = previous.clone();
theme.settings.foreground = Some(ThemeColor {
r: index,
g: 0,
b: 0,
a: 0,
});
set_syntax_theme(theme);
let options = ComposerRenderOptions::default();
let width = 24;
let area = Rect::new(
0,
0,
width,
composer.desired_height_with_options(width, options),
);
let mut buffer = Buffer::empty(area);
composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options);
buffer
.content
.iter()
.find(|cell| cell.symbol() == "λ")
.unwrap()
.fg
});
set_syntax_theme(previous);
assert_eq!(colors, [Color::Red, Color::Green]);
}
#[test]
fn extension_status_keeps_its_rows_when_effort_changes() {
let mut composer = composer();
let tree: crate::ui_tree::UiTree = r#""one\ntwo\nthree""#.parse().unwrap();
composer.set_status_line(Some(FooterContent::from(tree)));
composer.set_active_reasoning_effort_baseline(/*effort*/ None);
composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Max),
/*animations_enabled*/ true,
);
for separate_status_line in [false, true] {
let options = ComposerRenderOptions {
separate_status_line,
..Default::default()
};
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 7, /*height*/ 12,
);
assert_eq!(
status_rows(&composer, area, options),
["one", "two", "three"]
);
}
}
fn render(
@@ -1,10 +1,17 @@
//! Fit warning counts and the configured warnings shortcut into the existing passive hint row.
//! Interactive hints retain priority; warning delivery never changes composer geometry.
//! Interactive hints retain priority. Shared status content wraps beside the warning.
//! Only the count and warning label receive an amber accent; surrounding hints stay muted.
use super::*;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
pub(super) fn before_warning(mut area: Rect, warning: Option<Rect>) -> Rect {
if let Some(warning) = warning {
area.width = warning.x.saturating_sub(area.x).saturating_sub(/*rhs*/ 2);
}
area
}
impl ChatComposer {
pub(crate) fn warning_notice_contains(&self, position: ratatui::layout::Position) -> bool {
self.footer
-1
View File
@@ -48,7 +48,6 @@ impl PreparedUiTree {
})
}
#[cfg_attr(not(test), expect(dead_code))] // Used by composer layout in the next change.
pub(crate) fn measure(&self, bounds: Size) -> Size {
layout(self.0.as_ref(), bounds, /*draw*/ None)
}