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..268e0442c3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/live_ring_widget.rs @@ -0,0 +1,37 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; + +/// Minimal rendering-only widget for the transient ring rows. +pub(crate) struct LiveRingWidget { + max_rows: u16, + rows: Vec>, // newest at the end +} + +impl LiveRingWidget { + pub fn new() -> Self { + Self { max_rows: 3, rows: Vec::new() } + } + + pub fn set_max_rows(&mut self, n: u16) { self.max_rows = n.max(1); } + + pub fn set_rows(&mut self, rows: Vec>) { self.rows = rows; } + + pub fn desired_height(&self, _width: u16) -> u16 { + let len = self.rows.len() as u16; + len.min(self.max_rows).max(0) + } +} + +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 08c8b141f3..30aca08d2a 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -18,6 +18,7 @@ mod chat_composer_history; mod command_popup; mod file_search_popup; mod status_indicator_view; +mod live_ring_widget; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CancellationEvent { @@ -50,6 +51,10 @@ pub(crate) struct BottomPane<'a> { /// not replace the composer; it augments it. live_status: Option, + /// Optional transient ring shown above the composer. This is a rendering-only + /// container used during development before we wire it to ChatWidget events. + live_ring: Option, + /// True if the active view is the StatusIndicatorView that replaces the /// composer during a running task. status_view_active: bool, @@ -62,6 +67,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 { @@ -76,21 +82,36 @@ impl BottomPane<'_> { 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 { - let live_h = self + let overlay_status_h = self .live_status .as_ref() .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() { - live_h.saturating_add(view.desired_height(width)) + // 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 { - live_h.saturating_add(self.composer.desired_height()) + overlay_status_h + .saturating_add(ring_h) + .saturating_add(self.composer.desired_height()) + .saturating_add(Self::BOTTOM_PAD_LINES) } } @@ -144,10 +165,10 @@ impl BottomPane<'_> { } } - /// Update the status indicator text. + /// 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 a specialized view can handle status updates (e.g. the - // StatusIndicatorView that replaces the composer), prefer that. if let Some(view) = self.active_view.as_mut() { match view.update_status_text(text.clone()) { bottom_pane_view::ConditionalUpdate::NeedsRedraw => { @@ -157,8 +178,6 @@ impl BottomPane<'_> { bottom_pane_view::ConditionalUpdate::NoRedraw => {} } } else { - // No active view – show the status indicator in place of the - // composer to prevent typing while waiting. let mut v = StatusIndicatorView::new(self.app_event_tx.clone()); v.update_text(text); self.active_view = Some(Box::new(v)); @@ -167,9 +186,8 @@ impl BottomPane<'_> { return; } - // Fallback: if the current active view does not consume status - // updates, show a live overlay above the composer so the animation - // continues while the view is visible (e.g. approval modal). + // 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(), @@ -181,6 +199,7 @@ impl BottomPane<'_> { self.request_redraw(); } + pub(crate) fn show_ctrl_c_quit_hint(&mut self) { self.ctrl_c_quit_hint = true; self.composer @@ -299,6 +318,18 @@ impl BottomPane<'_> { self.request_redraw(); } + /// Set the rows and cap for the transient live ring overlay. + pub(crate) fn set_live_ring_rows(&mut self, max_rows: u16, rows: Vec>) { + let mut w = live_ring_widget::LiveRingWidget::new(); + w.set_max_rows(max_rows); + w.set_rows(rows); + self.live_ring = Some(w); + } + + pub(crate) fn clear_live_ring(&mut self) { + self.live_ring = None; + } + /// Clear the live status cell (e.g., when the streamed text has been /// inserted into history and we no longer need the inline preview). pub(crate) fn clear_live_status(&mut self) { @@ -342,6 +373,7 @@ impl BottomPane<'_> { self.request_redraw(); } + /// Remove the active StatusIndicatorView (composer takeover) if present, /// restoring the composer for user input. pub(crate) fn clear_status_view(&mut self) { @@ -356,6 +388,19 @@ impl BottomPane<'_> { impl WidgetRef for &BottomPane<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) { 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 { @@ -372,11 +417,14 @@ impl WidgetRef for &BottomPane<'_> { if let Some(ov) = &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: area.height - y_offset, + height: avail - pad, }; ov.render(view_rect, buf); } @@ -385,7 +433,10 @@ impl WidgetRef for &BottomPane<'_> { x: area.x, y: area.y + y_offset, width: area.width, - height: area.height - y_offset, + // 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); } @@ -396,6 +447,7 @@ impl WidgetRef for &BottomPane<'_> { mod tests { use super::*; use crate::app_event::AppEvent; + use ratatui::{buffer::Buffer, layout::Rect, text::Line}; use std::path::PathBuf; use std::sync::mpsc::channel; @@ -422,4 +474,167 @@ mod tests { assert!(pane.ctrl_c_quit_hint_visible()); assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c()); } + + #[test] + fn live_ring_renders_above_composer() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + has_input_focus: true, + enhanced_keys_supported: false, + }); + + // Provide 4 rows with max_rows=3; only the last 3 should be visible. + pane.set_live_ring_rows( + 3, + vec![ + Line::from("one".to_string()), + Line::from("two".to_string()), + Line::from("three".to_string()), + Line::from("four".to_string()), + ], + ); + + let area = Rect::new(0, 0, 10, 5); + let mut buf = Buffer::empty(area); + (&pane).render_ref(area, &mut buf); + + // Extract the first 3 rows and assert they contain the last three lines. + let mut lines: Vec = Vec::new(); + for y in 0..3 { + let mut s = String::new(); + for x in 0..area.width { s.push(buf.get(x, y).symbol().chars().next().unwrap_or(' ')); } + lines.push(s.trim_end().to_string()); + } + assert!(lines[0].contains("two"), "top row should be 'two': {lines:?}"); + assert!(lines[1].contains("three"), "middle row should be 'three': {lines:?}"); + assert!(lines[2].contains("four"), "bottom row should be 'four': {lines:?}"); + } + + #[test] + fn status_indicator_visible_with_live_ring() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + has_input_focus: true, + enhanced_keys_supported: false, + }); + + // Simulate task running which replaces composer with the status indicator. + pane.set_task_running(true); + pane.update_status_text("waiting for model".to_string()); + + // Provide 2 rows in the live ring (e.g., streaming CoT) and ensure the + // status indicator remains visible below them. + pane.set_live_ring_rows( + 2, + vec![Line::from("cot1".to_string()), Line::from("cot2".to_string())], + ); + + // Allow some frames so the dot animation is present. + std::thread::sleep(std::time::Duration::from_millis(120)); + + // Height should include both ring rows, 1 spacer, and the 1-line status. + let area = Rect::new(0, 0, 30, 4); + let mut buf = Buffer::empty(area); + (&pane).render_ref(area, &mut buf); + + // Top two rows are the live ring. + let mut r0 = String::new(); + let mut r1 = String::new(); + for x in 0..area.width { + r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); + r1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' ')); + } + assert!(r0.contains("cot1"), "expected first live row: {r0:?}"); + assert!(r1.contains("cot2"), "expected second live row: {r1:?}"); + + // Row 2 is the spacer (blank) + let mut r2 = String::new(); + for x in 0..area.width { r2.push(buf[(x, 2)].symbol().chars().next().unwrap_or(' ')); } + assert!(r2.trim().is_empty(), "expected blank spacer line: {r2:?}"); + + // Bottom row is the status line; it should contain the left bar and "Working [". + let mut r3 = String::new(); + for x in 0..area.width { + r3.push(buf[(x, 3)].symbol().chars().next().unwrap_or(' ')); + } + assert_eq!(buf[(0, 3)].symbol().chars().next().unwrap_or(' '), '▌'); + assert!(r3.contains("Working ["), "expected spinner prefix in status line: {r3:?}"); + } + + #[test] + fn bottom_padding_present_for_status_view() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + has_input_focus: true, + enhanced_keys_supported: false, + }); + + // Activate spinner (status view replaces composer) with no live ring. + pane.set_task_running(true); + pane.update_status_text("waiting for model".to_string()); + + // Use height == desired_height; expect 1 status row at top and 2 bottom padding rows. + let height = pane.desired_height(30); + assert!(height >= 3, "expected at least 3 rows with bottom padding; got {height}"); + let area = Rect::new(0, 0, 30, height); + let mut buf = Buffer::empty(area); + (&pane).render_ref(area, &mut buf); + + // Top row contains the spinner + 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 spinner on top row: {top:?}"); + + // Bottom two rows are blank padding + let mut r_last = String::new(); + let mut r_last2 = String::new(); + for x in 0..area.width { + r_last.push(buf[(x, height - 1)].symbol().chars().next().unwrap_or(' ')); + r_last2.push(buf[(x, height - 2)].symbol().chars().next().unwrap_or(' ')); + } + assert!(r_last.trim().is_empty(), "expected last row blank: {r_last:?}"); + assert!(r_last2.trim().is_empty(), "expected second-to-last row blank: {r_last2:?}"); + } + + #[test] + fn bottom_padding_shrinks_when_tiny() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + has_input_focus: true, + enhanced_keys_supported: false, + }); + + pane.set_task_running(true); + pane.update_status_text("waiting for model".to_string()); + + // Height=2 → pad shrinks to 1; bottom row is blank, top row has spinner. + let area2 = Rect::new(0, 0, 20, 2); + let mut buf2 = Buffer::empty(area2); + (&pane).render_ref(area2, &mut buf2); + let mut row0 = String::new(); + let mut row1 = String::new(); + for x in 0..area2.width { + row0.push(buf2[(x, 0)].symbol().chars().next().unwrap_or(' ')); + row1.push(buf2[(x, 1)].symbol().chars().next().unwrap_or(' ')); + } + assert!(row0.contains("Working ["), "expected spinner 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 spinner only with no padding: {only:?}"); + } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 669c16aea0..a159899cba 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -46,6 +46,8 @@ use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; use crate::user_approval_widget::ApprovalRequest; use codex_file_search::FileMatch; +use crate::live_wrap::RowBuilder; +use ratatui::style::Stylize; struct RunningCommand { command: Vec, @@ -69,6 +71,10 @@ pub(crate) struct ChatWidget<'a> { pending_commits: VecDeque, queued_status_text: Option, defer_task_stop: bool, + live_builder: RowBuilder, + current_stream: Option, + stream_header_emitted: bool, + live_max_rows: u16, } struct UserMessage { @@ -88,6 +94,12 @@ enum PendingHistoryCommit { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StreamKind { + Answer, + Reasoning, +} + impl From for UserMessage { fn from(text: String) -> Self { Self { @@ -171,6 +183,10 @@ impl ChatWidget<'_> { pending_commits: VecDeque::new(), queued_status_text: None, defer_task_stop: false, + live_builder: RowBuilder::new(80), + current_stream: None, + stream_header_emitted: false, + live_max_rows: 3, } } @@ -269,64 +285,39 @@ 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() { - // Queue for commit; if this is the only pending item start - // the typewriter animation now so we wait before committing. - self.pending_commits - .push_back(PendingHistoryCommit::AgentMessage(full.clone())); - if self.pending_commits.len() == 1 { - self.bottom_pane.restart_live_status_with_text(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 }) => { - // Stream partial assistant output live: update the in‑pane - // status view with the growing answer so the user sees typing - // feedback immediately. We still insert the final message as - // a single history entry on AgentMessage. + self.begin_stream(StreamKind::Answer); self.answer_buffer.push_str(&delta); - self.update_latest_log(self.answer_buffer.clone()); + 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); - // Animate chain-of-thought live in the status indicator. - self.update_latest_log(self.reasoning_buffer.clone()); + 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.pending_commits - .push_back(PendingHistoryCommit::AgentReasoning(full.clone())); - if self.pending_commits.len() == 1 { - self.bottom_pane.restart_live_status_with_text(full); - } - } + // Final reasoning: commit remaining rows and close with a blank. + let _ = text; // Deltas carried the content; finalize below. + 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 { @@ -334,6 +325,7 @@ impl ChatWidget<'_> { }) => { if self.pending_commits.is_empty() { self.bottom_pane.set_task_running(false); + self.bottom_pane.clear_live_ring(); } else { // Defer stopping the task UI until after the final // animated commit has been written to history. @@ -351,9 +343,8 @@ impl ChatWidget<'_> { self.bottom_pane.set_task_running(false); } EventMsg::PlanUpdate(update) => { - let preview = "plan updated".to_string(); - let cell = HistoryCell::new_plan_update(update); - self.queue_commit_with_preview(cell, preview); + // Commit plan updates directly to history (no status-line preview). + self.add_to_history(HistoryCell::new_plan_update(update)); } EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { call_id: _, @@ -361,8 +352,7 @@ impl ChatWidget<'_> { cwd, reason, }) => { - // Queue the command summary; animate it first, then commit it - // to history so the narrative matches the live cell. + // 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}", @@ -371,10 +361,7 @@ impl ChatWidget<'_> { .map(|r| format!("\n{r}")) .unwrap_or_default() ); - self.queue_commit_with_preview( - HistoryCell::new_background_event(text.clone()), - text, - ); + self.add_to_history(HistoryCell::new_background_event(text)); let request = ApprovalRequest::Exec { id, @@ -408,10 +395,10 @@ impl ChatWidget<'_> { .unwrap_or_default(); let preview = format!("patch approval requested for {file_count} file(s){reason_suffix}"); - self.queue_commit_with_preview( - HistoryCell::new_patch_event(PatchEventType::ApprovalRequest, changes), - preview, - ); + self.add_to_history(HistoryCell::new_patch_event( + PatchEventType::ApprovalRequest, + changes, + )); // Now surface the approval request in the BottomPane as before. let request = ApprovalRequest::ApplyPatch { @@ -435,10 +422,7 @@ impl ChatWidget<'_> { cwd: cwd.clone(), }, ); - self.queue_commit_with_preview( - HistoryCell::new_active_exec_command(command), - format!("$ {cmdline}"), - ); + self.add_to_history(HistoryCell::new_active_exec_command(command)); } EventMsg::ExecCommandOutputDelta(_) => {} EventMsg::PatchApplyBegin(PatchApplyBeginEvent { @@ -451,13 +435,10 @@ impl ChatWidget<'_> { } else { "applying patch" }; - self.queue_commit_with_preview( - HistoryCell::new_patch_event( - PatchEventType::ApplyBegin { auto_approved }, - changes, - ), - prefix.to_string(), - ); + self.add_to_history(HistoryCell::new_patch_event( + PatchEventType::ApplyBegin { auto_approved }, + changes, + )); } EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id, @@ -466,24 +447,16 @@ impl ChatWidget<'_> { stderr, }) => { // Compute summary before moving stdout into the history cell. - let summary = if !stdout.trim().is_empty() { - stdout.lines().next().unwrap_or("").to_string() - } else { - format!("command exited with code {exit_code}") - }; let cmd = self.running_commands.remove(&call_id); - self.queue_commit_with_preview( - HistoryCell::new_completed_exec_command( - cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]), - CommandOutput { - exit_code, - stdout, - stderr, - duration: Duration::from_secs(0), - }, - ), - summary, - ); + self.add_to_history(HistoryCell::new_completed_exec_command( + cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]), + CommandOutput { + exit_code, + stdout, + stderr, + duration: Duration::from_secs(0), + }, + )); } EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id: _, @@ -498,10 +471,7 @@ impl ChatWidget<'_> { let server = invocation.server.clone(); let tool = invocation.tool.clone(); let preview = format!("MCP {server}.{tool}({args_str})"); - self.queue_commit_with_preview( - HistoryCell::new_active_mcp_tool_call(invocation), - preview, - ); + self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation)); } EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: _, @@ -509,19 +479,16 @@ impl ChatWidget<'_> { invocation, result, }) => { - self.queue_commit_with_preview( - HistoryCell::new_completed_mcp_tool_call( - 80, - invocation, - duration, - result - .as_ref() - .map(|r| r.is_error.unwrap_or(false)) - .unwrap_or(false), - result, - ), - "MCP call finished".to_string(), - ); + self.add_to_history(HistoryCell::new_completed_mcp_tool_call( + 80, + invocation, + duration, + result + .as_ref() + .map(|r| r.is_error.unwrap_or(false)) + .unwrap_or(false), + result, + )); } EventMsg::GetHistoryEntryResponse(event) => { let codex_core::protocol::GetHistoryEntryResponseEvent { @@ -573,17 +540,19 @@ impl ChatWidget<'_> { /// current text. We then commit the corresponding pending entry to /// history and, if another entry is waiting, start animating it next. pub(crate) fn on_live_status_reveal_complete(&mut self) { - if let Some(pending) = self.pending_commits.pop_front() { - match pending { - PendingHistoryCommit::AgentMessage(text) => { - self.add_to_history(HistoryCell::new_agent_message(&self.config, text)); - } - PendingHistoryCommit::AgentReasoning(text) => { - self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, text)); - } - PendingHistoryCommit::HistoryCellWithPreview { cell, .. } => { - self.add_to_history(cell); - } + // If there are no pending commits, we are simply showing the waiting + // spinner; keep it visible and do nothing. + let Some(pending) = self.pending_commits.pop_front() else { return }; + + match pending { + PendingHistoryCommit::AgentMessage(text) => { + self.add_to_history(HistoryCell::new_agent_message(&self.config, text)); + } + PendingHistoryCommit::AgentReasoning(text) => { + self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, text)); + } + PendingHistoryCommit::HistoryCellWithPreview { cell, .. } => { + self.add_to_history(cell); } } @@ -597,19 +566,12 @@ impl ChatWidget<'_> { } PendingHistoryCommit::HistoryCellWithPreview { preview, .. } => preview.clone(), }; - // Restart the live status so the typewriter begins from the start - // for the new entry. self.bottom_pane.restart_live_status_with_text(text); } else if let Some(queued) = self.queued_status_text.take() { - // No more pending entries – show any queued status update now. self.bottom_pane.update_status_text(queued); } else { - // Nothing more to show; clear the live status and, if we deferred - // the TaskComplete UI update earlier, apply it now. - self.bottom_pane.clear_live_status(); - // If the live status replaced the composer (input takeover), - // restore the composer now that there is nothing left to animate. - self.bottom_pane.clear_status_view(); + // No more animated entries; leave the waiting spinner in place until + // TaskComplete arrives. If TaskComplete was deferred, clear it now. if self.defer_task_stop { self.bottom_pane.set_task_running(false); self.defer_task_stop = false; @@ -664,6 +626,97 @@ impl ChatWidget<'_> { } } +impl ChatWidget<'_> { + fn begin_stream(&mut self, kind: StreamKind) { + if self.current_stream != Some(kind) { + self.current_stream = Some(kind); + self.stream_header_emitted = false; + // Clear any previous live content; we're starting a new stream. + self.live_builder = RowBuilder::new(self.live_builder.width()); + // Ensure the waiting status is visible (composer replaced). + self.bottom_pane + .update_status_text("waiting for model".to_string()); + } + } + + fn stream_push_and_maybe_commit(&mut self, delta: &str) { + self.live_builder.push_fragment(delta); + + // Commit overflow rows (small batches) while keeping the last N rows visible. + let drained = self + .live_builder + .drain_commit_ready(self.live_max_rows as usize); + if !drained.is_empty() { + let mut lines: Vec> = Vec::new(); + if !self.stream_header_emitted { + match self.current_stream { + Some(StreamKind::Reasoning) => { + lines.push(ratatui::text::Line::from( + "thinking".magenta().italic(), + )); + } + Some(StreamKind::Answer) => { + lines.push(ratatui::text::Line::from("codex".magenta().bold())); + } + None => {} + } + self.stream_header_emitted = true; + } + for r in drained { + lines.push(ratatui::text::Line::from(r.text)); + } + self.app_event_tx.send(AppEvent::InsertHistory(lines)); + } + + // Update the live ring overlay lines (text-only, newest at bottom). + let rows = self + .live_builder + .display_rows() + .into_iter() + .map(|r| ratatui::text::Line::from(r.text)) + .collect::>(); + self.bottom_pane.set_live_ring_rows(self.live_max_rows, rows); + } + + fn finalize_stream(&mut self, kind: StreamKind) { + if self.current_stream != Some(kind) { + // Nothing to do; either already finalized or not the active stream. + return; + } + // Flush any partial line as a full row, then drain all remaining rows. + self.live_builder.end_line(); + let remaining = self.live_builder.drain_rows(); + if !remaining.is_empty() || !self.stream_header_emitted { + let mut lines: Vec> = Vec::new(); + if !self.stream_header_emitted { + match kind { + StreamKind::Reasoning => { + lines.push(ratatui::text::Line::from( + "thinking".magenta().italic(), + )); + } + StreamKind::Answer => { + lines.push(ratatui::text::Line::from("codex".magenta().bold())); + } + } + self.stream_header_emitted = true; + } + for r in remaining { + lines.push(ratatui::text::Line::from(r.text)); + } + // Close the block with a blank line for readability. + lines.push(ratatui::text::Line::from(String::new())); + 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/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 9006c299f6..88501e7852 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -113,19 +113,23 @@ fn wrapped_line_count(lines: &[Line], width: u16) -> u16 { } fn line_height(line: &Line, width: u16) -> u16 { - use unicode_width::UnicodeWidthStr; - // get the total display width of the line, accounting for double-width chars - let total_width = line + // Use the same visible-width slicing semantics as the live row builder so + // our pre-scroll estimation matches how rows will actually wrap. + let w = width.max(1) as usize; + let mut rows = 0u16; + let mut remaining = line .spans .iter() - .map(|span| span.content.width()) - .sum::(); - // divide by width to get the number of lines, rounding up - if width == 0 { - 1 - } else { - (total_width as u16).div_ceil(width).max(1) + .map(|s| s.content.as_ref()) + .collect::>() + .join(""); + while !remaining.is_empty() { + let (_prefix, suffix, taken) = crate::live_wrap::take_prefix_by_width(&remaining, w); + rows = rows.saturating_add(1); + if taken >= remaining.len() { break; } + remaining = suffix.to_string(); } + rows.max(1) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -308,4 +312,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 696ee9fdcf..ec82f4c78e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -42,6 +42,7 @@ mod log_layer; mod markdown; mod slash_command; mod status_indicator_widget; +pub mod live_wrap; mod text_block; mod text_formatting; mod tui; diff --git a/codex-rs/tui/src/live_wrap.rs b/codex-rs/tui/src/live_wrap.rs new file mode 100644 index 0000000000..8fa6dfc3cd --- /dev/null +++ b/codex-rs/tui/src/live_wrap.rs @@ -0,0 +1,252 @@ +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +/// A single visual row produced by RowBuilder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Row { + pub text: String, + /// True if this row ends with an explicit line break (as opposed to a hard wrap). + pub explicit_break: bool, +} + +impl Row { + pub fn width(&self) -> usize { + self.text.width() + } +} + +/// Incrementally wraps input text into visual rows of at most `width` cells. +/// +/// Step 1: plain-text only. ANSI-carry and styled spans will be added later. +pub struct RowBuilder { + target_width: usize, + /// Buffer for the current logical line (until a '\n' is seen). + current_line: String, + /// Output rows built so far for the current logical line and previous ones. + rows: Vec, +} + +impl RowBuilder { + pub fn new(target_width: usize) -> Self { + Self { + target_width: target_width.max(1), + current_line: String::new(), + rows: Vec::new(), + } + } + + pub fn width(&self) -> usize { + self.target_width + } + + pub fn set_width(&mut self, width: usize) { + self.target_width = width.max(1); + // Rewrap everything we have (simple approach for Step 1). + let mut all = String::new(); + for row in self.rows.drain(..) { + all.push_str(&row.text); + if row.explicit_break { + all.push('\n'); + } + } + all.push_str(&self.current_line); + self.current_line.clear(); + self.push_fragment(&all); + } + + /// Push an input fragment. May contain newlines. + pub fn push_fragment(&mut self, fragment: &str) { + if fragment.is_empty() { + return; + } + let mut start = 0usize; + for (i, ch) in fragment.char_indices() { + if ch == '\n' { + // Flush anything pending before the newline. + if start < i { + self.current_line.push_str(&fragment[start..i]); + } + self.flush_current_line(true); + start = i + ch.len_utf8(); + } + } + if start < fragment.len() { + self.current_line.push_str(&fragment[start..]); + self.wrap_current_line(); + } + } + + /// Mark the end of the current logical line (equivalent to pushing a '\n'). + pub fn end_line(&mut self) { + self.flush_current_line(true); + } + + /// Drain and return all produced rows. + pub fn drain_rows(&mut self) -> Vec { + std::mem::take(&mut self.rows) + } + + /// Return a snapshot of produced rows (non-draining). + pub fn rows(&self) -> &[Row] { + &self.rows + } + + /// Rows suitable for display, including the current partial line if any. + pub fn display_rows(&self) -> Vec { + let mut out = self.rows.clone(); + if !self.current_line.is_empty() { + out.push(Row { text: self.current_line.clone(), explicit_break: false }); + } + out + } + + /// Drain the oldest rows that exceed `max_keep` display rows (including the + /// current partial line, if any). Returns the drained rows in order. + pub fn drain_commit_ready(&mut self, max_keep: usize) -> Vec { + let display_count = self.rows.len() + if self.current_line.is_empty() { 0 } else { 1 }; + if display_count <= max_keep { return Vec::new(); } + let to_commit = display_count - max_keep; + let commit_count = to_commit.min(self.rows.len()); + let mut drained = Vec::with_capacity(commit_count); + for _ in 0..commit_count { + drained.push(self.rows.remove(0)); + } + drained + } + + fn flush_current_line(&mut self, explicit_break: bool) { + // Wrap any remaining content in the current line and then finalize with explicit_break. + self.wrap_current_line(); + // If the current line ended exactly on a width boundary and is non-empty, represent + // the explicit break as an empty explicit row so that fragmentation invariance holds. + if explicit_break { + if self.current_line.is_empty() { + // We ended on a boundary previously; add an empty explicit row. + self.rows.push(Row { text: String::new(), explicit_break: true }); + } else { + // There is leftover content that did not wrap yet; push it now with the explicit flag. + let mut s = String::new(); + std::mem::swap(&mut s, &mut self.current_line); + self.rows.push(Row { text: s, explicit_break: true }); + } + } + // Reset current line buffer for next logical line. + self.current_line.clear(); + } + + fn wrap_current_line(&mut self) { + // While the current_line exceeds width, cut a prefix. + loop { + if self.current_line.is_empty() { + break; + } + let (prefix, suffix, taken) = take_prefix_by_width(&self.current_line, self.target_width); + if taken == 0 { + // Avoid infinite loop on pathological inputs; take one scalar and continue. + if let Some((i, ch)) = self.current_line.char_indices().next() { + let len = i + ch.len_utf8(); + let p = self.current_line[..len].to_string(); + self.rows.push(Row { text: p, explicit_break: false }); + self.current_line = self.current_line[len..].to_string(); + continue; + } + break; + } + if suffix.is_empty() { + // Fits entirely; keep in buffer (do not push yet) so we can append more later. + break; + } else { + // Emit wrapped prefix as a non-explicit row and continue with the remainder. + self.rows.push(Row { text: prefix, explicit_break: false }); + self.current_line = suffix.to_string(); + } + } + } +} + +/// Take a prefix of `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()); + for r in rows { + assert!(r.width() <= 10, "row exceeds width: {:?}", r); + } + } + + #[test] + fn rows_do_not_exceed_width_emoji_cjk() { + // 😀 is width 2; 你/好 are width 2. + let mut rb = RowBuilder::new(6); + rb.push_fragment("😀😀 你好"); + for r in rb.rows() { + assert!(r.width() <= 6, "row exceeds width: {:?}", r); + } + } + + #[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 chunk in s.as_bytes().chunks(3) { + rb_chunks.push_fragment(std::str::from_utf8(chunk).unwrap()); + } + 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().len() >= 1); + rb.set_width(5); + for r in rb.rows() { + assert!(r.width() <= 5); + } + } +} diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 7cb6f853ed..12221715c5 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -80,7 +80,7 @@ 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, @@ -91,45 +91,7 @@ impl StatusIndicatorWidget { } } - pub fn desired_height(&self, width: u16) -> u16 { - // Compute wrapped height for the currently revealed text at the given width. - let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed); - let mut text = self.text.clone(); - // Only count what is currently revealed. - let shown = self.current_shown_len(current_frame); - if text.chars().count() > shown { - let mut count = 0usize; - let mut idx = text.len(); - for (i, _) in text.char_indices() { - if count == shown { - idx = i; - break; - } - count += 1; - } - text.truncate(idx); - } - - if width == 0 { - return 1; - } - - // Strip ANSI and hard-wrap to width (plain text). - let sanitized = strip_ansi_all(&text); - let wrapped = wrap_plain_text_to_width(&sanitized, width as usize); - let mut h = wrapped.len() as u16; - // Account for the blinking cursor potentially pushing the last line to the next row - // when it's already at full width. - if let Some(last) = wrapped.last() { - let last_w: usize = last.spans.iter().map(|s| s.content.width()).sum(); - if last_w >= width as usize { - h = h.saturating_add(1); - } - } - // Reserve one extra row for the dot animation indicator. - h = h.saturating_add(1); - h.max(1) - } + pub fn desired_height(&self, _width: u16) -> u16 { 1 } /// Update the line that is displayed in the widget. pub(crate) fn update_text(&mut self, text: String) { @@ -188,7 +150,8 @@ impl StatusIndicatorWidget { /// Calculate how many characters should currently be visible given the /// animation baseline and frame counter. fn current_shown_len(&self, current_frame: usize) -> usize { - const TYPING_CHARS_PER_FRAME: usize = 1; + // 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 @@ -206,56 +169,18 @@ impl Drop for StatusIndicatorWidget { impl WidgetRef for StatusIndicatorWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let widget_style = Style::default(); - // A subtle left border aligning visually with the input area. - 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; } + // Plain rendering: no borders or padding so the live cell is visually + // indistinguishable from terminal scrollback. No left bar. + let inner_width = area.width as usize; - // Ensure we do not overflow width. - let inner = block.inner(area); - let inner_width = inner.width as usize; + // Compose a single status line like: "▌ Working [·] waiting for model" + let mut spans: Vec> = Vec::new(); + spans.push(Span::styled("▌ ", Style::default().fg(Color::Cyan))); + spans.push(Span::raw("Working ")); - // Determine how many characters to reveal for the current frame and take - // the corresponding prefix so we can render markdown for it. - let shown = - self.current_shown_len(self.frame_idx.load(std::sync::atomic::Ordering::Relaxed)); - let mut shown_text = self.text.clone(); - if shown_text.chars().count() > shown { - let mut count = 0usize; - let mut idx = shown_text.len(); - for (i, _) in shown_text.char_indices() { - if count == shown { - idx = i; - break; - } - count += 1; - } - shown_text.truncate(idx); - } - - // Strip ANSI and hard-wrap to width. - let sanitized = strip_ansi_all(&shown_text); - let mut lines: Vec> = wrap_plain_text_to_width(&sanitized, inner_width); - - // Optional blinking cursor at the end of the last visual line. - if let Some(last) = lines.last_mut() { - let blink_on = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed) % 30 < 15; - if blink_on { - last.spans - .push(Span::styled("▋", Style::default().fg(Color::Cyan))); - } else { - last.spans.push(Span::raw(" ")); - } - } else { - lines.push(Line::from(Span::raw(" "))); - } - - // Append a single-line dot animation below the text that grows and shrinks - // in place according to the pattern (0,1,2,3,4,3,2,1,0). We map sizes to - // increasingly bold/large dot glyphs so the dot appears to "breathe". + // Append animated dot in brackets. const ANIM: [usize; 9] = [0, 1, 2, 3, 4, 3, 2, 1, 0]; const DOTS: [&str; 5] = ["·", "•", "●", "◉", "⬤"]; // small → large const DOT_SLOWDOWN: usize = 6; // slow down animation relative to frame tick @@ -263,7 +188,19 @@ impl WidgetRef for StatusIndicatorWidget { let idx = (frame / DOT_SLOWDOWN) % ANIM.len(); let size = ANIM[idx]; let glyph = DOTS[size]; - lines.push(Line::from(Span::styled(glyph, Style::default().fg(Color::Gray)))); + spans.push(Span::raw("[")); + spans.push(Span::styled(glyph, Style::default().fg(Color::Gray))); + spans.push(Span::raw("] ")); + spans.push(Span::raw(self.text.clone())); + + // Truncate spans to fit the width. + let mut acc: Vec> = Vec::new(); + let mut used = 0usize; + for s in spans { + let w = s.content.width(); + if used + w <= inner_width { acc.push(s); used += w; } else { break; } + } + let lines = vec![Line::from(acc)]; // If the animation for the current target has just finished, notify the app // so it can commit the cell to history and advance. @@ -279,7 +216,7 @@ impl WidgetRef for StatusIndicatorWidget { } } - let paragraph = Paragraph::new(lines).block(block); + let paragraph = Paragraph::new(lines); paragraph.render_ref(area, buf); } } @@ -346,3 +283,50 @@ fn take_prefix_by_width(s: &str, max_cols: usize) -> (String, &str, usize) { let suffix = &s[end_idx..]; (prefix, suffix, cols) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use std::sync::mpsc::channel; + + #[test] + fn renders_without_left_border_or_padding() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let mut w = StatusIndicatorWidget::new(tx); + w.restart_with_text("Hello".to_string()); + + let area = ratatui::layout::Rect::new(0, 0, 30, 1); + // Allow a short delay so the typewriter reveals the first character. + std::thread::sleep(std::time::Duration::from_millis(120)); + let mut buf = ratatui::buffer::Buffer::empty(area); + w.render_ref(area, &mut buf); + + // Leftmost column has the left bar + let ch0 = buf[(0, 0)].symbol().chars().next().unwrap_or(' '); + assert_eq!(ch0, '▌', "expected left bar at col 0: {ch0:?}"); + } + + #[test] + fn bracket_dot_animation_is_present_on_last_line() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let mut w = StatusIndicatorWidget::new(tx); + w.restart_with_text("Hi".to_string()); + // Ensure some frames elapse so we get a stable state. + std::thread::sleep(std::time::Duration::from_millis(120)); + + let area = ratatui::layout::Rect::new(0, 0, 30, 1); + let mut buf = ratatui::buffer::Buffer::empty(area); + w.render_ref(area, &mut buf); + + // Single line; it should contain "Working [" and closing "]" and the provided text. + 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 status prefix: {row:?}"); + assert!(row.contains("]"), "expected bracket: {row:?}"); + assert!(row.contains("Hi"), "expected provided text in status: {row:?}"); + } +} 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..769f42d46d --- /dev/null +++ b/codex-rs/tui/tests/vt100_live_commit.rs @@ -0,0 +1,84 @@ +#![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 = codex_tui::custom_terminal::Terminal::with_options(backend).unwrap(); + let area = Rect::new(0, 5, 20, 1); + term.set_viewport_area(area); + + // Build 5 explicit rows at width 20. + let mut rb = codex_tui::live_wrap::RowBuilder::new(20); + rb.push_fragment("one\n"); + rb.push_fragment("two\n"); + rb.push_fragment("three\n"); + rb.push_fragment("four\n"); + rb.push_fragment("five\n"); + + // Keep the last 3 in the live ring; commit the first 2. + let commit_rows = rb.drain_commit_ready(3); + let lines: Vec> = commit_rows + .into_iter() + .map(|r| Line::from(r.text)) + .collect(); + + let mut buf: Vec = Vec::new(); + codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines); + + let mut parser = vt100::Parser::new(6, 20, 0); + parser.process(&buf); + let screen = parser.screen(); + + // The words "one" and "two" should appear above the viewport. + let mut joined = String::new(); + for row in 0..6 { + for col in 0..20 { + if let Some(cell) = screen.cell(row, col) { + if let Some(ch) = cell.contents().chars().next() { + joined.push(ch); + } else { + joined.push(' '); + } + } + } + joined.push('\n'); + } + assert!(joined.contains("one"), "expected committed 'one' to be visible\n{joined}"); + assert!(joined.contains("two"), "expected committed 'two' to be visible\n{joined}"); + // The last three (three,four,five) remain in the live ring, not committed here. +} + +#[test] +fn live_002_pre_scroll_and_commit() { + let backend = TestBackend::new(20, 6); + let mut term = codex_tui::custom_terminal::Terminal::with_options(backend).unwrap(); + // Viewport not at bottom: y=3 + let area = Rect::new(0, 3, 20, 1); + term.set_viewport_area(area); + + let mut rb = codex_tui::live_wrap::RowBuilder::new(20); + rb.push_fragment("alpha\n"); + rb.push_fragment("beta\n"); + rb.push_fragment("gamma\n"); + rb.push_fragment("delta\n"); + + // Keep 3, commit 1. + let commit_rows = rb.drain_commit_ready(3); + let lines: Vec> = commit_rows + .into_iter() + .map(|r| Line::from(r.text)) + .collect(); + + let mut buf: Vec = Vec::new(); + codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines); + let s = String::from_utf8_lossy(&buf); + + // Expect a SetScrollRegion to [area.top()+1 .. screen_height] and a cursor move to top of that region. + assert!(s.contains("\u{1b}[4;6r"), "expected pre-scroll region 4..6, got: {s:?}"); + assert!(s.contains("\u{1b}[4;1H"), "expected cursor CUP 4;1H, got: {s:?}"); +} +