tui: show queued messages during response stream (#5540)

This fixes an issue where messages sent during the final response stream
would seem to disappear, because the "queued messages" UI wasn't shown
during streaming.
This commit is contained in:
Jeremy Rose
2025-10-28 09:59:19 -07:00
committed by GitHub
parent 9b33ce3409
commit 36eb071998
18 changed files with 626 additions and 153 deletions

View File

@@ -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<StatusIndicatorWidget>,
/// Queued user messages to show under the status indicator.
queued_user_messages: Vec<String>,
/// Queued user messages to show above the composer while a turn is running.
queued_user_messages: QueuedUserMessages,
context_window_percent: Option<i64>,
}
@@ -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<String>) {
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::<AppEvent>();
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::<AppEvent>();
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)
);
}
}

View File

@@ -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<String>,
}
impl QueuedUserMessages {
pub(crate) fn new() -> Self {
Self {
messages: Vec::new(),
}
}
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
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:?}"));
}
}

View File

@@ -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,
]
}

View File

@@ -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,
]
}

View File

@@ -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,
]
}

View File

@@ -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,
]
}

View File

@@ -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,
]
}

View File

@@ -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,
]
}

View File

@@ -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,
]
}

View File

@@ -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,
]
}

View File

@@ -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,
]
}

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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::<Vec<_>>(),
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),

View File

@@ -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 + ";

View File

@@ -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<String>,
/// 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<String>) {
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<Line<'static>> = 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::<AppEvent>();
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::<AppEvent>();

View File

@@ -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<Span<'a>> {
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<Line<'static>>
#[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<Line<'static>>
where
I: IntoIterator<Item = &'a Line<'a>>,
I: IntoIterator<Item = L>,
L: IntoLineInput<'a>,
O: Into<RtOptions<'a>>,
{
let base_opts: RtOptions<'a> = width_or_options.into();
let mut out: Vec<Line<'static>> = 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<String> = 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<String> = 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<String> = 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