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:
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
157
codex-rs/tui/src/bottom_pane/queued_user_messages.rs
Normal file
157
codex-rs/tui/src/bottom_pane/queued_user_messages.rs
Normal 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:?}"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 + ";
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user