Prioritize draft space when allocating composer footer rows (#60681)

## Why

Status content and footer hints need to fit within the composer's height budget while preserving room for editing and active search controls.

## What changed

- Share row allocation between composer measurement and rendering. Cap status and hints at two rows in inline scrollback, and scale the fullscreen cap from two to four rows with the available budget.
- Reserve space for active footer controls, give suggestions and image previews priority over additional status rows, and avoid reserving an empty hints row when status is enabled.
- Keep textarea row counts and scroll offsets as `usize`, saturating only the terminal height so very tall drafts remain scrollable with a visible caret.

## Testing

Add layout and snapshot coverage for constrained height budgets, status and hints, image previews, slash suggestions, and transcript search cursors. Add a regression test for rendering a draft beyond the `u16` row limit.

GitOrigin-RevId: 20fb0384ffc976dfd12e648004763feff0597dda
This commit is contained in:
Joe Savona
2026-11-10 23:34:54 +00:00
committed by copyberry
parent 8a7a723dcd
commit 192e2ef3ce
32 changed files with 601 additions and 213 deletions
@@ -61,7 +61,11 @@ async fn follow_control_click_preserves_draft_caret_and_composer_geometry() -> R
.scroll(&app.transcript_cells, /*rows*/ -10);
tui.screen_size_for_event(&TuiEvent::Resize(size))?;
app.render_owned_transcript(&mut tui, size)?;
assert_eq!(tui.terminal.last_known_cursor_pos, cursor);
let scrolled_cursor = tui.terminal.last_known_cursor_pos;
assert_eq!(
(scrolled_cursor.x, scrolled_cursor.y + u16::from(!running)),
(cursor.x, cursor.y)
);
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let scrolled = super::tests::buffer_text(buffer);
let first_row = scrolled.lines().next().expect("visible transcript row");
@@ -106,7 +110,7 @@ async fn follow_control_click_preserves_draft_caret_and_composer_geometry() -> R
(
"draft stays here".to_string(),
cursor.x,
size.height - cursor.y,
size.height - scrolled_cursor.y,
false
)
);
@@ -647,7 +647,8 @@ 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")
);
assert_eq!(tui.terminal.last_known_cursor_pos, caret);
let copied_cursor = tui.terminal.last_known_cursor_pos;
assert_eq!((copied_cursor.x, copied_cursor.y + 1), (caret.x, caret.y));
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
for pos in Rect::from(outer)
.positions()
@@ -1895,11 +1896,16 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> {
assert!(!app.handle_composer_copy_event(&mut tui, event, |_, _| unreachable!()));
}
// The selection hint can move the draft by one row.
app.render_owned_transcript(&mut tui, size)?;
let y = row_containing(&tui, "hellox world");
// A fresh composer click takes ownership away from the transcript selection.
app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?;
assert!(!app.transcript_view.has_active_interaction());
app.chat_widget.apply_external_edit("/".into());
assert!(!app.chat_widget.no_modal_or_popup_active());
app.render_owned_transcript(&mut tui, size)?;
let y = tui.terminal.last_known_cursor_pos.y;
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?);
app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Drag(Left), x + 1, y))?;
assert!(app.chat_widget.no_modal_or_popup_active());
@@ -1,15 +1,14 @@
---
source: tui/src/app/owned_transcript_input_tests.rs
assertion_line: 299
expression: "format!(\"before\\n{before}\\n\\nafter\\n{}\", screen(&tui))"
---
before
row 13
row 14
row 15
row 16
row 17
row 18
row 19
New activity · ↓ Back to bottom · esc
› draft preserved
@@ -18,6 +17,7 @@ row 19
New activity · esc latest
after
row 25
row 26
row 27
row 28
@@ -49,6 +49,7 @@ After paste
› [Pasted Content 2084 chars]
GPT-5.6-Sol default · /tmp/project
@@ -4,6 +4,7 @@ expression: normalize_snapshot_paths(screen)
---
····································
····································
····· ·····
·····› Second prompt ·····
····· ·····
····· ·····
@@ -17,6 +18,5 @@ expression: normalize_snapshot_paths(screen)
·····› preserved draft ·····
····· ·····
····· gpt-test default · /tmp…·····
····· ·····
····································
····································
@@ -68,6 +68,7 @@ hello world
Copied 5 chars to host clipboard
› hello world
@@ -1,6 +1,5 @@
---
source: tui/src/app/owned_transcript_tests.rs
assertion_line: 1654
expression: "normalize_snapshot_paths(frames.join(\"\\n\\n\"))"
---
Draft
@@ -11,11 +10,11 @@ History stays visible
[Image #1]
[Image #2]
[Image #3]
[Image #4]
› Edit this prompt
GPT-5.6-Sol default · /tmp/project
Up
History stays visible
@@ -23,13 +22,13 @@ History stays visible
[Image #9]
[Image #10]
[Image #11]
[Image #12]
› Edit this prompt
GPT-5.6-Sol default · /tmp/project
Up
History stays visible
@@ -37,13 +36,13 @@ History stays visible
[Image #8]
[Image #9]
[Image #10]
[Image #11]
› Edit this prompt
GPT-5.6-Sol default · /tmp/project
Delete
History stays visible
@@ -51,13 +50,13 @@ History stays visible
[Image #8]
[Image #9]
[Image #10]
[Image #11]
› Edit this prompt
GPT-5.6-Sol default · /tmp/project
Down
History stays visible
@@ -67,7 +66,7 @@ History stays visible
[Image #1]
[Image #2]
[Image #3]
[Image #4]
› Edit this prompt
GPT-5.6-Sol default · /tmp/project
@@ -1,6 +1,5 @@
---
source: tui/src/app/owned_transcript_tests.rs
assertion_line: 1600
expression: "normalize_snapshot_paths(frames.join(\"\\n\\n\"))"
---
Following caret
@@ -13,7 +12,9 @@ History 39
History 40
› Prompt 24 界 ↑
› Prompt 22 界 ↑
Prompt 23 界
Prompt 24 界
Prompt 25 界
Prompt 26 界
Prompt 27 界
@@ -26,8 +27,6 @@ History 40
Prompt 34 界
Prompt 35 界
GPT-5.6-Sol default · /tmp/project
Wheel up
History 34
@@ -39,7 +38,9 @@ History 39
History 40
› Prompt 15 界 ↑
› Prompt 13 界 ↑
Prompt 14 界
Prompt 15 界
Prompt 16 界
Prompt 17 界
Prompt 18 界
@@ -52,8 +53,6 @@ History 40
Prompt 25 界
Prompt 26 界 ↓
GPT-5.6-Sol default · /tmp/project
Edit at caret
History 34
@@ -65,7 +64,9 @@ History 39
History 40
› Prompt 24 界 ↑
› Prompt 22 界 ↑
Prompt 23 界
Prompt 24 界
Prompt 25 界
Prompt 26 界
Prompt 27 界
@@ -78,8 +79,6 @@ History 40
Prompt 34 界
Prompt 35 界
GPT-5.6-Sol default · /tmp/project
Paste at caret
History 34
@@ -91,7 +90,9 @@ History 39
History 40
› Edited 24 界 ↑
› Edited 22 界 ↑
Edited 23 界
Edited 24 界
Edited 25 界
Edited 26 界
Edited 27 界
@@ -104,16 +105,14 @@ History 40
Edited 34 界
Edited 35 界 pasted here
GPT-5.6-Sol default · /tmp/project
Short screen
History 40
› Edited 32 界 ↑
› Edited 30 界 ↑
Edited 31 界
Edited 32 界
Edited 33 界
Edited 34 界
Edited 35 界 pasted here
GPT-5.6-Sol default · /tmp/project
@@ -1,9 +1,9 @@
---
source: tui/src/app/owned_transcript_tests.rs
assertion_line: 350
expression: "snapshots.join(\"\\n\\n\")"
---
Latest
Transcript row 34
Transcript row 35
Transcript row 36
Transcript row 37
@@ -16,14 +16,13 @@ Transcript row 40
gpt-test default · /tmp/project
Reading
Transcript row 28
Transcript row 29
Transcript row 30
Transcript row 31
Transcript row 32
Transcript row 33
Transcript row 34
Transcript row 35
↓ Back to bottom · esc
› draft
@@ -32,10 +31,10 @@ Transcript row 35
esc latest
Resized multiline composer
Transcript row 28
Transcript row 29
Transcript row 30
Transcript row 31
Transcript row 32
Transcript row 33
↓ Back to bottom · esc
› draft
@@ -2,6 +2,7 @@
source: tui/src/app/warnings_tests.rs
expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(screen)"
---
Earlier transcript row 11
Earlier transcript row 12
Earlier transcript row 13
Earlier transcript row 14
@@ -11,7 +12,6 @@ Earlier transcript row 17
Earlier transcript row 18
Earlier transcript row 19
Earlier transcript row 20
Earlier transcript row 21
Warnings · 1 of 1 · Warning
@@ -2,6 +2,7 @@
source: tui/src/app/warnings_tests.rs
expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(screen)"
---
Earlier transcript row 11
Earlier transcript row 12
Earlier transcript row 13
Earlier transcript row 14
@@ -19,7 +20,6 @@ Earlier transcript row 25
Earlier transcript row 26
Earlier transcript row 27
Earlier transcript row 28
Earlier transcript row 29
↓ Back to bottom · esc
› draft
@@ -4,27 +4,28 @@ expression: "snapshots.join(\"\\n\\n\")"
---
owned=false
startup:
/tmp/project
› draft during startup
attached:
› draft during startup
GPT-6.1-Sol default · /tmp/project
owned=true
startup:
› draft during startup
attached:
› draft during startup
GPT-6.1-Sol default · /tmp/project
+12 -3
View File
@@ -28,7 +28,7 @@ async fn warning_notice_keeps_details_in_transcript_and_preserves_draft() -> Res
app.chat_widget
.apply_external_edit("draft stays here".into());
let before = app.render_owned_transcript(&mut tui, size)?;
let cursor = tui.terminal.last_known_cursor_pos;
let original_cursor = tui.terminal.last_known_cursor_pos;
for message in [
"MCP example handshake failed",
"MCP startup incomplete (example)",
@@ -50,8 +50,17 @@ async fn warning_notice_keeps_details_in_transcript_and_preserves_draft() -> Res
)),
);
}
assert_eq!(app.render_owned_transcript(&mut tui, size)?, before);
assert_eq!(tui.terminal.last_known_cursor_pos, cursor);
let after = app.render_owned_transcript(&mut tui, size)?;
let cursor = tui.terminal.last_known_cursor_pos;
assert_eq!(
(after.bottom(), after.height, cursor.x, cursor.y + 1),
(
before.bottom(),
before.height + 1,
original_cursor.x,
original_cursor.y
)
);
let live = screen(&tui);
assert!(live.contains("⚠ 2 warnings"));
let shortcut = app
@@ -5008,8 +5008,8 @@ impl ChatComposer {
if state.scroll > 0 {
buf.set_span(textarea_rect.right(), textarea_rect.y, &"↑".dim(), 1);
}
if state.scroll.saturating_add(textarea_rect.height)
< self.draft.textarea.desired_height(textarea_rect.width)
if state.scroll + usize::from(textarea_rect.height)
< self.draft.textarea.row_count(textarea_rect.width)
{
buf.set_span(
textarea_rect.right(),
@@ -8,6 +8,16 @@ use crate::tui::FrameRequester;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
pub(super) const MAX_FOOTER_ROWS: u16 = 4;
struct ComposerRows {
input: u16,
above_input: u16,
thumbnails: u16,
status: u16,
hints: u16,
}
impl ChatComposer {
pub(crate) fn set_voice_strip(
&mut self,
@@ -41,16 +51,13 @@ impl ChatComposer {
area: Rect,
options: ComposerRenderOptions<'_>,
) -> ComposerLayout {
let footer_hint_height = self.footer_hint_height(area.width, options);
let wanted = self.status_surface_height(area.width, /*available*/ 4, options);
let desired = self.height_with_rows(area.width, options, footer_hint_height, wanted);
let status_height = wanted
.saturating_sub(desired.saturating_sub(area.height))
.max(wanted.min(1))
.min(
area.height
.saturating_sub(footer_hint_height.saturating_add(3)),
);
let ComposerRows {
above_input,
thumbnails: thumbnail_height,
status: status_height,
hints: footer_hint_height,
..
} = self.composer_rows(area.width, area.height, options);
let status = Rect {
y: area
.bottom()
@@ -64,15 +71,6 @@ impl ChatComposer {
height: area.height - status_height,
..area
};
let popup_height = if self.popups.active.is_above_composer()
&& options.command_popup_placement != CommandPopupPlacement::AboveComposer
{
footer_hint_height
} else {
self.popups
.active
.required_height(area.width, footer_hint_height)
};
let voice_rows = self
.voice_strip
.as_ref()
@@ -80,7 +78,7 @@ impl ChatComposer {
let shortcuts_above = self.shortcuts_above_composer(options);
let (composer_rect, popup_rect, mut footer_rect) = if shortcuts_above {
let [shortcuts, composer, footer] = Layout::vertical([
Constraint::Max(footer_height(&self.hint_footer_props(), area.width)),
Constraint::Max(above_input),
Constraint::Min(3 + voice_rows),
Constraint::Length(footer_hint_height),
])
@@ -90,7 +88,7 @@ impl ChatComposer {
// Preserve the draft first, then its footer, and use remaining space for suggestions.
let [mut composer, mut popup, footer] = Layout::vertical([
Constraint::Min(3 + voice_rows),
Constraint::Max(popup_height.saturating_sub(footer_hint_height)),
Constraint::Max(above_input),
Constraint::Length(footer_hint_height),
])
.areas(area);
@@ -100,7 +98,7 @@ impl ChatComposer {
} else {
let [composer, popup] = Layout::vertical([
Constraint::Min(3 + voice_rows),
Constraint::Max(popup_height),
Constraint::Max(footer_hint_height),
])
.areas(area);
(composer, popup, popup)
@@ -129,22 +127,12 @@ impl ChatComposer {
width: textarea_rect.width,
height: remote_images_height,
};
let mut thumbnails = Rect {
let thumbnails = Rect {
y: textarea_rect.y.saturating_add(consumed),
height: self.thumbnail_height(textarea_rect.width, options),
height: thumbnail_height,
..textarea_rect
};
let available = textarea_rect.height.saturating_sub(consumed);
if thumbnails.height > 0
&& available.saturating_sub(thumbnails.height)
< self
.draft
.textarea
.desired_height(textarea_rect.width)
.max(1)
{
thumbnails.height = 0;
}
textarea_rect.y = thumbnails.bottom();
textarea_rect.height = available - thumbnails.height;
ComposerLayout {
@@ -158,12 +146,6 @@ impl ChatComposer {
}
}
fn thumbnail_height(&self, width: u16, options: ComposerRenderOptions<'_>) -> u16 {
options.image_previews.map_or(0, |previews| {
previews.height(width, self.attachments.local_images.len())
})
}
pub(crate) fn cursor_pos_with_options(
&self,
area: Rect,
@@ -199,21 +181,24 @@ impl ChatComposer {
width: u16,
options: ComposerRenderOptions<'_>,
) -> u16 {
self.height_with_rows(
width,
options,
self.footer_hint_height(width, options),
self.status_surface_height(width, /*available*/ 4, options),
)
let rows = self.composer_rows(width, options.max_height.unwrap_or(u16::MAX), options);
[
rows.input,
rows.above_input,
rows.thumbnails,
rows.status,
rows.hints,
]
.into_iter()
.fold(0, u16::saturating_add)
}
fn height_with_rows(
fn composer_rows(
&self,
width: u16,
height: u16,
options: ComposerRenderOptions<'_>,
footer_hint_height: u16,
status_height: u16,
) -> u16 {
) -> ComposerRows {
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
let inner_width =
width.saturating_sub(COLS_WITH_MARGIN.saturating_add(options.textarea_right_reserve));
@@ -224,32 +209,78 @@ impl ChatComposer {
.try_into()
.unwrap_or(u16::MAX);
let remote_images_separator = u16::from(remote_images_height > 0);
[
let input_height = [
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(), width)
} else {
0
},
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
} else {
self.popups
.active
.required_height(width, footer_hint_height)
},
]
.into_iter()
.fold(0, u16::saturating_add)
.fold(0, u16::saturating_add);
let above_input = if self.shortcuts_above_composer(options) {
footer_height(&self.hint_footer_props(), width)
} else if options.command_popup_placement == CommandPopupPlacement::AboveComposer {
self.popups
.active
.required_height(width, /*footer_total_height*/ 0)
} else {
0
};
let editing_hint = options.footer.is_some_and(|footer| footer.is_interactive)
|| matches!(
self.footer_mode(),
FooterMode::HistorySearch
| FooterMode::ShortcutOverlay
| FooterMode::QuitShortcutReminder
| FooterMode::EscHint
);
// The draft can scroll when another interaction owns the bottom row.
let mut available = height
.saturating_sub(input_height)
.max(u16::from(editing_hint && height > 3));
let cap = if options.separate_status_line {
// Use the frame's budget so measurement and painting choose the same cap.
let budget = options.max_height.unwrap_or(u16::MAX);
(budget.saturating_add(2) / 4).clamp(2, MAX_FOOTER_ROWS)
} else {
2
};
let wanted_hints = self.footer_hint_height(width, options);
// Find needs its query and result before the status line can use a row.
let find_active = options
.footer
.is_some_and(|footer| footer.cursor_column.is_some());
let reserved_hints = wanted_hints.min(if find_active { 2 } else { 1 });
let wanted_status = self.status_surface_height(
width,
available.min(cap).saturating_sub(reserved_hints),
options,
);
let basic = (reserved_hints + wanted_status.min(1)).min(2);
let above_input = above_input.min(available.saturating_sub(basic));
available -= above_input;
let mut thumbnails = options.image_previews.map_or(0, |previews| {
previews.height(inner_width, self.attachments.local_images.len())
});
if thumbnails <= available.saturating_sub(basic) {
available -= thumbnails;
} else {
thumbnails = 0;
}
let limit = cap.min(available);
let reserved_status = wanted_status
.min(1)
.min(limit.saturating_sub(reserved_hints));
let hints = wanted_hints.min(limit - reserved_status);
ComposerRows {
input: input_height,
above_input,
thumbnails,
status: wanted_status.min(limit - hints),
hints,
}
}
}
@@ -80,7 +80,7 @@ pub(crate) struct ComposerRenderOptions<'a> {
pub(crate) working_tip: Option<&'a crate::turn_tip::TurnTip>,
pub(crate) warning_count: usize,
pub(crate) textarea_right_reserve: u16,
/// The host is using the separate fullscreen transcript layout.
/// Fullscreen scales the footer cap with the composer budget. Inline scrollback allows two rows.
pub(crate) separate_status_line: bool,
/// A centered dialog owns input while the composer is painted behind it.
pub(crate) is_dialog_backdrop: bool,
@@ -289,19 +289,48 @@ impl super::ChatComposer {
if options.is_dialog_backdrop {
return 0;
}
if self.show_warning_notice(options) || self.shortcuts_above_composer(options) {
// The inactive pane keeps its bottom hints row blank.
if options.unfocused || self.shortcuts_above_composer(options) {
return 1;
}
options
.footer
.map_or_else(
|| {
self.custom_footer_height()
.unwrap_or_else(|| footer_height(&self.hint_footer_props(), width))
},
|footer| footer.text.height().try_into().unwrap_or(u16::MAX),
if self.footer.status_line_enabled
&& options.footer.is_none()
&& self.custom_footer_height().is_none()
&& matches!(
self.footer_mode(),
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
)
.max(u16::from(options.separate_status_line))
{
let mode = self.footer_mode();
let navigation = mode == FooterMode::ComposerEmpty
&& !self.is_in_paste_burst()
&& (self.agents_navigation_available()
|| self.footer.toggle_shortcuts_key.is_some());
let queue = mode == FooterMode::ComposerHasDraft
&& self.is_task_running
&& self.footer.queue_key.is_some();
return u16::from(
self.warning_notice_layout(
super::inset_footer_hint_area(Rect::new(0, 0, width, 1)),
options,
)
.is_some()
|| navigation
|| queue
|| self
.mode_indicator_line(/*show_cycle_hint*/ false)
.is_some()
|| self.shell_mode_footer_line().is_some()
|| self.footer.side_conversation_context_label.is_some(),
);
}
options.footer.map_or_else(
|| {
self.custom_footer_height()
.unwrap_or_else(|| footer_height(&self.hint_footer_props(), width))
},
|footer| footer.text.height().try_into().unwrap_or(u16::MAX),
)
}
pub(super) fn render_transcript_footer(
@@ -28,6 +28,7 @@ fn render(
) -> (Buffer, Option<(u16, u16)>) {
let options = composer.resolve_render_options(ComposerRenderOptions {
footer,
separate_status_line: true,
..ComposerRenderOptions::default()
});
let width = 60;
@@ -75,6 +76,7 @@ fn retained_command_suggestions_yield_paint_and_cursor_to_find() {
let options = composer.resolve_render_options(ComposerRenderOptions {
footer: Some(&footer),
command_popup_placement: CommandPopupPlacement::Hidden,
separate_status_line: true,
..ComposerRenderOptions::default()
});
let mut frames = Vec::new();
@@ -122,7 +124,10 @@ fn copying_from_find_preserves_the_query_above_selection_feedback() {
);
let (buffer, cursor) = render(&composer, Some(&footer));
assert_eq!(cursor, None);
insta::assert_snapshot!("transcript_find_copy_footer", text(&buffer));
let output = text(&buffer);
assert!(output.contains("Find: needle"));
assert!(output.contains("Copied selection to clipboard"));
insta::assert_snapshot!("transcript_find_copy_footer", output);
}
#[test]
@@ -31,11 +31,13 @@ fn composer_mouse_edits_flush_typing_and_reconcile_placeholders() {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4,
);
composer.render(area, &mut Buffer::empty(area));
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let (x, y) = composer.cursor_pos(area).unwrap();
assert_eq!(buf[(x - 5, y)].symbol(), "h");
mouse(&mut composer, area, Down(Left), x - 5, y);
composer.insert_str("X");
assert_eq!(composer.current_text(), "hello\nXworld");
assert_eq!(composer.current_text(), "Xhello\nworld");
composer.handle_paste("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1));
for path in [Some("first.png"), Some("second.png"), None] {
composer.render(area, &mut Buffer::empty(area));
@@ -1,6 +1,6 @@
---
source: tui/src/bottom_pane/chat_composer/footer_state_tests.rs
expression: text(&buffer)
expression: output
---
› Ask Codex
@@ -4,10 +4,9 @@ assertion_line: 58
expression: rows
---
› line 16 abc ↑
› line 15 abc ↑
line 16 abc
line 17 abc
line 18 abc
line 19 abc
line 20 ac
100% context left
line 20 ac
@@ -0,0 +1,8 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
expression: "frames.join(\"\\n---\\n\")"
---
codex-internal feat/statusline Build 1042 128 tests passed Deploy ready
? for shortcuts Plan mode (⇧tab to cycle) ⚠ 2 warnings · f2 to view
---
? for shortcuts Plan mode (⇧tab to cycle) ⚠ 2 warnings · f2 to view
@@ -1,9 +1,10 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
assertion_line: 344
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 9 },
area: Rect { x: 0, y: 0, width: 80, height: 8 },
content: [
" /model choose what model and reasoning effort to use ",
"› /memories configure memory use and generation ",
@@ -13,7 +14,6 @@ Buffer {
"› /m ",
" ",
" model · high · fast ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
@@ -0,0 +1,11 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
expression: frame(&buf)
---
|› /model choose what model and reasoning effort to use |
| /memories configure memory use and generation |
| /mention mention a file |
| |
|› /m |
| |
| one… |
@@ -0,0 +1,11 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
expression: frame(&buffer)
---
| |
|› a |
| b |
| c |
| |
| one |
| two… |
@@ -1,13 +0,0 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
expression: rows
---
[
" ",
"› a ",
" b ",
" c ",
" ",
" one… ",
" ",
]
@@ -0,0 +1,21 @@
---
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
expression: "frames.join(\"\\n\\n\")"
---
height 8
| |
|› [Image #1]draft |
| |
| one… |
| Plan mode (⇧tab to cycle) |
height 9
| |
| [Image |
| #1] |
| |
| |
|› [Image #1]draft |
| |
| one… |
| Plan mode (⇧tab to cycle) |
@@ -66,7 +66,11 @@ fn extension_status_line_clips_links_to_footer_layout() {
.collect();
assert_eq!(
linked_text,
if width == 12 { "PR#" } else { "PR #42" },
match (width, separate_status_line) {
(12, false) => "P",
(12, true) => "PR#",
_ => "PR #42",
},
"{buffer:?}"
);
assert!(linked.iter().all(|cell| !cell.symbol().contains('…')));
@@ -112,6 +116,21 @@ fn render_status(
(buf, rows)
}
fn frame(buffer: &Buffer) -> String {
buffer
.content
.chunks(usize::from(buffer.area.width))
.map(|row| {
let row = row
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
format!("|{}|", strip_osc8(&row))
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn extension_uses_the_full_width_after_resize() {
let mut composer = composer();
@@ -131,7 +150,7 @@ fn extension_uses_the_full_width_after_resize() {
for fullscreen in [false, true] {
for (width, mode, expected_rows) in [
(16, None, 1),
(15, None, 2),
(15, None, if fullscreen { 2 } else { 1 }),
(16, None, 1),
(27, Some(CollaborationModeIndicator::Plan), 1),
(19, Some(CollaborationModeIndicator::Plan), 1),
@@ -156,17 +175,61 @@ fn extension_uses_the_full_width_after_resize() {
}
#[test]
fn status_rows_yield_to_the_composer_and_shared_footer_overrides() {
fn provider_status_and_hints_use_separate_rows() {
let mut composer = composer();
let tree: crate::ui_tree::UiTree = r#"{"type":"row","props":{"wrap":"wrap","gap":2},"children":[{"type":"bold","children":["codex-internal"]},"feat/statusline","Build 1042","128 tests passed","Deploy ready"]}"#.parse().unwrap();
composer.set_status_line(Some(tree.into()));
composer.set_collaboration_modes_enabled(/*enabled*/ true);
composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan));
let options = ComposerRenderOptions {
warning_count: 2,
..Default::default()
};
let frames = [22, 4].map(|height| {
let area = Rect::new(0, 0, 84, height);
let (buffer, _) = render_status(&composer, area, options);
let rect = composer.footer.warning_notice_area.get().unwrap();
assert_eq!(buffer[(rect.x, rect.y)].symbol(), "⚠");
let layout = composer.layout_with_options(area, options);
(layout.status.y..layout.footer.bottom())
.map(|y| {
(0..area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>()
.join("\n")
});
insta::assert_snapshot!(frames.join("\n---\n"));
}
#[test]
fn status_rows_respect_host_budget_and_yield_to_the_draft() {
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());
composer.move_cursor_to_end();
for separate_status_line in [false, true] {
let options = ComposerRenderOptions {
separate_status_line,
..Default::default()
};
for (height, expected) in [(12, "one\ntwo\nthree\nfour…"), (7, "one…")] {
for (height, fullscreen) in [
(7, "one\ntwo…"),
(9, "one\ntwo…"),
(10, "one\ntwo\nthre…"),
(13, "one\ntwo\nthre…"),
(14, "one\ntwo\nthree\nfour…"),
] {
let options = ComposerRenderOptions {
separate_status_line,
max_height: Some(height),
..Default::default()
};
let expected = if separate_status_line {
fullscreen
} else {
"one\ntwo…"
};
let area = Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 7, height);
let (buffer, rows) = render_status(&composer, area, options);
assert_eq!(rows.join("\n"), expected);
@@ -174,25 +237,30 @@ fn status_rows_yield_to_the_composer_and_shared_footer_overrides() {
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);
assert_eq!(
composer.cursor_pos_with_options(area, options),
Some((input.x + 1, input.y + 2))
);
if height == 7 && separate_status_line {
insta::assert_snapshot!(
"status_rows_respect_host_budget_and_yield_to_the_draft",
frame(&buffer)
);
}
}
}
composer.set_collaboration_modes_enabled(/*enabled*/ true);
composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan));
assert_eq!(
render_status(&composer, Rect::new(0, 0, 48, 12), Default::default()).1,
["one…"]
);
composer.set_collaboration_mode_indicator(/*indicator*/ None);
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()),
1
0
);
composer.show_footer_flash("Copied".into(), Duration::from_secs(/*secs*/ 3));
assert_eq!(
@@ -233,6 +301,106 @@ fn sidebar_focus_collapses_extension_status_to_its_dim_line() {
");
}
#[test]
fn thumbnails_fit_after_basic_footer_rows_and_before_extra_status() {
let mut composer = composer();
composer.attach_image(PathBuf::from("preview.png"));
composer.insert_str("draft");
let tree: crate::ui_tree::UiTree = r#""one\ntwo\nthree\nfour""#.parse().unwrap();
composer.set_status_line(Some(tree.into()));
composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan));
let previews = crate::transcript_image::composer::ComposerImages::for_terminal(
crate::pets::ImageProtocol::Kitty,
/*previous*/ None,
)
.unwrap();
let mut frames = Vec::new();
for (fullscreen, cap, expected) in [
(true, 4, (0, 0, 1)),
(true, 5, (0, 1, 1)),
(true, 6, (0, 1, 1)),
(true, 8, (0, 1, 1)),
(true, 9, (4, 1, 1)),
(true, 10, (4, 2, 1)),
(false, 8, (0, 1, 1)),
(false, 9, (4, 1, 1)),
] {
let options = ComposerRenderOptions {
separate_status_line: fullscreen,
image_previews: Some(&previews),
max_height: Some(cap),
..Default::default()
};
let height = composer.desired_height_with_options(/*width*/ 32, options);
assert_eq!(height, 3 + expected.0 + expected.1 + expected.2);
let area = Rect::new(0, 0, 32, height);
let layout = composer.layout_with_options(area, options);
assert_eq!(
(
layout.thumbnails.height,
layout.status.height,
layout.footer.height
),
expected
);
if fullscreen && (cap == 8 || cap == 9) {
let (buffer, _) = render_status(&composer, area, options);
frames.push(format!("height {cap}\n{}", frame(&buffer)));
}
}
insta::assert_snapshot!(frames.join("\n\n"));
let options = ComposerRenderOptions {
image_previews: Some(&previews),
..Default::default()
};
composer.popups.active = ActivePopup::File(FileSearchPopup::new());
let with_popup = composer.layout_with_options(Rect::new(0, 0, 32, 9), options);
assert!(with_popup.popup.height > 0);
assert_eq!(with_popup.thumbnails.height, 0);
composer.popups.active = ActivePopup::None;
composer.set_status_line(/*status_line*/ None);
let hints_only = composer.layout_with_options(Rect::new(0, 0, 32, 8), options);
assert_eq!(
(
hints_only.thumbnails.height,
hints_only.status.height,
hints_only.footer.height
),
(4, 0, 1)
);
composer.insert_str("\nsecond line");
let search = TranscriptFooter {
text: "Find: abc".into(),
cursor_column: Some(9),
is_interactive: true,
};
for separate_status_line in [false, true] {
let options = ComposerRenderOptions {
separate_status_line,
image_previews: Some(&previews),
footer: Some(&search),
..Default::default()
};
let area = Rect::new(0, 0, 32, 4);
let layout = composer.layout_with_options(area, options);
assert_eq!(
(
layout.textarea.height,
layout.thumbnails.height,
layout.status.height,
layout.footer.height
),
(1, 0, 0, 1)
);
assert_eq!(
composer.cursor_pos_with_options(area, options),
Some((11, 3))
);
}
}
#[test]
fn extension_status_paints_with_the_current_theme_at_the_same_width() {
use crate::render::highlight::current_syntax_theme;
@@ -297,7 +465,11 @@ fn extension_status_keeps_its_rows_when_effort_changes() {
);
assert_eq!(
render_status(&composer, area, options).1,
["one", "two", "three"]
if separate_status_line {
vec!["one", "two", "three"]
} else {
vec!["one…"]
}
);
}
}
@@ -444,6 +616,26 @@ fn fullscreen_plan_indicator_keeps_the_cycle_hint_when_it_fits() {
);
}
#[test]
fn footer_layout_in_fullscreen_and_scrollback() {
let mut composer = composer();
composer.set_status_line(Some("Build ready".into()));
composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan));
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 31, /*height*/ 4,
);
for separate_status_line in [false, true] {
let options = ComposerRenderOptions {
separate_status_line,
..Default::default()
};
let layout = composer.layout_with_options(area, options);
assert_eq!((layout.status.height, layout.footer.height), (0, 1));
let (buf, _) = render_status(&composer, area, options);
assert!(frame(&buf).lines().last().unwrap().contains("Plan"));
}
}
#[test]
fn shortcut_help_stays_above_the_prompt_and_bottom_status() {
let mut composer = composer();
@@ -637,9 +829,26 @@ fn transcript_search_keeps_status_and_query_cursor() {
cursor_column: Some(8),
is_interactive: true,
};
let (text, cursor) = render(&composer, /*width*/ 48, Some(&footer));
assert_eq!(cursor, Some((10, text.lines().count() as u16 - 2)));
assert!(text.contains("MODEL · ~/project · Context 20% used"));
let options = ComposerRenderOptions {
separate_status_line: true,
footer: Some(&footer),
..Default::default()
};
for (height, status_rows, hint_rows) in [(4, 0, 1), (5, 0, 2), (6, 1, 2)] {
let area = Rect::new(0, 0, 48, height);
let layout = composer.layout_with_options(area, options);
assert_eq!(
(layout.status.height, layout.footer.height),
(status_rows, hint_rows)
);
let mut buf = Buffer::empty(area);
composer.render_with_options(area, &mut buf, /*mask_char*/ None, options);
assert_eq!(buf[(2, layout.footer.y)].symbol(), "F");
assert_eq!(
composer.cursor_pos_with_options(area, options),
Some((10, layout.footer.y))
);
}
}
#[test]
@@ -653,6 +862,27 @@ fn input_history_search_uses_the_final_row() {
);
}
#[test]
fn slash_suggestions_take_space_before_status_expands() {
let mut composer = composer();
let tree: crate::ui_tree::UiTree = r#""one\ntwo\nthree\nfour""#.parse().unwrap();
composer.set_status_line(Some(tree.into()));
for ch in ['/', 'm'] {
composer.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
let options = ComposerRenderOptions {
separate_status_line: true,
command_popup_placement: CommandPopupPlacement::AboveComposer,
..Default::default()
};
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 7,
);
let (buf, rows) = render_status(&composer, area, options);
assert_eq!(rows, ["one…"]);
insta::assert_snapshot!(frame(&buf));
}
#[test]
fn slash_popup_preserves_footer_and_selection_style() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
+54 -30
View File
@@ -180,7 +180,7 @@ struct WrapCache {
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct TextAreaState {
/// Index into wrapped lines of the first visible line.
pub(in crate::bottom_pane) scroll: u16,
pub(in crate::bottom_pane) scroll: usize,
/// Caret at the time of manual scrolling. A changed caret resumes following automatically.
manual_cursor: Option<usize>,
}
@@ -557,10 +557,14 @@ impl TextArea {
}
pub fn desired_height(&self, width: u16) -> u16 {
u16::try_from(self.row_count(width)).unwrap_or(u16::MAX)
}
pub(super) fn row_count(&self, width: u16) -> usize {
if self.single_line {
return 1;
}
self.wrapped_lines(width).len() as u16
self.wrapped_lines(width).len()
}
#[cfg_attr(not(test), allow(dead_code))]
@@ -583,7 +587,7 @@ impl TextArea {
let lines = self.wrapped_lines(area.width);
let effective_scroll = self.effective_scroll(area, &lines, state);
let (i, col) = wrapping::cursor_position(&self.text, &lines, area.width, self.cursor_pos)?;
let screen_row = i.checked_sub(usize::from(effective_scroll))?;
let screen_row = i.checked_sub(effective_scroll)?;
(screen_row < usize::from(area.height))
.then(|| (area.x + col as u16, area.y + screen_row as u16))
}
@@ -2183,17 +2187,17 @@ impl TextArea {
///
/// - Cursor is on screen unless the user scrolled away without moving it.
/// - No scrolling if content fits in the area.
fn effective_scroll(&self, area: Rect, lines: &[Range<usize>], state: TextAreaState) -> u16 {
let total_lines = u16::try_from(lines.len()).unwrap_or(u16::MAX);
if area.height >= total_lines {
fn effective_scroll(&self, area: Rect, lines: &[Range<usize>], state: TextAreaState) -> usize {
let height = usize::from(area.height);
if height >= lines.len() {
return 0;
}
let cursor_line_idx =
wrapping::cursor_position(&self.text, lines, area.width, self.cursor_pos)
.map_or(0, |(row, _)| row) as u16;
.map_or(0, |(row, _)| row);
let max_scroll = total_lines.saturating_sub(area.height);
let max_scroll = lines.len() - height;
let mut scroll = state.scroll.min(max_scroll);
if state.manual_cursor == Some(self.cursor_pos) {
return scroll;
@@ -2202,11 +2206,21 @@ impl TextArea {
// Ensure cursor is visible within [scroll, scroll + area_height)
if cursor_line_idx < scroll {
scroll = cursor_line_idx;
} else if cursor_line_idx >= scroll + area.height {
scroll = cursor_line_idx + 1 - area.height;
} else if cursor_line_idx >= scroll + height {
scroll = cursor_line_idx + 1 - height;
}
scroll
}
fn visible_rows(
&self,
area: Rect,
lines: &[Range<usize>],
state: &mut TextAreaState,
) -> Range<usize> {
state.scroll = self.effective_scroll(area, lines, *state);
state.scroll..(state.scroll + usize::from(area.height)).min(lines.len())
}
}
impl WidgetRef for &TextArea {
@@ -2247,12 +2261,8 @@ impl StatefulWidgetRef for &TextArea {
return;
}
let lines = self.wrapped_lines(area.width);
let scroll = self.effective_scroll(area, &lines, *state);
state.scroll = scroll;
let start = scroll as usize;
let end = (scroll + area.height).min(lines.len() as u16) as usize;
self.render_lines(area, buf, &lines, start..end, Style::default(), &[]);
let visible = self.visible_rows(area, &lines, state);
self.render_lines(area, buf, &lines, visible, Style::default(), &[]);
}
}
@@ -2266,12 +2276,8 @@ impl TextArea {
) {
self.rendered_area.set(area);
let lines = self.wrapped_lines(area.width);
let scroll = self.effective_scroll(area, &lines, *state);
state.scroll = scroll;
let start = scroll as usize;
let end = (scroll + area.height).min(lines.len() as u16) as usize;
self.render_lines_masked(area, buf, &lines, start..end, mask_char);
let visible = self.visible_rows(area, &lines, state);
self.render_lines_masked(area, buf, &lines, visible, mask_char);
}
/// Render the textarea with `base_style` plus additional render-only highlight ranges.
@@ -2288,12 +2294,8 @@ impl TextArea {
) {
self.rendered_area.set(area);
let lines = self.wrapped_lines(area.width);
let scroll = self.effective_scroll(area, &lines, *state);
state.scroll = scroll;
let start = scroll as usize;
let end = (scroll + area.height).min(lines.len() as u16) as usize;
self.render_lines(area, buf, &lines, start..end, base_style, highlights);
let visible = self.visible_rows(area, &lines, state);
self.render_lines(area, buf, &lines, visible, base_style, highlights);
}
/// Renders visible text and styled overlays without writing outside the textarea viewport.
@@ -3859,7 +3861,7 @@ mod tests {
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), small_area, &mut buf, &mut state);
// After render, state.scroll should be adjusted so cursor row fits
let effective_lines = t.desired_height(small_area.width);
assert!(state.scroll < effective_lines);
assert!(state.scroll < usize::from(effective_lines));
}
#[test]
@@ -4362,6 +4364,28 @@ mod tests {
}
}
#[test]
fn tall_draft_height_saturates_at_the_terminal_limit() {
let text = "\n".repeat(usize::from(u16::MAX)) + "x";
let mut textarea = ta_with(&text);
textarea.set_cursor(text.len());
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 2,
);
let mut buf = Buffer::empty(area);
let mut state = TextAreaState::default();
textarea.render_ref_styled_with_highlights(
area,
&mut buf,
&mut state,
Style::default(),
&[],
);
assert_eq!(textarea.desired_height(area.width), u16::MAX);
assert_eq!(buf[(0, 1)].symbol(), "x");
assert_eq!(textarea.cursor_pos_with_state(area, state), Some((1, 1)));
}
#[test]
fn cursor_pos_with_state_basic_and_scroll_behaviors() {
// Case 1: No wrapping needed, height fits — scroll ignored, y maps directly.
@@ -4403,7 +4427,7 @@ mod tests {
t.set_cursor(/*pos*/ 1);
let area = Rect::new(0, 0, wrap_width, 3);
let state = TextAreaState {
scroll: lines.saturating_mul(2),
scroll: usize::from(lines) * 2,
..Default::default()
};
let (_x, y) = t.cursor_pos_with_state(area, state).unwrap();
@@ -82,14 +82,14 @@ impl TextArea {
return false;
};
let lines = self.wrapped_lines(area.width);
let total = u16::try_from(lines.len()).unwrap_or(u16::MAX);
if total <= area.height {
let height = usize::from(area.height);
if lines.len() <= height {
return false;
}
let scroll = state
.scroll
.saturating_add_signed(rows)
.min(total - area.height);
.saturating_add_signed(isize::from(rows))
.min(lines.len() - height);
if scroll != state.scroll {
state.scroll = scroll;
state.manual_cursor = Some(self.cursor_pos);
@@ -120,8 +120,8 @@ impl TextArea {
}
let line = {
let lines = self.wrapped_lines(area.width);
let row = usize::from(state.scroll)
+ usize::from(event.row.saturating_sub(area.y).min(area.height - 1));
let row =
state.scroll + usize::from(event.row.saturating_sub(area.y).min(area.height - 1));
// Dragging beyond the viewport advances one wrapped row per event.
let row = if event.row < area.y {
row.saturating_sub(1)
@@ -145,7 +145,7 @@ fn mouse_drag_highlights_and_edits_wrapped_selection() {
mouse(&mut t, state, Drag(Left), end.0, end.1);
mouse(&mut t, state, Up(Left), end.0, end.1);
render(&t, area, &mut state);
assert_eq!(state.scroll, u16::from(end.1 > start.1));
assert_eq!(state.scroll, usize::from(end.1 > start.1));
let buffer = render(&t, Rect { height: 2, ..area }, &mut state);
let rows = buffer
.content
@@ -605,6 +605,7 @@ async fn clipped_voice_composer_keeps_the_draft_and_cursor_visible() {
insta::assert_snapshot!(layouts.join("\n\n"), @"
5 rows:
voice ● listening f9 mute /voice stop
› typed
6 rows:
+11 -1
View File
@@ -263,7 +263,17 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
complete_tx.send(()).unwrap();
terminal.wait_for_screen("Recovered turn completed.")?;
terminal.write_input(b"\r")?;
tokio::time::timeout(Duration::from_secs(/*secs*/ 10), submitted_rx).await??;
// Keep draining redraws so a full PTY cannot block the TUI before it submits.
let submitted = tokio::time::timeout(Duration::from_secs(/*secs*/ 10), submitted_rx);
tokio::pin!(submitted);
loop {
tokio::select! {
result = &mut submitted => break result??,
_ = tokio::time::sleep(Duration::from_millis(/*millis*/ 20)) => {
terminal.read_output(Duration::ZERO)?;
}
}
}
drop(terminal);
let methods = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), server).await???;
assert_eq!(