mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
streaming works
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user