mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
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:
@@ -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());
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
@@ -49,6 +49,7 @@ After paste
|
||||
|
||||
|
||||
|
||||
|
||||
› [Pasted Content 2084 chars]
|
||||
|
||||
GPT-5.6-Sol default · /tmp/project
|
||||
|
||||
+1
-1
@@ -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…·····
|
||||
····· ·····
|
||||
····································
|
||||
····································
|
||||
|
||||
+1
@@ -68,6 +68,7 @@ hello world
|
||||
|
||||
|
||||
|
||||
|
||||
Copied 5 chars to host clipboard
|
||||
|
||||
› hello world
|
||||
|
||||
+10
-11
@@ -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
|
||||
|
||||
+15
-16
@@ -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
|
||||
|
||||
+5
-6
@@ -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
|
||||
|
||||
+1
-1
@@ -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
-3
@@ -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
|
||||
|
||||
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer/footer_state_tests.rs
|
||||
expression: text(&buffer)
|
||||
expression: output
|
||||
---
|
||||
|
||||
› Ask Codex
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
+8
@@ -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
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+11
@@ -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… |
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
|
||||
expression: frame(&buffer)
|
||||
---
|
||||
| |
|
||||
|› a |
|
||||
| b |
|
||||
| c |
|
||||
| |
|
||||
| one |
|
||||
| two… |
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
|
||||
expression: rows
|
||||
---
|
||||
[
|
||||
" ",
|
||||
"› a ",
|
||||
" b ",
|
||||
" c ",
|
||||
" ",
|
||||
" one… ",
|
||||
" ",
|
||||
]
|
||||
+21
@@ -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>();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user