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!(