diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c23709fe..38a5ecb6 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -2,6 +2,10 @@ 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::tui::FrameRequester; use bottom_pane_view::BottomPaneView; use codex_file_search::FileMatch; @@ -32,6 +36,7 @@ pub(crate) use feedback_view::feedback_selection_params; pub(crate) use feedback_view::feedback_upload_consent_params; mod paste_burst; pub mod popup_consts; +mod queued_user_messages; mod scroll_state; mod selection_popup_common; mod textarea; @@ -70,8 +75,8 @@ pub(crate) struct BottomPane { /// Inline status indicator shown above the composer while a task is running. status: Option, - /// Queued user messages to show under the status indicator. - queued_user_messages: Vec, + /// Queued user messages to show above the composer while a turn is running. + queued_user_messages: QueuedUserMessages, context_window_percent: Option, } @@ -85,7 +90,6 @@ pub(crate) struct BottomPaneParams { } impl BottomPane { - const BOTTOM_PAD_LINES: u16 = 0; pub fn new(params: BottomPaneParams) -> Self { let enhanced_keys_supported = params.enhanced_keys_supported; Self { @@ -103,7 +107,7 @@ impl BottomPane { is_task_running: false, ctrl_c_quit_hint: false, status: None, - queued_user_messages: Vec::new(), + queued_user_messages: QueuedUserMessages::new(), esc_backtrack_hint: false, context_window_percent: None, } @@ -123,49 +127,61 @@ impl BottomPane { } pub fn desired_height(&self, width: u16) -> u16 { - // Always reserve one blank row above the pane for visual spacing. 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 => self.composer.desired_height(width).saturating_add( - self.status - .as_ref() - .map_or(0, |status| status.desired_height(width)), - ), - }; - // Account for bottom padding rows. Top spacing is handled in layout(). - base.saturating_add(Self::BOTTOM_PAD_LINES) - .saturating_add(top_margin) - } - - fn layout(&self, area: Rect) -> [Rect; 2] { - // At small heights, bottom pane takes the entire height. - let (top_margin, bottom_margin) = if area.height <= BottomPane::BOTTOM_PAD_LINES + 1 { - (0, 0) - } else { - (1, BottomPane::BOTTOM_PAD_LINES) - }; - - let area = Rect { - x: area.x, - y: area.y + top_margin, - width: area.width, - height: area.height - top_margin - bottom_margin, - }; - match self.active_view() { - Some(_) => [Rect::ZERO, area], None => { let status_height = self .status .as_ref() - .map_or(0, |status| status.desired_height(area.width)) - .min(area.height.saturating_sub(1)); - - Layout::vertical([Constraint::Max(status_height), Constraint::Min(1)]).areas(area) + .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)> { @@ -349,7 +365,6 @@ impl BottomPane { } if let Some(status) = self.status.as_mut() { status.set_interrupt_hint_visible(true); - status.set_queued_messages(self.queued_user_messages.clone()); } self.request_redraw(); } else { @@ -398,12 +413,9 @@ impl BottomPane { self.push_view(Box::new(view)); } - /// Update the queued messages shown under the status header. + /// Update the queued messages preview shown above the composer. pub(crate) fn set_queued_user_messages(&mut self, queued: Vec) { - self.queued_user_messages = queued.clone(); - if let Some(status) = self.status.as_mut() { - status.set_queued_messages(queued); - } + self.queued_user_messages.messages = queued; self.request_redraw(); } @@ -532,20 +544,34 @@ impl BottomPane { impl WidgetRef for &BottomPane { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let [status_area, content] = self.layout(area); + 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, buf); + view.render(content_area, buf); } else { - // No active modal: - // If a status indicator is active, render it above the composer. - if let Some(status) = &self.status { - status.render_ref(status_area, buf); + 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); } - // Render the composer in the remaining area. - self.composer.render_ref(content, 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); } } } @@ -794,4 +820,55 @@ mod tests { render_snapshot(&pane, area1) ); } + + #[test] + fn queued_messages_visible_when_status_hidden_snapshot() { + 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); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + pane.hide_status_indicator(); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "queued_messages_visible_when_status_hidden_snapshot", + render_snapshot(&pane, area) + ); + } + + #[test] + fn status_and_queued_messages_snapshot() { + 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); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "status_and_queued_messages_snapshot", + render_snapshot(&pane, area) + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs new file mode 100644 index 00000000..ae33aead --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs @@ -0,0 +1,157 @@ +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; + +use crate::key_hint; +use crate::render::renderable::Renderable; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; + +/// Widget that displays a list of user messages queued while a turn is in progress. +pub(crate) struct QueuedUserMessages { + pub messages: Vec, +} + +impl QueuedUserMessages { + pub(crate) fn new() -> Self { + Self { + messages: Vec::new(), + } + } + + fn as_renderable(&self, width: u16) -> Box { + if self.messages.is_empty() || width < 4 { + return Box::new(()); + } + + let mut lines = vec![]; + + for message in &self.messages { + let wrapped = word_wrap_lines( + message.lines().map(|line| line.dim().italic()), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + let len = wrapped.len(); + for line in wrapped.into_iter().take(3) { + lines.push(line); + } + if len > 3 { + lines.push(Line::from(" …".dim().italic())); + } + } + + lines.push( + Line::from(vec![ + " ".into(), + key_hint::alt(KeyCode::Up).into(), + " edit".into(), + ]) + .dim(), + ); + + Paragraph::new(lines).into() + } +} + +impl Renderable for QueuedUserMessages { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + self.as_renderable(area.width).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable(width).desired_height(width) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + + #[test] + fn desired_height_empty() { + let queue = QueuedUserMessages::new(); + assert_eq!(queue.desired_height(40), 0); + } + + #[test] + fn desired_height_one_message() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push("Hello, world!".to_string()); + assert_eq!(queue.desired_height(40), 2); + } + + #[test] + fn render_one_message() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push("Hello, world!".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_one_message", format!("{buf:?}")); + } + + #[test] + fn render_two_messages() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push("Hello, world!".to_string()); + queue.messages.push("This is another message".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_two_messages", format!("{buf:?}")); + } + + #[test] + fn render_more_than_three_messages() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push("Hello, world!".to_string()); + queue.messages.push("This is another message".to_string()); + queue.messages.push("This is a third message".to_string()); + queue.messages.push("This is a fourth message".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_more_than_three_messages", format!("{buf:?}")); + } + + #[test] + fn render_wrapped_message() { + let mut queue = QueuedUserMessages::new(); + queue + .messages + .push("This is a longer message that should be wrapped".to_string()); + queue.messages.push("This is another message".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_wrapped_message", format!("{buf:?}")); + } + + #[test] + fn render_many_line_message() { + let mut queue = QueuedUserMessages::new(); + queue + .messages + .push("This is\na message\nwith many\nlines".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_many_line_message", format!("{buf:?}")); + } +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap new file mode 100644 index 00000000..cf1f7248 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ This is ", + " a message ", + " with many ", + " … ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap new file mode 100644 index 00000000..5e403e1b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 2 }, + content: [ + " ↳ Hello, world! ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap new file mode 100644 index 00000000..44845096 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap new file mode 100644 index 00000000..16d63612 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 4 }, + content: [ + " ↳ This is a longer message that should", + " be wrapped ", + " ↳ This is another message ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap new file mode 100644 index 00000000..d2afbf7d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ This is ", + " a message ", + " with many ", + " … ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap new file mode 100644 index 00000000..9d7527d1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " ↳ This is a third message ", + " ↳ This is a fourth message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 28, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap new file mode 100644 index 00000000..d47fa978 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 2 }, + content: [ + " ↳ Hello, world! ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap new file mode 100644 index 00000000..1f020fec --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap new file mode 100644 index 00000000..4f2917a6 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 4 }, + content: [ + " ↳ This is a longer message that should", + " be wrapped ", + " ↳ This is another message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} 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 new file mode 100644 index 00000000..7abd3baa --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- + + ↳ Queued follow-up question + ⌥ + ↑ edit + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts 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 new file mode 100644 index 00000000..aa36a9d4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- + +• Working (0s • esc to interrupt) + ↳ Queued follow-up question + ⌥ + ↑ edit + + +› 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 52664362..3bfb29f8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -38,6 +38,7 @@ use codex_core::protocol::UndoCompletedEvent; use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_protocol::ConversationId; +use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 406a8925..66d4899b 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -127,11 +127,7 @@ impl HistoryCell for UserHistoryCell { let style = user_message_style(); let wrapped = word_wrap_lines( - &self - .message - .lines() - .map(|l| Line::from(l).style(style)) - .collect::>(), + self.message.lines().map(|l| Line::from(l).style(style)), // Wrap algorithm matches textarea.rs. RtOptions::new(usize::from(wrap_width)) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 795d1545..1126b802 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -6,9 +6,11 @@ use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Span; -#[cfg(target_os = "macos")] +#[cfg(test)] const ALT_PREFIX: &str = "⌥ + "; -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(test), target_os = "macos"))] +const ALT_PREFIX: &str = "⌥ + "; +#[cfg(all(not(test), not(target_os = "macos")))] const ALT_PREFIX: &str = "alt + "; const CTRL_PREFIX: &str = "ctrl + "; const SHIFT_PREFIX: &str = "shift + "; diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index cda52ffe..73692295 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -10,7 +10,6 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use crate::app_event::AppEvent; @@ -23,9 +22,6 @@ use crate::tui::FrameRequester; pub(crate) struct StatusIndicatorWidget { /// Animated header text (defaults to "Working"). header: String, - /// Queued user messages to display under the status line. - queued_messages: Vec, - /// Whether to show the interrupt hint (Esc). show_interrupt_hint: bool, elapsed_running: Duration, @@ -56,7 +52,6 @@ impl StatusIndicatorWidget { pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self { Self { header: String::from("Working"), - queued_messages: Vec::new(), show_interrupt_hint: true, elapsed_running: Duration::ZERO, last_resume_at: Instant::now(), @@ -67,32 +62,8 @@ impl StatusIndicatorWidget { } } - pub fn desired_height(&self, width: u16) -> u16 { - // Status line + optional blank line + wrapped queued messages (up to 3 lines per message) - // + optional ellipsis line per truncated message + 1 spacer line - let inner_width = width.max(1) as usize; - let mut total: u16 = 1; // status line - if !self.queued_messages.is_empty() { - total = total.saturating_add(1); // blank line between status and queued messages - } - let text_width = inner_width.saturating_sub(3); // account for " ↳ " prefix - if text_width > 0 { - for q in &self.queued_messages { - let wrapped = textwrap::wrap(q, text_width); - let lines = wrapped.len().min(3) as u16; - total = total.saturating_add(lines); - if wrapped.len() > 3 { - total = total.saturating_add(1); // ellipsis line - } - } - if !self.queued_messages.is_empty() { - total = total.saturating_add(1); // keybind hint line - } - } else { - // At least one line per message if width is extremely narrow - total = total.saturating_add(self.queued_messages.len() as u16); - } - total.saturating_add(1) // spacer line + pub fn desired_height(&self, _width: u16) -> u16 { + 1 } pub(crate) fn interrupt(&self) { @@ -118,13 +89,6 @@ impl StatusIndicatorWidget { self.show_interrupt_hint } - /// Replace the queued messages displayed beneath the header. - pub(crate) fn set_queued_messages(&mut self, queued: Vec) { - self.queued_messages = queued; - // Ensure a redraw so changes are visible. - self.frame_requester.schedule_frame(); - } - pub(crate) fn pause_timer(&mut self) { self.pause_timer_at(Instant::now()); } @@ -196,38 +160,7 @@ impl WidgetRef for StatusIndicatorWidget { spans.push(format!("({pretty_elapsed})").dim()); } - // Build lines: status, then queued messages, then spacer. - let mut lines: Vec> = Vec::new(); - lines.push(Line::from(spans)); - if !self.queued_messages.is_empty() { - lines.push(Line::from("")); - } - // Wrap queued messages using textwrap and show up to the first 3 lines per message. - let text_width = area.width.saturating_sub(3); // " ↳ " prefix - for q in &self.queued_messages { - let wrapped = textwrap::wrap(q, text_width as usize); - for (i, piece) in wrapped.iter().take(3).enumerate() { - let prefix = if i == 0 { " ↳ " } else { " " }; - let content = format!("{prefix}{piece}"); - lines.push(Line::from(content.dim().italic())); - } - if wrapped.len() > 3 { - lines.push(Line::from(" …".dim().italic())); - } - } - if !self.queued_messages.is_empty() { - lines.push( - Line::from(vec![ - " ".into(), - key_hint::alt(KeyCode::Up).into(), - " edit".into(), - ]) - .dim(), - ); - } - - let paragraph = Paragraph::new(lines); - paragraph.render_ref(area, buf); + Line::from(spans).render_ref(area, buf); } } @@ -286,26 +219,6 @@ mod tests { insta::assert_snapshot!(terminal.backend()); } - #[test] - fn renders_with_queued_messages() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); - w.set_queued_messages(vec!["first".to_string(), "second".to_string()]); - - // Render into a fixed-size test terminal and snapshot the backend. - let mut terminal = Terminal::new(TestBackend::new(80, 8)).expect("terminal"); - terminal - .draw(|f| w.render_ref(f.area(), f.buffer_mut())) - .expect("draw"); - #[cfg(target_os = "macos")] - insta::with_settings!({ snapshot_suffix => "macos" }, { - insta::assert_snapshot!(terminal.backend()); - }); - #[cfg(not(target_os = "macos"))] - insta::assert_snapshot!(terminal.backend()); - } - #[test] fn timer_pauses_when_requested() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs index 3c3b775e..561da78b 100644 --- a/codex-rs/tui/src/wrapping.rs +++ b/codex-rs/tui/src/wrapping.rs @@ -1,5 +1,6 @@ use ratatui::text::Line; use ratatui::text::Span; +use std::borrow::Cow; use std::ops::Range; use textwrap::Options; use textwrap::wrap_algorithms::Penalties; @@ -238,18 +239,89 @@ where out } +/// Utilities to allow wrapping either borrowed or owned lines. +#[derive(Debug)] +enum LineInput<'a> { + Borrowed(&'a Line<'a>), + Owned(Line<'a>), +} + +impl<'a> LineInput<'a> { + fn as_ref(&self) -> &Line<'a> { + match self { + LineInput::Borrowed(line) => line, + LineInput::Owned(line) => line, + } + } +} + +/// This trait makes it easier to pass whatever we need into word_wrap_lines. +trait IntoLineInput<'a> { + fn into_line_input(self) -> LineInput<'a>; +} + +impl<'a> IntoLineInput<'a> for &'a Line<'a> { + fn into_line_input(self) -> LineInput<'a> { + LineInput::Borrowed(self) + } +} + +impl<'a> IntoLineInput<'a> for &'a mut Line<'a> { + fn into_line_input(self) -> LineInput<'a> { + LineInput::Borrowed(self) + } +} + +impl<'a> IntoLineInput<'a> for Line<'a> { + fn into_line_input(self) -> LineInput<'a> { + LineInput::Owned(self) + } +} + +impl<'a> IntoLineInput<'a> for String { + fn into_line_input(self) -> LineInput<'a> { + LineInput::Owned(Line::from(self)) + } +} + +impl<'a> IntoLineInput<'a> for &'a str { + fn into_line_input(self) -> LineInput<'a> { + LineInput::Owned(Line::from(self)) + } +} + +impl<'a> IntoLineInput<'a> for Cow<'a, str> { + fn into_line_input(self) -> LineInput<'a> { + LineInput::Owned(Line::from(self)) + } +} + +impl<'a> IntoLineInput<'a> for Span<'a> { + fn into_line_input(self) -> LineInput<'a> { + LineInput::Owned(Line::from(self)) + } +} + +impl<'a> IntoLineInput<'a> for Vec> { + fn into_line_input(self) -> LineInput<'a> { + LineInput::Owned(Line::from(self)) + } +} + /// Wrap a sequence of lines, applying the initial indent only to the very first /// output line, and using the subsequent indent for all later wrapped pieces. -#[allow(dead_code)] -pub(crate) fn word_wrap_lines<'a, I, O>(lines: I, width_or_options: O) -> Vec> +#[allow(private_bounds)] // IntoLineInput isn't public, but it doesn't really need to be. +pub(crate) fn word_wrap_lines<'a, I, O, L>(lines: I, width_or_options: O) -> Vec> where - I: IntoIterator>, + I: IntoIterator, + L: IntoLineInput<'a>, O: Into>, { let base_opts: RtOptions<'a> = width_or_options.into(); let mut out: Vec> = Vec::new(); for (idx, line) in lines.into_iter().enumerate() { + let line_input = line.into_line_input(); let opts = if idx == 0 { base_opts.clone() } else { @@ -258,7 +330,7 @@ where o = o.initial_indent(sub); o }; - let wrapped = word_wrap_line(line, opts); + let wrapped = word_wrap_line(line_input.as_ref(), opts); push_owned_lines(&wrapped, &mut out); } @@ -492,7 +564,7 @@ mod tests { .subsequent_indent(Line::from(" ")); let lines = vec![Line::from("hello world"), Line::from("foo bar baz")]; - let out = word_wrap_lines(&lines, opts); + let out = word_wrap_lines(lines, opts); // Expect: first line prefixed with "- ", subsequent wrapped pieces with " " // and for the second input line, there should be no "- " prefix on its first piece @@ -506,7 +578,7 @@ mod tests { #[test] fn wrap_lines_without_indents_is_concat_of_single_wraps() { let lines = vec![Line::from("hello"), Line::from("world!")]; - let out = word_wrap_lines(&lines, 10); + let out = word_wrap_lines(lines, 10); let rendered: Vec = out.iter().map(concat_line).collect(); assert_eq!(rendered, vec!["hello", "world!"]); } @@ -535,6 +607,22 @@ mod tests { assert_eq!(rendered, vec!["hello", "world!"]); } + #[test] + fn wrap_lines_accepts_borrowed_iterators() { + let lines = [Line::from("hello world"), Line::from("foo bar baz")]; + let out = word_wrap_lines(lines, 10); + let rendered: Vec = out.iter().map(concat_line).collect(); + assert_eq!(rendered, vec!["hello", "world", "foo bar", "baz"]); + } + + #[test] + fn wrap_lines_accepts_str_slices() { + let lines = ["hello world", "goodnight moon"]; + let out = word_wrap_lines(lines, 12); + let rendered: Vec = out.iter().map(concat_line).collect(); + assert_eq!(rendered, vec!["hello world", "goodnight", "moon"]); + } + #[test] fn line_height_counts_double_width_emoji() { let line = "😀😀😀".into(); // each emoji ~ width 2