streaming works

This commit is contained in:
easong-openai
2025-08-02 23:22:32 -07:00
parent 4fb1d12073
commit ea1312c90f
8 changed files with 874 additions and 236 deletions
@@ -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<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).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);
}
}
+228 -13
View File
@@ -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<crate::status_indicator_widget::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,
@@ -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<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;
}
/// 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::<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.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::<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 spinner prefix 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 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::<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 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:?}");
}
}
+175 -122
View File
@@ -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<String>,
@@ -69,6 +71,10 @@ pub(crate) struct ChatWidget<'a> {
pending_commits: VecDeque<PendingHistoryCommit>,
queued_status_text: Option<String>,
defer_task_stop: bool,
live_builder: RowBuilder,
current_stream: Option<StreamKind>,
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<String> 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<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();
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()));
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
+22 -10
View File
@@ -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::<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)]
@@ -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);
}
}
+1
View File
@@ -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;
+252
View File
@@ -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<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());
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);
}
}
}
+75 -91
View File
@@ -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<Span<'static>> = 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<Line<'static>> = 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<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 { 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::<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 bracket_dot_animation_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 "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:?}");
}
}
+84
View File
@@ -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<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 = 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<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:?}");
}