From 6ece7bfc21bc28c1ae4e7d7aec8ca828b2b6e3c0 Mon Sep 17 00:00:00 2001 From: Ian MacLeod Date: Thu, 1 Oct 2026 21:25:46 +0000 Subject: [PATCH] Keep fullscreen prompts bounded and scrollable (#50109) ## Why Long drafts need to remain browsable while leaving room for the transcript. Remote image attachments also need to leave an editable prompt row visible. ## What changed - Cap the fullscreen composer, including padding and hints, at two-thirds of the screen, with a minimum of eight rows when the screen allows. - Support mouse-wheel browsing without moving the caret or changing transcript selection, search, or activity focus. Show arrows for hidden prompt rows and hide the cursor when the caret is offscreen. - Resume caret following on editor input, paste, and external edits. - Keep the selected remote image visible while reserving space for prompt text. ## Testing Add regression tests and snapshots for fullscreen scrolling, caret restoration after navigation, paste and external edits, remote image navigation and deletion, resizing at scroll boundaries, and disconnected editing. GitOrigin-RevId: 9a1e4ce14b3b3747e6d136207546327531339258 --- codex-rs/tui/src/app/owned_transcript.rs | 63 ++++--- .../tui/src/app/owned_transcript_tests.rs | 158 +++++++++++++++++- ...ests__fullscreen_prompt_remote_images.snap | 73 ++++++++ ...cript__tests__fullscreen_prompt_wheel.snap | 119 +++++++++++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 44 ++++- .../chat_composer/composer_layout.rs | 2 +- .../bottom_pane/chat_composer/footer_state.rs | 2 + .../src/bottom_pane/chat_composer/mouse.rs | 9 + .../bottom_pane/chat_composer/paste_input.rs | 1 + .../bottom_pane/chat_composer/reconnect.rs | 1 + .../chat_composer/reconnect_tests.rs | 58 +++++++ ...ct__tests__offline_delete_after_wheel.snap | 13 ++ codex-rs/tui/src/bottom_pane/mod.rs | 4 +- codex-rs/tui/src/bottom_pane/textarea.rs | 53 ++++-- .../tui/src/bottom_pane/textarea/mouse.rs | 27 +++ .../src/bottom_pane/textarea/mouse_tests.rs | 58 +++++++ ...e__tests__wheel_at_edges_after_resize.snap | 24 +++ codex-rs/tui/src/chatwidget/rendering.rs | 42 ++--- .../tui/src/chatwidget/rendering_tests.rs | 10 +- 19 files changed, 673 insertions(+), 88 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_prompt_remote_images.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_prompt_wheel.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__reconnect__tests__offline_delete_after_wheel.snap create mode 100644 codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__mouse__tests__wheel_at_edges_after_resize.snap diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 9822d0324a..aa442e77ba 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -116,16 +116,24 @@ impl App { /*height*/ 1, )); let footer = view.footer_with_navigation(footer_area.width, motion, latest_navigation); - let bottom = chat_widget.bottom_pane_renderable( - prompt_footer.as_ref().or(footer.as_ref()), - if view.has_active_interaction() { - crate::bottom_pane::CommandPopupPlacement::Hidden - } else { - crate::bottom_pane::CommandPopupPlacement::Overlay - }, - composer_gap.as_ref(), - working_tip, - ); + // Cap the whole composer (including padding and hints) at two-thirds of the screen, + // but allow at least 8 rows on small screens, without exceeding the screen itself. + let max_composer_height = ((u32::from(screen_size.height) * 2 / 3) as u16) + .max(8) + .min(screen_size.height); + let bottom = + chat_widget.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions { + max_height: Some(max_composer_height), + footer: prompt_footer.as_ref().or(footer.as_ref()), + command_popup_placement: if view.has_active_interaction() { + crate::bottom_pane::CommandPopupPlacement::Hidden + } else { + crate::bottom_pane::CommandPopupPlacement::Overlay + }, + composer_gap: composer_gap.as_ref(), + working_tip, + ..Default::default() + }); let dashboard_visible = chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) .is_some(); @@ -185,16 +193,19 @@ impl App { { *progress = crate::bottom_pane::footer_hint_items_line(&items); } - let bottom = chat_widget.bottom_pane_renderable( - prompt_footer.as_ref().or(footer.as_ref()), - if view.has_active_interaction() { - crate::bottom_pane::CommandPopupPlacement::Hidden - } else { - crate::bottom_pane::CommandPopupPlacement::Overlay - }, - composer_gap.as_ref(), - working_tip, - ); + let bottom = + chat_widget.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions { + max_height: Some(max_composer_height), + footer: prompt_footer.as_ref().or(footer.as_ref()), + command_popup_placement: if view.has_active_interaction() { + crate::bottom_pane::CommandPopupPlacement::Hidden + } else { + crate::bottom_pane::CommandPopupPlacement::Overlay + }, + composer_gap: composer_gap.as_ref(), + working_tip, + ..Default::default() + }); footer_height_changed = !dashboard_visible && bottom .desired_height(screen_size.width) @@ -361,9 +372,15 @@ impl App { return Ok(true); } if composer_ready && self.chat_widget.handle_composer_mouse(*mouse) { - self.transcript_view.end_selection(&self.transcript_cells); - self.transcript_view.cancel_search(); - self.transcript_view.clear_activity_focus(); + if !matches!( + mouse.kind, + crossterm::event::MouseEventKind::ScrollUp + | crossterm::event::MouseEventKind::ScrollDown + ) { + self.transcript_view.end_selection(&self.transcript_cells); + self.transcript_view.cancel_search(); + self.transcript_view.clear_activity_focus(); + } tui.frame_requester().schedule_frame(); return Ok(true); } diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index 9a88d4711c..1548028051 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -511,12 +511,11 @@ async fn owned_details_keep_the_composer_cursor_and_screen() -> Result<()> { app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?; let expected_cursor = app .chat_widget - .bottom_pane_renderable( - /*footer*/ None, - crate::bottom_pane::CommandPopupPlacement::Overlay, - Some(&crate::bottom_pane::ComposerGap::default()), - /*working_tip*/ None, - ) + .bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions { + command_popup_placement: crate::bottom_pane::CommandPopupPlacement::Overlay, + composer_gap: Some(&crate::bottom_pane::ComposerGap::default()), + ..Default::default() + }) .cursor_pos(bottom_area) .expect("composer cursor"); assert_eq!( @@ -1537,3 +1536,150 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> { tui.set_owned_screen(/*owned*/ false)?; Ok(()) } + +#[tokio::test] +async fn fullscreen_composer_scrolls_without_moving_caret_or_transcript() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + let size = Size::new(/*width*/ 60, /*height*/ 24); + tui.terminal.resize(size)?; + app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new( + (1..=40).map(|n| format!("History {n:02}").into()).collect(), + ))]; + let draft = (1..=35) + .map(|n| format!("Prompt {n:02} 界")) + .collect::>() + .join("\n"); + app.chat_widget.apply_external_edit(draft.clone()); + let bottom = app.render_owned_transcript(&mut tui, size)?; + assert!(bottom.y > 0); + let caret = tui.terminal.last_known_cursor_pos; + let original = app.chat_widget.capture_thread_input_state(); + let mut frames = Vec::new(); + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + frames.push(format!("Following caret\n{}", buffer_text(buffer))); + let wheel = mouse(MouseEventKind::ScrollUp, caret.x, caret.y); + for _ in 0..3 { + assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &wheel)?); + } + assert_eq!(app.chat_widget.capture_thread_input_state(), original); + assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom); + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let rendered = buffer_text(buffer); + assert!(!rendered.contains("Prompt 35"), "{rendered}"); + assert!(rendered.contains("History 40"), "{rendered}"); + frames.push(format!("Wheel up\n{rendered}")); + + // The new draft key is applied at the real caret even while that caret is offscreen. + app.chat_widget.handle_key_event(KeyCode::Left.into()); + app.render_owned_transcript(&mut tui, size)?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + assert!(buffer_text(buffer).contains("Prompt 35")); + frames.push(format!("Edit at caret\n{}", buffer_text(buffer))); + // Returning from an external editor restores the caret even for a same-length edit. + app.chat_widget.handle_key_event(KeyCode::Right.into()); + app.render_owned_transcript(&mut tui, size)?; + let caret = tui.terminal.last_known_cursor_pos; + for _ in 0..3 { + assert!(app.handle_owned_transcript_event( + &mut tui, + &mut server, + &mouse(MouseEventKind::ScrollUp, caret.x, caret.y), + )?); + } + app.chat_widget + .apply_external_edit(draft.replace("Prompt", "Edited")); + app.render_owned_transcript(&mut tui, size)?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + assert!(buffer_text(buffer).contains("Edited 35")); + + // Pasting while browsing also resumes following, without inserting into a visible earlier row. + let caret = tui.terminal.last_known_cursor_pos; + for _ in 0..3 { + assert!(app.handle_owned_transcript_event( + &mut tui, + &mut server, + &mouse(MouseEventKind::ScrollUp, caret.x, caret.y), + )?); + } + app.chat_widget.handle_paste(" pasted here".to_string()); + app.render_owned_transcript(&mut tui, size)?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let rendered = buffer_text(buffer); + assert!( + rendered + .lines() + .any(|line| line.contains("Edited 35") && line.contains("pasted here")), + "{rendered}" + ); + frames.push(format!("Paste at caret\n{rendered}")); + + // A shorter screen still leaves a usable composer. + let small = Size::new(/*width*/ 40, /*height*/ 10); + tui.terminal.resize(small)?; + app.render_owned_transcript(&mut tui, small)?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + frames.push(format!("Short screen\n{}", buffer_text(buffer))); + insta::assert_snapshot!( + "fullscreen_prompt_wheel", + normalize_snapshot_paths(frames.join("\n\n")) + ); + server.shutdown().await?; + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} + +#[tokio::test] +async fn fullscreen_composer_remote_images_leave_an_editable_prompt_row() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + let size = Size::new(/*width*/ 60, /*height*/ 12); + tui.terminal.resize(size)?; + app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new(vec![ + "History stays visible".into(), + ]))]; + app.chat_widget + .apply_external_edit("Edit this prompt".to_string()); + app.chat_widget.set_remote_image_urls( + (1..=12) + .map(|n| format!("https://example.com/image-{n}.png")) + .collect(), + ); + let bottom = app.render_owned_transcript(&mut tui, size)?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let rendered = buffer_text(buffer); + assert!(bottom.y > 0); + assert!(rendered.contains("Edit this prompt"), "{rendered}"); + let mut frames = vec![format!("Draft\n{rendered}")]; + app.chat_widget.handle_key_event(KeyCode::Home.into()); + for (key, label) in [ + (KeyCode::Up, "[Image #12]"), + (KeyCode::Up, "[Image #11]"), + (KeyCode::Delete, "[Image #11]"), + (KeyCode::Down, "Edit this prompt"), + ] { + app.chat_widget.handle_key_event(key.into()); + app.render_owned_transcript(&mut tui, size)?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let rendered = buffer_text(buffer); + assert!(rendered.contains(label), "{key:?}: {rendered}"); + assert!(rendered.contains("Edit this prompt"), "{key:?}: {rendered}"); + frames.push(format!("{key:?}\n{rendered}")); + } + assert_eq!( + app.chat_widget.remote_image_urls(), + (1..=12) + .filter(|n| *n != 11) + .map(|n| format!("https://example.com/image-{n}.png")) + .collect::>() + ); + insta::assert_snapshot!( + "fullscreen_prompt_remote_images", + normalize_snapshot_paths(frames.join("\n\n")) + ); + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} 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 new file mode 100644 index 0000000000..030a70d072 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_prompt_remote_images.snap @@ -0,0 +1,73 @@ +--- +source: tui/src/app/owned_transcript_tests.rs +assertion_line: 1654 +expression: "normalize_snapshot_paths(frames.join(\"\\n\\n\"))" +--- +Draft +History stays visible + + + + + [Image #1] + [Image #2] + +› Edit this prompt + + GPT-5.6-Sol default · /tmp/project + + +Up +History stays visible + + + + + [Image #11] + [Image #12] + +› Edit this prompt + + GPT-5.6-Sol default · /tmp/project + + +Up +History stays visible + + + + + [Image #10] + [Image #11] + +› Edit this prompt + + GPT-5.6-Sol default · /tmp/project + + +Delete +History stays visible + + + + + [Image #10] + [Image #11] + +› Edit this prompt + + GPT-5.6-Sol default · /tmp/project + + +Down +History stays visible + + + + + [Image #1] + [Image #2] + +› 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 new file mode 100644 index 0000000000..76d66c8e13 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_prompt_wheel.snap @@ -0,0 +1,119 @@ +--- +source: tui/src/app/owned_transcript_tests.rs +assertion_line: 1600 +expression: "normalize_snapshot_paths(frames.join(\"\\n\\n\"))" +--- +Following caret +History 34 +History 35 +History 36 +History 37 +History 38 +History 39 +History 40 + + +› Prompt 24 界 ↑ + Prompt 25 界 + Prompt 26 界 + Prompt 27 界 + Prompt 28 界 + Prompt 29 界 + Prompt 30 界 + Prompt 31 界 + Prompt 32 界 + Prompt 33 界 + Prompt 34 界 + Prompt 35 界 + + GPT-5.6-Sol default · /tmp/project + + +Wheel up +History 34 +History 35 +History 36 +History 37 +History 38 +History 39 +History 40 + + +› Prompt 15 界 ↑ + Prompt 16 界 + Prompt 17 界 + Prompt 18 界 + Prompt 19 界 + Prompt 20 界 + Prompt 21 界 + Prompt 22 界 + Prompt 23 界 + Prompt 24 界 + Prompt 25 界 + Prompt 26 界 ↓ + + GPT-5.6-Sol default · /tmp/project + + +Edit at caret +History 34 +History 35 +History 36 +History 37 +History 38 +History 39 +History 40 + + +› Prompt 24 界 ↑ + Prompt 25 界 + Prompt 26 界 + Prompt 27 界 + Prompt 28 界 + Prompt 29 界 + Prompt 30 界 + Prompt 31 界 + Prompt 32 界 + Prompt 33 界 + Prompt 34 界 + Prompt 35 界 + + GPT-5.6-Sol default · /tmp/project + + +Paste at caret +History 34 +History 35 +History 36 +History 37 +History 38 +History 39 +History 40 + + +› Edited 24 界 ↑ + Edited 25 界 + Edited 26 界 + Edited 27 界 + Edited 28 界 + Edited 29 界 + Edited 30 界 + Edited 31 界 + Edited 32 界 + Edited 33 界 + Edited 34 界 + Edited 35 界 pasted here + + GPT-5.6-Sol default · /tmp/project + + +Short screen +History 40 + + +› Edited 32 界 ↑ + Edited 33 界 + Edited 34 界 + Edited 35 界 pasted here + + GPT-5.6-Sol default · /tmp/project diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 64f9df8573..ad78613dc5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -14,6 +14,7 @@ //! Shortcut help expands above the composer, with its close hint replacing the final shortcuts row //! so input and persistent status stay anchored when help opens or closes. //! Escape dismisses visible shortcut help before editing, transcript backtracking, or interruption. +//! Fullscreen limits the composer height; wheel browsing preserves the caret until editor input. //! Transcript interactions borrow the hint footer through `ComposerRenderOptions`; //! its resolved presentation drives height, painting, and the focused cursor together. //! @@ -1277,6 +1278,7 @@ impl ChatComposer { self.attachments.local_images = kept_images; // Import literally so placeholders remain atomic and Replace recovery starts empty. + self.draft.textarea_state.get_mut().follow_cursor(); self.draft.textarea.set_text_clearing_elements(""); let mut remaining: HashMap<&str, usize> = HashMap::new(); for img in &self.attachments.local_images { @@ -1531,6 +1533,7 @@ impl ChatComposer { // Clear any existing content, placeholders, and attachments first. self.footer.flash = None; self.vim_history = VimHistory::default(); + self.draft.textarea_state.get_mut().follow_cursor(); self.draft.textarea.set_text_clearing_elements(""); self.draft.is_bash_mode = false; self.draft.pending_pastes.clear(); @@ -1912,6 +1915,7 @@ impl ChatComposer { return (InputResult::None, false); } + self.draft.textarea_state.get_mut().follow_cursor(); let before = self.before_sparkle_key(key_event); let result = self.handle_key_event_inner(key_event); self.after_sparkle_key(before, &result.0); @@ -4786,9 +4790,20 @@ impl ChatComposer { let style = user_message_style(); Block::default().style(style).render(composer_rect, buf); if !remote_images_rect.is_empty() { - Paragraph::new(self.attachments.remote_image_lines()) - .style(style) - .render(remote_images_rect, buf); + let first = self + .attachments + .selected_remote_image_index + .unwrap_or_default() + .saturating_sub(usize::from(remote_images_rect.height) - 1); + Paragraph::new( + self.attachments + .remote_image_lines() + .into_iter() + .skip(first) + .collect::>(), + ) + .style(style) + .render(remote_images_rect, buf); } if !textarea_rect.is_empty() { let prompt = if self.draft.input_enabled { @@ -4821,6 +4836,9 @@ impl ChatComposer { } let mut state = self.draft.textarea_state.borrow_mut(); + if options.max_height.is_none() { + state.follow_cursor(); + } let textarea_is_empty = self.draft.textarea.text().is_empty() && !self.draft.is_bash_mode; if self.draft.input_enabled { if let Some(mask_char) = mask_char { @@ -4870,6 +4888,26 @@ impl ChatComposer { Line::from(vec![placeholder]).render(textarea_rect.inner(Margin::new(0, 0)), buf); } } + // The reserved right margin indicates hidden rows without changing wrapping. + if options.max_height.is_some() + && self.draft.input_enabled + && !textarea_rect.is_empty() + && textarea_rect.right() < composer_rect.right() + { + 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) + { + buf.set_span( + textarea_rect.right(), + textarea_rect.bottom() - 1, + &"↓".dim(), + 1, + ); + } + } if matches!(self.popups.active, ActivePopup::None) && let Some(ignition) = &self.effort_ignition && !ignition.is_finished() 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 b79c28b98a..949edcf916 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 @@ -116,7 +116,7 @@ impl ChatComposer { .len() .try_into() .unwrap_or(u16::MAX) - .min(textarea_rect.height.saturating_sub(1)); + .min(textarea_rect.height.saturating_sub(2)); let remote_images_separator = u16::from(remote_images_height > 0); let consumed = remote_images_height.saturating_add(remote_images_separator); let remote_images_rect = Rect { 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 625418ec86..5b8784a4fa 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 @@ -68,6 +68,8 @@ pub(crate) enum CommandPopupPlacement { /// A borrowed presentation shared by measurement, painting, and cursor placement. #[derive(Clone, Copy, Default)] pub(crate) struct ComposerRenderOptions<'a> { + /// Fullscreen limits the composer independently of activity and modal views. + pub(crate) max_height: Option, /// Reserve a shared hint row independently of whether it currently contains a notice. pub(crate) composer_gap: Option<&'a crate::bottom_pane::ComposerGap>, pub(crate) working_tip: Option<&'a crate::turn_tip::TurnTip>, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs index beb2aad831..ec95356ef5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs @@ -2,6 +2,7 @@ //! editable text using the textarea's last rendered viewport and hides completion suggestions. //! Double/triple clicks select words/logical lines using the transcript's shared gesture rules. //! Copy preserves the draft and cursor; confirmed copies clear selection for every gesture. +//! Wheel browsing uses the current viewport without changing popup or selection ownership. use super::*; use crate::clipboard_copy::CopyStatus; @@ -85,11 +86,19 @@ impl ChatComposer { /// Dispatch after preparation and rendering have refreshed the viewport. pub(crate) fn handle_mouse(&mut self, event: MouseEvent) -> bool { + if self + .draft + .textarea + .scroll_mouse(event, &mut self.draft.textarea_state.borrow_mut()) + { + return true; + } let handled = self .draft .textarea .handle_mouse(event, *self.draft.textarea_state.borrow()); if handled { + self.draft.textarea_state.borrow_mut().follow_cursor(); self.attachments.clear_remote_image_selection(); self.sync_popups(); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs index 21481ddfc0..b3c7739776 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs @@ -138,6 +138,7 @@ impl ChatComposer { query.editor.insert_str(&pasted); return true; } + self.draft.textarea_state.get_mut().follow_cursor(); let started_vim_edit = self.begin_direct_vim_edit(); let elements_before = self.draft.textarea.element_payloads(); let target = self.draft.textarea.edit_target(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs index 2950d7a9f6..ac64a4ea6a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs @@ -104,6 +104,7 @@ impl ChatComposer { let before = if key.code == KeyCode::Null { None } else { + self.draft.textarea_state.get_mut().follow_cursor(); self.before_sparkle_editor_key(key) }; let (result, _) = self.handle_input_basic(key); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs index c1f58a8b54..d1d7cd7bce 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs @@ -2,8 +2,66 @@ use super::super::tests::new_test_composer; use super::*; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; use pretty_assertions::assert_eq; +#[test] +fn offline_delete_reveals_the_edited_row_without_moving_the_caret() { + let (mut composer, _) = new_test_composer(); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 8, + ); + let options = ComposerRenderOptions { + max_height: Some(area.height), + ..ComposerRenderOptions::default() + }; + let text = (1..=20) + .map(|n| format!("line {n:02} abc")) + .collect::>() + .join("\n"); + composer.set_text_content(text.clone(), Vec::new(), Vec::new()); + let cursor = text.len() - 2; + composer.draft.textarea.set_cursor(cursor); + let mut buffer = Buffer::empty(area); + composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options); + let (x, y) = composer.cursor_pos_with_options(area, options).unwrap(); + for _ in 0..3 { + assert!(composer.handle_mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + column: x, + row: y, + modifiers: KeyModifiers::NONE, + })); + } + // Disconnect cleanup and blocked submits must not count as edits. + for key in [KeyCode::Null, KeyCode::Enter, KeyCode::Tab] { + composer.handle_restricted_key(key.into(), RestrictedInputMode::Disconnected); + let mut buffer = Buffer::empty(area); + composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options); + assert_eq!(composer.cursor_pos_with_options(area, options), None); + } + composer.handle_restricted_key(KeyCode::Delete.into(), RestrictedInputMode::Disconnected); + let mut buffer = Buffer::empty(area); + composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options); + assert_eq!( + (composer.current_text(), composer.draft.textarea.cursor()), + (text.replace("line 20 abc", "line 20 ac"), cursor) + ); + assert!(composer.cursor_pos_with_options(area, options).is_some()); + let rows = buffer + .content + .chunks(usize::from(area.width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!("offline_delete_after_wheel", rows); +} + #[test] fn reconnect_expands_pastes_preserving_images_and_cursor() { let (mut composer, _) = new_test_composer(); 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 new file mode 100644 index 0000000000..4c8ea73ba4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__reconnect__tests__offline_delete_after_wheel.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer/reconnect_tests.rs +assertion_line: 58 +expression: rows +--- + +› line 16 abc ↑ + line 17 abc + line 18 abc + line 19 abc + line 20 ac + + 100% context left diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 75a7fee4a0..e1128431ba 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -2300,7 +2300,8 @@ impl BottomPane { flex2.push(/*flex*/ 1, RenderableItem::Owned(above_composer)); let composer: RenderableItem<'_> = if let Some(questions) = question_editor { RenderableItem::Borrowed(questions.as_ref()) - } else if options.textarea_right_reserve == 0 + } else if options.max_height.is_none() + && options.textarea_right_reserve == 0 && options.warning_count == 0 && options.footer.is_none() && !options.separate_status_line @@ -2383,6 +2384,7 @@ impl Renderable for ChatComposerPresentation<'_> { fn desired_height(&self, width: u16) -> u16 { self.composer .desired_height_with_options(width, self.options) + .min(self.options.max_height.unwrap_or(u16::MAX)) } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 51f988b830..e92031784d 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -15,7 +15,8 @@ //! breaks, interior separators hang off the preceding row without changing the editable text. //! Visible web URLs carry their complete terminal hyperlink destination across wrapped rows; //! masked rendering never exposes hyperlink destinations. -//! Mouse selection uses those same visual rows and keeps graphemes and elements atomic. +//! Mouse selection and wheel browsing use those same visual rows and keep graphemes and elements +//! atomic. Wheel browsing leaves the caret in place; moving or editing returns to caret following. //! The editing module resolves replacement targets shared by insertion and paste-context inspection. use crate::key_hint::KeyBindingListExt; @@ -173,7 +174,15 @@ struct WrapCache { #[derive(Debug, Default, Clone, Copy)] pub(crate) struct TextAreaState { /// Index into wrapped lines of the first visible line. - scroll: u16, + pub(in crate::bottom_pane) scroll: u16, + /// Caret at the time of manual scrolling. A changed caret resumes following automatically. + manual_cursor: Option, +} + +impl TextAreaState { + pub(crate) fn follow_cursor(&mut self) { + self.manual_cursor = None; + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -525,20 +534,18 @@ impl TextArea { /// Returns an on-screen cursor position within `area`, accounting for wrapping and scrolling. /// - /// Returns `None` when the viewport has no visible cells. + /// Returns `None` when the viewport or caret is not visible. pub fn cursor_pos_with_state(&self, area: Rect, state: TextAreaState) -> Option<(u16, u16)> { if area.is_empty() { return None; } let lines = self.wrapped_lines(area.width); - let effective_scroll = self.effective_scroll(area, &lines, state.scroll); + 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 - .saturating_sub(effective_scroll as usize) - .try_into() - .unwrap_or(0); - Some((area.x + col as u16, area.y + screen_row)) + let screen_row = i.checked_sub(usize::from(effective_scroll))?; + (screen_row < usize::from(area.height)) + .then(|| (area.x + col as u16, area.y + screen_row as u16)) } pub fn is_empty(&self) -> bool { @@ -2094,10 +2101,10 @@ impl TextArea { /// Calculate the scroll offset that should be used to satisfy the /// invariants given the current area size and wrapped lines. /// - /// - Cursor is always on screen. + /// - 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], current_scroll: u16) -> u16 { - let total_lines = lines.len() as u16; + 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 { return 0; } @@ -2107,7 +2114,10 @@ impl TextArea { .map_or(0, |(row, _)| row) as u16; let max_scroll = total_lines.saturating_sub(area.height); - let mut scroll = current_scroll.min(max_scroll); + let mut scroll = state.scroll.min(max_scroll); + if state.manual_cursor == Some(self.cursor_pos) { + return scroll; + } // Ensure cursor is visible within [scroll, scroll + area_height) if cursor_line_idx < scroll { @@ -2139,7 +2149,7 @@ impl StatefulWidgetRef for &TextArea { fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { self.rendered_area.set(area); let lines = self.wrapped_lines(area.width); - let scroll = self.effective_scroll(area, &lines, state.scroll); + let scroll = self.effective_scroll(area, &lines, *state); state.scroll = scroll; let start = scroll as usize; @@ -2158,7 +2168,7 @@ impl TextArea { ) { self.rendered_area.set(area); let lines = self.wrapped_lines(area.width); - let scroll = self.effective_scroll(area, &lines, state.scroll); + let scroll = self.effective_scroll(area, &lines, *state); state.scroll = scroll; let start = scroll as usize; @@ -2180,7 +2190,7 @@ impl TextArea { ) { self.rendered_area.set(area); let lines = self.wrapped_lines(area.width); - let scroll = self.effective_scroll(area, &lines, state.scroll); + let scroll = self.effective_scroll(area, &lines, *state); state.scroll = scroll; let start = scroll as usize; @@ -4246,7 +4256,10 @@ mod tests { let area = Rect::new(2, 5, 20, 3); // Even if an absurd scroll is provided, when content fits the area the // effective scroll is 0 and the cursor position matches cursor_pos. - let bad_state = TextAreaState { scroll: 999 }; + let bad_state = TextAreaState { + scroll: 999, + ..Default::default() + }; let (x1, y1) = t.cursor_pos(area).unwrap(); let (x2, y2) = t.cursor_pos_with_state(area, bad_state).unwrap(); assert_eq!((x2, y2), (x1, y1)); @@ -4260,7 +4273,10 @@ mod tests { // Put cursor somewhere near the end so it's definitely below the first window. t.set_cursor(t.text().len().saturating_sub(2)); let small_area = Rect::new(0, 0, wrap_width, 2); - let state = TextAreaState { scroll: 0 }; + let state = TextAreaState { + scroll: 0, + ..Default::default() + }; let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap(); assert_eq!(y, small_area.y + small_area.height - 1); @@ -4274,6 +4290,7 @@ mod tests { let area = Rect::new(0, 0, wrap_width, 3); let state = TextAreaState { scroll: lines.saturating_mul(2), + ..Default::default() }; let (_x, y) = t.cursor_pos_with_state(area, state).unwrap(); assert_eq!(y, area.y); diff --git a/codex-rs/tui/src/bottom_pane/textarea/mouse.rs b/codex-rs/tui/src/bottom_pane/textarea/mouse.rs index ccbe221a67..681fc9bcae 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/mouse.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/mouse.rs @@ -63,6 +63,33 @@ impl TextArea { (!range.is_empty()).then_some(range) } + /// Consume events at the edges too, but only detach from the caret if the viewport moves. + pub(crate) fn scroll_mouse(&self, event: MouseEvent, state: &mut TextAreaState) -> bool { + let rows = match event.kind { + MouseEventKind::ScrollUp => -3, + MouseEventKind::ScrollDown => 3, + _ => return false, + }; + if !event.modifiers.is_empty() || !self.contains_mouse(event) { + return false; + } + let area = self.rendered_area.get(); + let lines = self.wrapped_lines(area.width); + let total = u16::try_from(lines.len()).unwrap_or(u16::MAX); + if total <= area.height { + return false; + } + let scroll = state + .scroll + .saturating_add_signed(rows) + .min(total - area.height); + if scroll != state.scroll { + state.scroll = scroll; + state.manual_cursor = Some(self.cursor_pos); + } + true + } + pub(crate) fn handle_mouse(&mut self, event: MouseEvent, state: TextAreaState) -> bool { let area = self.rendered_area.get(); let dragging = self.mouse_selection.as_ref().is_some_and(|s| s.dragging); 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 f9f95457b0..3b24c32202 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/mouse_tests.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/mouse_tests.rs @@ -26,6 +26,64 @@ fn mouse(t: &mut TextArea, state: TextAreaState, kind: MouseEventKind, x: u16, y )); } +#[test] +fn wheel_at_edges_preserves_caret_following_or_browsing_after_resize() { + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 14, /*height*/ 4, + ); + let down = MouseEvent { + kind: MouseEventKind::ScrollDown, + column: area.x, + row: area.y, + modifiers: KeyModifiers::NONE, + }; + let up = MouseEvent { + kind: MouseEventKind::ScrollUp, + ..down + }; + let mut t = TextArea::new(); + t.insert_str( + "line one abc\nline two abc\nline three ab\nline four abc\nline five abc\nline six abc", + ); + let mut frames = Vec::new(); + for resized in [Rect { height: 2, ..area }, Rect { width: 7, ..area }] { + for browsing in [false, true] { + let mut state = TextAreaState::default(); + render(&t, area, &mut state); + if browsing { + // The first event scrolls to the top; the second is a no-op. + assert!(t.scroll_mouse(up, &mut state)); + assert!(t.scroll_mouse(up, &mut state)); + } else { + // The caret was already at the bottom; consume this without detaching it. + assert!(t.scroll_mouse(down, &mut state)); + } + let buffer = render(&t, resized, &mut state); + let expected_cursor = if browsing { + None + } else { + t.cursor_pos(resized) + }; + assert_eq!(t.cursor_pos_with_state(resized, state), expected_cursor); + let rows = buffer + .content + .chunks(usize::from(resized.width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + }) + .collect::>() + .join("\n"); + frames.push(format!( + "{}x{} browsing={browsing}\n{rows}", + resized.width, resized.height + )); + } + } + insta::assert_snapshot!("wheel_at_edges_after_resize", frames.join("\n\n")); +} + #[test] fn mouse_click_maps_wrapping_unicode_tabs_and_empty_lines() { let area = Rect::new( diff --git a/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__mouse__tests__wheel_at_edges_after_resize.snap b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__mouse__tests__wheel_at_edges_after_resize.snap new file mode 100644 index 0000000000..bd6e6457ea --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__mouse__tests__wheel_at_edges_after_resize.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/bottom_pane/textarea/mouse_tests.rs +assertion_line: 78 +expression: "frames.join(\"\\n\\n\")" +--- +14x2 browsing=false +line five abc +line six abc + +14x2 browsing=true +line one abc +line two abc + +7x4 browsing=false +abc +line +six abc + + +7x4 browsing=true +line +one abc + +line diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index 36c873351f..ebfc641a2c 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -132,12 +132,8 @@ impl ChatWidget { .selected_index_for_active_view(crate::app::AGENTS_OVERVIEW_VIEW_ID) .is_some() { - return self.bottom_pane_renderable( - /*footer*/ None, - crate::bottom_pane::CommandPopupPlacement::AboveComposer, - /*composer_gap*/ None, - /*working_tip*/ None, - ); + return self + .bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions::default()); } let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols(); @@ -198,15 +194,10 @@ impl ChatWidget { } flex.push( /*flex*/ 0, - self.bottom_pane_renderable( - /*footer*/ None, - crate::bottom_pane::CommandPopupPlacement::AboveComposer, - /*composer_gap*/ None, - /*working_tip*/ None, - ) - .inset(Insets::tlbr( - /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, - )), + self.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions::default()) + .inset(Insets::tlbr( + /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, + )), ); RenderableItem::Owned(Box::new(flex)) } @@ -217,10 +208,7 @@ impl ChatWidget { /// remain consistent. Owned transcripts reserve their shared hint row above the composer. pub(crate) fn bottom_pane_renderable<'a>( &'a self, - footer: Option<&'a crate::bottom_pane::TranscriptFooter>, - command_popup_placement: crate::bottom_pane::CommandPopupPlacement, - composer_gap: Option<&'a crate::bottom_pane::ComposerGap>, - working_tip: Option<&'a crate::turn_tip::TurnTip>, + mut options: crate::bottom_pane::ComposerRenderOptions<'a>, ) -> RenderableItem<'a> { if self.fork_in_progress { RenderableItem::Owned(Box::new( @@ -244,17 +232,11 @@ impl ChatWidget { } else { self.ambient_pet_wrap_reserved_cols() }; - self.bottom_pane - .as_renderable_with_options(crate::bottom_pane::ComposerRenderOptions { - composer_gap, - working_tip, - warning_count: self.warning_display_state.count, - textarea_right_reserve: right_reserve, - separate_status_line: command_popup_placement - != crate::bottom_pane::CommandPopupPlacement::AboveComposer, - command_popup_placement, - footer, - }) + options.warning_count = self.warning_display_state.count; + options.textarea_right_reserve = right_reserve; + options.separate_status_line = options.command_popup_placement + != crate::bottom_pane::CommandPopupPlacement::AboveComposer; + self.bottom_pane.as_renderable_with_options(options) } } diff --git a/codex-rs/tui/src/chatwidget/rendering_tests.rs b/codex-rs/tui/src/chatwidget/rendering_tests.rs index f2e58846d4..6a95ec466b 100644 --- a/codex-rs/tui/src/chatwidget/rendering_tests.rs +++ b/codex-rs/tui/src/chatwidget/rendering_tests.rs @@ -83,12 +83,10 @@ async fn owned_bottom_pane_preserves_draft_cursor_and_read_only_notice() { /*x*/ 0, /*y*/ 8, /*width*/ 60, /*height*/ 8, ); let render_bottom = |widget: &ChatWidget| { - let bottom = widget.bottom_pane_renderable( - /*footer*/ None, - crate::bottom_pane::CommandPopupPlacement::Overlay, - /*composer_gap*/ None, - /*working_tip*/ None, - ); + let bottom = widget.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions { + command_popup_placement: crate::bottom_pane::CommandPopupPlacement::Overlay, + ..Default::default() + }); let mut buffer = Buffer::empty(area); bottom.render(area, &mut buffer); (buffer, bottom.cursor_pos(area), bottom.cursor_style(area))