From 192e2ef3ce9710418f59d7bc42d6b2e52d486416 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Tue, 10 Nov 2026 23:19:11 +0000 Subject: [PATCH] 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 --- .../src/app/owned_transcript_follow_tests.rs | 8 +- .../tui/src/app/owned_transcript_tests.rs | 8 +- ...input_tests__escape_returns_to_latest.snap | 4 +- ...__input_tests__raw_paste_burst_render.snap | 1 + ...t__tests__conversation_in_offset_rect.snap | 2 +- ..._fullscreen_composer_right_click_copy.snap | 1 + ...ests__fullscreen_prompt_remote_images.snap | 21 +- ...cript__tests__fullscreen_prompt_wheel.snap | 31 +- ..._tests__owned_transcript_composer_gap.snap | 11 +- ...i__app__warnings_tests__warnings_page.snap | 2 +- ...ngs_tests__warnings_partial_dismissal.snap | 2 +- ...sts__command_center_new_draft_handoff.snap | 7 +- codex-rs/tui/src/app/warning_notice_tests.rs | 15 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 4 +- .../chat_composer/composer_layout.rs | 167 ++++++----- .../bottom_pane/chat_composer/footer_state.rs | 51 +++- .../chat_composer/footer_state_tests.rs | 7 +- .../bottom_pane/chat_composer/mouse_tests.rs | 6 +- ...e__tests__transcript_find_copy_footer.snap | 2 +- ...ct__tests__offline_delete_after_wheel.snap | 7 +- ...er_status_and_hints_use_separate_rows.snap | 8 + ...rface__tests__slash_popup_footer_wide.snap | 4 +- ...ions_take_space_before_status_expands.snap | 11 + ...ct_host_budget_and_yield_to_the_draft.snap | 11 + ..._composer_and_shared_footer_overrides.snap | 13 - ...c_footer_rows_and_before_extra_status.snap | 21 ++ .../chat_composer/status_surface_tests.rs | 278 ++++++++++++++++-- codex-rs/tui/src/bottom_pane/textarea.rs | 84 ++++-- .../tui/src/bottom_pane/textarea/mouse.rs | 12 +- .../src/bottom_pane/textarea/mouse_tests.rs | 2 +- .../realtime/recording_controls_tests.rs | 1 + codex-rs/tui/tests/suite/reconnect.rs | 12 +- 32 files changed, 601 insertions(+), 213 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__provider_status_and_hints_use_separate_rows.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_suggestions_take_space_before_status_expands.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__status_rows_respect_host_budget_and_yield_to_the_draft.snap delete mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__status_rows_yield_to_the_composer_and_shared_footer_overrides.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__thumbnails_fit_after_basic_footer_rows_and_before_extra_status.snap diff --git a/codex-rs/tui/src/app/owned_transcript_follow_tests.rs b/codex-rs/tui/src/app/owned_transcript_follow_tests.rs index 4234e6d6d2..181a8959a1 100644 --- a/codex-rs/tui/src/app/owned_transcript_follow_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_follow_tests.rs @@ -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 ) ); diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index 7df5da628b..f990145d31 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -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()); diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__escape_returns_to_latest.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__escape_returns_to_latest.snap index 3233b6a851..597d6c02be 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__escape_returns_to_latest.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__escape_returns_to_latest.snap @@ -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 diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__raw_paste_burst_render.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__raw_paste_burst_render.snap index 324f2da7e4..fa9f7121b5 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__raw_paste_burst_render.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__raw_paste_burst_render.snap @@ -49,6 +49,7 @@ After paste + › [Pasted Content 2084 chars] GPT-5.6-Sol default · /tmp/project diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__conversation_in_offset_rect.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__conversation_in_offset_rect.snap index d18190e127..3dae559699 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__conversation_in_offset_rect.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__conversation_in_offset_rect.snap @@ -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…····· -····· ····· ···································· ···································· diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_composer_right_click_copy.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_composer_right_click_copy.snap index ef78c11d19..91c4986502 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_composer_right_click_copy.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_composer_right_click_copy.snap @@ -68,6 +68,7 @@ hello world + Copied 5 chars to host clipboard › hello world diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_prompt_remote_images.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_prompt_remote_images.snap index 030a70d072..e59d018d82 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_prompt_remote_images.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_prompt_remote_images.snap @@ -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 diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_prompt_wheel.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_prompt_wheel.snap index 76d66c8e13..3722d650da 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_prompt_wheel.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_prompt_wheel.snap @@ -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 diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_transcript_composer_gap.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_transcript_composer_gap.snap index b2417f42ea..6439e0a4f1 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_transcript_composer_gap.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_transcript_composer_gap.snap @@ -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 diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__warnings_tests__warnings_page.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__warnings_tests__warnings_page.snap index 3c372260db..905cea6f8b 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__warnings_tests__warnings_page.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__warnings_tests__warnings_page.snap @@ -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 diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__warnings_tests__warnings_partial_dismissal.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__warnings_tests__warnings_partial_dismissal.snap index 915b91463e..f719544853 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__warnings_tests__warnings_partial_dismissal.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__warnings_tests__warnings_partial_dismissal.snap @@ -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 diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_new_draft_handoff.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_new_draft_handoff.snap index 0e9dda54bb..ab1f16378b 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_new_draft_handoff.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_new_draft_handoff.snap @@ -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 diff --git a/codex-rs/tui/src/app/warning_notice_tests.rs b/codex-rs/tui/src/app/warning_notice_tests.rs index e8f36e183f..73a54fb26d 100644 --- a/codex-rs/tui/src/app/warning_notice_tests.rs +++ b/codex-rs/tui/src/app/warning_notice_tests.rs @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2ac6da93ac..912488b910 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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(), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs b/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs index 8d90ef234b..848a15c02e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs @@ -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, + } } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs index 3a6479e823..f439175828 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -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( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs index e3d556c464..6abfc00a75 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs @@ -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] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs index 9715d72b2c..8207ecc24b 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs @@ -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)); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__transcript_find_copy_footer.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__transcript_find_copy_footer.snap index 1cc9f31ee6..d12ea489b6 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__transcript_find_copy_footer.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__transcript_find_copy_footer.snap @@ -1,6 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer/footer_state_tests.rs -expression: text(&buffer) +expression: output --- › Ask Codex diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__reconnect__tests__offline_delete_after_wheel.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__reconnect__tests__offline_delete_after_wheel.snap index 4c8ea73ba4..f50318b5cc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__reconnect__tests__offline_delete_after_wheel.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__reconnect__tests__offline_delete_after_wheel.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__provider_status_and_hints_use_separate_rows.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__provider_status_and_hints_use_separate_rows.snap new file mode 100644 index 0000000000..c147176403 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__provider_status_and_hints_use_separate_rows.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap index 9e9f659dcb..bb446cf9e1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap @@ -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, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_suggestions_take_space_before_status_expands.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_suggestions_take_space_before_status_expands.snap new file mode 100644 index 0000000000..f6b958bb7c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_suggestions_take_space_before_status_expands.snap @@ -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… | diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__status_rows_respect_host_budget_and_yield_to_the_draft.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__status_rows_respect_host_budget_and_yield_to_the_draft.snap new file mode 100644 index 0000000000..9a00c553fd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__status_rows_respect_host_budget_and_yield_to_the_draft.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs +expression: frame(&buffer) +--- +| | +|› a | +| b | +| c | +| | +| one | +| two… | diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__status_rows_yield_to_the_composer_and_shared_footer_overrides.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__status_rows_yield_to_the_composer_and_shared_footer_overrides.snap deleted file mode 100644 index b407541488..0000000000 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__status_rows_yield_to_the_composer_and_shared_footer_overrides.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs -expression: rows ---- -[ - " ", - "› a ", - " b ", - " c ", - " ", - " one… ", - " ", -] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__thumbnails_fit_after_basic_footer_rows_and_before_extra_status.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__thumbnails_fit_after_basic_footer_rows_and_before_extra_status.snap new file mode 100644 index 0000000000..9e4d8fc8f1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__thumbnails_fit_after_basic_footer_rows_and_before_extra_status.snap @@ -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) | diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs index 705c363948..8c639d6c7e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs @@ -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::(); + format!("|{}|", strip_osc8(&row)) + }) + .collect::>() + .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::() + .trim_end() + .to_owned() + }) + .collect::>() + .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 = 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::(); diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index a7d8ecf5eb..5955f86179 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -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, } @@ -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], 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], 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], + state: &mut TextAreaState, + ) -> Range { + 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(); diff --git a/codex-rs/tui/src/bottom_pane/textarea/mouse.rs b/codex-rs/tui/src/bottom_pane/textarea/mouse.rs index ec00cf99a3..01fdfc7220 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/mouse.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/mouse.rs @@ -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) diff --git a/codex-rs/tui/src/bottom_pane/textarea/mouse_tests.rs b/codex-rs/tui/src/bottom_pane/textarea/mouse_tests.rs index 3b24c32202..6f85be9acf 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/mouse_tests.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/mouse_tests.rs @@ -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 diff --git a/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs b/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs index 81acef34aa..f0efff94d2 100644 --- a/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs +++ b/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs @@ -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: diff --git a/codex-rs/tui/tests/suite/reconnect.rs b/codex-rs/tui/tests/suite/reconnect.rs index 1677ba7b81..920689c224 100644 --- a/codex-rs/tui/tests/suite/reconnect.rs +++ b/codex-rs/tui/tests/suite/reconnect.rs @@ -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!(