Files
codex/prs/bolinfest/PR-1810.md
2025-09-02 15:17:45 -07:00

156 KiB
Raw Blame History

PR #1810: Stream model responses

Description

Stream models thoughts and responses instead of waiting for the whole thing to come through. Very rough right now.

Full Diff

diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 4daae977b0..2e20a7d624 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 b5ade23b9d..e1804b191e 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<S>(
                 .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 568d87c4a8..8d24356460 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 f5254f339e..1d55b125bc 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<Target = ResponseItem>,
     {
         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<crate::models::ContentItem>,
+    src: &Vec<crate::models::ContentItem>,
+) {
+    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<crate::models::ContentItem>, 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 166404915a..91bfb3bc8c 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<ResponseInputItem> 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<String>,
     pub timeout_ms: Option<u64>,
@@ -130,7 +130,7 @@ pub struct LocalShellExecAction {
     pub user: Option<String>,
 }
 
-#[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<u64>,
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
 pub struct FunctionCallOutputPayload {
     pub content: String,
-    #[expect(dead_code)]
     pub success: Option<bool>,
 }
 
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index a571b32c8d..60af056a2d 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 0000000000..13f91acc5d
--- /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<Line<'static>>, // 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<Line<'static>>) {
+        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 cab78bbe3f..fde0b3bde8 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, multiline 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<StatusIndicatorWidget>,
+
+    /// 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<live_ring_widget::LiveRingWidget>,
+
+    /// 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<Line<'static>>) {
+        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::<AppEvent>();
+        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<String> = 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::<AppEvent>();
+        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::<AppEvent>();
+        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::<AppEvent>();
+        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 e5ebf58a07..f63810b62a 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<String>,
@@ -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<String, RunningCommand>,
+    live_builder: RowBuilder,
+    current_stream: Option<StreamKind>,
+    stream_header_emitted: bool,
+    live_max_rows: u16,
 }
 
 struct UserMessage {
@@ -71,6 +77,12 @@ struct UserMessage {
     image_paths: Vec<PathBuf>,
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum StreamKind {
+    Answer,
+    Reasoning,
+}
+
 impl From<String> 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 autoapproved 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<ratatui::text::Line<'static>> = 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::<Vec<_>>();
+        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<ratatui::text::Line<'static>> = 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 c2aafdd522..17f0e683c0 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<Line<'static>> = 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<Line<'static>> = 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<String>) -> 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 87d88b7f03..5c316637b1 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<Line>) {
+    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<B, W>(
+    terminal: &mut crate::custom_terminal::Terminal<B>,
+    writer: &mut W,
+    lines: Vec<Line>,
+) 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<Line>) {
         // 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<Line>) {
     // ││                            ││
     // │╰────────────────────────────╯│
     // └──────────────────────────────┘
-    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::<usize>();
-    // 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::<Vec<_>>()
+        .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 0ec9be6153..c619ce8ff0 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 0000000000..e78710dc6c
--- /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<Row>,
+}
+
+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<Row> {
+        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<Row> {
+        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<Row> {
+        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 ab20138298..910a6869ec 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<Line<'static>>,
@@ -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<Line<'static>>,
@@ -60,6 +61,7 @@ fn append_markdown_with_opener_and_cwd(
 /// ```text
 /// <scheme>://file<ABS_PATH>:<LINE>
 /// ```
+#[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 aa18ac6fa5..fad7e41a39 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<AtomicUsize>,
     running: Arc<AtomicBool>,
     // 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::<Vec<_>>()
+                .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::<Vec<_>>()
+                .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<char> = 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<Span<'static>> = 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<Span<'static>> = 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 colourstrip the potentially colourful log text.  This
-        // ensures that **no** raw ANSI escape sequences leak into the
-        // backbuffer 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::<Vec<_>>()
-            .join("");
-
-        // Truncate *after* stripping escape codes so width calculation is
-        // accurate. See UTF8 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<Span<'static>> = 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;
-
-        // Reapply 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()));
+        // No-op once full text is revealed; the app no longer reacts to a completion event.
 
-        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::<AppEvent>();
+        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::<AppEvent>();
+        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 0000000000..11ee044041
--- /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<TestBackend>,
+}
+
+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<Line<'static>>) -> Vec<u8> {
+        let mut buf: Vec<u8> = 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<String> {
+        let mut parser = vt100::Parser::new(self.height, self.width, 0);
+        parser.process(bytes);
+        let screen = parser.screen();
+
+        let mut rows: Vec<String> = 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 0000000000..c0cfb3211a
--- /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<Line<'static>> = commit_rows
+        .into_iter()
+        .map(|r| Line::from(r.text))
+        .collect();
+
+    let mut buf: Vec<u8> = 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<Line<'static>> = commit_rows
+        .into_iter()
+        .map(|r| Line::from(r.text))
+        .collect();
+
+    let mut buf: Vec<u8> = 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:?}"
+    );
+}

Review Comments

codex-rs/core/src/codex.rs

@@ -122,8 +122,17 @@ impl Codex {
         // experimental resume path (undocumented)
         let resume_path = config.experimental_resume.clone();
         info!("resume_path: {resume_path:?}");
+        // Use a bounded channel for submissions to retain backpressure on the

decide whether to keep this?

@@ -1374,6 +1383,13 @@ async fn try_run_turn(
                 return Ok(output);
             }
             ResponseEvent::OutputTextDelta(delta) => {
+                // Stream assistant text into in-memory conversation history so

?

codex-rs/core/src/conversation_history.rs

@@ -24,9 +24,53 @@ impl ConversationHistory {
         I::Item: std::ops::Deref<Target = ResponseItem>,
     {
         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);
+                }
+                _ => {
+                    // Note agent-loop.ts also does filtering on some of the fields.

TS ref?

@@ -24,9 +24,53 @@ impl ConversationHistory {
         I::Item: std::ops::Deref<Target = ResponseItem>,
     {
         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 needs a test.

@@ -72,3 +115,131 @@ 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<crate::models::ContentItem>,
+    src: &Vec<crate::models::ContentItem>,
+) {
+    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<crate::models::ContentItem>, 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.len(), 1, "adjacent assistant messages should merge");
+        if let ResponseItem::Message { role, content, .. } = &items[0] {
+            assert_eq!(role, "assistant");
+            let text = match &content[0] {
+                ContentItem::OutputText { text } => text,
+                _ => panic!("expected OutputText"),
+            };
+            assert_eq!(text, "Hello, world!");
+        } else {
+            panic!("expected Message");
+        }

Can you just make this:

assert_eq!(items, vec![...]);

we should know the exact output we are getting, correct?

Same for the other tests, as well.

codex-rs/tui/Cargo.toml

@@ -65,6 +69,7 @@ tui-markdown = "0.3.3"
 unicode-segmentation = "1.12.0"
 unicode-width = "0.1"
 uuid = "1"
+vt100 = { version = "0.16.2", optional = true }

[dev-dependencies]

codex-rs/tui/src/app.rs

@@ -220,6 +220,7 @@ impl App<'_> {
                 AppEvent::Redraw => {
                     std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
                 }
+                AppEvent::LiveStatusRevealComplete => {}

unused?

codex-rs/tui/src/bottom_pane/mod.rs

@@ -148,19 +182,38 @@ 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 => {
+        if let Some(view) = self.active_view.as_mut() {
+            match view.update_status_text(text.clone()) {
+                bottom_pane_view::ConditionalUpdate::NeedsRedraw => {
                     self.request_redraw();
+                    return;
                 }
-                ConditionalUpdate::NoRedraw => {
-                    // No redraw needed.
-                }
+                bottom_pane_view::ConditionalUpdate::NoRedraw => {}

This is the only case in which we don't return;, so maybe move the logic from "Fallback" into here and then eliminate the return; statements?

@@ -148,19 +182,38 @@ 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 => {
+        if let Some(view) = self.active_view.as_mut() {
+            match view.update_status_text(text.clone()) {
+                bottom_pane_view::ConditionalUpdate::NeedsRedraw => {
                     self.request_redraw();
+                    return;
                 }
-                ConditionalUpdate::NoRedraw => {
-                    // No redraw needed.
-                }
+                bottom_pane_view::ConditionalUpdate::NoRedraw => {}
             }
+        } else {
+            let mut v = StatusIndicatorView::new(self.app_event_tx.clone());
+            v.update_text(text);
+            self.active_view = Some(Box::new(v));
+            self.status_view_active = true;
+            self.request_redraw();
+            return;
+        }
+
+        // Fallback: if the current active view does not consume status updates,
+        // present an overlay above the composer.
+        if self.live_status.is_none() {
+            self.live_status = Some(crate::status_indicator_widget::StatusIndicatorWidget::new(
+                self.app_event_tx.clone(),
+            ));
         }
+        if let Some(status) = &mut self.live_status {
+            status.update_text(text);
+        }
+        self.request_redraw();

It looks like all code paths result in self.request_redraw().

@@ -68,14 +82,42 @@ 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);
+
+        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
+            };
+            overlay_status_h
+                .saturating_add(ring_h)
+                .saturating_add(spacer)
+                .saturating_add(view.desired_height(width))
+                .saturating_add(Self::BOTTOM_PAD_LINES)
+        } else {
+            overlay_status_h
+                .saturating_add(ring_h)
+                .saturating_add(self.composer.desired_height(width))
+                .saturating_add(Self::BOTTOM_PAD_LINES)
+        }

Up to you:

        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)
@@ -148,19 +182,40 @@ 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(crate::status_indicator_widget::StatusIndicatorWidget::new(

I would favor declaring types like these as top-level import statements.

@@ -186,28 +241,26 @@ 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);
+                    self.status_view_active = false;
+                } else {
+                    self.status_view_active = false;

self.status_view_active = false in both cases, right?

                if !view.should_hide_when_task_is_done() {
                    self.active_view = Some(view);
                }
                self.status_view_active = false;
@@ -281,15 +335,84 @@ 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<ratatui::text::Line<'static>>,

use ratatui::text::Line up top?

        rows: Vec<Line<'static>>,
@@ -281,15 +335,84 @@ 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<ratatui::text::Line<'static>>,
+    ) {
+        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.
+        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(ov) = &self.active_view {
-            ov.render(area, buf);
-        } else {
-            (&self.composer).render_ref(area, buf);
+            if y_offset < area.height {

What is ov supposed to stand for? I haven't noted it across the PR, but we generally avoid single character variable names in this codebase.

@@ -324,4 +450,211 @@ 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::<AppEvent>();
+        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<String> = 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!(

Can we not assert_eq!()? What is the exact contents here?

codex-rs/tui/src/chatwidget.rs

@@ -235,57 +251,46 @@ 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));
-                }
+                // 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.
+                let _ = message; // Already streamed via deltas in most providers.

Maybe this above as a different way to ignore message:

AgentMessageEvent {
    // Already streamed via deltas in most providers.
    message: _
}
@@ -235,57 +251,46 @@ 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));
-                }
+                // 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.
+                let _ = message; // Already streamed via deltas in most providers.
+                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));
-                }
+                // Final reasoning: commit remaining rows and close with a blank.
+                let _ = text; // Deltas carried the content; finalize below.

Same as above.

@@ -515,6 +518,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<ratatui::text::Line<'static>> = 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::<Vec<_>>();
+        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<ratatui::text::Line<'static>> = 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(String::new()));

Does & 'static str work instead of String?

            lines.push(ratatui::text::Line::from(""));

codex-rs/tui/src/history_cell.rs

@@ -231,27 +223,7 @@ impl HistoryCell {
         }
     }
 
-    pub(crate) fn new_agent_message(config: &Config, message: String) -> Self {
-        let mut lines: Vec<Line<'static>> = 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<Line<'static>> = 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),
-        }
-    }
+    // Removed unused new_agent_message and new_agent_reasoning constructors.

