From 906d44976001aa6fbd51c9a40129e20b92e819ce Mon Sep 17 00:00:00 2001 From: easong-openai Date: Mon, 4 Aug 2025 21:23:22 -0700 Subject: [PATCH] Stream model responses (#1810) Stream models thoughts and responses instead of waiting for the whole thing to come through. Very rough right now, but I'm making the risk call to push through. --- codex-rs/Cargo.lock | 50 ++- codex-rs/core/src/chat_completions.rs | 18 +- codex-rs/core/src/codex.rs | 12 +- codex-rs/core/src/conversation_history.rs | 186 +++++++- codex-rs/core/src/models.rs | 17 +- codex-rs/tui/Cargo.toml | 5 + .../tui/src/bottom_pane/live_ring_widget.rs | 45 ++ codex-rs/tui/src/bottom_pane/mod.rs | 399 ++++++++++++++++-- codex-rs/tui/src/chatwidget.rs | 174 ++++++-- codex-rs/tui/src/history_cell.rs | 32 +- codex-rs/tui/src/insert_history.rs | 81 +++- codex-rs/tui/src/lib.rs | 5 +- codex-rs/tui/src/live_wrap.rs | 290 +++++++++++++ codex-rs/tui/src/markdown.rs | 6 +- codex-rs/tui/src/status_indicator_widget.rs | 215 +++++++--- codex-rs/tui/tests/vt100_history.rs | 214 ++++++++++ codex-rs/tui/tests/vt100_live_commit.rs | 101 +++++ 17 files changed, 1616 insertions(+), 234 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/live_ring_widget.rs create mode 100644 codex-rs/tui/src/live_wrap.rs create mode 100644 codex-rs/tui/tests/vt100_history.rs create mode 100644 codex-rs/tui/tests/vt100_live_commit.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4daae977..2e20a7d6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -881,6 +881,7 @@ dependencies = [ "unicode-segmentation", "unicode-width 0.1.14", "uuid", + "vt100", ] [[package]] @@ -1473,7 +1474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -1553,7 +1554,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.0.8", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1756,7 +1757,7 @@ version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1" dependencies = [ - "unicode-width 0.2.0", + "unicode-width 0.2.1", ] [[package]] @@ -2336,7 +2337,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3392,7 +3393,7 @@ dependencies = [ [[package]] name = "ratatui" version = "0.29.0" -source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#bca287ddc5d38fe088c79e2eda22422b96226f2e" +source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2" dependencies = [ "bitflags 2.9.1", "cassowary", @@ -3406,7 +3407,7 @@ dependencies = [ "strum 0.26.3", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width 0.2.1", ] [[package]] @@ -3720,7 +3721,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3733,7 +3734,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -4499,7 +4500,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.0.8", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4546,7 +4547,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "smawk", "unicode-linebreak", - "unicode-width 0.2.0", + "unicode-width 0.2.1", ] [[package]] @@ -4994,7 +4995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "911e93158bf80bbc94bad533b2b16e3d711e1132d69a6a6980c3920a63422c19" dependencies = [ "ratatui", - "unicode-width 0.2.0", + "unicode-width 0.2.1", ] [[package]] @@ -5062,9 +5063,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" [[package]] name = "unicode-xid" @@ -5149,6 +5150,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width 0.2.1", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -5337,7 +5359,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index b5ade23b..e1804b19 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -260,6 +260,11 @@ async fn process_chat_sse( .and_then(|d| d.get("content")) .and_then(|c| c.as_str()) { + // Emit a delta so downstream consumers can stream text live. + let _ = tx_event + .send(Ok(ResponseEvent::OutputTextDelta(content.to_string()))) + .await; + let item = ResponseItem::Message { role: "assistant".to_string(), content: vec![ContentItem::OutputText { @@ -439,11 +444,14 @@ where // will never appear in a Chat Completions stream. continue; } - Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(_)))) - | Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(_)))) => { - // Deltas are ignored here since aggregation waits for the - // final OutputItemDone. - continue; + Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta)))) => { + // Forward deltas unchanged so callers can stream text + // live while still receiving a single aggregated + // OutputItemDone at the end of the turn. + return Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta)))); + } + Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(delta)))) => { + return Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(delta)))); } } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 568d87c4..8d243564 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -123,7 +123,7 @@ impl Codex { let resume_path = config.experimental_resume.clone(); info!("resume_path: {resume_path:?}"); let (tx_sub, rx_sub) = async_channel::bounded(64); - let (tx_event, rx_event) = async_channel::bounded(1600); + let (tx_event, rx_event) = async_channel::unbounded(); let user_instructions = get_user_instructions(&config).await; @@ -701,7 +701,7 @@ async fn submission_loop( cwd, resume_path, } => { - info!( + debug!( "Configuring session: model={model}; provider={provider:?}; resume={resume_path:?}" ); if !cwd.is_absolute() { @@ -1374,6 +1374,11 @@ async fn try_run_turn( return Ok(output); } ResponseEvent::OutputTextDelta(delta) => { + { + let mut st = sess.state.lock().unwrap(); + st.history.append_assistant_text(&delta); + } + let event = Event { id: sub_id.to_string(), msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }), @@ -1921,7 +1926,8 @@ async fn handle_sandbox_error( // include additional metadata on the command to indicate whether non-zero // exit codes merit a retry. - // For now, we categorically ask the user to retry without sandbox. + // For now, we categorically ask the user to retry without sandbox and + // emit the raw error as a background event. sess.notify_background_event(&sub_id, format!("Execution failed: {error}")) .await; diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs index f5254f33..1d55b125 100644 --- a/codex-rs/core/src/conversation_history.rs +++ b/codex-rs/core/src/conversation_history.rs @@ -24,9 +24,52 @@ impl ConversationHistory { I::Item: std::ops::Deref, { for item in items { - if is_api_message(&item) { - // Note agent-loop.ts also does filtering on some of the fields. - self.items.push(item.clone()); + if !is_api_message(&item) { + continue; + } + + // Merge adjacent assistant messages into a single history entry. + // This prevents duplicates when a partial assistant message was + // streamed into history earlier in the turn and the final full + // message is recorded at turn end. + match (&*item, self.items.last_mut()) { + ( + ResponseItem::Message { + role: new_role, + content: new_content, + .. + }, + Some(ResponseItem::Message { + role: last_role, + content: last_content, + .. + }), + ) if new_role == "assistant" && last_role == "assistant" => { + append_text_content(last_content, new_content); + } + _ => { + self.items.push(item.clone()); + } + } + } + } + + /// Append a text `delta` to the latest assistant message, creating a new + /// assistant entry if none exists yet (e.g. first delta for this turn). + pub(crate) fn append_assistant_text(&mut self, delta: &str) { + match self.items.last_mut() { + Some(ResponseItem::Message { role, content, .. }) if role == "assistant" => { + append_text_delta(content, delta); + } + _ => { + // Start a new assistant message with the delta. + self.items.push(ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![crate::models::ContentItem::OutputText { + text: delta.to_string(), + }], + }); } } } @@ -72,3 +115,140 @@ fn is_api_message(message: &ResponseItem) -> bool { ResponseItem::Other => false, } } + +/// Helper to append the textual content from `src` into `dst` in place. +fn append_text_content( + dst: &mut Vec, + src: &Vec, +) { + for c in src { + if let crate::models::ContentItem::OutputText { text } = c { + append_text_delta(dst, text); + } + } +} + +/// Append a single text delta to the last OutputText item in `content`, or +/// push a new OutputText item if none exists. +fn append_text_delta(content: &mut Vec, delta: &str) { + if let Some(crate::models::ContentItem::OutputText { text }) = content + .iter_mut() + .rev() + .find(|c| matches!(c, crate::models::ContentItem::OutputText { .. })) + { + text.push_str(delta); + } else { + content.push(crate::models::ContentItem::OutputText { + text: delta.to_string(), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::ContentItem; + + fn assistant_msg(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + } + } + + fn user_msg(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + } + } + + #[test] + fn merges_adjacent_assistant_messages() { + let mut h = ConversationHistory::default(); + let a1 = assistant_msg("Hello"); + let a2 = assistant_msg(", world!"); + h.record_items([&a1, &a2]); + + let items = h.contents(); + assert_eq!( + items, + vec![ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "Hello, world!".to_string() + }] + }] + ); + } + + #[test] + fn append_assistant_text_creates_and_appends() { + let mut h = ConversationHistory::default(); + h.append_assistant_text("Hello"); + h.append_assistant_text(", world"); + + // Now record a final full assistant message and verify it merges. + let final_msg = assistant_msg("!"); + h.record_items([&final_msg]); + + let items = h.contents(); + assert_eq!( + items, + vec![ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "Hello, world!".to_string() + }] + }] + ); + } + + #[test] + fn filters_non_api_messages() { + let mut h = ConversationHistory::default(); + // System message is not an API message; Other is ignored. + let system = ResponseItem::Message { + id: None, + role: "system".to_string(), + content: vec![ContentItem::OutputText { + text: "ignored".to_string(), + }], + }; + h.record_items([&system, &ResponseItem::Other]); + + // User and assistant should be retained. + let u = user_msg("hi"); + let a = assistant_msg("hello"); + h.record_items([&u, &a]); + + let items = h.contents(); + assert_eq!( + items, + vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::OutputText { + text: "hi".to_string() + }] + }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "hello".to_string() + }] + } + ] + ); + } +} diff --git a/codex-rs/core/src/models.rs b/codex-rs/core/src/models.rs index 16640491..91bfb3bc 100644 --- a/codex-rs/core/src/models.rs +++ b/codex-rs/core/src/models.rs @@ -9,7 +9,7 @@ use serde::ser::Serializer; use crate::protocol::InputItem; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseInputItem { Message { @@ -26,7 +26,7 @@ pub enum ResponseInputItem { }, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentItem { InputText { text: String }, @@ -34,7 +34,7 @@ pub enum ContentItem { OutputText { text: String }, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { Message { @@ -107,7 +107,7 @@ impl From for ResponseItem { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum LocalShellStatus { Completed, @@ -115,13 +115,13 @@ pub enum LocalShellStatus { Incomplete, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum LocalShellAction { Exec(LocalShellExecAction), } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct LocalShellExecAction { pub command: Vec, pub timeout_ms: Option, @@ -130,7 +130,7 @@ pub struct LocalShellExecAction { pub user: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ReasoningItemReasoningSummary { SummaryText { text: String }, @@ -185,10 +185,9 @@ pub struct ShellToolCallParams { pub timeout_ms: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct FunctionCallOutputPayload { pub content: String, - #[expect(dead_code)] pub success: Option, } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index a571b32c..60af056a 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -11,6 +11,10 @@ path = "src/main.rs" name = "codex_tui" path = "src/lib.rs" +[features] +# Enable vt100-based tests (emulator) when running with `--features vt100-tests`. +vt100-tests = [] + [lints] workspace = true @@ -73,3 +77,4 @@ insta = "1.43.1" pretty_assertions = "1" rand = "0.8" chrono = { version = "0.4", features = ["serde"] } +vt100 = "0.16.2" diff --git a/codex-rs/tui/src/bottom_pane/live_ring_widget.rs b/codex-rs/tui/src/bottom_pane/live_ring_widget.rs new file mode 100644 index 00000000..13f91acc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/live_ring_widget.rs @@ -0,0 +1,45 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; + +/// Minimal rendering-only widget for the transient ring rows. +pub(crate) struct LiveRingWidget { + max_rows: u16, + rows: Vec>, // newest at the end +} + +impl LiveRingWidget { + pub fn new() -> Self { + Self { + max_rows: 3, + rows: Vec::new(), + } + } + + pub fn set_max_rows(&mut self, n: u16) { + self.max_rows = n.max(1); + } + + pub fn set_rows(&mut self, rows: Vec>) { + self.rows = rows; + } + + pub fn desired_height(&self, _width: u16) -> u16 { + let len = self.rows.len() as u16; + len.min(self.max_rows) + } +} + +impl WidgetRef for LiveRingWidget { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 { + return; + } + let visible = self.rows.len().saturating_sub(self.max_rows as usize); + let slice = &self.rows[visible..]; + let para = Paragraph::new(slice.to_vec()); + para.render_ref(area, buf); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index cab78bbe..fde0b3bd 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -4,12 +4,12 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::user_approval_widget::ApprovalRequest; use bottom_pane_view::BottomPaneView; -use bottom_pane_view::ConditionalUpdate; use codex_core::protocol::TokenUsage; use codex_file_search::FileMatch; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::text::Line; use ratatui::widgets::WidgetRef; mod approval_modal_view; @@ -18,6 +18,7 @@ mod chat_composer; mod chat_composer_history; mod command_popup; mod file_search_popup; +mod live_ring_widget; mod status_indicator_view; mod textarea; @@ -30,6 +31,7 @@ pub(crate) enum CancellationEvent { pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::InputResult; +use crate::status_indicator_widget::StatusIndicatorWidget; use approval_modal_view::ApprovalModalView; use status_indicator_view::StatusIndicatorView; @@ -46,6 +48,19 @@ pub(crate) struct BottomPane<'a> { has_input_focus: bool, is_task_running: bool, ctrl_c_quit_hint: bool, + + /// Optional live, multi‑line status/"live cell" rendered directly above + /// the composer while a task is running. Unlike `active_view`, this does + /// not replace the composer; it augments it. + live_status: Option, + + /// Optional transient ring shown above the composer. This is a rendering-only + /// container used during development before we wire it to ChatWidget events. + live_ring: Option, + + /// True if the active view is the StatusIndicatorView that replaces the + /// composer during a running task. + status_view_active: bool, } pub(crate) struct BottomPaneParams { @@ -55,6 +70,7 @@ pub(crate) struct BottomPaneParams { } impl BottomPane<'_> { + const BOTTOM_PAD_LINES: u16 = 2; pub fn new(params: BottomPaneParams) -> Self { let enhanced_keys_supported = params.enhanced_keys_supported; Self { @@ -68,14 +84,40 @@ impl BottomPane<'_> { has_input_focus: params.has_input_focus, is_task_running: false, ctrl_c_quit_hint: false, + live_status: None, + live_ring: None, + status_view_active: false, } } pub fn desired_height(&self, width: u16) -> u16 { - self.active_view + let overlay_status_h = self + .live_status .as_ref() - .map(|v| v.desired_height(width)) - .unwrap_or(self.composer.desired_height(width)) + .map(|s| s.desired_height(width)) + .unwrap_or(0); + let ring_h = self + .live_ring + .as_ref() + .map(|r| r.desired_height(width)) + .unwrap_or(0); + + let view_height = if let Some(view) = self.active_view.as_ref() { + // Add a single blank spacer line between live ring and status view when active. + let spacer = if self.live_ring.is_some() && self.status_view_active { + 1 + } else { + 0 + }; + spacer + view.desired_height(width) + } else { + self.composer.desired_height(width) + }; + + overlay_status_h + .saturating_add(ring_h) + .saturating_add(view_height) + .saturating_add(Self::BOTTOM_PAD_LINES) } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { @@ -96,10 +138,6 @@ impl BottomPane<'_> { view.handle_key_event(self, key_event); if !view.is_complete() { self.active_view = Some(view); - } else if self.is_task_running { - self.active_view = Some(Box::new(StatusIndicatorView::new( - self.app_event_tx.clone(), - ))); } self.request_redraw(); InputResult::None @@ -125,10 +163,6 @@ impl BottomPane<'_> { CancellationEvent::Handled => { if !view.is_complete() { self.active_view = Some(view); - } else if self.is_task_running { - self.active_view = Some(Box::new(StatusIndicatorView::new( - self.app_event_tx.clone(), - ))); } self.show_ctrl_c_quit_hint(); } @@ -148,19 +182,37 @@ impl BottomPane<'_> { } } - /// Update the status indicator text (only when the `StatusIndicatorView` is - /// active). + /// Update the status indicator text. Prefer replacing the composer with + /// the StatusIndicatorView so the input pane shows a single-line status + /// like: `▌ Working waiting for model`. pub(crate) fn update_status_text(&mut self, text: String) { - if let Some(view) = &mut self.active_view { - match view.update_status_text(text) { - ConditionalUpdate::NeedsRedraw => { - self.request_redraw(); - } - ConditionalUpdate::NoRedraw => { - // No redraw needed. - } + let mut handled_by_view = false; + if let Some(view) = self.active_view.as_mut() { + if matches!( + view.update_status_text(text.clone()), + bottom_pane_view::ConditionalUpdate::NeedsRedraw + ) { + handled_by_view = true; + } + } else { + let mut v = StatusIndicatorView::new(self.app_event_tx.clone()); + v.update_text(text.clone()); + self.active_view = Some(Box::new(v)); + self.status_view_active = true; + handled_by_view = true; + } + + // Fallback: if the current active view did not consume status updates, + // present an overlay above the composer. + if !handled_by_view { + if self.live_status.is_none() { + self.live_status = Some(StatusIndicatorWidget::new(self.app_event_tx.clone())); + } + if let Some(status) = &mut self.live_status { + status.update_text(text); } } + self.request_redraw(); } pub(crate) fn show_ctrl_c_quit_hint(&mut self) { @@ -186,27 +238,23 @@ impl BottomPane<'_> { pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; - match (running, self.active_view.is_some()) { - (true, false) => { - // Show status indicator overlay. + if running { + if self.active_view.is_none() { self.active_view = Some(Box::new(StatusIndicatorView::new( self.app_event_tx.clone(), ))); - self.request_redraw(); + self.status_view_active = true; } - (false, true) => { - if let Some(mut view) = self.active_view.take() { - if view.should_hide_when_task_is_done() { - // Leave self.active_view as None. - self.request_redraw(); - } else { - // Preserve the view. - self.active_view = Some(view); - } + self.request_redraw(); + } else { + self.live_status = None; + // Drop the status view when a task completes, but keep other + // modal views (e.g. approval dialogs). + if let Some(mut view) = self.active_view.take() { + if !view.should_hide_when_task_is_done() { + self.active_view = Some(view); } - } - _ => { - // No change. + self.status_view_active = false; } } } @@ -248,6 +296,7 @@ impl BottomPane<'_> { // Otherwise create a new approval modal overlay. let modal = ApprovalModalView::new(request, self.app_event_tx.clone()); self.active_view = Some(Box::new(modal)); + self.status_view_active = false; self.request_redraw() } @@ -281,15 +330,80 @@ impl BottomPane<'_> { self.composer.on_file_search_result(query, matches); self.request_redraw(); } + + /// Set the rows and cap for the transient live ring overlay. + pub(crate) fn set_live_ring_rows(&mut self, max_rows: u16, rows: Vec>) { + let mut w = live_ring_widget::LiveRingWidget::new(); + w.set_max_rows(max_rows); + w.set_rows(rows); + self.live_ring = Some(w); + } + + pub(crate) fn clear_live_ring(&mut self) { + self.live_ring = None; + } + + // Removed restart_live_status_with_text – no longer used by the current streaming UI. } impl WidgetRef for &BottomPane<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - // Show BottomPaneView if present. - if let Some(ov) = &self.active_view { - ov.render(area, buf); - } else { - (&self.composer).render_ref(area, buf); + let mut y_offset = 0u16; + if let Some(ring) = &self.live_ring { + let live_h = ring.desired_height(area.width).min(area.height); + if live_h > 0 { + let live_rect = Rect { + x: area.x, + y: area.y, + width: area.width, + height: live_h, + }; + ring.render_ref(live_rect, buf); + y_offset = live_h; + } + } + // Spacer between live ring and status view when active + if self.live_ring.is_some() && self.status_view_active && y_offset < area.height { + // Leave one empty line + y_offset = y_offset.saturating_add(1); + } + if let Some(status) = &self.live_status { + let live_h = status.desired_height(area.width).min(area.height); + if live_h > 0 { + let live_rect = Rect { + x: area.x, + y: area.y, + width: area.width, + height: live_h, + }; + status.render_ref(live_rect, buf); + y_offset = live_h; + } + } + + if let Some(view) = &self.active_view { + if y_offset < area.height { + // Reserve bottom padding lines; keep at least 1 line for the view. + let avail = area.height - y_offset; + let pad = BottomPane::BOTTOM_PAD_LINES.min(avail.saturating_sub(1)); + let view_rect = Rect { + x: area.x, + y: area.y + y_offset, + width: area.width, + height: avail - pad, + }; + view.render(view_rect, buf); + } + } else if y_offset < area.height { + let composer_rect = Rect { + x: area.x, + y: area.y + y_offset, + width: area.width, + // Reserve bottom padding + height: (area.height - y_offset) + - BottomPane::BOTTOM_PAD_LINES.min((area.height - y_offset).saturating_sub(1)), + }; + (&self.composer).render_ref(composer_rect, buf); } } } @@ -298,6 +412,9 @@ impl WidgetRef for &BottomPane<'_> { mod tests { use super::*; use crate::app_event::AppEvent; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::text::Line; use std::path::PathBuf; use std::sync::mpsc::channel; @@ -324,4 +441,200 @@ mod tests { assert!(pane.ctrl_c_quit_hint_visible()); assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c()); } + + #[test] + fn live_ring_renders_above_composer() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + has_input_focus: true, + enhanced_keys_supported: false, + }); + + // Provide 4 rows with max_rows=3; only the last 3 should be visible. + pane.set_live_ring_rows( + 3, + vec![ + Line::from("one".to_string()), + Line::from("two".to_string()), + Line::from("three".to_string()), + Line::from("four".to_string()), + ], + ); + + let area = Rect::new(0, 0, 10, 5); + let mut buf = Buffer::empty(area); + (&pane).render_ref(area, &mut buf); + + // Extract the first 3 rows and assert they contain the last three lines. + let mut lines: Vec = Vec::new(); + for y in 0..3 { + let mut s = String::new(); + for x in 0..area.width { + s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(s.trim_end().to_string()); + } + assert_eq!(lines, vec!["two", "three", "four"]); + } + + #[test] + fn status_indicator_visible_with_live_ring() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + has_input_focus: true, + enhanced_keys_supported: false, + }); + + // Simulate task running which replaces composer with the status indicator. + pane.set_task_running(true); + pane.update_status_text("waiting for model".to_string()); + + // Provide 2 rows in the live ring (e.g., streaming CoT) and ensure the + // status indicator remains visible below them. + pane.set_live_ring_rows( + 2, + vec![ + Line::from("cot1".to_string()), + Line::from("cot2".to_string()), + ], + ); + + // Allow some frames so the dot animation is present. + std::thread::sleep(std::time::Duration::from_millis(120)); + + // Height should include both ring rows, 1 spacer, and the 1-line status. + let area = Rect::new(0, 0, 30, 4); + let mut buf = Buffer::empty(area); + (&pane).render_ref(area, &mut buf); + + // Top two rows are the live ring. + let mut r0 = String::new(); + let mut r1 = String::new(); + for x in 0..area.width { + r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); + r1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' ')); + } + assert!(r0.contains("cot1"), "expected first live row: {r0:?}"); + assert!(r1.contains("cot2"), "expected second live row: {r1:?}"); + + // Row 2 is the spacer (blank) + let mut r2 = String::new(); + for x in 0..area.width { + r2.push(buf[(x, 2)].symbol().chars().next().unwrap_or(' ')); + } + assert!(r2.trim().is_empty(), "expected blank spacer line: {r2:?}"); + + // Bottom row is the status line; it should contain the left bar and "Working". + let mut r3 = String::new(); + for x in 0..area.width { + r3.push(buf[(x, 3)].symbol().chars().next().unwrap_or(' ')); + } + assert_eq!(buf[(0, 3)].symbol().chars().next().unwrap_or(' '), '▌'); + assert!( + r3.contains("Working"), + "expected Working header in status line: {r3:?}" + ); + } + + #[test] + fn bottom_padding_present_for_status_view() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + has_input_focus: true, + enhanced_keys_supported: false, + }); + + // Activate spinner (status view replaces composer) with no live ring. + pane.set_task_running(true); + pane.update_status_text("waiting for model".to_string()); + + // Use height == desired_height; expect 1 status row at top and 2 bottom padding rows. + let height = pane.desired_height(30); + assert!( + height >= 3, + "expected at least 3 rows with bottom padding; got {height}" + ); + let area = Rect::new(0, 0, 30, height); + let mut buf = Buffer::empty(area); + (&pane).render_ref(area, &mut buf); + + // Top row contains the status header + let mut top = String::new(); + for x in 0..area.width { + top.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); + } + assert_eq!(buf[(0, 0)].symbol().chars().next().unwrap_or(' '), '▌'); + assert!( + top.contains("Working"), + "expected Working header on top row: {top:?}" + ); + + // Bottom two rows are blank padding + let mut r_last = String::new(); + let mut r_last2 = String::new(); + for x in 0..area.width { + r_last.push(buf[(x, height - 1)].symbol().chars().next().unwrap_or(' ')); + r_last2.push(buf[(x, height - 2)].symbol().chars().next().unwrap_or(' ')); + } + assert!( + r_last.trim().is_empty(), + "expected last row blank: {r_last:?}" + ); + assert!( + r_last2.trim().is_empty(), + "expected second-to-last row blank: {r_last2:?}" + ); + } + + #[test] + fn bottom_padding_shrinks_when_tiny() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + has_input_focus: true, + enhanced_keys_supported: false, + }); + + pane.set_task_running(true); + pane.update_status_text("waiting for model".to_string()); + + // Height=2 → pad shrinks to 1; bottom row is blank, top row has spinner. + let area2 = Rect::new(0, 0, 20, 2); + let mut buf2 = Buffer::empty(area2); + (&pane).render_ref(area2, &mut buf2); + let mut row0 = String::new(); + let mut row1 = String::new(); + for x in 0..area2.width { + row0.push(buf2[(x, 0)].symbol().chars().next().unwrap_or(' ')); + row1.push(buf2[(x, 1)].symbol().chars().next().unwrap_or(' ')); + } + assert!( + row0.contains("Working"), + "expected Working header on row 0: {row0:?}" + ); + assert!( + row1.trim().is_empty(), + "expected bottom padding on row 1: {row1:?}" + ); + + // Height=1 → no padding; single row is the spinner. + let area1 = Rect::new(0, 0, 20, 1); + let mut buf1 = Buffer::empty(area1); + (&pane).render_ref(area1, &mut buf1); + let mut only = String::new(); + for x in 0..area1.width { + only.push(buf1[(x, 0)].symbol().chars().next().unwrap_or(' ')); + } + assert!( + only.contains("Working"), + "expected Working header with no padding: {only:?}" + ); + } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e5ebf58a..f63810b6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -42,8 +42,10 @@ use crate::exec_command::strip_bash_lc_and_escape; use crate::history_cell::CommandOutput; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; +use crate::live_wrap::RowBuilder; use crate::user_approval_widget::ApprovalRequest; use codex_file_search::FileMatch; +use ratatui::style::Stylize; struct RunningCommand { command: Vec, @@ -64,6 +66,10 @@ pub(crate) struct ChatWidget<'a> { // at once into scrollback so the history contains a single message. answer_buffer: String, running_commands: HashMap, + live_builder: RowBuilder, + current_stream: Option, + stream_header_emitted: bool, + live_max_rows: u16, } struct UserMessage { @@ -71,6 +77,12 @@ struct UserMessage { image_paths: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StreamKind { + Answer, + Reasoning, +} + impl From for UserMessage { fn from(text: String) -> Self { Self { @@ -151,6 +163,10 @@ impl ChatWidget<'_> { reasoning_buffer: String::new(), answer_buffer: String::new(), running_commands: HashMap::new(), + live_builder: RowBuilder::new(80), + current_stream: None, + stream_header_emitted: false, + live_max_rows: 3, } } @@ -234,58 +250,45 @@ impl ChatWidget<'_> { self.request_redraw(); } - EventMsg::AgentMessage(AgentMessageEvent { message }) => { - // Final assistant answer. Prefer the fully provided message - // from the event; if it is empty fall back to any accumulated - // delta buffer (some providers may only stream deltas and send - // an empty final message). - let full = if message.is_empty() { - std::mem::take(&mut self.answer_buffer) - } else { - self.answer_buffer.clear(); - message - }; - if !full.is_empty() { - self.add_to_history(HistoryCell::new_agent_message(&self.config, full)); - } + EventMsg::AgentMessage(AgentMessageEvent { message: _ }) => { + // Final assistant answer: commit all remaining rows and close with + // a blank line. Use the final text if provided, otherwise rely on + // streamed deltas already in the builder. + self.finalize_stream(StreamKind::Answer); self.request_redraw(); } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { - // Buffer only – do not emit partial lines. This avoids cases - // where long responses appear truncated if the terminal - // wrapped early. The full message is emitted on - // AgentMessage. + self.begin_stream(StreamKind::Answer); self.answer_buffer.push_str(&delta); + self.stream_push_and_maybe_commit(&delta); + self.request_redraw(); } EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { - // Buffer only – disable incremental reasoning streaming so we - // avoid truncated intermediate lines. Full text emitted on - // AgentReasoning. + // Stream CoT into the live pane; keep input visible and commit + // overflow rows incrementally to scrollback. + self.begin_stream(StreamKind::Reasoning); self.reasoning_buffer.push_str(&delta); + self.stream_push_and_maybe_commit(&delta); + self.request_redraw(); } - EventMsg::AgentReasoning(AgentReasoningEvent { text }) => { - // Emit full reasoning text once. Some providers might send - // final event with empty text if only deltas were used. - let full = if text.is_empty() { - std::mem::take(&mut self.reasoning_buffer) - } else { - self.reasoning_buffer.clear(); - text - }; - if !full.is_empty() { - self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, full)); - } + EventMsg::AgentReasoning(AgentReasoningEvent { text: _ }) => { + // Final reasoning: commit remaining rows and close with a blank. + self.finalize_stream(StreamKind::Reasoning); self.request_redraw(); } EventMsg::TaskStarted => { self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); + // Replace composer with single-line spinner while waiting. + self.bottom_pane + .update_status_text("waiting for model".to_string()); self.request_redraw(); } EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message: _, }) => { self.bottom_pane.set_task_running(false); + self.bottom_pane.clear_live_ring(); self.request_redraw(); } EventMsg::TokenCount(token_usage) => { @@ -298,8 +301,8 @@ impl ChatWidget<'_> { self.bottom_pane.set_task_running(false); } EventMsg::PlanUpdate(update) => { + // Commit plan updates directly to history (no status-line preview). self.add_to_history(HistoryCell::new_plan_update(update)); - self.request_redraw(); } EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { call_id: _, @@ -307,8 +310,7 @@ impl ChatWidget<'_> { cwd, reason, }) => { - // Print the command to the history so it is visible in the - // transcript *before* the modal asks for approval. + // Log a background summary immediately so the history is chronological. let cmdline = strip_bash_lc_and_escape(&command); let text = format!( "command requires approval:\n$ {cmdline}{reason}", @@ -344,7 +346,6 @@ impl ChatWidget<'_> { // approval dialog) and avoids surprising the user with a modal // prompt before they have seen *what* is being requested. // ------------------------------------------------------------------ - self.add_to_history(HistoryCell::new_patch_event( PatchEventType::ApprovalRequest, changes, @@ -379,8 +380,6 @@ impl ChatWidget<'_> { auto_approved, changes, }) => { - // Even when a patch is auto‑approved we still display the - // summary so the user can follow along. self.add_to_history(HistoryCell::new_patch_event( PatchEventType::ApplyBegin { auto_approved }, changes, @@ -393,6 +392,7 @@ impl ChatWidget<'_> { stdout, stderr, }) => { + // Compute summary before moving stdout into the history cell. let cmd = self.running_commands.remove(&call_id); self.add_to_history(HistoryCell::new_completed_exec_command( cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]), @@ -442,14 +442,15 @@ impl ChatWidget<'_> { self.app_event_tx.send(AppEvent::ExitRequest); } event => { - self.add_to_history(HistoryCell::new_background_event(format!("{event:?}"))); + let text = format!("{event:?}"); + self.add_to_history(HistoryCell::new_background_event(text.clone())); + self.update_latest_log(text); } } } /// Update the live log preview while a task is running. pub(crate) fn update_latest_log(&mut self, line: String) { - // Forward only if we are currently showing the status indicator. self.bottom_pane.update_status_text(line); } @@ -515,6 +516,97 @@ impl ChatWidget<'_> { } } +impl ChatWidget<'_> { + fn begin_stream(&mut self, kind: StreamKind) { + if self.current_stream != Some(kind) { + self.current_stream = Some(kind); + self.stream_header_emitted = false; + // Clear any previous live content; we're starting a new stream. + self.live_builder = RowBuilder::new(self.live_builder.width()); + // Ensure the waiting status is visible (composer replaced). + self.bottom_pane + .update_status_text("waiting for model".to_string()); + } + } + + fn stream_push_and_maybe_commit(&mut self, delta: &str) { + self.live_builder.push_fragment(delta); + + // Commit overflow rows (small batches) while keeping the last N rows visible. + let drained = self + .live_builder + .drain_commit_ready(self.live_max_rows as usize); + if !drained.is_empty() { + let mut lines: Vec> = Vec::new(); + if !self.stream_header_emitted { + match self.current_stream { + Some(StreamKind::Reasoning) => { + lines.push(ratatui::text::Line::from("thinking".magenta().italic())); + } + Some(StreamKind::Answer) => { + lines.push(ratatui::text::Line::from("codex".magenta().bold())); + } + None => {} + } + self.stream_header_emitted = true; + } + for r in drained { + lines.push(ratatui::text::Line::from(r.text)); + } + self.app_event_tx.send(AppEvent::InsertHistory(lines)); + } + + // Update the live ring overlay lines (text-only, newest at bottom). + let rows = self + .live_builder + .display_rows() + .into_iter() + .map(|r| ratatui::text::Line::from(r.text)) + .collect::>(); + self.bottom_pane + .set_live_ring_rows(self.live_max_rows, rows); + } + + fn finalize_stream(&mut self, kind: StreamKind) { + if self.current_stream != Some(kind) { + // Nothing to do; either already finalized or not the active stream. + return; + } + // Flush any partial line as a full row, then drain all remaining rows. + self.live_builder.end_line(); + let remaining = self.live_builder.drain_rows(); + // TODO: Re-add markdown rendering for assistant answers and reasoning. + // When finalizing, pass the accumulated text through `markdown::append_markdown` + // to build styled `Line<'static>` entries instead of raw plain text lines. + if !remaining.is_empty() || !self.stream_header_emitted { + let mut lines: Vec> = Vec::new(); + if !self.stream_header_emitted { + match kind { + StreamKind::Reasoning => { + lines.push(ratatui::text::Line::from("thinking".magenta().italic())); + } + StreamKind::Answer => { + lines.push(ratatui::text::Line::from("codex".magenta().bold())); + } + } + self.stream_header_emitted = true; + } + for r in remaining { + lines.push(ratatui::text::Line::from(r.text)); + } + // Close the block with a blank line for readability. + lines.push(ratatui::text::Line::from("")); + self.app_event_tx.send(AppEvent::InsertHistory(lines)); + } + + // Clear the live overlay and reset state for the next stream. + self.live_builder = RowBuilder::new(self.live_builder.width()); + self.bottom_pane.clear_live_ring(); + self.current_stream = None; + self.stream_header_emitted = false; + } +} + impl WidgetRef for &ChatWidget<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) { // In the hybrid inline viewport mode we only draw the interactive diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index c2aafdd5..17f0e683 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,5 +1,4 @@ use crate::exec_command::strip_bash_lc_and_escape; -use crate::markdown::append_markdown; use crate::text_block::TextBlock; use crate::text_formatting::format_and_truncate_tool_result; use base64::Engine; @@ -68,12 +67,7 @@ pub(crate) enum HistoryCell { /// Message from the user. UserPrompt { view: TextBlock }, - /// Message from the agent. - AgentMessage { view: TextBlock }, - - /// Reasoning event from the agent. - AgentReasoning { view: TextBlock }, - + // AgentMessage and AgentReasoning variants were unused and have been removed. /// An exec tool call that has not finished yet. ActiveExecCommand { view: TextBlock }, @@ -128,8 +122,6 @@ impl HistoryCell { match self { HistoryCell::WelcomeMessage { view } | HistoryCell::UserPrompt { view } - | HistoryCell::AgentMessage { view } - | HistoryCell::AgentReasoning { view } | HistoryCell::BackgroundEvent { view } | HistoryCell::GitDiffOutput { view } | HistoryCell::ErrorEvent { view } @@ -231,28 +223,6 @@ impl HistoryCell { } } - pub(crate) fn new_agent_message(config: &Config, message: String) -> Self { - let mut lines: Vec> = Vec::new(); - lines.push(Line::from("codex".magenta().bold())); - append_markdown(&message, &mut lines, config); - lines.push(Line::from("")); - - HistoryCell::AgentMessage { - view: TextBlock::new(lines), - } - } - - pub(crate) fn new_agent_reasoning(config: &Config, text: String) -> Self { - let mut lines: Vec> = Vec::new(); - lines.push(Line::from("thinking".magenta().italic())); - append_markdown(&text, &mut lines, config); - lines.push(Line::from("")); - - HistoryCell::AgentReasoning { - view: TextBlock::new(lines), - } - } - pub(crate) fn new_active_exec_command(command: Vec) -> Self { let command_escaped = strip_bash_lc_and_escape(&command); diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 87d88b7f..5c316637 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -14,7 +14,6 @@ use crossterm::style::SetBackgroundColor; use crossterm::style::SetColors; use crossterm::style::SetForegroundColor; use ratatui::layout::Size; -use ratatui::prelude::Backend; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::text::Line; @@ -22,6 +21,20 @@ use ratatui::text::Span; /// Insert `lines` above the viewport. pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec) { + let mut out = std::io::stdout(); + insert_history_lines_to_writer(terminal, &mut out, lines); +} + +/// Like `insert_history_lines`, but writes ANSI to the provided writer. This +/// is intended for testing where a capture buffer is used instead of stdout. +pub fn insert_history_lines_to_writer( + terminal: &mut crate::custom_terminal::Terminal, + writer: &mut W, + lines: Vec, +) where + B: ratatui::backend::Backend, + W: Write, +{ let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); let cursor_pos = terminal.get_cursor_position().ok(); @@ -32,10 +45,22 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec) { // If the viewport is not at the bottom of the screen, scroll it down to make room. // Don't scroll it past the bottom of the screen. let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom()); - terminal - .backend_mut() - .scroll_region_down(area.top()..screen_size.height, scroll_amount) - .ok(); + + // Emit ANSI to scroll the lower region (from the top of the viewport to the bottom + // of the screen) downward by `scroll_amount` lines. We do this by: + // 1) Limiting the scroll region to [area.top()+1 .. screen_height] (1-based bounds) + // 2) Placing the cursor at the top margin of that region + // 3) Emitting Reverse Index (RI, ESC M) `scroll_amount` times + // 4) Resetting the scroll region back to full screen + let top_1based = area.top() + 1; // Convert 0-based row to 1-based for DECSTBM + queue!(writer, SetScrollRegion(top_1based..screen_size.height)).ok(); + queue!(writer, MoveTo(0, area.top())).ok(); + for _ in 0..scroll_amount { + // Reverse Index (RI): ESC M + queue!(writer, Print("\x1bM")).ok(); + } + queue!(writer, ResetScrollRegion).ok(); + let cursor_top = area.top().saturating_sub(1); area.y += scroll_amount; terminal.set_viewport_area(area); @@ -59,23 +84,23 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec) { // ││ ││ // │╰────────────────────────────╯│ // └──────────────────────────────┘ - queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok(); + queue!(writer, SetScrollRegion(1..area.top())).ok(); // NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the // terminal's last_known_cursor_position, which hopefully will still be accurate after we // fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :) - queue!(std::io::stdout(), MoveTo(0, cursor_top)).ok(); + queue!(writer, MoveTo(0, cursor_top)).ok(); for line in lines { - queue!(std::io::stdout(), Print("\r\n")).ok(); - write_spans(&mut std::io::stdout(), line.iter()).ok(); + queue!(writer, Print("\r\n")).ok(); + write_spans(writer, line.iter()).ok(); } - queue!(std::io::stdout(), ResetScrollRegion).ok(); + queue!(writer, ResetScrollRegion).ok(); // Restore the cursor position to where it was before we started. if let Some(cursor_pos) = cursor_pos { - queue!(std::io::stdout(), MoveTo(cursor_pos.x, cursor_pos.y)).ok(); + queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok(); } } @@ -88,19 +113,25 @@ fn wrapped_line_count(lines: &[Line], width: u16) -> u16 { } fn line_height(line: &Line, width: u16) -> u16 { - use unicode_width::UnicodeWidthStr; - // get the total display width of the line, accounting for double-width chars - let total_width = line + // Use the same visible-width slicing semantics as the live row builder so + // our pre-scroll estimation matches how rows will actually wrap. + let w = width.max(1) as usize; + let mut rows = 0u16; + let mut remaining = line .spans .iter() - .map(|span| span.content.width()) - .sum::(); - // divide by width to get the number of lines, rounding up - if width == 0 { - 1 - } else { - (total_width as u16).div_ceil(width).max(1) + .map(|s| s.content.as_ref()) + .collect::>() + .join(""); + while !remaining.is_empty() { + let (_prefix, suffix, taken) = crate::live_wrap::take_prefix_by_width(&remaining, w); + rows = rows.saturating_add(1); + if taken >= remaining.len() { + break; + } + remaining = suffix.to_string(); } + rows.max(1) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -283,4 +314,12 @@ mod tests { String::from_utf8(expected).unwrap() ); } + + #[test] + fn line_height_counts_double_width_emoji() { + let line = Line::from("😀😀😀"); // each emoji ~ width 2 + assert_eq!(line_height(&line, 4), 2); + assert_eq!(line_height(&line, 2), 3); + assert_eq!(line_height(&line, 6), 1); + } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0ec9be61..c619ce8f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -25,13 +25,14 @@ mod bottom_pane; mod chatwidget; mod citation_regex; mod cli; -mod custom_terminal; +pub mod custom_terminal; mod exec_command; mod file_search; mod get_git_diff; mod git_warning_screen; mod history_cell; -mod insert_history; +pub mod insert_history; +pub mod live_wrap; mod log_layer; mod markdown; mod slash_command; diff --git a/codex-rs/tui/src/live_wrap.rs b/codex-rs/tui/src/live_wrap.rs new file mode 100644 index 00000000..e78710dc --- /dev/null +++ b/codex-rs/tui/src/live_wrap.rs @@ -0,0 +1,290 @@ +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; + +/// A single visual row produced by RowBuilder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Row { + pub text: String, + /// True if this row ends with an explicit line break (as opposed to a hard wrap). + pub explicit_break: bool, +} + +impl Row { + pub fn width(&self) -> usize { + self.text.width() + } +} + +/// Incrementally wraps input text into visual rows of at most `width` cells. +/// +/// Step 1: plain-text only. ANSI-carry and styled spans will be added later. +pub struct RowBuilder { + target_width: usize, + /// Buffer for the current logical line (until a '\n' is seen). + current_line: String, + /// Output rows built so far for the current logical line and previous ones. + rows: Vec, +} + +impl RowBuilder { + pub fn new(target_width: usize) -> Self { + Self { + target_width: target_width.max(1), + current_line: String::new(), + rows: Vec::new(), + } + } + + pub fn width(&self) -> usize { + self.target_width + } + + pub fn set_width(&mut self, width: usize) { + self.target_width = width.max(1); + // Rewrap everything we have (simple approach for Step 1). + let mut all = String::new(); + for row in self.rows.drain(..) { + all.push_str(&row.text); + if row.explicit_break { + all.push('\n'); + } + } + all.push_str(&self.current_line); + self.current_line.clear(); + self.push_fragment(&all); + } + + /// Push an input fragment. May contain newlines. + pub fn push_fragment(&mut self, fragment: &str) { + if fragment.is_empty() { + return; + } + let mut start = 0usize; + for (i, ch) in fragment.char_indices() { + if ch == '\n' { + // Flush anything pending before the newline. + if start < i { + self.current_line.push_str(&fragment[start..i]); + } + self.flush_current_line(true); + start = i + ch.len_utf8(); + } + } + if start < fragment.len() { + self.current_line.push_str(&fragment[start..]); + self.wrap_current_line(); + } + } + + /// Mark the end of the current logical line (equivalent to pushing a '\n'). + pub fn end_line(&mut self) { + self.flush_current_line(true); + } + + /// Drain and return all produced rows. + pub fn drain_rows(&mut self) -> Vec { + std::mem::take(&mut self.rows) + } + + /// Return a snapshot of produced rows (non-draining). + pub fn rows(&self) -> &[Row] { + &self.rows + } + + /// Rows suitable for display, including the current partial line if any. + pub fn display_rows(&self) -> Vec { + let mut out = self.rows.clone(); + if !self.current_line.is_empty() { + out.push(Row { + text: self.current_line.clone(), + explicit_break: false, + }); + } + out + } + + /// Drain the oldest rows that exceed `max_keep` display rows (including the + /// current partial line, if any). Returns the drained rows in order. + pub fn drain_commit_ready(&mut self, max_keep: usize) -> Vec { + let display_count = self.rows.len() + if self.current_line.is_empty() { 0 } else { 1 }; + if display_count <= max_keep { + return Vec::new(); + } + let to_commit = display_count - max_keep; + let commit_count = to_commit.min(self.rows.len()); + let mut drained = Vec::with_capacity(commit_count); + for _ in 0..commit_count { + drained.push(self.rows.remove(0)); + } + drained + } + + fn flush_current_line(&mut self, explicit_break: bool) { + // Wrap any remaining content in the current line and then finalize with explicit_break. + self.wrap_current_line(); + // If the current line ended exactly on a width boundary and is non-empty, represent + // the explicit break as an empty explicit row so that fragmentation invariance holds. + if explicit_break { + if self.current_line.is_empty() { + // We ended on a boundary previously; add an empty explicit row. + self.rows.push(Row { + text: String::new(), + explicit_break: true, + }); + } else { + // There is leftover content that did not wrap yet; push it now with the explicit flag. + let mut s = String::new(); + std::mem::swap(&mut s, &mut self.current_line); + self.rows.push(Row { + text: s, + explicit_break: true, + }); + } + } + // Reset current line buffer for next logical line. + self.current_line.clear(); + } + + fn wrap_current_line(&mut self) { + // While the current_line exceeds width, cut a prefix. + loop { + if self.current_line.is_empty() { + break; + } + let (prefix, suffix, taken) = + take_prefix_by_width(&self.current_line, self.target_width); + if taken == 0 { + // Avoid infinite loop on pathological inputs; take one scalar and continue. + if let Some((i, ch)) = self.current_line.char_indices().next() { + let len = i + ch.len_utf8(); + let p = self.current_line[..len].to_string(); + self.rows.push(Row { + text: p, + explicit_break: false, + }); + self.current_line = self.current_line[len..].to_string(); + continue; + } + break; + } + if suffix.is_empty() { + // Fits entirely; keep in buffer (do not push yet) so we can append more later. + break; + } else { + // Emit wrapped prefix as a non-explicit row and continue with the remainder. + self.rows.push(Row { + text: prefix, + explicit_break: false, + }); + self.current_line = suffix.to_string(); + } + } + } +} + +/// Take a prefix of `text` whose visible width is at most `max_cols`. +/// Returns (prefix, suffix, prefix_width). +pub fn take_prefix_by_width(text: &str, max_cols: usize) -> (String, &str, usize) { + if max_cols == 0 || text.is_empty() { + return (String::new(), text, 0); + } + let mut cols = 0usize; + let mut end_idx = 0usize; + for (i, ch) in text.char_indices() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if cols.saturating_add(ch_width) > max_cols { + break; + } + cols += ch_width; + end_idx = i + ch.len_utf8(); + if cols == max_cols { + break; + } + } + let prefix = text[..end_idx].to_string(); + let suffix = &text[end_idx..]; + (prefix, suffix, cols) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn rows_do_not_exceed_width_ascii() { + let mut rb = RowBuilder::new(10); + rb.push_fragment("hello whirl this is a test"); + let rows = rb.rows().to_vec(); + assert_eq!( + rows, + vec![ + Row { + text: "hello whir".to_string(), + explicit_break: false + }, + Row { + text: "l this is ".to_string(), + explicit_break: false + } + ] + ); + } + + #[test] + fn rows_do_not_exceed_width_emoji_cjk() { + // 😀 is width 2; 你/好 are width 2. + let mut rb = RowBuilder::new(6); + rb.push_fragment("😀😀 你好"); + let rows = rb.rows().to_vec(); + // At width 6, we expect the first row to fit exactly two emojis and a space + // (2 + 2 + 1 = 5) plus one more column for the first CJK char (2 would overflow), + // so only the two emojis and the space fit; the rest remains buffered. + assert_eq!( + rows, + vec![Row { + text: "😀😀 ".to_string(), + explicit_break: false + }] + ); + } + + #[test] + fn fragmentation_invariance_long_token() { + let s = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; // 26 chars + let mut rb_all = RowBuilder::new(7); + rb_all.push_fragment(s); + let all_rows = rb_all.rows().to_vec(); + + let mut rb_chunks = RowBuilder::new(7); + for i in (0..s.len()).step_by(3) { + let end = (i + 3).min(s.len()); + rb_chunks.push_fragment(&s[i..end]); + } + let chunk_rows = rb_chunks.rows().to_vec(); + + assert_eq!(all_rows, chunk_rows); + } + + #[test] + fn newline_splits_rows() { + let mut rb = RowBuilder::new(10); + rb.push_fragment("hello\nworld"); + let rows = rb.display_rows(); + assert!(rows.iter().any(|r| r.explicit_break)); + assert_eq!(rows[0].text, "hello"); + // Second row should begin with 'world' + assert!(rows.iter().any(|r| r.text.starts_with("world"))); + } + + #[test] + fn rewrap_on_width_change() { + let mut rb = RowBuilder::new(10); + rb.push_fragment("abcdefghijK"); + assert!(!rb.rows().is_empty()); + rb.set_width(5); + for r in rb.rows() { + assert!(r.width() <= 5); + } + } +} diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index ab201382..910a6869 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -1,3 +1,4 @@ +use crate::citation_regex::CITATION_REGEX; use codex_core::config::Config; use codex_core::config_types::UriBasedFileOpener; use ratatui::text::Line; @@ -5,8 +6,7 @@ use ratatui::text::Span; use std::borrow::Cow; use std::path::Path; -use crate::citation_regex::CITATION_REGEX; - +#[allow(dead_code)] pub(crate) fn append_markdown( markdown_source: &str, lines: &mut Vec>, @@ -15,6 +15,7 @@ pub(crate) fn append_markdown( append_markdown_with_opener_and_cwd(markdown_source, lines, config.file_opener, &config.cwd); } +#[allow(dead_code)] fn append_markdown_with_opener_and_cwd( markdown_source: &str, lines: &mut Vec>, @@ -60,6 +61,7 @@ fn append_markdown_with_opener_and_cwd( /// ```text /// ://file: /// ``` +#[allow(dead_code)] fn rewrite_file_citations<'a>( src: &'a str, file_opener: UriBasedFileOpener, diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index aa18ac6f..fad7e41a 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -9,24 +9,22 @@ use std::thread; use std::time::Duration; use ratatui::buffer::Buffer; -use ratatui::layout::Alignment; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; -use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; -use ratatui::widgets::Block; -use ratatui::widgets::BorderType; -use ratatui::widgets::Borders; -use ratatui::widgets::Padding; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; +use unicode_width::UnicodeWidthStr; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +// We render the live text using markdown so it visually matches the history +// cells. Before rendering we strip any ANSI escape sequences to avoid writing +// raw control bytes into the back buffer. use codex_ansi_escape::ansi_escape_line; pub(crate) struct StatusIndicatorWidget { @@ -34,6 +32,14 @@ pub(crate) struct StatusIndicatorWidget { /// time). text: String, + /// Animation state: reveal target `text` progressively like a typewriter. + /// We compute the currently visible prefix length based on the current + /// frame index and a constant typing speed. The `base_frame` and + /// `reveal_len_at_base` form the anchor from which we advance. + last_target_len: usize, + base_frame: usize, + reveal_len_at_base: usize, + frame_idx: Arc, running: Arc, // Keep one sender alive to prevent the channel from closing while the @@ -66,9 +72,13 @@ impl StatusIndicatorWidget { } Self { - text: String::from("waiting for logs…"), + text: String::from("waiting for model"), + last_target_len: 0, + base_frame: 0, + reveal_len_at_base: 0, frame_idx, running, + _app_event_tx: app_event_tx, } } @@ -79,7 +89,67 @@ impl StatusIndicatorWidget { /// Update the line that is displayed in the widget. pub(crate) fn update_text(&mut self, text: String) { - self.text = text.replace(['\n', '\r'], " "); + // If the text hasn't changed, don't reset the baseline; let the + // animation continue advancing naturally. + if text == self.text { + return; + } + // Update the target text, preserving newlines so wrapping matches history cells. + // Strip ANSI escapes for the character count so the typewriter animation speed is stable. + let stripped = { + let line = ansi_escape_line(&text); + line.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::>() + .join("") + }; + let new_len = stripped.chars().count(); + + // Compute how many characters are currently revealed so we can carry + // this forward as the new baseline when target text changes. + let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed); + let shown_now = self.current_shown_len(current_frame); + + self.text = text; + self.last_target_len = new_len; + self.base_frame = current_frame; + self.reveal_len_at_base = shown_now.min(new_len); + } + + /// Reset the animation and start revealing `text` from the beginning. + #[cfg(test)] + pub(crate) fn restart_with_text(&mut self, text: String) { + let sanitized = text.replace(['\n', '\r'], " "); + let stripped = { + let line = ansi_escape_line(&sanitized); + line.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::>() + .join("") + }; + + let new_len = stripped.chars().count(); + let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed); + + self.text = sanitized; + self.last_target_len = new_len; + self.base_frame = current_frame; + // Start from zero revealed characters for a fresh typewriter cycle. + self.reveal_len_at_base = 0; + } + + /// Calculate how many characters should currently be visible given the + /// animation baseline and frame counter. + fn current_shown_len(&self, current_frame: usize) -> usize { + // Increase typewriter speed (~5x): reveal more characters per frame. + const TYPING_CHARS_PER_FRAME: usize = 7; + let frames = current_frame.saturating_sub(self.base_frame); + let advanced = self + .reveal_len_at_base + .saturating_add(frames.saturating_mul(TYPING_CHARS_PER_FRAME)); + advanced.min(self.last_target_len) } } @@ -92,26 +162,22 @@ impl Drop for StatusIndicatorWidget { impl WidgetRef for StatusIndicatorWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let widget_style = Style::default(); - let block = Block::default() - .padding(Padding::new(1, 0, 0, 0)) - .borders(Borders::LEFT) - .border_type(BorderType::QuadrantOutside) - .border_style(widget_style.dim()); + // Ensure minimal height + if area.height == 0 || area.width == 0 { + return; + } + + // Build animated gradient header for the word "Working". let idx = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed); let header_text = "Working"; let header_chars: Vec = header_text.chars().collect(); - let padding = 4usize; // virtual padding around the word for smoother loop let period = header_chars.len() + padding * 2; let pos = idx % period; - let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout) .map(|level| level.has_16m) .unwrap_or(false); - - // Width of the bright band (in characters). - let band_half_width = 2.0; + let band_half_width = 2.0; // width of the bright band in characters let mut header_spans: Vec> = Vec::new(); for (i, ch) in header_chars.iter().enumerate() { @@ -133,64 +199,46 @@ impl WidgetRef for StatusIndicatorWidget { .fg(Color::Rgb(level, level, level)) .add_modifier(Modifier::BOLD) } else { - // Bold makes dark gray and gray look the same, so don't use it - // when true color is not supported. + // Bold makes dark gray and gray look the same, so don't use it when true color is not supported. Style::default().fg(color_for_level(level)) }; header_spans.push(Span::styled(ch.to_string(), style)); } - header_spans.push(Span::styled( + // Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback. + let inner_width = area.width as usize; + + // Compose a single status line like: "▌ Working [•] waiting for model" + let mut spans: Vec> = Vec::new(); + spans.push(Span::styled("▌ ", Style::default().fg(Color::Cyan))); + // Gradient header + spans.extend(header_spans); + // Space after header + spans.push(Span::styled( " ", Style::default() .fg(Color::White) .add_modifier(Modifier::BOLD), )); - // Ensure we do not overflow width. - let inner_width = block.inner(area).width as usize; - - // Sanitize and colour‑strip the potentially colourful log text. This - // ensures that **no** raw ANSI escape sequences leak into the - // back‑buffer which would otherwise cause cursor jumps or stray - // artefacts when the terminal is resized. - let line = ansi_escape_line(&self.text); - let mut sanitized_tail: String = line - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect::>() - .join(""); - - // Truncate *after* stripping escape codes so width calculation is - // accurate. See UTF‑8 boundary comments above. - let header_len: usize = header_spans.iter().map(|s| s.content.len()).sum(); - - if header_len + sanitized_tail.len() > inner_width { - let available_bytes = inner_width.saturating_sub(header_len); - - if sanitized_tail.is_char_boundary(available_bytes) { - sanitized_tail.truncate(available_bytes); + // Truncate spans to fit the width. + let mut acc: Vec> = Vec::new(); + let mut used = 0usize; + for s in spans { + let w = s.content.width(); + if used + w <= inner_width { + acc.push(s); + used += w; } else { - let mut idx = available_bytes; - while idx < sanitized_tail.len() && !sanitized_tail.is_char_boundary(idx) { - idx += 1; - } - sanitized_tail.truncate(idx); + break; } } + let lines = vec![Line::from(acc)]; - let mut spans = header_spans; + // No-op once full text is revealed; the app no longer reacts to a completion event. - // Re‑apply the DIM modifier so the tail appears visually subdued - // irrespective of the colour information preserved by - // `ansi_escape_line`. - spans.push(Span::styled(sanitized_tail, Style::default().dim())); - - let paragraph = Paragraph::new(Line::from(spans)) - .block(block) - .alignment(Alignment::Left); + let paragraph = Paragraph::new(lines); paragraph.render_ref(area, buf); } } @@ -204,3 +252,50 @@ fn color_for_level(level: u8) -> Color { Color::White } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use std::sync::mpsc::channel; + + #[test] + fn renders_without_left_border_or_padding() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let mut w = StatusIndicatorWidget::new(tx); + w.restart_with_text("Hello".to_string()); + + let area = ratatui::layout::Rect::new(0, 0, 30, 1); + // Allow a short delay so the typewriter reveals the first character. + std::thread::sleep(std::time::Duration::from_millis(120)); + let mut buf = ratatui::buffer::Buffer::empty(area); + w.render_ref(area, &mut buf); + + // Leftmost column has the left bar + let ch0 = buf[(0, 0)].symbol().chars().next().unwrap_or(' '); + assert_eq!(ch0, '▌', "expected left bar at col 0: {ch0:?}"); + } + + #[test] + fn working_header_is_present_on_last_line() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let mut w = StatusIndicatorWidget::new(tx); + w.restart_with_text("Hi".to_string()); + // Ensure some frames elapse so we get a stable state. + std::thread::sleep(std::time::Duration::from_millis(120)); + + let area = ratatui::layout::Rect::new(0, 0, 30, 1); + let mut buf = ratatui::buffer::Buffer::empty(area); + w.render_ref(area, &mut buf); + + // Single line; it should contain the animated "Working" header. + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); + } + assert!(row.contains("Working"), "expected Working header: {row:?}"); + } +} diff --git a/codex-rs/tui/tests/vt100_history.rs b/codex-rs/tui/tests/vt100_history.rs new file mode 100644 index 00000000..11ee0440 --- /dev/null +++ b/codex-rs/tui/tests/vt100_history.rs @@ -0,0 +1,214 @@ +#![cfg(feature = "vt100-tests")] +#![expect(clippy::expect_used)] + +use ratatui::backend::TestBackend; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; + +// Small helper macro to assert a collection contains an item with a clearer +// failure message. +macro_rules! assert_contains { + ($collection:expr, $item:expr $(,)?) => { + assert!( + $collection.contains(&$item), + "Expected {:?} to contain {:?}", + $collection, + $item + ); + }; + ($collection:expr, $item:expr, $($arg:tt)+) => { + assert!($collection.contains(&$item), $($arg)+); + }; +} + +struct TestScenario { + width: u16, + height: u16, + term: codex_tui::custom_terminal::Terminal, +} + +impl TestScenario { + fn new(width: u16, height: u16, viewport: Rect) -> Self { + let backend = TestBackend::new(width, height); + let mut term = codex_tui::custom_terminal::Terminal::with_options(backend) + .expect("failed to construct terminal"); + term.set_viewport_area(viewport); + Self { + width, + height, + term, + } + } + + fn run_insert(&mut self, lines: Vec>) -> Vec { + let mut buf: Vec = Vec::new(); + codex_tui::insert_history::insert_history_lines_to_writer(&mut self.term, &mut buf, lines); + buf + } + + fn screen_rows_from_bytes(&self, bytes: &[u8]) -> Vec { + let mut parser = vt100::Parser::new(self.height, self.width, 0); + parser.process(bytes); + let screen = parser.screen(); + + let mut rows: Vec = Vec::with_capacity(self.height as usize); + for row in 0..self.height { + let mut s = String::with_capacity(self.width as usize); + for col in 0..self.width { + if let Some(cell) = screen.cell(row, col) { + if let Some(ch) = cell.contents().chars().next() { + s.push(ch); + } else { + s.push(' '); + } + } else { + s.push(' '); + } + } + rows.push(s.trim_end().to_string()); + } + rows + } +} + +#[test] +fn hist_001_basic_insertion_no_wrap() { + // Screen of 20x6; viewport is the last row (height=1 at y=5) + let area = Rect::new(0, 5, 20, 1); + let mut scenario = TestScenario::new(20, 6, area); + + let lines = vec![Line::from("first"), Line::from("second")]; + let buf = scenario.run_insert(lines); + let rows = scenario.screen_rows_from_bytes(&buf); + assert_contains!(rows, String::from("first")); + assert_contains!(rows, String::from("second")); + let first_idx = rows + .iter() + .position(|r| r == "first") + .expect("expected 'first' row to be present"); + let second_idx = rows + .iter() + .position(|r| r == "second") + .expect("expected 'second' row to be present"); + assert_eq!(second_idx, first_idx + 1, "rows should be adjacent"); +} + +#[test] +fn hist_002_long_token_wraps() { + let area = Rect::new(0, 5, 20, 1); + let mut scenario = TestScenario::new(20, 6, area); + + let long = "A".repeat(45); // > 2 lines at width 20 + let lines = vec![Line::from(long.clone())]; + let buf = scenario.run_insert(lines); + let mut parser = vt100::Parser::new(6, 20, 0); + parser.process(&buf); + let screen = parser.screen(); + + // Count total A's on the screen + let mut count_a = 0usize; + for row in 0..6 { + for col in 0..20 { + if let Some(cell) = screen.cell(row, col) { + if let Some(ch) = cell.contents().chars().next() { + if ch == 'A' { + count_a += 1; + } + } + } + } + } + + assert_eq!( + count_a, + long.len(), + "wrapped content did not preserve all characters" + ); +} + +#[test] +fn hist_003_emoji_and_cjk() { + let area = Rect::new(0, 5, 20, 1); + let mut scenario = TestScenario::new(20, 6, area); + + let text = String::from("😀😀😀😀😀 你好世界"); + let lines = vec![Line::from(text.clone())]; + let buf = scenario.run_insert(lines); + let rows = scenario.screen_rows_from_bytes(&buf); + let reconstructed: String = rows.join("").chars().filter(|c| *c != ' ').collect(); + for ch in text.chars().filter(|c| !c.is_whitespace()) { + assert!( + reconstructed.contains(ch), + "missing character {ch:?} in reconstructed screen" + ); + } +} + +#[test] +fn hist_004_mixed_ansi_spans() { + let area = Rect::new(0, 5, 20, 1); + let mut scenario = TestScenario::new(20, 6, area); + + let line = Line::from(vec![ + Span::styled("red", Style::default().fg(Color::Red)), + Span::raw("+plain"), + ]); + let buf = scenario.run_insert(vec![line]); + let rows = scenario.screen_rows_from_bytes(&buf); + assert_contains!(rows, String::from("red+plain")); +} + +#[test] +fn hist_006_cursor_restoration() { + let area = Rect::new(0, 5, 20, 1); + let mut scenario = TestScenario::new(20, 6, area); + + let lines = vec![Line::from("x")]; + let buf = scenario.run_insert(lines); + let s = String::from_utf8_lossy(&buf); + // CUP to 1;1 (ANSI: ESC[1;1H) + assert!( + s.contains("\u{1b}[1;1H"), + "expected final CUP to 1;1 in output, got: {s:?}" + ); + // Reset scroll region + assert!( + s.contains("\u{1b}[r"), + "expected reset scroll region in output, got: {s:?}" + ); +} + +#[test] +fn hist_005_pre_scroll_region_down() { + // Viewport not at bottom: y=3 (0-based), height=1 + let area = Rect::new(0, 3, 20, 1); + let mut scenario = TestScenario::new(20, 6, area); + + let lines = vec![Line::from("first"), Line::from("second")]; + let buf = scenario.run_insert(lines); + let s = String::from_utf8_lossy(&buf); + // Expect we limited scroll region to [top+1 .. screen_height] => [4 .. 6] (1-based) + assert!( + s.contains("\u{1b}[4;6r"), + "expected pre-scroll SetScrollRegion 4..6, got: {s:?}" + ); + // Expect we moved cursor to top of that region: row 3 (0-based) => CUP 4;1H + assert!( + s.contains("\u{1b}[4;1H"), + "expected cursor at top of pre-scroll region, got: {s:?}" + ); + // Expect at least two Reverse Index commands (ESC M) for two inserted lines + let ri_count = s.matches("\u{1b}M").count(); + assert!( + ri_count >= 1, + "expected at least one RI (ESC M), got: {s:?}" + ); + // After pre-scroll, we set insertion scroll region to [1 .. new_top] => [1 .. 5] + assert!( + s.contains("\u{1b}[1;5r"), + "expected insertion SetScrollRegion 1..5, got: {s:?}" + ); +} diff --git a/codex-rs/tui/tests/vt100_live_commit.rs b/codex-rs/tui/tests/vt100_live_commit.rs new file mode 100644 index 00000000..c0cfb321 --- /dev/null +++ b/codex-rs/tui/tests/vt100_live_commit.rs @@ -0,0 +1,101 @@ +#![cfg(feature = "vt100-tests")] + +use ratatui::backend::TestBackend; +use ratatui::layout::Rect; +use ratatui::text::Line; + +#[test] +fn live_001_commit_on_overflow() { + let backend = TestBackend::new(20, 6); + let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) { + Ok(t) => t, + Err(e) => panic!("failed to construct terminal: {e}"), + }; + let area = Rect::new(0, 5, 20, 1); + term.set_viewport_area(area); + + // Build 5 explicit rows at width 20. + let mut rb = codex_tui::live_wrap::RowBuilder::new(20); + rb.push_fragment("one\n"); + rb.push_fragment("two\n"); + rb.push_fragment("three\n"); + rb.push_fragment("four\n"); + rb.push_fragment("five\n"); + + // Keep the last 3 in the live ring; commit the first 2. + let commit_rows = rb.drain_commit_ready(3); + let lines: Vec> = commit_rows + .into_iter() + .map(|r| Line::from(r.text)) + .collect(); + + let mut buf: Vec = Vec::new(); + codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines); + + let mut parser = vt100::Parser::new(6, 20, 0); + parser.process(&buf); + let screen = parser.screen(); + + // The words "one" and "two" should appear above the viewport. + let mut joined = String::new(); + for row in 0..6 { + for col in 0..20 { + if let Some(cell) = screen.cell(row, col) { + if let Some(ch) = cell.contents().chars().next() { + joined.push(ch); + } else { + joined.push(' '); + } + } + } + joined.push('\n'); + } + assert!( + joined.contains("one"), + "expected committed 'one' to be visible\n{joined}" + ); + assert!( + joined.contains("two"), + "expected committed 'two' to be visible\n{joined}" + ); + // The last three (three,four,five) remain in the live ring, not committed here. +} + +#[test] +fn live_002_pre_scroll_and_commit() { + let backend = TestBackend::new(20, 6); + let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) { + Ok(t) => t, + Err(e) => panic!("failed to construct terminal: {e}"), + }; + // Viewport not at bottom: y=3 + let area = Rect::new(0, 3, 20, 1); + term.set_viewport_area(area); + + let mut rb = codex_tui::live_wrap::RowBuilder::new(20); + rb.push_fragment("alpha\n"); + rb.push_fragment("beta\n"); + rb.push_fragment("gamma\n"); + rb.push_fragment("delta\n"); + + // Keep 3, commit 1. + let commit_rows = rb.drain_commit_ready(3); + let lines: Vec> = commit_rows + .into_iter() + .map(|r| Line::from(r.text)) + .collect(); + + let mut buf: Vec = Vec::new(); + codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines); + let s = String::from_utf8_lossy(&buf); + + // Expect a SetScrollRegion to [area.top()+1 .. screen_height] and a cursor move to top of that region. + assert!( + s.contains("\u{1b}[4;6r"), + "expected pre-scroll region 4..6, got: {s:?}" + ); + assert!( + s.contains("\u{1b}[4;1H"), + "expected cursor CUP 4;1H, got: {s:?}" + ); +}