mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
probably never looking at this again
This commit is contained in:
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user