probably never looking at this again

This commit is contained in:
easong-openai
2025-08-01 03:16:45 -07:00
parent 575590e4c2
commit 22421e30b3
10 changed files with 816 additions and 186 deletions
+13 -5
View File
@@ -260,6 +260,11 @@ async fn process_chat_sse<S>(
.and_then(|d| d.get("content"))
.and_then(|c| c.as_str())
{
// Emit a delta so downstream consumers can stream text live.
let _ = tx_event
.send(Ok(ResponseEvent::OutputTextDelta(content.to_string())))
.await;
let item = ResponseItem::Message {
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
@@ -439,11 +444,14 @@ where
// will never appear in a Chat Completions stream.
continue;
}
Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(_))))
| Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(_)))) => {
// Deltas are ignored here since aggregation waits for the
// final OutputItemDone.
continue;
Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta)))) => {
// Forward deltas unchanged so callers can stream text
// live while still receiving a single aggregated
// OutputItemDone at the end of the turn.
return Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta))));
}
Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(delta)))) => {
return Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(delta))));
}
}
}
+103 -4
View File
@@ -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}: <empty>");
}
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::<String>();
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)]
+75 -3
View File
@@ -24,9 +24,53 @@ impl ConversationHistory {
I::Item: std::ops::Deref<Target = ResponseItem>,
{
for item in items {
if is_api_message(&item) {
// Note agent-loop.ts also does filtering on some of the fields.
self.items.push(item.clone());
if !is_api_message(&item) {
continue;
}
// Merge adjacent assistant messages into a single history entry.
// This prevents duplicates when a partial assistant message was
// streamed into history earlier in the turn and the final full
// message is recorded at turn end.
match (&*item, self.items.last_mut()) {
(
ResponseItem::Message {
role: new_role,
content: new_content,
..
},
Some(ResponseItem::Message {
role: last_role,
content: last_content,
..
}),
) if new_role == "assistant" && last_role == "assistant" => {
append_text_content(last_content, new_content);
}
_ => {
// Note agent-loop.ts also does filtering on some of the fields.
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<crate::models::ContentItem>,
src: &Vec<crate::models::ContentItem>,
) {
for c in src {
if let crate::models::ContentItem::OutputText { text } = c {
append_text_delta(dst, text);
}
}
}
/// Append a single text delta to the last OutputText item in `content`, or
/// push a new OutputText item if none exists.
fn append_text_delta(content: &mut Vec<crate::models::ContentItem>, delta: &str) {
if let Some(crate::models::ContentItem::OutputText { text }) = content
.iter_mut()
.rev()
.find(|c| matches!(c, crate::models::ContentItem::OutputText { .. }))
{
text.push_str(delta);
} else {
content.push(crate::models::ContentItem::OutputText {
text: delta.to_string(),
});
}
}
+5
View File
@@ -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 {
+5
View File
@@ -48,4 +48,9 @@ pub(crate) enum AppEvent {
},
InsertHistory(Vec<Line<'static>>),
/// 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,
}
@@ -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
+151 -40
View File
@@ -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<crate::status_indicator_widget::StatusIndicatorWidget>,
/// 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);
}
}
}
@@ -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
}
+200 -47
View File
@@ -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<String, RunningCommand>,
pending_commits: VecDeque<PendingHistoryCommit>,
queued_status_text: Option<String>,
defer_task_stop: bool,
}
struct UserMessage {
@@ -72,6 +76,18 @@ struct UserMessage {
image_paths: Vec<PathBuf>,
}
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<String> 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<InputItem> = 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).
+244 -87
View File
@@ -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<AtomicUsize>,
running: Arc<AtomicBool>,
/// 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::<Vec<_>>()
.join("")
};
let new_len = stripped.chars().count();
// Compute how many characters are currently revealed so we can carry
// this forward as the new baseline when target text changes.
let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
let shown_now = self.current_shown_len(current_frame);
self.text = text;
self.last_target_len = new_len;
self.base_frame = current_frame;
self.reveal_len_at_base = shown_now.min(new_len);
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::<Vec<_>>()
.join("")
};
let new_len = stripped.chars().count();
let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
self.text = sanitized;
self.last_target_len = new_len;
self.base_frame = current_frame;
// Start from zero revealed characters for a fresh typewriter cycle.
self.reveal_len_at_base = 0;
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<Span<'static>> = 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::<Vec<_>>()
.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<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".
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::<Vec<_>>()
.join("")
})
.collect::<Vec<_>>()
.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<Line<'static>> {
let w = width.max(1);
let mut out: Vec<Line<'static>> = 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)
}