diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 5ede774b1c..378ca4229a 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -260,6 +260,11 @@ async fn process_chat_sse( .and_then(|d| d.get("content")) .and_then(|c| c.as_str()) { + // Emit a delta so downstream consumers can stream text live. + let _ = tx_event + .send(Ok(ResponseEvent::OutputTextDelta(content.to_string()))) + .await; + let item = ResponseItem::Message { role: "assistant".to_string(), content: vec![ContentItem::OutputText { @@ -439,11 +444,14 @@ where // will never appear in a Chat Completions stream. continue; } - Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(_)))) - | Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(_)))) => { - // Deltas are ignored here since aggregation waits for the - // final OutputItemDone. - continue; + Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta)))) => { + // Forward deltas unchanged so callers can stream text + // live while still receiving a single aggregated + // OutputItemDone at the end of the turn. + return Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta)))); + } + Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(delta)))) => { + return Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(delta)))); } } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 3dd1d513a2..985042fafb 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -119,8 +119,17 @@ impl Codex { // experimental resume path (undocumented) let resume_path = config.experimental_resume.clone(); info!("resume_path: {resume_path:?}"); + // Use a bounded channel for submissions to retain backpressure on the + // producer of Ops, but avoid blocking the agent on event delivery to + // the UI. If the event queue fills and is not drained (e.g., if the + // UI forwarder task exits unexpectedly), a bounded channel here would + // cause the agent to block inside `send(event).await`, which in turn + // prevents it from handling interrupts (Ctrl-C) or any further Ops. + // An unbounded channel for events ensures the agent never deadlocks on + // UI delivery; the UI remains responsible for rendering/consuming at + // its own pace. let (tx_sub, rx_sub) = async_channel::bounded(64); - let (tx_event, rx_event) = async_channel::bounded(1600); + let (tx_event, rx_event) = async_channel::unbounded(); let user_instructions = get_user_instructions(&config).await; @@ -644,7 +653,7 @@ async fn submission_loop( cwd, resume_path, } => { - info!( + debug!( "Configuring session: model={model}; provider={provider:?}; resume={resume_path:?}" ); if !cwd.is_absolute() { @@ -1276,6 +1285,13 @@ async fn try_run_turn( return Ok(output); } ResponseEvent::OutputTextDelta(delta) => { + // Stream assistant text into in-memory conversation history so + // subsequent turns (e.g. tool calls) see the partial message. + { + let mut st = sess.state.lock().unwrap(); + st.history.append_assistant_text(&delta); + } + let event = Event { id: sub_id.to_string(), msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }), @@ -1714,8 +1730,19 @@ async fn handle_sandbox_error( // exit codes merit a retry. // For now, we categorically ask the user to retry without sandbox. - sess.notify_background_event(&sub_id, format!("Execution failed: {error}")) - .await; + // + // The raw error for a sandbox denial includes full stdout/stderr which can be very long. + // Emit a compressed background event to keep the transcript readable. + match &error { + SandboxErr::Denied(exit_code, stdout, stderr) => { + let summary = summarize_sandbox_denied_streams(*exit_code, stdout, stderr); + sess.notify_background_event(&sub_id, summary).await; + } + _ => { + sess.notify_background_event(&sub_id, format!("Execution failed: {error}")) + .await; + } + } let rx_approve = sess .request_command_approval( @@ -1810,6 +1837,78 @@ async fn handle_sandbox_error( } } +/// Create a compact, human-friendly summary for sandbox-denied executions. +/// +/// This avoids dumping potentially thousands of characters of stdout/stderr in +/// the background event stream by: +/// - limiting the number of lines shown per stream +/// - collapsing consecutive duplicate lines into a single line with a repeat count +/// - truncating overly long lines +fn summarize_sandbox_denied_streams(exit_code: i32, stdout: &str, stderr: &str) -> String { + const MAX_LINES_PER_STREAM: usize = 8; + const MAX_LINE_LEN: usize = 160; + + fn summarize_stream(label: &str, s: &str) -> String { + if s.trim().is_empty() { + return format!("{label}: "); + } + + let total_lines = s.lines().count(); + let mut lines = Vec::new(); + let mut iter = s.lines().peekable(); + let mut consumed_lines = 0usize; + while lines.len() < MAX_LINES_PER_STREAM { + let line = match iter.next() { + Some(l) => l, + None => break, + }; + // Count consecutive duplicates so we can collapse noisy spam. + let mut repeat = 1usize; + while let Some(&next) = iter.peek() { + if next == line { + repeat += 1; + iter.next(); + } else { + break; + } + } + consumed_lines += repeat; + + let mut display = if line.len() > MAX_LINE_LEN { + // Truncate long lines to keep the summary compact. + let mut truncated = line.chars().take(MAX_LINE_LEN).collect::(); + truncated.push('…'); + truncated + } else { + line.to_string() + }; + if repeat > 1 { + display.push_str(&format!(" (repeated {repeat} times)")); + } + lines.push(display); + } + + let omitted = total_lines.saturating_sub(consumed_lines); + if omitted > 0 { + lines.push(format!("… +{omitted} more lines omitted")); + } + format!("{label}:\n{}", lines.join("\n")) + } + + // Prefer stderr as it usually contains the failure context. + let mut parts = Vec::with_capacity(3); + parts.push(format!( + "Execution failed in sandbox: exit code {exit_code}" + )); + if !stderr.is_empty() { + parts.push(summarize_stream("stderr", stderr)); + } + if !stdout.is_empty() { + parts.push(summarize_stream("stdout", stdout)); + } + parts.join("\n") +} + /// Exec output is a pre-serialized JSON payload fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> String { #[derive(Serialize)] diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs index 4cd989cbd9..d91c64939f 100644 --- a/codex-rs/core/src/conversation_history.rs +++ b/codex-rs/core/src/conversation_history.rs @@ -24,9 +24,53 @@ impl ConversationHistory { I::Item: std::ops::Deref, { for item in items { - if is_api_message(&item) { - // Note agent-loop.ts also does filtering on some of the fields. - self.items.push(item.clone()); + if !is_api_message(&item) { + continue; + } + + // Merge adjacent assistant messages into a single history entry. + // This prevents duplicates when a partial assistant message was + // streamed into history earlier in the turn and the final full + // message is recorded at turn end. + match (&*item, self.items.last_mut()) { + ( + ResponseItem::Message { + role: new_role, + content: new_content, + .. + }, + Some(ResponseItem::Message { + role: last_role, + content: last_content, + .. + }), + ) if new_role == "assistant" && last_role == "assistant" => { + append_text_content(last_content, new_content); + } + _ => { + // Note agent-loop.ts also does filtering on some of the fields. + self.items.push(item.clone()); + } + } + } + } + + /// Append a text `delta` to the latest assistant message, creating a new + /// assistant entry if none exists yet (e.g. first delta for this turn). + pub(crate) fn append_assistant_text(&mut self, delta: &str) { + match self.items.last_mut() { + Some(ResponseItem::Message { role, content, .. }) if role == "assistant" => { + append_text_delta(content, delta); + } + _ => { + // Start a new assistant message with the delta. + self.items.push(ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![crate::models::ContentItem::OutputText { + text: delta.to_string(), + }], + }); } } } @@ -44,3 +88,31 @@ fn is_api_message(message: &ResponseItem) -> bool { ResponseItem::Other => false, } } + +/// Helper to append the textual content from `src` into `dst` in place. +fn append_text_content( + dst: &mut Vec, + src: &Vec, +) { + for c in src { + if let crate::models::ContentItem::OutputText { text } = c { + append_text_delta(dst, text); + } + } +} + +/// Append a single text delta to the last OutputText item in `content`, or +/// push a new OutputText item if none exists. +fn append_text_delta(content: &mut Vec, delta: &str) { + if let Some(crate::models::ContentItem::OutputText { text }) = content + .iter_mut() + .rev() + .find(|c| matches!(c, crate::models::ContentItem::OutputText { .. })) + { + text.push_str(delta); + } else { + content.push(crate::models::ContentItem::OutputText { + text: delta.to_string(), + }); + } +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cc3118e860..f91b9ef7e0 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -219,6 +219,11 @@ impl App<'_> { AppEvent::Redraw => { std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??; } + AppEvent::LiveStatusRevealComplete => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.on_live_status_reveal_complete(); + } + } AppEvent::KeyEvent(key_event) => { match key_event { KeyEvent { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 77a600d304..c9517b59d4 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -48,4 +48,9 @@ pub(crate) enum AppEvent { }, InsertHistory(Vec>), + + /// Emitted by the live status widget when the current text has been fully + /// revealed by the typewriter animation. The app uses this signal to + /// commit the live cell to history and advance to the next entry. + LiveStatusRevealComplete, } diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index a5616371d2..82d93a7037 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -39,6 +39,13 @@ pub(crate) trait BottomPaneView<'a> { ConditionalUpdate::NoRedraw } + /// Restart the live status animation with fresh text. Returns true if the + /// view handled the restart itself (e.g. status indicator view), false if + /// the caller should fall back to another mechanism (e.g. overlay). + fn restart_live_status_with_text(&mut self, _pane: &mut BottomPane<'a>, _text: String) -> bool { + false + } + /// Called when task completes to check if the view should be hidden. fn should_hide_when_task_is_done(&mut self) -> bool { false diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 281f0859a5..08c8b141f3 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -4,7 +4,6 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::user_approval_widget::ApprovalRequest; use bottom_pane_view::BottomPaneView; -use bottom_pane_view::ConditionalUpdate; use codex_core::protocol::TokenUsage; use codex_file_search::FileMatch; use crossterm::event::KeyEvent; @@ -45,6 +44,15 @@ pub(crate) struct BottomPane<'a> { has_input_focus: bool, is_task_running: bool, ctrl_c_quit_hint: bool, + + /// Optional live, multi‑line status/"live cell" rendered directly above + /// the composer while a task is running. Unlike `active_view`, this does + /// not replace the composer; it augments it. + live_status: Option, + + /// True if the active view is the StatusIndicatorView that replaces the + /// composer during a running task. + status_view_active: bool, } pub(crate) struct BottomPaneParams { @@ -67,14 +75,23 @@ impl BottomPane<'_> { has_input_focus: params.has_input_focus, is_task_running: false, ctrl_c_quit_hint: false, + live_status: None, + status_view_active: false, } } pub fn desired_height(&self, width: u16) -> u16 { - self.active_view + let live_h = self + .live_status .as_ref() - .map(|v| v.desired_height(width)) - .unwrap_or(self.composer.desired_height()) + .map(|s| s.desired_height(width)) + .unwrap_or(0); + + if let Some(view) = self.active_view.as_ref() { + live_h.saturating_add(view.desired_height(width)) + } else { + live_h.saturating_add(self.composer.desired_height()) + } } /// Forward a key event to the active view or the composer. @@ -83,10 +100,6 @@ impl BottomPane<'_> { view.handle_key_event(self, key_event); if !view.is_complete() { self.active_view = Some(view); - } else if self.is_task_running { - self.active_view = Some(Box::new(StatusIndicatorView::new( - self.app_event_tx.clone(), - ))); } self.request_redraw(); InputResult::None @@ -112,10 +125,6 @@ impl BottomPane<'_> { CancellationEvent::Handled => { if !view.is_complete() { self.active_view = Some(view); - } else if self.is_task_running { - self.active_view = Some(Box::new(StatusIndicatorView::new( - self.app_event_tx.clone(), - ))); } self.show_ctrl_c_quit_hint(); } @@ -135,19 +144,41 @@ impl BottomPane<'_> { } } - /// Update the status indicator text (only when the `StatusIndicatorView` is - /// active). + /// Update the status indicator text. pub(crate) fn update_status_text(&mut self, text: String) { - if let Some(view) = &mut self.active_view { - match view.update_status_text(text) { - ConditionalUpdate::NeedsRedraw => { + // If 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 => { self.request_redraw(); + return; } - ConditionalUpdate::NoRedraw => { - // No redraw needed. - } + 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)); + self.status_view_active = true; + self.request_redraw(); + 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). + if self.live_status.is_none() { + self.live_status = Some(crate::status_indicator_widget::StatusIndicatorWidget::new( + self.app_event_tx.clone(), + )); + } + if let Some(status) = &mut self.live_status { + status.update_text(text); + } + self.request_redraw(); } pub(crate) fn show_ctrl_c_quit_hint(&mut self) { @@ -173,28 +204,26 @@ impl BottomPane<'_> { pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; - match (running, self.active_view.is_some()) { - (true, false) => { - // Show status indicator overlay. + if running { + if self.active_view.is_none() { self.active_view = Some(Box::new(StatusIndicatorView::new( self.app_event_tx.clone(), ))); - self.request_redraw(); + self.status_view_active = true; } - (false, true) => { - if let Some(mut view) = self.active_view.take() { - if view.should_hide_when_task_is_done() { - // Leave self.active_view as None. - self.request_redraw(); - } else { - // Preserve the view. - self.active_view = Some(view); - } + self.request_redraw(); + } else { + self.live_status = None; + // Drop the status view when a task completes, but keep other + // modal views (e.g. approval dialogs). + if let Some(mut view) = self.active_view.take() { + if !view.should_hide_when_task_is_done() { + self.active_view = Some(view); + self.status_view_active = false; + } else { + self.status_view_active = false; } } - _ => { - // No change. - } } } @@ -235,6 +264,7 @@ impl BottomPane<'_> { // Otherwise create a new approval modal overlay. let modal = ApprovalModalView::new(request, self.app_event_tx.clone()); self.active_view = Some(Box::new(modal)); + self.status_view_active = false; self.request_redraw() } @@ -268,15 +298,96 @@ impl BottomPane<'_> { self.composer.on_file_search_result(query, matches); self.request_redraw(); } + + /// 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) { + self.live_status = None; + self.request_redraw(); + } + + /// Restart the live status animation for the next entry. Prefer taking + /// over the composer when possible; if another view is active (e.g. a + /// modal), fall back to using the overlay so animation can continue. + pub(crate) fn restart_live_status_with_text(&mut self, text: String) { + // Try to restart in the active view (if it's the status view). + let mut handled = false; + if let Some(mut view) = self.active_view.take() { + if view.restart_live_status_with_text(self, text.clone()) { + handled = true; + } + self.status_view_active = true; + self.active_view = Some(view); + } else { + // No view – create a fresh status view which replaces the composer. + let mut v = StatusIndicatorView::new(self.app_event_tx.clone()); + v.restart_with_text(text); + self.active_view = Some(Box::new(v)); + self.status_view_active = true; + self.request_redraw(); + return; + } + if handled { + self.request_redraw(); + return; + } + + // Fallback: show a fresh overlay widget if another view is active. + self.live_status = Some(crate::status_indicator_widget::StatusIndicatorWidget::new( + self.app_event_tx.clone(), + )); + if let Some(status) = &mut self.live_status { + status.restart_with_text(text); + } + 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) { + if self.status_view_active { + self.active_view = None; + self.status_view_active = false; + self.request_redraw(); + } + } } impl WidgetRef for &BottomPane<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - // Show BottomPaneView if present. + let mut y_offset = 0u16; + if let Some(status) = &self.live_status { + let live_h = status.desired_height(area.width).min(area.height); + if live_h > 0 { + let live_rect = Rect { + x: area.x, + y: area.y, + width: area.width, + height: live_h, + }; + status.render_ref(live_rect, buf); + y_offset = live_h; + } + } + if let Some(ov) = &self.active_view { - ov.render(area, buf); - } else { - (&self.composer).render_ref(area, buf); + if y_offset < area.height { + let view_rect = Rect { + x: area.x, + y: area.y + y_offset, + width: area.width, + height: area.height - y_offset, + }; + ov.render(view_rect, buf); + } + } else if y_offset < area.height { + let composer_rect = Rect { + x: area.x, + y: area.y + y_offset, + width: area.width, + height: area.height - y_offset, + }; + (&self.composer).render_ref(composer_rect, buf); } } } diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs index a944271e45..5abf672f45 100644 --- a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs +++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs @@ -21,6 +21,10 @@ impl StatusIndicatorView { pub fn update_text(&mut self, text: String) { self.view.update_text(text); } + + pub fn restart_with_text(&mut self, text: String) { + self.view.restart_with_text(text); + } } impl BottomPaneView<'_> for StatusIndicatorView { @@ -29,6 +33,15 @@ impl BottomPaneView<'_> for StatusIndicatorView { ConditionalUpdate::NeedsRedraw } + fn restart_live_status_with_text( + &mut self, + _pane: &mut super::BottomPane<'_>, + text: String, + ) -> bool { + self.restart_with_text(text); + true + } + fn should_hide_when_task_is_done(&mut self) -> bool { true } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4c415777a9..47420d64e4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::VecDeque; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -65,6 +66,9 @@ pub(crate) struct ChatWidget<'a> { // at once into scrollback so the history contains a single message. answer_buffer: String, running_commands: HashMap, + pending_commits: VecDeque, + queued_status_text: Option, + defer_task_stop: bool, } struct UserMessage { @@ -72,6 +76,18 @@ struct UserMessage { image_paths: Vec, } +enum PendingHistoryCommit { + AgentMessage(String), + AgentReasoning(String), + /// Generic deferred history commit with a preview string to animate + /// in the live cell before committing the full `HistoryCell` to + /// scrollback. + HistoryCellWithPreview { + cell: HistoryCell, + preview: String, + }, +} + impl From for UserMessage { fn from(text: String) -> Self { Self { @@ -152,6 +168,9 @@ impl ChatWidget<'_> { reasoning_buffer: String::new(), answer_buffer: String::new(), running_commands: HashMap::new(), + pending_commits: VecDeque::new(), + queued_status_text: None, + defer_task_stop: false, } } @@ -181,6 +200,20 @@ impl ChatWidget<'_> { .send(AppEvent::InsertHistory(cell.plain_lines())); } + /// Queue a history cell to be inserted after the current typewriter + /// animation completes. If no animation is active, start one now. + fn queue_commit_with_preview(&mut self, cell: HistoryCell, preview: String) { + self.pending_commits + .push_back(PendingHistoryCommit::HistoryCellWithPreview { + cell, + preview: preview.clone(), + }); + if self.pending_commits.len() == 1 { + self.bottom_pane.restart_live_status_with_text(preview); + } + self.request_redraw(); + } + fn submit_user_message(&mut self, user_message: UserMessage) { let UserMessage { text, image_paths } = user_message; let mut items: Vec = Vec::new(); @@ -247,22 +280,31 @@ impl ChatWidget<'_> { message }; if !full.is_empty() { - self.add_to_history(HistoryCell::new_agent_message(&self.config, full)); + // 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); + } } self.request_redraw(); } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { - // Buffer only – do not emit partial lines. This avoids cases - // where long responses appear truncated if the terminal - // wrapped early. The full message is emitted on - // AgentMessage. + // 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.answer_buffer.push_str(&delta); + self.update_latest_log(self.answer_buffer.clone()); } EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { // Buffer only – disable incremental reasoning streaming so we // avoid truncated intermediate lines. Full text emitted on // AgentReasoning. self.reasoning_buffer.push_str(&delta); + // Animate chain-of-thought live in the status indicator. + self.update_latest_log(self.reasoning_buffer.clone()); } EventMsg::AgentReasoning(AgentReasoningEvent { text }) => { // Emit full reasoning text once. Some providers might send @@ -274,7 +316,11 @@ impl ChatWidget<'_> { text }; if !full.is_empty() { - self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, full)); + self.pending_commits + .push_back(PendingHistoryCommit::AgentReasoning(full.clone())); + if self.pending_commits.len() == 1 { + self.bottom_pane.restart_live_status_with_text(full); + } } self.request_redraw(); } @@ -286,7 +332,13 @@ impl ChatWidget<'_> { EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message: _, }) => { - self.bottom_pane.set_task_running(false); + if self.pending_commits.is_empty() { + self.bottom_pane.set_task_running(false); + } else { + // Defer stopping the task UI until after the final + // animated commit has been written to history. + self.defer_task_stop = true; + } self.request_redraw(); } EventMsg::TokenCount(token_usage) => { @@ -299,8 +351,9 @@ impl ChatWidget<'_> { self.bottom_pane.set_task_running(false); } EventMsg::PlanUpdate(update) => { - self.add_to_history(HistoryCell::new_plan_update(update)); - self.request_redraw(); + let preview = "plan updated".to_string(); + let cell = HistoryCell::new_plan_update(update); + self.queue_commit_with_preview(cell, preview); } EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { call_id: _, @@ -308,8 +361,8 @@ impl ChatWidget<'_> { cwd, reason, }) => { - // Print the command to the history so it is visible in the - // transcript *before* the modal asks for approval. + // Queue the command summary; animate it first, then commit it + // to history so the narrative matches the live cell. let cmdline = strip_bash_lc_and_escape(&command); let text = format!( "command requires approval:\n$ {cmdline}{reason}", @@ -318,7 +371,10 @@ impl ChatWidget<'_> { .map(|r| format!("\n{r}")) .unwrap_or_default() ); - self.add_to_history(HistoryCell::new_background_event(text)); + self.queue_commit_with_preview( + HistoryCell::new_background_event(text.clone()), + text, + ); let request = ApprovalRequest::Exec { id, @@ -345,11 +401,17 @@ impl ChatWidget<'_> { // approval dialog) and avoids surprising the user with a modal // prompt before they have seen *what* is being requested. // ------------------------------------------------------------------ - - self.add_to_history(HistoryCell::new_patch_event( - PatchEventType::ApprovalRequest, - changes, - )); + let file_count = changes.len(); + let reason_suffix = reason + .as_ref() + .map(|r| format!(" – {r}")) + .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, + ); // Now surface the approval request in the BottomPane as before. let request = ApprovalRequest::ApplyPatch { @@ -365,6 +427,7 @@ impl ChatWidget<'_> { command, cwd, }) => { + let cmdline = strip_bash_lc_and_escape(&command); self.running_commands.insert( call_id, RunningCommand { @@ -372,19 +435,28 @@ impl ChatWidget<'_> { cwd: cwd.clone(), }, ); - self.add_to_history(HistoryCell::new_active_exec_command(command)); + self.queue_commit_with_preview( + HistoryCell::new_active_exec_command(command), + format!("$ {cmdline}"), + ); } EventMsg::PatchApplyBegin(PatchApplyBeginEvent { call_id: _, auto_approved, changes, }) => { - // Even when a patch is auto‑approved we still display the - // summary so the user can follow along. - self.add_to_history(HistoryCell::new_patch_event( - PatchEventType::ApplyBegin { auto_approved }, - changes, - )); + let prefix = if auto_approved { + "applying patch (auto-approved)" + } else { + "applying patch" + }; + self.queue_commit_with_preview( + HistoryCell::new_patch_event( + PatchEventType::ApplyBegin { auto_approved }, + changes, + ), + prefix.to_string(), + ); } EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id, @@ -392,22 +464,43 @@ impl ChatWidget<'_> { stdout, 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.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), - }, - )); + 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, + ); } EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id: _, invocation, }) => { - self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation)); + // Build brief one-line invocation summary before moving `invocation`. + let args_str = invocation + .arguments + .as_ref() + .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string())) + .unwrap_or_default(); + 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, + ); } EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: _, @@ -415,16 +508,19 @@ impl ChatWidget<'_> { invocation, result, }) => { - 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, - )); + 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(), + ); } EventMsg::GetHistoryEntryResponse(event) => { let codex_core::protocol::GetHistoryEntryResponseEvent { @@ -441,15 +537,22 @@ impl ChatWidget<'_> { self.app_event_tx.send(AppEvent::ExitRequest); } event => { - self.add_to_history(HistoryCell::new_background_event(format!("{event:?}"))); + let text = format!("{event:?}"); + self.add_to_history(HistoryCell::new_background_event(text.clone())); + self.update_latest_log(text); } } } /// Update the live log preview while a task is running. pub(crate) fn update_latest_log(&mut self, line: String) { - // Forward only if we are currently showing the status indicator. - self.bottom_pane.update_status_text(line); + // If we have pending commits waiting to be flushed, hold off on + // updating the live cell so the current entry can finish its animation. + if !self.pending_commits.is_empty() { + self.queued_status_text = Some(line); + } else { + self.bottom_pane.update_status_text(line); + } } fn request_redraw(&mut self) { @@ -465,6 +568,56 @@ impl ChatWidget<'_> { self.bottom_pane.on_file_search_result(query, matches); } + /// Called by the app when the live status widget has fully revealed its + /// 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 is another pending entry, start animating it fresh. We do + // not remove it from the queue yet; we will commit it on the next + // completion callback. + if let Some(next) = self.pending_commits.front() { + let text = match next { + PendingHistoryCommit::AgentMessage(t) | PendingHistoryCommit::AgentReasoning(t) => { + t.clone() + } + 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(); + if self.defer_task_stop { + self.bottom_pane.set_task_running(false); + self.defer_task_stop = false; + } + } + + self.request_redraw(); + } + /// Handle Ctrl-C key press. /// Returns CancellationEvent::Handled if the event was consumed by the UI, or /// CancellationEvent::Ignored if the caller should handle it (e.g. exit). diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 7e6d267481..7cb6f853ed 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -9,10 +9,8 @@ use std::thread; use std::time::Duration; use ratatui::buffer::Buffer; -use ratatui::layout::Alignment; use ratatui::layout::Rect; use ratatui::style::Color; -use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; @@ -23,10 +21,15 @@ use ratatui::widgets::Borders; use ratatui::widgets::Padding; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +// We render the live text using markdown so it visually matches the history +// cells. Before rendering we strip any ANSI escape sequences to avoid writing +// raw control bytes into the back buffer. use codex_ansi_escape::ansi_escape_line; pub(crate) struct StatusIndicatorWidget { @@ -34,8 +37,19 @@ pub(crate) struct StatusIndicatorWidget { /// time). text: String, + /// Animation state: reveal target `text` progressively like a typewriter. + /// We compute the currently visible prefix length based on the current + /// frame index and a constant typing speed. The `base_frame` and + /// `reveal_len_at_base` form the anchor from which we advance. + last_target_len: usize, + base_frame: usize, + reveal_len_at_base: usize, + frame_idx: Arc, running: Arc, + /// Ensure we only notify the app once per target text when the full + /// reveal completes. + completion_sent: AtomicBool, // Keep one sender alive to prevent the channel from closing while the // animation thread is still running. The field itself is currently not // accessed anywhere, therefore the leading underscore silences the @@ -57,7 +71,7 @@ impl StatusIndicatorWidget { thread::spawn(move || { let mut counter = 0usize; while running_clone.load(Ordering::Relaxed) { - std::thread::sleep(Duration::from_millis(200)); + std::thread::sleep(Duration::from_millis(33)); counter = counter.wrapping_add(1); frame_idx_clone.store(counter, Ordering::Relaxed); app_event_tx_clone.send(AppEvent::RequestRedraw); @@ -67,19 +81,119 @@ impl StatusIndicatorWidget { Self { text: String::from("waiting for logs…"), + last_target_len: 0, + base_frame: 0, + reveal_len_at_base: 0, frame_idx, running, + completion_sent: AtomicBool::new(false), _app_event_tx: app_event_tx, } } - pub fn desired_height(&self, _width: u16) -> u16 { - 1 + 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) } /// Update the line that is displayed in the widget. pub(crate) fn update_text(&mut self, text: String) { - self.text = text.replace(['\n', '\r'], " "); + // If the text hasn't changed, don't reset the baseline; let the + // animation continue advancing naturally. + if text == self.text { + return; + } + // Update the target text, preserving newlines so wrapping matches history cells. + // Strip ANSI escapes for the character count so the typewriter animation speed is stable. + let stripped = { + let line = ansi_escape_line(&text); + line.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::>() + .join("") + }; + let new_len = stripped.chars().count(); + + // Compute how many characters are currently revealed so we can carry + // this forward as the new baseline when target text changes. + let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed); + let shown_now = self.current_shown_len(current_frame); + + self.text = text; + self.last_target_len = new_len; + self.base_frame = current_frame; + self.reveal_len_at_base = shown_now.min(new_len); + self.completion_sent.store(false, Ordering::Relaxed); + } + + /// Reset the animation and start revealing `text` from the beginning. + pub(crate) fn restart_with_text(&mut self, text: String) { + let sanitized = text.replace(['\n', '\r'], " "); + let stripped = { + let line = ansi_escape_line(&sanitized); + line.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::>() + .join("") + }; + + let new_len = stripped.chars().count(); + let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed); + + self.text = sanitized; + self.last_target_len = new_len; + self.base_frame = current_frame; + // Start from zero revealed characters for a fresh typewriter cycle. + self.reveal_len_at_base = 0; + self.completion_sent.store(false, Ordering::Relaxed); + } + + /// 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; + let frames = current_frame.saturating_sub(self.base_frame); + let advanced = self + .reveal_len_at_base + .saturating_add(frames.saturating_mul(TYPING_CHARS_PER_FRAME)); + advanced.min(self.last_target_len) } } @@ -93,99 +207,142 @@ 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()); - // Animated 3‑dot pattern inside brackets. The *active* dot is bold - // white, the others are dim. - const DOT_COUNT: usize = 3; - let idx = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed); - let phase = idx % (DOT_COUNT * 2 - 2); - let active = if phase < DOT_COUNT { - phase - } else { - (DOT_COUNT * 2 - 2) - phase - }; - - let mut header_spans: Vec> = Vec::new(); - - header_spans.push(Span::styled( - "Working ", - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - )); - - header_spans.push(Span::styled( - "[", - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - )); - - for i in 0..DOT_COUNT { - let style = if i == active { - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD) - } else { - Style::default().dim() - }; - header_spans.push(Span::styled(".", style)); - } - - header_spans.push(Span::styled( - "] ", - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - )); // Ensure we do not overflow width. - let inner_width = block.inner(area).width as usize; + let inner = block.inner(area); + let inner_width = inner.width as usize; - // Sanitize and colour‑strip the potentially colourful log text. This - // ensures that **no** raw ANSI escape sequences leak into the - // back‑buffer which would otherwise cause cursor jumps or stray - // artefacts when the terminal is resized. - let line = ansi_escape_line(&self.text); - let mut sanitized_tail: String = line - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect::>() - .join(""); - - // Truncate *after* stripping escape codes so width calculation is - // accurate. See UTF‑8 boundary comments above. - let header_len: usize = header_spans.iter().map(|s| s.content.len()).sum(); - - if header_len + sanitized_tail.len() > inner_width { - let available_bytes = inner_width.saturating_sub(header_len); - - if sanitized_tail.is_char_boundary(available_bytes) { - sanitized_tail.truncate(available_bytes); - } else { - let mut idx = available_bytes; - while idx < sanitized_tail.len() && !sanitized_tail.is_char_boundary(idx) { - idx += 1; + // 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; } - sanitized_tail.truncate(idx); + count += 1; + } + shown_text.truncate(idx); + } + + // Strip ANSI and hard-wrap to width. + let sanitized = strip_ansi_all(&shown_text); + let mut lines: Vec> = wrap_plain_text_to_width(&sanitized, inner_width); + + // Optional blinking cursor at the end of the last visual line. + if let Some(last) = lines.last_mut() { + let blink_on = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed) % 30 < 15; + if blink_on { + last.spans + .push(Span::styled("▋", Style::default().fg(Color::Cyan))); + } else { + last.spans.push(Span::raw(" ")); + } + } else { + lines.push(Line::from(Span::raw(" "))); + } + + // Append a single-line dot animation below the text that grows and shrinks + // in place according to the pattern (0,1,2,3,4,3,2,1,0). We map sizes to + // increasingly bold/large dot glyphs so the dot appears to "breathe". + 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 + let frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed); + 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)))); + + // If the animation for the current target has just finished, notify the app + // so it can commit the cell to history and advance. + { + let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed); + let shown = self.current_shown_len(current_frame); + if self.last_target_len > 0 + && shown >= self.last_target_len + && !self.completion_sent.swap(true, Ordering::Relaxed) + { + self._app_event_tx + .send(crate::app_event::AppEvent::LiveStatusRevealComplete); } } - let mut spans = header_spans; - - // Re‑apply the DIM modifier so the tail appears visually subdued - // irrespective of the colour information preserved by - // `ansi_escape_line`. - spans.push(Span::styled(sanitized_tail, Style::default().dim())); - - let paragraph = Paragraph::new(Line::from(spans)) - .block(block) - .alignment(Alignment::Left); + let paragraph = Paragraph::new(lines).block(block); paragraph.render_ref(area, buf); } } + +/// Strip ANSI escapes from a multi-line string. +fn strip_ansi_all(s: &str) -> String { + s.split('\n') + .map(|line| { + let l = ansi_escape_line(line); + l.spans + .iter() + .map(|sp| sp.content.as_ref()) + .collect::>() + .join("") + }) + .collect::>() + .join("\n") +} + +/// Hard-wrap plain text to a given terminal width using display cells. +fn wrap_plain_text_to_width(s: &str, width: usize) -> Vec> { + let w = width.max(1); + let mut out: Vec> = Vec::new(); + for raw_line in s.split('\n') { + if raw_line.is_empty() { + out.push(Line::from(String::new())); + continue; + } + let mut remaining = raw_line; + while !remaining.is_empty() { + let (prefix, suffix, taken_w) = take_prefix_by_width(remaining, w); + out.push(Line::from(Span::raw(prefix))); + if taken_w >= remaining.width() { + break; + } + remaining = suffix; + } + } + if out.is_empty() { + out.push(Line::from(String::new())); + } + out +} + +/// Take a prefix of `s` whose display width is at most `max_cols` terminal cells. +/// Returns (prefix, suffix, prefix_width). +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(); + } + + let prefix = s[..end_idx].to_string(); + let suffix = &s[end_idx..]; + (prefix, suffix, cols) +}