delete comment?

codex-rs/tui/src/insert_history.rs

@@ -14,14 +14,27 @@ 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;
 use ratatui::text::Span;
 
 /// Insert `lines` above the viewport.
 pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
+    let mut out = std::io::stdout();

Does doing the write directly (not through Ratatui) cause any issues?

@@ -59,23 +84,23 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
     // ││                            ││
     // │╰────────────────────────────╯│
     // └──────────────────────────────┘
-    queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok();

Oh, I guess we were already doing this?

codex-rs/tui/src/lib.rs

@@ -25,13 +25,20 @@ mod bottom_pane;
 mod chatwidget;
 mod citation_regex;
 mod cli;
+#[cfg(feature = "vt100-tests")]

Is this less work to just always make it pub? This is only within our own workspace, anyway.

codex-rs/tui/src/live_wrap.rs

@@ -0,0 +1,272 @@
+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<Row>,
+}
+
+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<Row> {
+        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<Row> {
+        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<Row> {
+        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 `s` whose visible width is at most `max_cols`.

Why not name it prefix instead of s?

@@ -0,0 +1,272 @@
+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<Row>,
+}
+
+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<Row> {
+        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<Row> {
+        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<Row> {
+        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 `s` whose visible width is at most `max_cols`.
+/// Returns (prefix, suffix, prefix_width).
+pub fn take_prefix_by_width(s: &str, max_cols: usize) -> (String, &str, usize) {
+    if max_cols == 0 || s.is_empty() {
+        return (String::new(), s, 0);
+    }
+    let mut cols = 0usize;
+    let mut end_idx = 0usize;
+    for (i, ch) in s.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 = s[..end_idx].to_string();
+    let suffix = &s[end_idx..];
+    (prefix, suffix, cols)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn rows_do_not_exceed_width_ascii() {
+        let mut rb = RowBuilder::new(10);
+        rb.push_fragment("hello world this is a test");
+        let rows = rb.rows();
+        assert!(!rows.is_empty());

Again, please just assert_eq!() throughout. I would also make liberal use of use pretty_assertions::assert_eq; in tests.

codex-rs/tui/src/markdown.rs

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

Is this used in an upcoming PR?

@@ -60,6 +61,7 @@ fn append_markdown_with_opener_and_cwd(
 /// ```text
 /// <scheme>://file<ABS_PATH>:<LINE>
 /// ```
+#[allow(dead_code)]

Same question.

codex-rs/tui/tests/vt100_history.rs

@@ -0,0 +1,272 @@
+#![cfg(feature = "vt100-tests")]
+
+use ratatui::backend::TestBackend;
+use ratatui::layout::Rect;
+use ratatui::style::Color;
+use ratatui::style::Style;
+use ratatui::text::Line;
+use ratatui::text::Span;
+
+/// HIST-001: Basic insertion at bottom, no wrap.
+///
+/// This test captures the ANSI bytes produced by `insert_history_lines_to_writer`
+/// when the viewport is at the bottom of the screen (so no pre-scroll is
+/// required). It feeds the bytes into a vt100 parser and asserts that the
+/// inserted lines are visible near the bottom of the screen.
+#[test]
+fn hist_001_basic_insertion_no_wrap() {
+    // Screen of 20x6; viewport is the last row (height=1 at y=5)
+    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}"),

FYI, I would favor putting this at the top of an integration test:

#![expect(clippy::expect_used)]

and then use expect() liberally with an expression that evaluates to Result so you don't have to do a match across multiple lines.

@@ -0,0 +1,272 @@
+#![cfg(feature = "vt100-tests")]
+
+use ratatui::backend::TestBackend;
+use ratatui::layout::Rect;
+use ratatui::style::Color;
+use ratatui::style::Style;
+use ratatui::text::Line;
+use ratatui::text::Span;
+
+/// HIST-001: Basic insertion at bottom, no wrap.
+///
+/// This test captures the ANSI bytes produced by `insert_history_lines_to_writer`
+/// when the viewport is at the bottom of the screen (so no pre-scroll is
+/// required). It feeds the bytes into a vt100 parser and asserts that the
+/// inserted lines are visible near the bottom of the screen.
+#[test]
+fn hist_001_basic_insertion_no_wrap() {
+    // Screen of 20x6; viewport is the last row (height=1 at y=5)
+    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}"),
+    };
+
+    // Place the viewport at the bottom row
+    let area = Rect::new(0, 5, 20, 1);
+    term.set_viewport_area(area);
+
+    let lines = vec![Line::from("first"), Line::from("second")];
+    let mut buf: Vec<u8> = Vec::new();
+
+    codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
+
+    // Feed captured bytes into vt100 emulator
+    let mut parser = vt100::Parser::new(6, 20, 0);
+    parser.process(&buf);
+    let screen = parser.screen();
+
+    // Gather visible rows as strings

Seems like a common operation for these tests that should be a helper function?

@@ -0,0 +1,272 @@
+#![cfg(feature = "vt100-tests")]
+
+use ratatui::backend::TestBackend;
+use ratatui::layout::Rect;
+use ratatui::style::Color;
+use ratatui::style::Style;
+use ratatui::text::Line;
+use ratatui::text::Span;
+
+/// HIST-001: Basic insertion at bottom, no wrap.
+///
+/// This test captures the ANSI bytes produced by `insert_history_lines_to_writer`
+/// when the viewport is at the bottom of the screen (so no pre-scroll is
+/// required). It feeds the bytes into a vt100 parser and asserts that the
+/// inserted lines are visible near the bottom of the screen.
+#[test]
+fn hist_001_basic_insertion_no_wrap() {
+    // Screen of 20x6; viewport is the last row (height=1 at y=5)
+    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}"),
+    };
+
+    // Place the viewport at the bottom row
+    let area = Rect::new(0, 5, 20, 1);
+    term.set_viewport_area(area);
+
+    let lines = vec![Line::from("first"), Line::from("second")];
+    let mut buf: Vec<u8> = Vec::new();
+
+    codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
+
+    // Feed captured bytes into vt100 emulator
+    let mut parser = vt100::Parser::new(6, 20, 0);
+    parser.process(&buf);
+    let screen = parser.screen();
+
+    // Gather visible rows as strings
+    let mut rows: Vec<String> = Vec::new();
+    for row in 0..6 {
+        let mut s = String::new();
+        for col in 0..20 {
+            if let Some(cell) = screen.cell(row, col) {
+                let cont = cell.contents();
+                if let Some(ch) = cont.chars().next() {
+                    s.push(ch);
+                } else {
+                    s.push(' ');
+                }
+            } else {
+                s.push(' ');
+            }
+        }
+        rows.push(s);
+    }
+
+    // The inserted lines should appear somewhere above the viewport; in this
+    // simple case, they will occupy the two rows immediately above the final
+    // row of the scroll region.
+    let joined = rows.join("\n");

Should this be true:

assert_eq!(vec!["", "", "", "", "first", "second"], rows);
@@ -0,0 +1,272 @@
+#![cfg(feature = "vt100-tests")]
+
+use ratatui::backend::TestBackend;
+use ratatui::layout::Rect;
+use ratatui::style::Color;
+use ratatui::style::Style;
+use ratatui::text::Line;
+use ratatui::text::Span;
+
+/// HIST-001: Basic insertion at bottom, no wrap.
+///
+/// This test captures the ANSI bytes produced by `insert_history_lines_to_writer`
+/// when the viewport is at the bottom of the screen (so no pre-scroll is
+/// required). It feeds the bytes into a vt100 parser and asserts that the
+/// inserted lines are visible near the bottom of the screen.
+#[test]
+fn hist_001_basic_insertion_no_wrap() {
+    // Screen of 20x6; viewport is the last row (height=1 at y=5)
+    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}"),
+    };
+
+    // Place the viewport at the bottom row
+    let area = Rect::new(0, 5, 20, 1);
+    term.set_viewport_area(area);
+
+    let lines = vec![Line::from("first"), Line::from("second")];
+    let mut buf: Vec<u8> = Vec::new();
+
+    codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
+
+    // Feed captured bytes into vt100 emulator
+    let mut parser = vt100::Parser::new(6, 20, 0);
+    parser.process(&buf);
+    let screen = parser.screen();
+
+    // Gather visible rows as strings
+    let mut rows: Vec<String> = Vec::new();
+    for row in 0..6 {
+        let mut s = String::new();
+        for col in 0..20 {
+            if let Some(cell) = screen.cell(row, col) {
+                let cont = cell.contents();
+                if let Some(ch) = cont.chars().next() {
+                    s.push(ch);
+                } else {
+                    s.push(' ');
+                }
+            } else {
+                s.push(' ');
+            }
+        }
+        rows.push(s);
+    }
+
+    // The inserted lines should appear somewhere above the viewport; in this
+    // simple case, they will occupy the two rows immediately above the final
+    // row of the scroll region.
+    let joined = rows.join("\n");
+    assert!(

If you want to get fancy, chat suggested:

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)+
        );
    };
}
@@ -0,0 +1,272 @@
+#![cfg(feature = "vt100-tests")]
+
+use ratatui::backend::TestBackend;
+use ratatui::layout::Rect;
+use ratatui::style::Color;
+use ratatui::style::Style;
+use ratatui::text::Line;
+use ratatui::text::Span;
+
+/// HIST-001: Basic insertion at bottom, no wrap.

What's the significance of the id HIST-001?

@@ -0,0 +1,272 @@
+#![cfg(feature = "vt100-tests")]
+
+use ratatui::backend::TestBackend;
+use ratatui::layout::Rect;
+use ratatui::style::Color;
+use ratatui::style::Style;
+use ratatui::text::Line;
+use ratatui::text::Span;
+
+/// HIST-001: Basic insertion at bottom, no wrap.
+///
+/// This test captures the ANSI bytes produced by `insert_history_lines_to_writer`
+/// when the viewport is at the bottom of the screen (so no pre-scroll is
+/// required). It feeds the bytes into a vt100 parser and asserts that the
+/// inserted lines are visible near the bottom of the screen.
+#[test]
+fn hist_001_basic_insertion_no_wrap() {
+    // Screen of 20x6; viewport is the last row (height=1 at y=5)
+    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}"),
+    };
+
+    // Place the viewport at the bottom row
+    let area = Rect::new(0, 5, 20, 1);
+    term.set_viewport_area(area);
+
+    let lines = vec![Line::from("first"), Line::from("second")];
+    let mut buf: Vec<u8> = Vec::new();
+
+    codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
+
+    // Feed captured bytes into vt100 emulator
+    let mut parser = vt100::Parser::new(6, 20, 0);
+    parser.process(&buf);
+    let screen = parser.screen();
+
+    // Gather visible rows as strings
+    let mut rows: Vec<String> = Vec::new();
+    for row in 0..6 {
+        let mut s = String::new();
+        for col in 0..20 {
+            if let Some(cell) = screen.cell(row, col) {
+                let cont = cell.contents();
+                if let Some(ch) = cont.chars().next() {
+                    s.push(ch);
+                } else {
+                    s.push(' ');
+                }
+            } else {
+                s.push(' ');
+            }
+        }
+        rows.push(s);
+    }
+
+    // The inserted lines should appear somewhere above the viewport; in this
+    // simple case, they will occupy the two rows immediately above the final
+    // row of the scroll region.
+    let joined = rows.join("\n");
+    assert!(
+        joined.contains("first"),
+        "screen did not contain 'first'\n{joined}"
+    );
+    assert!(
+        joined.contains("second"),
+        "screen did not contain 'second'\n{joined}"
+    );
+}
+
+/// HIST-002: Long token wraps across rows within the scroll region.
+#[test]
+fn hist_002_long_token_wraps() {
+    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);

Much of this setup seems repeated across tests, so you may want to create a setup function that returns something like:

struct TestScenario {
    backend: TestBackend,
    term: Terminal,
}

and you could add methods like get_screen_contents(buf: &Vec<u8>) -> String perhaps?

@@ -0,0 +1,231 @@
+#![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<TestBackend>,
+}
+
+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<Line<'static>>) -> Vec<u8> {
+        let mut buf: Vec<u8> = 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<String> {
+        let mut parser = vt100::Parser::new(self.height, self.width, 0);
+        parser.process(bytes);
+        let screen = parser.screen();
+
+        let mut rows: Vec<String> = 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 mut parser = vt100::Parser::new(6, 20, 0);
+    parser.process(&buf);
+    let screen = parser.screen();
+
+    // Reconstruct string by concatenating non-space cells; ensure all emojis and CJK are present.
+    let mut reconstructed = String::new();

Why not screen_rows_from_bytes