diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3c06113c..1d2c9690 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9,6 +9,7 @@ use crate::file_search::FileSearchManager; use crate::history_cell::HistoryCell; use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; +use crate::render::renderable::Renderable; use crate::resume_picker::ResumeSelection; use crate::tui; use crate::tui::TuiEvent; @@ -233,7 +234,7 @@ impl App { tui.draw( self.chat_widget.desired_height(tui.terminal.size()?.width), |frame| { - frame.render_widget_ref(&self.chat_widget, frame.area()); + self.chat_widget.render(frame.area(), frame.buffer); if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { frame.set_cursor_position((x, y)); } diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 140b5386..d1d434c0 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -260,10 +260,6 @@ impl BottomPaneView for ApprovalOverlay { self.enqueue_request(request); None } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.list.cursor_pos(area) - } } impl Renderable for ApprovalOverlay { @@ -274,6 +270,10 @@ impl Renderable for ApprovalOverlay { fn render(&self, area: Rect, buf: &mut Buffer) { self.list.render(area, buf); } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.list.cursor_pos(area) + } } struct ApprovalRequestState { diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 0271a7e1..499801cb 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -1,7 +1,6 @@ use crate::bottom_pane::ApprovalRequest; use crate::render::renderable::Renderable; use crossterm::event::KeyEvent; -use ratatui::layout::Rect; use super::CancellationEvent; @@ -27,11 +26,6 @@ pub(crate) trait BottomPaneView: Renderable { false } - /// Cursor position when this view is active. - fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> { - None - } - /// Try to handle approval request; return the original value if not /// consumed. fn try_consume_approval_request( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8691b5b1..61539b6e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -35,6 +35,9 @@ use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::prompt_args::prompt_argument_names; use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders; use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; +use crate::render::Insets; +use crate::render::RectExt; +use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; use crate::style::user_message_style; @@ -158,24 +161,6 @@ impl ChatComposer { this } - pub fn desired_height(&self, width: u16) -> u16 { - let footer_props = self.footer_props(); - let footer_hint_height = self - .custom_footer_height() - .unwrap_or_else(|| footer_height(footer_props)); - let footer_spacing = Self::footer_spacing(footer_hint_height); - let footer_total_height = footer_hint_height + footer_spacing; - const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; - self.textarea - .desired_height(width.saturating_sub(COLS_WITH_MARGIN)) - + 2 - + match &self.active_popup { - ActivePopup::None => footer_total_height, - ActivePopup::Command(c) => c.calculate_required_height(width), - ActivePopup::File(c) => c.calculate_required_height(), - } - } - fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -190,18 +175,9 @@ impl ChatComposer { ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), ActivePopup::None => Constraint::Max(footer_total_height), }; - let mut area = area; - if area.height > 1 { - area.height -= 1; - area.y += 1; - } let [composer_rect, popup_rect] = - Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area); - let mut textarea_rect = composer_rect; - textarea_rect.width = textarea_rect.width.saturating_sub( - LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ - ); - textarea_rect.x = textarea_rect.x.saturating_add(LIVE_PREFIX_COLS); + Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area); + let textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1)); [composer_rect, textarea_rect, popup_rect] } @@ -213,12 +189,6 @@ impl ChatComposer { } } - pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let [_, textarea_rect, _] = self.layout_areas(area); - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) - } - /// Returns true if the composer currently contains no user input. pub(crate) fn is_empty(&self) -> bool { self.textarea.is_empty() @@ -1541,8 +1511,32 @@ impl ChatComposer { } } -impl WidgetRef for ChatComposer { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { +impl Renderable for ChatComposer { + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let [_, textarea_rect, _] = self.layout_areas(area); + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) + } + + fn desired_height(&self, width: u16) -> u16 { + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let footer_total_height = footer_hint_height + footer_spacing; + const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; + self.textarea + .desired_height(width.saturating_sub(COLS_WITH_MARGIN)) + + 2 + + match &self.active_popup { + ActivePopup::None => footer_total_height, + ActivePopup::Command(c) => c.calculate_required_height(width), + ActivePopup::File(c) => c.calculate_required_height(), + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area); match &self.active_popup { ActivePopup::Command(popup) => { @@ -1591,16 +1585,15 @@ impl WidgetRef for ChatComposer { } } let style = user_message_style(); - let mut block_rect = composer_rect; - block_rect.y = composer_rect.y.saturating_sub(1); - block_rect.height = composer_rect.height.saturating_add(1); - Block::default().style(style).render_ref(block_rect, buf); - buf.set_span( - composer_rect.x, - composer_rect.y, - &"›".bold(), - composer_rect.width, - ); + Block::default().style(style).render_ref(composer_rect, buf); + if !textarea_rect.is_empty() { + buf.set_span( + textarea_rect.x - LIVE_PREFIX_COLS, + textarea_rect.y, + &"›".bold(), + textarea_rect.width, + ); + } let mut state = self.textarea_state.borrow_mut(); StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); @@ -1692,7 +1685,7 @@ mod tests { let area = Rect::new(0, 0, 40, 6); let mut buf = Buffer::empty(area); - composer.render_ref(area, &mut buf); + composer.render(area, &mut buf); let row_to_string = |y: u16| { let mut row = String::new(); @@ -1756,7 +1749,7 @@ mod tests { let height = footer_lines + footer_spacing + 8; let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal - .draw(|f| f.render_widget_ref(composer, f.area())) + .draw(|f| composer.render(f.area(), f.buffer_mut())) .unwrap(); insta::assert_snapshot!(name, terminal.backend()); } @@ -2276,7 +2269,7 @@ mod tests { } terminal - .draw(|f| f.render_widget_ref(composer, f.area())) + .draw(|f| composer.render(f.area(), f.buffer_mut())) .unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}")); insta::assert_snapshot!(name, terminal.backend()); @@ -2302,12 +2295,12 @@ mod tests { // Type "/mo" humanlike so paste-burst doesn’t interfere. type_chars_humanlike(&mut composer, &['/', 'm', 'o']); - let mut terminal = match Terminal::new(TestBackend::new(60, 4)) { + let mut terminal = match Terminal::new(TestBackend::new(60, 5)) { Ok(t) => t, Err(e) => panic!("Failed to create terminal: {e}"), }; terminal - .draw(|f| f.render_widget_ref(composer, f.area())) + .draw(|f| composer.render(f.area(), f.buffer_mut())) .unwrap_or_else(|e| panic!("Failed to draw composer: {e}")); // Visual snapshot should show the slash popup with /model as the first entry. diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index 54f474fb..e9f0ee69 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -103,26 +103,6 @@ impl BottomPaneView for CustomPromptView { self.textarea.insert_str(&pasted); true } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - if area.height < 2 || area.width <= 2 { - return None; - } - let text_area_height = self.input_height(area.width).saturating_sub(1); - if text_area_height == 0 { - return None; - } - let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 }; - let top_line_count = 1u16 + extra_offset; - let textarea_rect = Rect { - x: area.x.saturating_add(2), - y: area.y.saturating_add(top_line_count).saturating_add(1), - width: area.width.saturating_sub(2), - height: text_area_height, - }; - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) - } } impl Renderable for CustomPromptView { @@ -232,6 +212,26 @@ impl Renderable for CustomPromptView { ); } } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if area.height < 2 || area.width <= 2 { + return None; + } + let text_area_height = self.input_height(area.width).saturating_sub(1); + if text_area_height == 0 { + return None; + } + let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 }; + let top_line_count = 1u16 + extra_offset; + let textarea_rect = Rect { + x: area.x.saturating_add(2), + y: area.y.saturating_add(top_line_count).saturating_add(1), + width: area.width.saturating_sub(2), + height: text_area_height, + }; + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) + } } impl CustomPromptView { diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 0d42c0be..a8476df0 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -163,6 +163,12 @@ impl BottomPaneView for FeedbackNoteView { self.textarea.insert_str(&pasted); true } +} + +impl Renderable for FeedbackNoteView { + fn desired_height(&self, width: u16) -> u16 { + 1u16 + self.input_height(width) + 3u16 + } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { if area.height < 2 || area.width <= 2 { @@ -182,12 +188,6 @@ impl BottomPaneView for FeedbackNoteView { let state = *self.textarea_state.borrow(); self.textarea.cursor_pos_with_state(textarea_rect, state) } -} - -impl Renderable for FeedbackNoteView { - fn desired_height(&self, width: u16) -> u16 { - 1u16 + self.input_height(width) + 3u16 - } fn render(&self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 38a5ecb6..685c71c8 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -3,19 +3,16 @@ use std::path::PathBuf; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::queued_user_messages::QueuedUserMessages; -use crate::render::Insets; -use crate::render::RectExt; -use crate::render::renderable::Renderable as _; +use crate::render::renderable::FlexRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; use codex_file_search::FileMatch; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; use ratatui::layout::Rect; -use ratatui::widgets::WidgetRef; use std::time::Duration; mod approval_overlay; @@ -126,77 +123,6 @@ impl BottomPane { self.request_redraw(); } - pub fn desired_height(&self, width: u16) -> u16 { - let top_margin = 1; - - // Base height depends on whether a modal/overlay is active. - let base = match self.active_view().as_ref() { - Some(view) => view.desired_height(width), - None => { - let status_height = self - .status - .as_ref() - .map_or(0, |status| status.desired_height(width)); - let queue_height = self.queued_user_messages.desired_height(width); - let spacing_height = if status_height == 0 && queue_height == 0 { - 0 - } else { - 1 - }; - self.composer - .desired_height(width) - .saturating_add(spacing_height) - .saturating_add(status_height) - .saturating_add(queue_height) - } - }; - // Account for bottom padding rows. Top spacing is handled in layout(). - base.saturating_add(top_margin) - } - - fn layout(&self, area: Rect) -> [Rect; 2] { - // At small heights, bottom pane takes the entire height. - let top_margin = if area.height <= 1 { 0 } else { 1 }; - - let area = area.inset(Insets::tlbr(top_margin, 0, 0, 0)); - if self.active_view().is_some() { - return [Rect::ZERO, area]; - } - let has_queue = !self.queued_user_messages.messages.is_empty(); - let mut status_height = self - .status - .as_ref() - .map_or(0, |status| status.desired_height(area.width)) - .min(area.height.saturating_sub(1)); - if has_queue && status_height > 1 { - status_height = status_height.saturating_sub(1); - } - let combined_height = status_height - .saturating_add(self.queued_user_messages.desired_height(area.width)) - .min(area.height.saturating_sub(1)); - - let [status_area, _, content_area] = Layout::vertical([ - Constraint::Length(combined_height), - Constraint::Length(if combined_height == 0 { 0 } else { 1 }), - Constraint::Min(1), - ]) - .areas(area); - [status_area, content_area] - } - - pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - // Hide the cursor whenever an overlay view is active (e.g. the - // status indicator shown while a task is running, or approval modal). - // In these states the textarea is not interactable, so we should not - // show its caret. - let [_, content] = self.layout(area); - if let Some(view) = self.active_view() { - view.cursor_pos(content) - } else { - self.composer.cursor_pos(content) - } - } - /// Forward a key event to the active view or the composer. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { // If a modal/view is active, handle it here; otherwise forward to composer. @@ -540,39 +466,36 @@ impl BottomPane { pub(crate) fn take_recent_submission_images(&mut self) -> Vec { self.composer.take_recent_submission_images() } + + fn as_renderable(&'_ self) -> RenderableItem<'_> { + if let Some(view) = self.active_view() { + RenderableItem::Borrowed(view) + } else { + let mut flex = FlexRenderable::new(); + if let Some(status) = &self.status { + flex.push(0, RenderableItem::Borrowed(status)); + } + flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); + if self.status.is_some() || !self.queued_user_messages.messages.is_empty() { + flex.push(0, RenderableItem::Owned("".into())); + } + let mut flex2 = FlexRenderable::new(); + flex2.push(1, RenderableItem::Owned(flex.into())); + flex2.push(0, RenderableItem::Borrowed(&self.composer)); + RenderableItem::Owned(Box::new(flex2)) + } + } } -impl WidgetRef for &BottomPane { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let [top_area, content_area] = self.layout(area); - - // When a modal view is active, it owns the whole content area. - if let Some(view) = self.active_view() { - view.render(content_area, buf); - } else { - let status_height = self - .status - .as_ref() - .map(|status| status.desired_height(top_area.width).min(top_area.height)) - .unwrap_or(0); - if let Some(status) = &self.status - && status_height > 0 - { - status.render_ref(top_area, buf); - } - - let queue_area = Rect { - x: top_area.x, - y: top_area.y.saturating_add(status_height), - width: top_area.width, - height: top_area.height.saturating_sub(status_height), - }; - if queue_area.height > 0 { - self.queued_user_messages.render(queue_area, buf); - } - - self.composer.render_ref(content_area, buf); - } +impl Renderable for BottomPane { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.as_renderable().render(area, buf); + } + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable().desired_height(width) + } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.as_renderable().cursor_pos(area) } } @@ -599,7 +522,7 @@ mod tests { fn render_snapshot(pane: &BottomPane, area: Rect) -> String { let mut buf = Buffer::empty(area); - (&pane).render_ref(area, &mut buf); + pane.render(area, &mut buf); snapshot_buffer(&buf) } @@ -651,7 +574,7 @@ mod tests { // Render and verify the top row does not include an overlay. let area = Rect::new(0, 0, 60, 6); let mut buf = Buffer::empty(area); - (&pane).render_ref(area, &mut buf); + pane.render(area, &mut buf); let mut r0 = String::new(); for x in 0..area.width { @@ -665,7 +588,7 @@ mod tests { #[test] fn composer_shown_after_denied_while_task_running() { - let (tx_raw, rx) = unbounded_channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, @@ -700,14 +623,14 @@ mod tests { std::thread::sleep(Duration::from_millis(120)); let area = Rect::new(0, 0, 40, 6); let mut buf = Buffer::empty(area); - (&pane).render_ref(area, &mut buf); - let mut row1 = String::new(); + pane.render(area, &mut buf); + let mut row0 = String::new(); for x in 0..area.width { - row1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' ')); + row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); } assert!( - row1.contains("Working"), - "expected Working header after denial on row 1: {row1:?}" + row0.contains("Working"), + "expected Working header after denial on row 0: {row0:?}" ); // Composer placeholder should be visible somewhere below. @@ -726,9 +649,6 @@ mod tests { found_composer, "expected composer visible under status line" ); - - // Drain the channel to avoid unused warnings. - drop(rx); } #[test] @@ -750,16 +670,10 @@ mod tests { // Use a height that allows the status line to be visible above the composer. let area = Rect::new(0, 0, 40, 6); let mut buf = Buffer::empty(area); - (&pane).render_ref(area, &mut buf); + pane.render(area, &mut buf); - let mut row0 = String::new(); - for x in 0..area.width { - row0.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' ')); - } - assert!( - row0.contains("Working"), - "expected Working header: {row0:?}" - ); + let bufs = snapshot_buffer(&buf); + assert!(bufs.contains("• Working"), "expected Working header"); } #[test] @@ -791,36 +705,6 @@ mod tests { ); } - #[test] - fn status_hidden_when_height_too_small() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - }); - - pane.set_task_running(true); - - // Height=2 → composer takes the full space; status collapses when there is no room. - let area2 = Rect::new(0, 0, 20, 2); - assert_snapshot!( - "status_hidden_when_height_too_small_height_2", - render_snapshot(&pane, area2) - ); - - // Height=1 → no padding; single row is the composer (status hidden). - let area1 = Rect::new(0, 0, 20, 1); - assert_snapshot!( - "status_hidden_when_height_too_small_height_1", - render_snapshot(&pane, area1) - ); - } - #[test] fn queued_messages_visible_when_status_hidden_snapshot() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap index 42532620..661e82e3 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -4,5 +4,6 @@ expression: terminal.backend() --- " " "› /mo " +" " " /model choose what model and reasoning effort to use " " /mention mention a file " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap index 7abd3baa..123a5eb3 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -2,7 +2,6 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- - ↳ Queued follow-up question ⌥ + ↑ edit diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap index ecf92583..86e3da45 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap @@ -2,7 +2,6 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- - • Working (0s • esc to interru diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap index aa36a9d4..27df671e 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -2,7 +2,6 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- - • Working (0s • esc to interrupt) ↳ Queued follow-up question ⌥ + ↑ edit diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap deleted file mode 100644 index 964bf7ed..00000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area2)" ---- - -› Ask Codex to do a diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5239c66e..f4235d52 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -54,15 +54,11 @@ use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use rand::Rng; use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; -use ratatui::widgets::Widget; -use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use tokio::sync::mpsc::UnboundedSender; use tracing::debug; @@ -92,8 +88,12 @@ use crate::history_cell::McpToolCallCell; use crate::markdown::append_markdown; #[cfg(target_os = "windows")] use crate::onboarding::WSL_INSTRUCTIONS; +use crate::render::Insets; use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableExt; +use crate::render::renderable::RenderableItem; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; use crate::text_formatting::truncate_text; @@ -293,6 +293,15 @@ impl From for UserMessage { } } +impl From<&str> for UserMessage { + fn from(text: &str) -> Self { + Self { + text: text.to_string(), + image_paths: Vec::new(), + } + } +} + fn create_initial_user_message(text: String, image_paths: Vec) -> Option { if text.is_empty() && image_paths.is_empty() { None @@ -951,27 +960,6 @@ impl ChatWidget { } } - fn layout_areas(&self, area: Rect) -> [Rect; 3] { - let bottom_min = self.bottom_pane.desired_height(area.width).min(area.height); - let remaining = area.height.saturating_sub(bottom_min); - - let active_desired = self - .active_cell - .as_ref() - .map_or(0, |c| c.desired_height(area.width) + 1); - let active_height = active_desired.min(remaining); - // Note: no header area; remaining is not used beyond computing active height. - - let header_height = 0u16; - - Layout::vertical([ - Constraint::Length(header_height), - Constraint::Length(active_height), - Constraint::Min(bottom_min), - ]) - .areas(area) - } - pub(crate) fn new( common: ChatWidgetInit, conversation_manager: Arc, @@ -1100,14 +1088,6 @@ impl ChatWidget { } } - pub fn desired_height(&self, width: u16) -> u16 { - self.bottom_pane.desired_height(width) - + self - .active_cell - .as_ref() - .map_or(0, |c| c.desired_height(width) + 1) - } - pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { @@ -1158,12 +1138,7 @@ impl ChatWidget { text, image_paths: self.bottom_pane.take_recent_submission_images(), }; - if self.bottom_pane.is_task_running() { - self.queued_user_messages.push_back(user_message); - self.refresh_queued_user_messages(); - } else { - self.submit_user_message(user_message); - } + self.queue_user_message(user_message); } InputResult::Command(cmd) => { self.dispatch_command(cmd); @@ -1220,7 +1195,7 @@ impl ChatWidget { return; } const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - self.submit_text_message(INIT_PROMPT.to_string()); + self.submit_user_message(INIT_PROMPT.to_string().into()); } SlashCommand::Compact => { self.clear_token_usage(); @@ -1368,6 +1343,15 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } + fn queue_user_message(&mut self, user_message: UserMessage) { + if self.bottom_pane.is_task_running() { + self.queued_user_messages.push_back(user_message); + self.refresh_queued_user_messages(); + } else { + self.submit_user_message(user_message); + } + } + fn submit_user_message(&mut self, user_message: UserMessage) { let UserMessage { text, image_paths } = user_message; if text.is_empty() && image_paths.is_empty() { @@ -2322,16 +2306,6 @@ impl ChatWidget { self.bottom_pane.show_view(Box::new(view)); } - /// Programmatically submit a user text message as if typed in the - /// composer. The text will be added to conversation history and sent to - /// the agent. - pub(crate) fn submit_text_message(&mut self, text: String) { - if text.is_empty() { - return; - } - self.submit_user_message(text.into()); - } - pub(crate) fn token_usage(&self) -> TokenUsage { self.token_info .as_ref() @@ -2357,30 +2331,34 @@ impl ChatWidget { self.token_info = None; } - pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let [_, _, bottom_pane_area] = self.layout_areas(area); - self.bottom_pane.cursor_pos(bottom_pane_area) + fn as_renderable(&self) -> RenderableItem<'_> { + let active_cell_renderable = match &self.active_cell { + Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr(1, 0, 0, 0)), + None => RenderableItem::Owned(Box::new(())), + }; + let mut flex = FlexRenderable::new(); + flex.push(1, active_cell_renderable); + flex.push( + 0, + RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(1, 0, 0, 0)), + ); + RenderableItem::Owned(Box::new(flex)) } } -impl WidgetRef for &ChatWidget { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let [_, active_cell_area, bottom_pane_area] = self.layout_areas(area); - (&self.bottom_pane).render(bottom_pane_area, buf); - if !active_cell_area.is_empty() - && let Some(cell) = &self.active_cell - { - let mut area = active_cell_area; - area.y = area.y.saturating_add(1); - area.height = area.height.saturating_sub(1); - if let Some(exec) = cell.as_any().downcast_ref::() { - exec.render_ref(area, buf); - } else if let Some(tool) = cell.as_any().downcast_ref::() { - tool.render_ref(area, buf); - } - } +impl Renderable for ChatWidget { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.as_renderable().render(area, buf); self.last_rendered_width.set(Some(area.width as usize)); } + + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable().desired_height(width) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.as_renderable().cursor_pos(area) + } } enum Notification { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap index 51f7901d..1e73a237 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -"› Ask Codex to do anything " +" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap index fb07268d..7a04b0ef 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap @@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- " " -"› Ask Codex to do anything " +" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap index adb00d4b..4487d065 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap @@ -1,8 +1,7 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1470 expression: terminal.backend() --- " " " " -"› Ask Codex to do anything " +" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap index 51f7901d..1e73a237 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -"› Ask Codex to do anything " +" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap index 4e1b74a2..7a04b0ef 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap @@ -1,7 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1500 expression: terminal.backend() --- " " -"› Ask Codex to do anything " +" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap index 45d69ab1..4487d065 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap @@ -1,8 +1,7 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1500 expression: terminal.backend() --- " " -"• Thinking (0s • esc to interrupt) " -"› Ask Codex to do anything " +" " +" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap new file mode 100644 index 00000000..6d9aa515 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- +• Working (0s • esc to interrupt) + ↳ Hello, world! 0 + ↳ Hello, world! 1 + ↳ Hello, world! 2 + ↳ Hello, world! 3 + ↳ Hello, world! 4 + ↳ Hello, world! 5 + ↳ Hello, world! 6 + ↳ Hello, world! 7 + ↳ Hello, world! 8 + ↳ Hello, world! 9 + ↳ Hello, world! 10 + ↳ Hello, world! 11 + ↳ Hello, world! 12 + ↳ Hello, world! 13 + ↳ Hello, world! 14 + ↳ Hello, world! 15 + ↳ Hello, world! 16 + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 135dd249..50ff0b25 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -424,7 +424,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { // The approval modal should display the command snippet for user confirmation. let area = Rect::new(0, 0, 80, chat.desired_height(80)); let mut buf = ratatui::buffer::Buffer::empty(area); - (&chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); assert_snapshot!("exec_approval_modal_exec", format!("{buf:?}")); // Approve via keyboard and verify a concise decision history line is added @@ -465,7 +465,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { let area = Rect::new(0, 0, 80, chat.desired_height(80)); let mut buf = ratatui::buffer::Buffer::empty(area); - (&chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); let mut saw_first_line = false; for y in 0..area.height { let mut row = String::new(); @@ -1039,7 +1039,7 @@ fn review_commit_picker_shows_subjects_without_timestamps() { let height = chat.desired_height(width); let area = ratatui::layout::Rect::new(0, 0, width, height); let mut buf = ratatui::buffer::Buffer::empty(area); - (&chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); let mut blob = String::new(); for y in 0..area.height { @@ -1267,7 +1267,7 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String { let height = chat.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); - (chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); for y in 0..area.height { let mut row = String::new(); for x in 0..area.width { @@ -1289,7 +1289,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { let height = chat.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); - (chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); let mut lines: Vec = (0..area.height) .map(|row| { @@ -1701,7 +1701,7 @@ fn approval_modal_exec_snapshot() { terminal.set_viewport_area(viewport); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw approval modal"); assert!( terminal @@ -1742,7 +1742,7 @@ fn approval_modal_exec_without_reason_snapshot() { ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal"); terminal.set_viewport_area(Rect::new(0, 0, 80, height)); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw approval modal (no reason)"); assert_snapshot!( "approval_modal_exec_no_reason", @@ -1781,7 +1781,7 @@ fn approval_modal_patch_snapshot() { ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal"); terminal.set_viewport_area(Rect::new(0, 0, 80, height)); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw patch approval modal"); assert_snapshot!( "approval_modal_patch", @@ -1873,7 +1873,7 @@ fn ui_snapshots_small_heights_idle() { let name = format!("chat_small_idle_h{h}"); let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw chat idle"); assert_snapshot!(name, terminal.backend()); } @@ -1903,7 +1903,7 @@ fn ui_snapshots_small_heights_task_running() { let name = format!("chat_small_running_h{h}"); let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw chat running"); assert_snapshot!(name, terminal.backend()); } @@ -1953,7 +1953,7 @@ fn status_widget_and_approval_modal_snapshot() { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) .expect("create terminal"); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw status + approval modal"); assert_snapshot!("status_widget_and_approval_modal", terminal.backend()); } @@ -1982,7 +1982,7 @@ fn status_widget_active_snapshot() { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) .expect("create terminal"); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw status widget"); assert_snapshot!("status_widget_active", terminal.backend()); } @@ -2017,7 +2017,7 @@ fn apply_patch_events_emit_history_cells() { let area = Rect::new(0, 0, 80, chat.desired_height(80)); let mut buf = ratatui::buffer::Buffer::empty(area); - (&chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); let mut saw_summary = false; for y in 0..area.height { let mut row = String::new(); @@ -2304,7 +2304,7 @@ fn apply_patch_untrusted_shows_approval_modal() { // Render and ensure the approval modal title is present let area = Rect::new(0, 0, 80, 12); let mut buf = Buffer::empty(area); - (&chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); let mut contains_title = false; for y in 0..area.height { @@ -2358,7 +2358,7 @@ fn apply_patch_request_shows_diff_summary() { let area = Rect::new(0, 0, 80, chat.desired_height(80)); let mut buf = ratatui::buffer::Buffer::empty(area); - (&chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); let mut saw_header = false; let mut saw_line1 = false; @@ -2683,7 +2683,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() { } term.draw(|f| { - (&chat).render_ref(f.area(), f.buffer_mut()); + chat.render(f.area(), f.buffer_mut()); }) .unwrap(); @@ -2781,3 +2781,28 @@ printf 'fenced within fenced\n' assert_snapshot!(term.backend().vt100().screen().contents()); } + +#[test] +fn chatwidget_tall() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + for i in 0..30 { + chat.queue_user_message(format!("Hello, world! {i}").into()); + } + let width: u16 = 80; + let height: u16 = 24; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!(term.backend().vt100().screen().contents()); +} diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 665fad15..24c5be59 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -67,7 +67,7 @@ impl From for Box { rows.push(Box::new(path)); rows.push(Box::new(RtLine::from(""))); rows.push(Box::new(InsetRenderable::new( - row.change, + Box::new(row.change) as Box, Insets::tlbr(0, 2, 0, 0), ))); } diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 4f21f96e..3ccc0527 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -19,9 +19,6 @@ use itertools::Itertools; use ratatui::prelude::*; use ratatui::style::Modifier; use ratatui::style::Stylize; -use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; -use ratatui::widgets::Wrap; use textwrap::WordSplitter; use unicode_width::UnicodeWidthStr; @@ -205,31 +202,6 @@ impl HistoryCell for ExecCell { } } -impl WidgetRef for &ExecCell { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 { - return; - } - let content_area = Rect { - x: area.x, - y: area.y, - width: area.width, - height: area.height, - }; - let lines = self.display_lines(area.width); - let max_rows = area.height as usize; - let rendered = if lines.len() > max_rows { - lines[lines.len() - max_rows..].to_vec() - } else { - lines - }; - - Paragraph::new(Text::from(rendered)) - .wrap(Wrap { trim: false }) - .render(content_area, buf); - } -} - impl ExecCell { fn exploring_display_lines(&self, width: u16) -> Vec> { let mut out: Vec> = Vec::new(); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 73d61dc4..27f8220a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -11,6 +11,7 @@ use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; +use crate::render::renderable::Renderable; use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; @@ -45,7 +46,6 @@ use ratatui::style::Style; use ratatui::style::Styled; use ratatui::style::Stylize; use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use std::any::Any; use std::collections::HashMap; @@ -99,6 +99,24 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { } } +impl Renderable for Box { + fn render(&self, area: Rect, buf: &mut Buffer) { + let lines = self.display_lines(area.width); + let y = if area.height == 0 { + 0 + } else { + let overflow = lines.len().saturating_sub(usize::from(area.height)); + u16::try_from(overflow).unwrap_or(u16::MAX) + }; + Paragraph::new(Text::from(lines)) + .scroll((y, 0)) + .render(area, buf); + } + fn desired_height(&self, width: u16) -> u16 { + HistoryCell::desired_height(self.as_ref(), width) + } +} + impl dyn HistoryCell { pub(crate) fn as_any(&self) -> &dyn Any { self @@ -929,23 +947,6 @@ impl HistoryCell for McpToolCallCell { } } -impl WidgetRef for &McpToolCallCell { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 { - return; - } - let lines = self.display_lines(area.width); - let max_rows = area.height as usize; - let rendered = if lines.len() > max_rows { - lines[lines.len() - max_rows..].to_vec() - } else { - lines - }; - - Text::from(rendered).render(area, buf); - } -} - pub(crate) fn new_active_mcp_tool_call( call_id: String, invocation: McpInvocation, diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs index 457e37fe..2a80c087 100644 --- a/codex-rs/tui/src/public_widgets/composer_input.rs +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -7,13 +7,13 @@ use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::widgets::WidgetRef; use std::time::Duration; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ChatComposer; use crate::bottom_pane::InputResult; +use crate::render::renderable::Renderable; /// Action returned from feeding a key event into the ComposerInput. pub enum ComposerAction { @@ -94,7 +94,7 @@ impl ComposerInput { /// Render the input into the provided buffer at `area`. pub fn render_ref(&self, area: Rect, buf: &mut Buffer) { - WidgetRef::render_ref(&self.inner, area, buf); + self.inner.render(area, buf); } /// Return true if a paste-burst detection is currently active. diff --git a/codex-rs/tui/src/render/renderable.rs b/codex-rs/tui/src/render/renderable.rs index df064495..8581468c 100644 --- a/codex-rs/tui/src/render/renderable.rs +++ b/codex-rs/tui/src/render/renderable.rs @@ -13,9 +13,49 @@ use crate::render::RectExt as _; pub trait Renderable { fn render(&self, area: Rect, buf: &mut Buffer); fn desired_height(&self, width: u16) -> u16; + fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> { + None + } } -impl From for Box { +pub enum RenderableItem<'a> { + Owned(Box), + Borrowed(&'a dyn Renderable), +} + +impl<'a> Renderable for RenderableItem<'a> { + fn render(&self, area: Rect, buf: &mut Buffer) { + match self { + RenderableItem::Owned(child) => child.render(area, buf), + RenderableItem::Borrowed(child) => child.render(area, buf), + } + } + + fn desired_height(&self, width: u16) -> u16 { + match self { + RenderableItem::Owned(child) => child.desired_height(width), + RenderableItem::Borrowed(child) => child.desired_height(width), + } + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + match self { + RenderableItem::Owned(child) => child.cursor_pos(area), + RenderableItem::Borrowed(child) => child.cursor_pos(area), + } + } +} + +impl<'a> From> for RenderableItem<'a> { + fn from(value: Box) -> Self { + RenderableItem::Owned(value) + } +} + +impl<'a, R> From for Box +where + R: Renderable + 'a, +{ fn from(value: R) -> Self { Box::new(value) } @@ -98,11 +138,11 @@ impl Renderable for Arc { } } -pub struct ColumnRenderable { - children: Vec>, +pub struct ColumnRenderable<'a> { + children: Vec>, } -impl Renderable for ColumnRenderable { +impl Renderable for ColumnRenderable<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { let mut y = area.y; for child in &self.children { @@ -121,29 +161,166 @@ impl Renderable for ColumnRenderable { .map(|child| child.desired_height(width)) .sum() } + + /// Returns the cursor position of the first child that has a cursor position, offset by the + /// child's position in the column. + /// + /// It is generally assumed that either zero or one child will have a cursor position. + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let mut y = area.y; + for child in &self.children { + let child_area = Rect::new(area.x, y, area.width, child.desired_height(area.width)) + .intersection(area); + if !child_area.is_empty() + && let Some((px, py)) = child.cursor_pos(child_area) + { + return Some((px, py)); + } + y += child_area.height; + } + None + } } -impl ColumnRenderable { +impl<'a> ColumnRenderable<'a> { pub fn new() -> Self { - Self::with(vec![]) + Self { children: vec![] } } - pub fn with(children: impl IntoIterator>) -> Self { + pub fn with(children: I) -> Self + where + I: IntoIterator, + T: Into>, + { Self { - children: children.into_iter().collect(), + children: children.into_iter().map(Into::into).collect(), } } - pub fn push(&mut self, child: impl Into>) { - self.children.push(child.into()); + pub fn push(&mut self, child: impl Into>) { + self.children.push(RenderableItem::Owned(child.into())); + } + + #[allow(dead_code)] + pub fn push_ref(&mut self, child: &'a R) + where + R: Renderable + 'a, + { + self.children + .push(RenderableItem::Borrowed(child as &'a dyn Renderable)); } } -pub struct RowRenderable { - children: Vec<(u16, Box)>, +pub struct FlexChild<'a> { + flex: i32, + child: RenderableItem<'a>, } -impl Renderable for RowRenderable { +pub struct FlexRenderable<'a> { + children: Vec>, +} + +/// Lays out children in a column, with the ability to specify a flex factor for each child. +/// +/// Children with flex factor > 0 will be allocated the remaining space after the non-flex children, +/// proportional to the flex factor. +impl<'a> FlexRenderable<'a> { + pub fn new() -> Self { + Self { children: vec![] } + } + + pub fn push(&mut self, flex: i32, child: impl Into>) { + self.children.push(FlexChild { + flex, + child: child.into(), + }); + } + + /// Loosely inspired by Flutter's Flex widget. + /// + /// Ref https://github.com/flutter/flutter/blob/3fd81edbf1e015221e143c92b2664f4371bdc04a/packages/flutter/lib/src/rendering/flex.dart#L1205-L1209 + fn allocate(&self, area: Rect) -> Vec { + let mut allocated_rects = Vec::with_capacity(self.children.len()); + let mut child_sizes = vec![0; self.children.len()]; + let mut allocated_size = 0; + let mut total_flex = 0; + + // 1. Allocate space to non-flex children. + let max_size = area.height; + let mut last_flex_child_idx = 0; + for (i, FlexChild { flex, child }) in self.children.iter().enumerate() { + if *flex > 0 { + total_flex += flex; + last_flex_child_idx = i; + } else { + child_sizes[i] = child + .desired_height(area.width) + .min(max_size.saturating_sub(allocated_size)); + allocated_size += child_sizes[i]; + } + } + let free_space = max_size.saturating_sub(allocated_size); + // 2. Allocate space to flex children, proportional to their flex factor. + let mut allocated_flex_space = 0; + if total_flex > 0 { + let space_per_flex = free_space / total_flex as u16; + for (i, FlexChild { flex, child }) in self.children.iter().enumerate() { + if *flex > 0 { + // Last flex child gets all the remaining space, to prevent a rounding error + // from not allocating all the space. + let max_child_extent = if i == last_flex_child_idx { + free_space - allocated_flex_space + } else { + space_per_flex * *flex as u16 + }; + let child_size = child.desired_height(area.width).min(max_child_extent); + child_sizes[i] = child_size; + allocated_size += child_size; + allocated_flex_space += child_size; + } + } + } + + let mut y = area.y; + for size in child_sizes { + let child_area = Rect::new(area.x, y, area.width, size); + allocated_rects.push(child_area); + y += child_area.height; + } + allocated_rects + } +} + +impl<'a> Renderable for FlexRenderable<'a> { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.allocate(area) + .into_iter() + .zip(self.children.iter()) + .for_each(|(rect, child)| { + child.child.render(rect, buf); + }); + } + + fn desired_height(&self, width: u16) -> u16 { + self.allocate(Rect::new(0, 0, width, u16::MAX)) + .last() + .map(|rect| rect.bottom()) + .unwrap_or(0) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.allocate(area) + .into_iter() + .zip(self.children.iter()) + .find_map(|(rect, child)| child.child.cursor_pos(rect)) + } +} + +pub struct RowRenderable<'a> { + children: Vec<(u16, RenderableItem<'a>)>, +} + +impl Renderable for RowRenderable<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { let mut x = area.x; for (width, child) in &self.children { @@ -172,24 +349,49 @@ impl Renderable for RowRenderable { } max_height } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let mut x = area.x; + for (width, child) in &self.children { + let available_width = area.width.saturating_sub(x - area.x); + let child_area = Rect::new(x, area.y, (*width).min(available_width), area.height); + if !child_area.is_empty() + && let Some(pos) = child.cursor_pos(child_area) + { + return Some(pos); + } + x = x.saturating_add(*width); + } + None + } } -impl RowRenderable { +impl<'a> RowRenderable<'a> { pub fn new() -> Self { Self { children: vec![] } } pub fn push(&mut self, width: u16, child: impl Into>) { - self.children.push((width, child.into())); + self.children + .push((width, RenderableItem::Owned(child.into()))); + } + + #[allow(dead_code)] + pub fn push_ref(&mut self, width: u16, child: &'a R) + where + R: Renderable + 'a, + { + self.children + .push((width, RenderableItem::Borrowed(child as &'a dyn Renderable))); } } -pub struct InsetRenderable { - child: Box, +pub struct InsetRenderable<'a> { + child: RenderableItem<'a>, insets: Insets, } -impl Renderable for InsetRenderable { +impl<'a> Renderable for InsetRenderable<'a> { fn render(&self, area: Rect, buf: &mut Buffer) { self.child.render(area.inset(self.insets), buf); } @@ -199,10 +401,13 @@ impl Renderable for InsetRenderable { + self.insets.top + self.insets.bottom } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.child.cursor_pos(area.inset(self.insets)) + } } -impl InsetRenderable { - pub fn new(child: impl Into>, insets: Insets) -> Self { +impl<'a> InsetRenderable<'a> { + pub fn new(child: impl Into>, insets: Insets) -> Self { Self { child: child.into(), insets, @@ -210,15 +415,17 @@ impl InsetRenderable { } } -pub trait RenderableExt { - fn inset(self, insets: Insets) -> Box; +pub trait RenderableExt<'a> { + fn inset(self, insets: Insets) -> RenderableItem<'a>; } -impl>> RenderableExt for R { - fn inset(self, insets: Insets) -> Box { - Box::new(InsetRenderable { - child: self.into(), - insets, - }) +impl<'a, R> RenderableExt<'a> for R +where + R: Renderable + 'a, +{ + fn inset(self, insets: Insets) -> RenderableItem<'a> { + let child: RenderableItem<'a> = + RenderableItem::Owned(Box::new(self) as Box); + RenderableItem::Owned(Box::new(InsetRenderable { child, insets })) } } diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 73692295..ea7627a4 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -16,6 +16,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::exec_cell::spinner; use crate::key_hint; +use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; @@ -62,10 +63,6 @@ impl StatusIndicatorWidget { } } - pub fn desired_height(&self, _width: u16) -> u16 { - 1 - } - pub(crate) fn interrupt(&self) { self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); } @@ -75,15 +72,15 @@ impl StatusIndicatorWidget { self.header = header; } - pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { - self.show_interrupt_hint = visible; - } - #[cfg(test)] pub(crate) fn header(&self) -> &str { &self.header } + pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { + self.show_interrupt_hint = visible; + } + #[cfg(test)] pub(crate) fn interrupt_hint_visible(&self) -> bool { self.show_interrupt_hint @@ -131,8 +128,12 @@ impl StatusIndicatorWidget { } } -impl WidgetRef for StatusIndicatorWidget { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { +impl Renderable for StatusIndicatorWidget { + fn desired_height(&self, _width: u16) -> u16 { + 1 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { if area.is_empty() { return; } @@ -200,7 +201,7 @@ mod tests { // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal"); terminal - .draw(|f| w.render_ref(f.area(), f.buffer_mut())) + .draw(|f| w.render(f.area(), f.buffer_mut())) .expect("draw"); insta::assert_snapshot!(terminal.backend()); } @@ -214,7 +215,7 @@ mod tests { // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal"); terminal - .draw(|f| w.render_ref(f.area(), f.buffer_mut())) + .draw(|f| w.render(f.area(), f.buffer_mut())) .expect("draw"); insta::assert_snapshot!(terminal.backend()); }