Compare commits

...

1 Commits

Author SHA1 Message Date
Felipe Coury
01f639fdf8 feat(tui): show animated apply_patch progress
Render streaming apply_patch updates as a transient live history cell so users can see file and line-count progress before patch execution starts.

Port the Hopper-style floating counter animation for added and removed line totals, including coalescing updates, drain timing, reduced-motion handling, and snapshot coverage.

Keep the implementation protocol-driven and remove the temporary debug replay hook used during local visual testing.
2026-05-12 14:01:49 -03:00
10 changed files with 1199 additions and 1 deletions

View File

@@ -69,6 +69,7 @@ use crate::mention_codec::encode_history_mentions;
use crate::model_catalog::ModelCatalog;
use crate::multi_agents;
use crate::multi_agents::AgentMetadata;
use crate::patch_progress_animation::PATCH_PROGRESS_FRAME_MS;
use crate::session_state::SessionNetworkProxyRuntime;
use crate::session_state::ThreadSessionState;
use crate::status::RateLimitWindowDisplay;
@@ -3804,6 +3805,55 @@ impl ChatWidget {
}
}
/// Renders or updates the transient live preview for a streaming patch snapshot.
///
/// Patch updates are complete snapshots from the app-server, so a matching
/// active preview is mutated in place and a mismatched preview is dropped
/// before the new one starts. Replay events are filtered before this method
/// is called; feeding replay snapshots here would create transient rows
/// while restoring historical turns.
fn on_patch_apply_updated(&mut self, call_id: String, changes: HashMap<PathBuf, FileChange>) {
if changes.is_empty() {
return;
}
self.flush_answer_stream_with_separator();
if self
.active_cell
.as_ref()
.and_then(|cell| {
cell.as_any()
.downcast_ref::<history_cell::StreamingPatchHistoryCell>()
})
.is_some_and(|cell| cell.call_id() == call_id)
&& let Some(cell) = self.active_cell.as_mut().and_then(|cell| {
cell.as_any_mut()
.downcast_mut::<history_cell::StreamingPatchHistoryCell>()
})
{
cell.update(changes);
self.bump_active_cell_revision();
self.request_redraw();
return;
}
self.flush_active_cell();
self.active_cell = Some(Box::new(history_cell::new_active_patch_event(
call_id,
changes,
&self.config.cwd,
self.config.animations,
)));
self.bump_active_cell_revision();
self.request_redraw();
}
/// Appends the durable patch history cell after patch execution starts.
///
/// The item-started notification is the point where the patch has moved
/// from preview streaming to execution. If a live patch preview is still
/// active, the generic active-cell flush path drops it before inserting the
/// durable summary.
fn on_patch_apply_begin(&mut self, changes: HashMap<PathBuf, FileChange>) {
self.add_to_history(history_cell::new_patch_event(changes, &self.config.cwd));
}
@@ -4193,6 +4243,30 @@ impl ChatWidget {
self.frame_requester.schedule_frame_in(delay);
}
/// Schedules another frame while the active patch preview is animating.
///
/// Server notifications update the data model, but drain and fade phases
/// also need timer-driven redraws. This intentionally looks only at the
/// active cell because streaming patch previews are transient and should
/// not keep animating after they are flushed to history.
fn schedule_patch_progress_timer_if_needed(&self) {
if self.config.animations
&& self
.active_cell
.as_ref()
.and_then(|cell| {
cell.as_any()
.downcast_ref::<history_cell::StreamingPatchHistoryCell>()
})
.is_some_and(history_cell::StreamingPatchHistoryCell::has_active_counter_animation)
{
self.frame_requester
.schedule_frame_in(Duration::from_millis(
/*millis*/ PATCH_PROGRESS_FRAME_MS,
));
}
}
fn on_stream_error(&mut self, message: String, additional_details: Option<String>) {
if self.retry_status_header.is_none() {
self.retry_status_header = Some(self.current_status.header.clone());
@@ -4210,6 +4284,7 @@ impl ChatWidget {
pub(crate) fn pre_draw_tick(&mut self) {
self.update_due_hook_visibility();
self.schedule_hook_timer_if_needed();
self.schedule_patch_progress_timer_if_needed();
self.bottom_pane.pre_draw_tick();
self.refresh_plan_mode_nudge();
self.refresh_goal_status_indicator_for_time_tick();
@@ -5532,6 +5607,14 @@ impl ChatWidget {
fn flush_active_cell(&mut self) {
if let Some(active) = self.active_cell.take() {
if active
.as_any()
.is::<history_cell::StreamingPatchHistoryCell>()
{
self.bump_active_cell_revision();
return;
}
self.needs_final_message_separator = true;
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
}
@@ -6440,7 +6523,6 @@ impl ChatWidget {
| ServerNotification::CommandExecOutputDelta(_)
| ServerNotification::ProcessOutputDelta(_)
| ServerNotification::ProcessExited(_)
| ServerNotification::FileChangePatchUpdated(_)
| ServerNotification::McpToolCallProgress(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
| ServerNotification::AppListUpdated(_)
@@ -6454,6 +6536,14 @@ impl ChatWidget {
| ServerNotification::WindowsWorldWritableWarning(_)
| ServerNotification::WindowsSandboxSetupCompleted(_)
| ServerNotification::AccountLoginCompleted(_) => {}
ServerNotification::FileChangePatchUpdated(notification) => {
if !from_replay {
self.on_patch_apply_updated(
notification.item_id,
file_update_changes_to_display(notification.changes),
);
}
}
ServerNotification::ContextCompacted(_) => {}
}
}
@@ -6780,6 +6870,12 @@ impl ChatWidget {
exec.mark_failed();
} else if let Some(tool) = cell.as_any_mut().downcast_mut::<McpToolCallCell>() {
tool.mark_failed();
} else if cell
.as_any()
.is::<history_cell::StreamingPatchHistoryCell>()
{
self.bump_active_cell_revision();
return;
}
self.add_boxed_history(cell);
}

View File

@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
expression: second_blob
---
+1
• Editing 2 files +1 -0
-1
└ bar.rs
└ foo.txt

View File

@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
expression: single_blob
---
• Editing old.rs → new.rs +1 -1

View File

@@ -0,0 +1,8 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
expression: multi_blob
---
+1
• Editing 2 files +1 -1
└ added.txt
└ old.rs → new.rs

View File

@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
expression: lines_to_single_string(&first_live)
---
• Creating foo.txt +1

View File

@@ -1426,6 +1426,205 @@ async fn apply_patch_manual_approval_adjusts_header() {
);
}
#[tokio::test]
async fn apply_patch_streaming_updates_render_live_state_until_apply_begins() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut first_changes = HashMap::new();
first_changes.insert(
PathBuf::from("foo.txt"),
FileChange::Add {
content: "hello\n".to_string(),
},
);
handle_patch_apply_updated(&mut chat, "c1", "turn-c1", first_changes);
assert!(
drain_insert_history(&mut rx).is_empty(),
"streaming patch previews should stay transient"
);
let first_live = chat
.active_cell_transcript_lines(/*width*/ 80)
.expect("live patch transcript lines");
assert_chatwidget_snapshot!(
"apply_patch_streaming_live_single_file",
lines_to_single_string(&first_live)
);
let mut second_changes = HashMap::new();
second_changes.insert(
PathBuf::from("foo.txt"),
FileChange::Add {
content: "hello\n".to_string(),
},
);
second_changes.insert(
PathBuf::from("bar.rs"),
FileChange::Update {
unified_diff: "@@ -1 +1 @@\n-old\n+new\n".to_string(),
move_path: None,
},
);
handle_patch_apply_updated(&mut chat, "c1", "turn-c1", second_changes.clone());
assert!(
drain_insert_history(&mut rx).is_empty(),
"streaming patch updates should mutate the existing active cell"
);
let second_live = chat
.active_cell_transcript_lines(/*width*/ 80)
.expect("updated live patch transcript lines");
let second_blob = lines_to_single_string(&second_live);
assert_chatwidget_snapshot!("apply_patch_streaming_live_multi_file", second_blob);
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", second_changes);
assert!(
chat.active_cell_transcript_lines(/*width*/ 80).is_none(),
"the provisional live preview should clear when the real patch item starts"
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected only the durable patch summary");
let durable_blob = lines_to_single_string(&cells[0]);
assert!(durable_blob.contains("Edited 2 files"), "{durable_blob:?}");
}
#[tokio::test]
async fn apply_patch_streaming_updates_render_rename_targets() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut changes = HashMap::new();
changes.insert(
PathBuf::from("old.rs"),
FileChange::Update {
unified_diff: "@@ -1 +1 @@\n-old\n+new\n".to_string(),
move_path: Some(PathBuf::from("new.rs")),
},
);
handle_patch_apply_updated(&mut chat, "c1", "turn-c1", changes.clone());
assert!(
drain_insert_history(&mut rx).is_empty(),
"streaming rename previews should stay transient"
);
let single_live = chat
.active_cell_transcript_lines(/*width*/ 80)
.expect("live rename patch transcript lines");
let single_blob = lines_to_single_string(&single_live);
assert!(
single_blob.contains("Editing old.rs → new.rs"),
"{single_blob:?}"
);
assert_chatwidget_snapshot!("apply_patch_streaming_live_rename", single_blob);
changes.insert(
PathBuf::from("added.txt"),
FileChange::Add {
content: "hello\n".to_string(),
},
);
handle_patch_apply_updated(&mut chat, "c1", "turn-c1", changes);
let multi_live = chat
.active_cell_transcript_lines(/*width*/ 80)
.expect("live multi-file rename patch transcript lines");
let multi_blob = lines_to_single_string(&multi_live);
assert!(multi_blob.contains("old.rs → new.rs"), "{multi_blob:?}");
assert_chatwidget_snapshot!("apply_patch_streaming_live_rename_multi_file", multi_blob);
}
#[tokio::test]
async fn apply_patch_interleaved_streaming_previews_stay_transient() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut first_changes = HashMap::new();
first_changes.insert(
PathBuf::from("first.txt"),
FileChange::Add {
content: "first\n".to_string(),
},
);
handle_patch_apply_updated(&mut chat, "c1", "turn-c1", first_changes.clone());
assert!(
drain_insert_history(&mut rx).is_empty(),
"first streaming patch preview should stay transient"
);
let mut second_changes = HashMap::new();
second_changes.insert(
PathBuf::from("second.txt"),
FileChange::Add {
content: "second\n".to_string(),
},
);
handle_patch_apply_updated(&mut chat, "c2", "turn-c2", second_changes.clone());
assert!(
drain_insert_history(&mut rx).is_empty(),
"replacing a streaming patch preview should not commit the stale preview"
);
let active_blob = lines_to_single_string(
&chat
.active_cell_transcript_lines(/*width*/ 80)
.expect("second live patch transcript lines"),
);
assert!(active_blob.contains("second.txt"), "{active_blob:?}");
assert!(!active_blob.contains("first.txt"), "{active_blob:?}");
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", first_changes);
let first_durable = drain_insert_history(&mut rx);
assert_eq!(first_durable.len(), 1);
let first_durable_blob = lines_to_single_string(&first_durable[0]);
assert!(
first_durable_blob.contains("Added first.txt"),
"{first_durable_blob:?}"
);
assert!(
chat.active_cell_transcript_lines(/*width*/ 80).is_none(),
"the second preview should be dropped before the first durable row is inserted"
);
handle_patch_apply_begin(&mut chat, "c2", "turn-c2", second_changes);
assert!(
chat.active_cell_transcript_lines(/*width*/ 80).is_none(),
"the matching second preview should clear when its durable patch item starts"
);
let second_durable = drain_insert_history(&mut rx);
assert_eq!(second_durable.len(), 1);
let second_durable_blob = lines_to_single_string(&second_durable[0]);
assert!(
second_durable_blob.contains("Added second.txt"),
"{second_durable_blob:?}"
);
}
#[tokio::test]
async fn apply_patch_streaming_preview_is_dropped_before_unrelated_history() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut changes = HashMap::new();
changes.insert(
PathBuf::from("preview.txt"),
FileChange::Add {
content: "preview\n".to_string(),
},
);
handle_patch_apply_updated(&mut chat, "c1", "turn-c1", changes);
assert!(
drain_insert_history(&mut rx).is_empty(),
"streaming patch preview should stay transient"
);
chat.add_to_history(crate::history_cell::PlainHistoryCell::new(vec![
"unrelated history".into(),
]));
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let blob = lines_to_single_string(&cells[0]);
assert!(blob.contains("unrelated history"), "{blob:?}");
assert!(!blob.contains("preview.txt"), "{blob:?}");
assert!(
chat.active_cell_transcript_lines(/*width*/ 80).is_none(),
"generic active-cell flush should drop the preview"
);
}
#[tokio::test]
async fn apply_patch_manual_flow_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

View File

@@ -762,6 +762,25 @@ fn file_update_changes_from_tui(changes: HashMap<PathBuf, FileChange>) -> Vec<Fi
.collect()
}
pub(super) fn handle_patch_apply_updated(
chat: &mut ChatWidget,
call_id: impl Into<String>,
turn_id: impl Into<String>,
changes: HashMap<PathBuf, FileChange>,
) {
chat.handle_server_notification(
ServerNotification::FileChangePatchUpdated(
codex_app_server_protocol::FileChangePatchUpdatedNotification {
thread_id: thread_id(chat),
turn_id: turn_id.into(),
item_id: call_id.into(),
changes: file_update_changes_from_tui(changes),
},
),
/*replay_kind*/ None,
);
}
pub(super) fn handle_patch_apply_begin(
chat: &mut ChatWidget,
call_id: impl Into<String>,

View File

@@ -11,6 +11,7 @@
//! rendered transcript output can change.
use crate::diff_model::FileChange;
use crate::diff_render::calculate_add_remove_from_diff;
use crate::diff_render::create_diff_summary;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
@@ -25,6 +26,10 @@ use crate::markdown::append_markdown;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
use crate::patch_progress_animation::PATCH_PROGRESS_FRAME_MS;
use crate::patch_progress_animation::PatchCounterAnim;
use crate::patch_progress_animation::PatchCounterKind;
use crate::patch_progress_animation::update_counter_anim;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
@@ -1263,6 +1268,437 @@ impl HistoryCell for PatchHistoryCell {
}
}
/// Transient history cell for a patch preview that is still streaming.
///
/// The cell is owned by ChatWidget::active_cell and never enters durable
/// transcript history. It is keyed by the app-server file-change item id so
/// repeated FileChangePatchUpdated notifications mutate the same live row, and
/// ItemStarted(FileChange) can clear the preview before adding the real patch
/// summary cell.
///
/// The stored changes map is always the latest complete snapshot from the
/// app-server. Animation state is derived from differences between consecutive
/// snapshots and may be absent when animations are disabled, when rendering raw
/// lines, or when a snapshot decreases a counter.
#[derive(Debug)]
pub(crate) struct StreamingPatchHistoryCell {
call_id: String,
changes: HashMap<PathBuf, FileChange>,
cwd: PathBuf,
start_time: Instant,
animations_enabled: bool,
added_anim: Option<PatchCounterAnim>,
removed_anim: Option<PatchCounterAnim>,
}
impl StreamingPatchHistoryCell {
/// Returns the app-server file-change item id that owns this preview.
///
/// ChatWidget uses this to decide whether an update should mutate the
/// current active cell or flush that cell and start a separate preview.
/// Treating updates for different ids as the same cell would merge two
/// independent patch previews into one transcript row.
pub(crate) fn call_id(&self) -> &str {
&self.call_id
}
/// Replaces the preview with a newer complete patch snapshot.
///
/// The method compares line totals before replacing self.changes so the
/// added and removed counters can animate only the newly observed increase.
/// Callers should pass the full snapshot from the notification rather than
/// only changed files; passing a partial snapshot would make unchanged
/// files disappear from the live preview and could clear animations as a
/// false decrease.
pub(crate) fn update(&mut self, changes: HashMap<PathBuf, FileChange>) {
let had_existing_changes = !self.changes.is_empty();
let current_stats = streaming_patch_stats(self.changes.values());
let next_stats = streaming_patch_stats(changes.values());
let now = Instant::now();
update_counter_anim(
&mut self.added_anim,
PatchCounterKind::Added,
current_stats.added,
next_stats.added,
had_existing_changes,
self.animations_enabled,
now,
);
update_counter_anim(
&mut self.removed_anim,
PatchCounterKind::Removed,
current_stats.removed,
next_stats.removed,
had_existing_changes,
self.animations_enabled,
now,
);
self.changes = changes;
}
fn render_lines(
&self,
bullet: Span<'static>,
include_counter_animation: bool,
) -> Vec<Line<'static>> {
let mut paths: Vec<_> = self.changes.iter().collect();
paths.sort_by_key(|(path, _)| *path);
let file_count = paths.len();
let actual_stats = streaming_patch_stats(paths.iter().map(|(_, change)| *change));
let animated = include_counter_animation && self.animations_enabled;
let stats_render = patch_stats_render(
actual_stats,
if animated {
self.added_anim.as_ref()
} else {
None
},
if animated {
self.removed_anim.as_ref()
} else {
None
},
Instant::now(),
);
let mut lines = Vec::new();
let (mut header_spans, header_prefix_width) = if let [(path, change)] = paths.as_slice() {
let verb = match change {
FileChange::Add { .. } => "Creating",
FileChange::Delete { .. } => "Deleting",
FileChange::Update { .. } => "Editing",
};
let path_display = streaming_patch_path_display(path, change, &self.cwd);
let prefix_width = span_width(&bullet)
+ 1
+ UnicodeWidthStr::width(verb)
+ 1
+ UnicodeWidthStr::width(path_display.as_str());
(
vec![
bullet,
" ".into(),
verb.bold(),
" ".into(),
path_display.into(),
],
prefix_width,
)
} else {
let noun = if file_count == 1 { "file" } else { "files" };
let file_label = format!(" {file_count} {noun}");
let prefix_width =
span_width(&bullet) + 1 + UnicodeWidthStr::width("Editing") + file_label.len();
(
vec![bullet, " ".into(), "Editing".bold(), file_label.into()],
prefix_width,
)
};
let counter_positions =
stats_render.push_counter_spans(&mut header_spans, header_prefix_width);
if let Some(line) =
stats_render.floating_line(PatchCounterKind::Added, counter_positions.added_end_col)
{
lines.push(line);
}
lines.push(Line::from(header_spans));
if let Some(line) =
stats_render.floating_line(PatchCounterKind::Removed, counter_positions.removed_end_col)
{
lines.push(line);
}
if file_count > 1 {
for (path, change) in paths {
lines.push(Line::from(vec![
"".dim(),
streaming_patch_path_display(path, change, &self.cwd).into(),
]));
}
}
lines
}
/// Returns whether any patch counter still has visible animation work.
///
/// ChatWidget calls this during pre_draw_tick to decide whether it must
/// schedule another frame even without new server input. When animations are
/// disabled, the cell is still renderable but never requests timer-driven
/// redraws.
pub(crate) fn has_active_counter_animation(&self) -> bool {
if !self.animations_enabled {
return false;
}
let now = Instant::now();
let is_active = |animation: &Option<PatchCounterAnim>| {
animation.as_ref().is_some_and(|anim| anim.is_active(now))
};
is_active(&self.added_anim) || is_active(&self.removed_anim)
}
}
#[derive(Clone, Copy)]
struct PatchLineStats {
added: usize,
removed: usize,
}
#[derive(Clone, Copy)]
struct CounterPositions {
added_end_col: Option<usize>,
removed_end_col: Option<usize>,
}
struct PatchStatsRender {
added: Option<CounterRender>,
removed: Option<CounterRender>,
}
struct CounterRender {
text: String,
slot_width: usize,
floating: Option<FloatingCounterRender>,
}
struct FloatingCounterRender {
text: String,
style: Style,
}
fn streaming_patch_stats<'a>(changes: impl IntoIterator<Item = &'a FileChange>) -> PatchLineStats {
let (added, removed) = changes
.into_iter()
.fold((0, 0), |(added, removed), change| {
let (change_added, change_removed) = file_change_line_counts(change);
(added + change_added, removed + change_removed)
});
PatchLineStats { added, removed }
}
fn file_change_line_counts(change: &FileChange) -> (usize, usize) {
match change {
FileChange::Add { content } => (content.lines().count(), 0),
FileChange::Delete { content } => (0, content.lines().count()),
FileChange::Update { unified_diff, .. } => update_patch_line_counts(unified_diff),
}
}
#[derive(Default)]
struct UpdatePatchChunkLines {
old: Vec<String>,
new: Vec<String>,
}
fn update_patch_line_counts(diff: &str) -> (usize, usize) {
let mut chunks = Vec::new();
let mut current: Option<UpdatePatchChunkLines> = None;
for line in diff.lines() {
if line.starts_with("@@") {
if let Some(chunk) = current.take() {
chunks.push(chunk);
}
current = Some(UpdatePatchChunkLines::default());
continue;
}
if line == "*** End of File" || line.starts_with("+++") || line.starts_with("---") {
continue;
}
let Some((prefix, text)) = line.split_at_checked(1) else {
continue;
};
let chunk = current.get_or_insert_with(UpdatePatchChunkLines::default);
match prefix {
"-" => chunk.old.push(text.to_string()),
"+" => chunk.new.push(text.to_string()),
" " => {
chunk.old.push(text.to_string());
chunk.new.push(text.to_string());
}
_ => {}
}
}
if let Some(chunk) = current {
chunks.push(chunk);
}
if chunks.is_empty() {
return calculate_add_remove_from_diff(diff);
}
let lines_to_text = |lines: &[String]| {
if lines.is_empty() {
String::new()
} else {
format!("{}\n", lines.join("\n"))
}
};
chunks
.iter()
.map(|chunk| {
let old_text = lines_to_text(&chunk.old);
let new_text = lines_to_text(&chunk.new);
let diff = diffy::create_patch(&old_text, &new_text).to_string();
calculate_add_remove_from_diff(&diff)
})
.fold((0, 0), |(added, removed), (chunk_added, chunk_removed)| {
(added + chunk_added, removed + chunk_removed)
})
}
fn streaming_patch_path_display(path: &Path, change: &FileChange, cwd: &Path) -> String {
let path_display = display_path_for(path, cwd);
if let FileChange::Update {
move_path: Some(move_path),
..
} = change
{
format!("{path_display}{}", display_path_for(move_path, cwd))
} else {
path_display
}
}
fn patch_stats_render(
PatchLineStats { added, removed }: PatchLineStats,
added_anim: Option<&PatchCounterAnim>,
removed_anim: Option<&PatchCounterAnim>,
now: Instant,
) -> PatchStatsRender {
PatchStatsRender {
added: counter_render(PatchCounterKind::Added, added, added_anim, now),
removed: counter_render(PatchCounterKind::Removed, removed, removed_anim, now),
}
}
fn counter_render(
kind: PatchCounterKind,
actual_value: usize,
animation: Option<&PatchCounterAnim>,
now: Instant,
) -> Option<CounterRender> {
let active_animation = animation.filter(|anim| anim.is_active(now));
let value = active_animation.map_or(actual_value, |anim| anim.committed(now));
let floating = active_animation.and_then(|anim| {
let floating_value = anim.floating(now);
(floating_value > 0).then(|| FloatingCounterRender {
text: counter_text(kind, floating_value),
style: anim.floating_style(now),
})
});
if value == 0 && floating.is_none() {
return None;
}
Some(CounterRender {
text: counter_text(kind, value),
slot_width: counter_text_width(kind, value.max(actual_value)),
floating,
})
}
impl PatchStatsRender {
fn counter(&self, kind: PatchCounterKind) -> Option<&CounterRender> {
match kind {
PatchCounterKind::Added => self.added.as_ref(),
PatchCounterKind::Removed => self.removed.as_ref(),
}
}
fn push_counter_spans(
&self,
spans: &mut Vec<Span<'static>>,
prefix_width: usize,
) -> CounterPositions {
let mut current_col = prefix_width;
let mut positions = CounterPositions {
added_end_col: None,
removed_end_col: None,
};
for kind in [PatchCounterKind::Added, PatchCounterKind::Removed] {
let Some(counter) = self.counter(kind) else {
continue;
};
spans.push(" ".into());
current_col += 1;
let text_width = UnicodeWidthStr::width(counter.text.as_str());
let padding = counter.slot_width.saturating_sub(text_width);
spans.push(Span::styled(
format!("{}{}", " ".repeat(padding), counter.text),
kind.normal_style(),
));
current_col += counter.slot_width;
match kind {
PatchCounterKind::Added => positions.added_end_col = Some(current_col),
PatchCounterKind::Removed => positions.removed_end_col = Some(current_col),
}
}
positions
}
fn floating_line(
&self,
kind: PatchCounterKind,
end_col: Option<usize>,
) -> Option<Line<'static>> {
let counter = self.counter(kind)?;
let floating = counter.floating.as_ref()?;
let end_col = end_col?;
let width = UnicodeWidthStr::width(floating.text.as_str());
let pad = end_col.saturating_sub(width);
Some(Line::from(vec![
Span::from(" ".repeat(pad)),
Span::styled(floating.text.clone(), floating.style),
]))
}
}
fn counter_text(kind: PatchCounterKind, value: usize) -> String {
format!("{}{value}", kind.sigil())
}
fn counter_text_width(kind: PatchCounterKind, value: usize) -> usize {
UnicodeWidthStr::width(counter_text(kind, value).as_str())
}
fn span_width(span: &Span<'_>) -> usize {
UnicodeWidthStr::width(span.content.as_ref())
}
impl HistoryCell for StreamingPatchHistoryCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
let bullet = activity_indicator(
Some(self.start_time),
MotionMode::from_animations_enabled(self.animations_enabled),
ReducedMotionIndicator::StaticBullet,
)
.unwrap_or_else(|| "".dim());
self.render_lines(bullet, /*include_counter_animation*/ true)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.render_lines("".into(), /*include_counter_animation*/ false))
}
fn transcript_animation_tick(&self) -> Option<u64> {
if !self.has_active_counter_animation() {
return None;
}
Some((self.start_time.elapsed().as_millis() / u128::from(PATCH_PROGRESS_FRAME_MS)) as u64)
}
}
#[derive(Debug)]
struct CompletedMcpToolCallWithImageOutput {
_image: DynamicImage,
@@ -3143,6 +3579,29 @@ pub(crate) fn new_patch_event(
}
}
/// Creates the transient live patch preview for a streaming patch update.
///
/// The returned cell starts with no counter animation because the initial
/// snapshot is the visual baseline. Subsequent calls to
/// StreamingPatchHistoryCell::update may animate increases relative to this
/// baseline until the durable patch item starts.
pub(crate) fn new_active_patch_event(
call_id: String,
changes: HashMap<PathBuf, FileChange>,
cwd: &Path,
animations_enabled: bool,
) -> StreamingPatchHistoryCell {
StreamingPatchHistoryCell {
call_id,
changes,
cwd: cwd.to_path_buf(),
start_time: Instant::now(),
animations_enabled,
added_anim: None,
removed_anim: None,
}
}
pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
@@ -3476,6 +3935,39 @@ mod tests {
std::env::temp_dir()
}
#[test]
fn update_patch_line_counts_ignore_duplicated_progress_context() {
let change = FileChange::Update {
unified_diff:
"@@\n-context before\n-old\n-context after\n+context before\n+new\n+context after\n"
.to_string(),
move_path: None,
};
assert_eq!(file_change_line_counts(&change), (1, 1));
}
#[test]
fn update_patch_line_counts_ignore_context_for_insertions() {
let change = FileChange::Update {
unified_diff: "@@\n-context\n+context\n+added\n".to_string(),
move_path: None,
};
assert_eq!(file_change_line_counts(&change), (1, 0));
}
#[test]
fn update_patch_line_counts_handle_standard_unified_context() {
let change = FileChange::Update {
unified_diff: "@@ -1,3 +1,3 @@\n context before\n-old\n+new\n context after\n"
.to_string(),
move_path: None,
};
assert_eq!(file_change_line_counts(&change), (1, 1));
}
fn stdio_server_config(
command: &str,
args: Vec<&str>,

View File

@@ -150,6 +150,7 @@ mod npm_registry;
pub(crate) mod onboarding;
mod oss_selection;
mod pager_overlay;
mod patch_progress_animation;
mod permission_compat;
pub(crate) mod public_widgets;
mod render;

View File

@@ -0,0 +1,364 @@
//! Animation state for live apply_patch line-count counters.
//!
//! The TUI receives patch previews as complete snapshots of the current file
//! changes, not as line-count deltas. This module converts monotonically
//! increasing added/removed totals into a small presentation state machine:
//! newly observed lines appear as a floating counter, drain into the committed
//! header total, and stop once no floating value remains.
//!
//! The module deliberately does not own patch parsing, file labels, or
//! transcript rendering. Callers compute the current and next totals from the
//! active patch cell, pass those totals to update_counter_anim, and schedule
//! redraws while PatchCounterAnim::is_active remains true.
//!
//! A first snapshot establishes the baseline and is not animated; otherwise a
//! newly-created patch would show the entire file as a fresh delta. Decreases
//! also do not animate, because they usually mean the app-server sent a revised
//! snapshot rather than a user-visible removal of already-rendered progress.
use std::time::Duration;
use std::time::Instant;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
/// Millisecond cadence used while a live patch counter animation is active.
///
/// The same cadence is used both to schedule TUI redraws and to produce the
/// transcript animation tick, so a running counter invalidates the active cell
/// even when no new app-server notification has arrived.
pub(crate) const PATCH_PROGRESS_FRAME_MS: u64 = 50;
const PULSE_IN_MS: u64 = 180;
const STEP_MS: u64 = 60;
const DRAIN_CAP_MS: u64 = 800;
/// Identifies which line-count side a patch progress counter represents.
///
/// The kind owns only display concerns that are shared by committed and
/// floating counters: the sigil and the stable foreground color. It does not
/// encode any parsing behavior, so callers must compute added and removed line
/// totals before constructing animations.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PatchCounterKind {
/// Added-line counter rendered with a green plus sigil.
Added,
/// Removed-line counter rendered with a red minus sigil.
Removed,
}
impl PatchCounterKind {
/// Returns the sign rendered before the numeric counter value.
pub(crate) fn sigil(self) -> char {
match self {
PatchCounterKind::Added => '+',
PatchCounterKind::Removed => '-',
}
}
/// Returns the stable style for the committed counter for this side.
///
/// Floating counters may temporarily add pulse styling, but the committed
/// header value always uses this style so the target column stays visually
/// anchored.
pub(crate) fn normal_style(self) -> Style {
match self {
PatchCounterKind::Added => Style::default().green(),
PatchCounterKind::Removed => Style::default().red(),
}
}
}
/// Tracks one floating-to-committed patch line-count animation.
///
/// An instance is owned by a single StreamingPatchHistoryCell and is mutated
/// only on the UI thread when newer patch snapshots arrive. base is the
/// committed value at the start of the current drain, and target is the total
/// value that will be committed once the drain finishes. Calling
/// PatchCounterAnim::add_delta while the animation is active preserves the
/// current committed value before extending the target, which avoids visible
/// jumps when app-server notifications arrive close together.
#[derive(Clone, Debug)]
pub(crate) struct PatchCounterAnim {
kind: PatchCounterKind,
base: usize,
target: usize,
pulse_in_end: Instant,
drain_start: Instant,
drain_end: Instant,
}
impl PatchCounterAnim {
/// Starts a new counter animation from an already-rendered total.
///
/// base must be the committed total currently visible in the header, and
/// delta must be the newly observed increase for the same counter kind.
/// Passing the full next total as delta would double-count the baseline
/// and make the floating counter report too many lines.
pub(crate) fn start(kind: PatchCounterKind, base: usize, delta: usize, now: Instant) -> Self {
let pulse_in_end = now + Duration::from_millis(PULSE_IN_MS);
let drain_start = pulse_in_end;
let drain_end = drain_start + drain_duration_for(delta);
Self {
kind,
base,
target: base.saturating_add(delta),
pulse_in_end,
drain_start,
drain_end,
}
}
/// Coalesces an additional increase into a running animation.
///
/// The committed value at now becomes the new base before the target is
/// extended, so the rendered total remains continuous across snapshots.
/// Callers should only use this for increases on an active animation; use
/// update_counter_anim for normal snapshot reconciliation.
pub(crate) fn add_delta(&mut self, delta: usize, now: Instant) {
let current = self.committed(now);
self.base = current;
self.target = self.target.saturating_add(delta);
self.drain_start = now.max(self.pulse_in_end);
self.drain_end = self.drain_start + drain_duration_for(self.target.saturating_sub(current));
}
/// Returns the portion of the target value that should render in the header.
///
/// The value is clamped between base and target and is safe to query with
/// any Instant. Querying before the drain starts intentionally keeps the
/// committed header unchanged while the floating counter pulses in.
pub(crate) fn committed(&self, now: Instant) -> usize {
if now <= self.drain_start || self.target == self.base {
return self.base;
}
if now >= self.drain_end {
return self.target;
}
let total = self
.drain_end
.saturating_duration_since(self.drain_start)
.as_secs_f64()
.max(f64::EPSILON);
let elapsed = now
.saturating_duration_since(self.drain_start)
.as_secs_f64();
let span = self.target.saturating_sub(self.base) as f64;
let value = self.base as f64 + span * (elapsed / total);
value.round().clamp(self.base as f64, self.target as f64) as usize
}
/// Returns the portion of the target value that should render as floating.
///
/// This is always target - committed(now), so callers can render the
/// floating value above or below the header without separately tracking how
/// much of the delta has already drained.
pub(crate) fn floating(&self, now: Instant) -> usize {
self.target.saturating_sub(self.committed(now))
}
#[cfg(test)]
fn target(&self) -> usize {
self.target
}
/// Returns whether the animation still needs redraws.
///
/// An animation remains active while there is a floating value to render.
/// Once this returns false the caller may keep the struct around, but
/// scheduling more animation frames for it would waste redraws with no
/// visual change.
pub(crate) fn is_active(&self, now: Instant) -> bool {
self.floating(now) > 0
}
/// Returns the style for the current floating counter phase.
///
/// Pulse-in is bold and steady drain uses the normal counter style. Callers
/// should not use this for committed header values because doing so would
/// make the target column itself pulse.
pub(crate) fn floating_style(&self, now: Instant) -> Style {
if now < self.pulse_in_end {
return self.kind.normal_style().add_modifier(Modifier::BOLD);
}
self.kind.normal_style()
}
}
/// Reconciles a complete patch snapshot into optional counter animation state.
///
/// The first non-empty snapshot establishes the baseline, disabled animations
/// clear any existing state, and decreases clear the animation because they
/// represent snapshot replacement rather than forward progress. This function
/// is the preferred entry point for callers that receive complete snapshots; a
/// caller that directly starts an animation for the baseline would make the
/// first preview look like newly-arrived progress.
pub(crate) fn update_counter_anim(
animation: &mut Option<PatchCounterAnim>,
kind: PatchCounterKind,
current_value: usize,
next_value: usize,
had_existing_changes: bool,
animations_enabled: bool,
now: Instant,
) {
if current_value == next_value {
return;
}
if !animations_enabled || !had_existing_changes || next_value <= current_value {
*animation = None;
return;
}
let delta = next_value - current_value;
match animation {
Some(anim) if anim.is_active(now) => anim.add_delta(delta, now),
_ => {
*animation = Some(PatchCounterAnim::start(kind, current_value, delta, now));
}
}
}
fn drain_duration_for(delta: usize) -> Duration {
let ms = STEP_MS
.saturating_mul(delta as u64)
.clamp(STEP_MS, DRAIN_CAP_MS);
Duration::from_millis(ms)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn t0() -> Instant {
Instant::now()
}
#[test]
fn committed_starts_at_base() {
let now = t0();
let anim = PatchCounterAnim::start(
PatchCounterKind::Added,
/*base*/ 20,
/*delta*/ 5,
now,
);
assert_eq!(anim.committed(now), 20);
assert_eq!(anim.floating(now), 5);
}
#[test]
fn committed_reaches_target_after_drain() {
let now = t0();
let anim = PatchCounterAnim::start(
PatchCounterKind::Added,
/*base*/ 20,
/*delta*/ 5,
now,
);
let after = now + Duration::from_millis(/*millis*/ PULSE_IN_MS + DRAIN_CAP_MS + 10);
assert_eq!(anim.committed(after), 25);
assert_eq!(anim.floating(after), 0);
}
#[test]
fn coalesce_keeps_committed_continuous_and_extends_target() {
let now = t0();
let mut anim = PatchCounterAnim::start(
PatchCounterKind::Added,
/*base*/ 20,
/*delta*/ 5,
now,
);
let mid = now + Duration::from_millis(/*millis*/ PULSE_IN_MS + 150);
let before = anim.committed(mid);
anim.add_delta(/*delta*/ 3, mid);
assert_eq!(anim.committed(mid), before);
assert_eq!(anim.target(), 28);
let later = mid + Duration::from_millis(/*millis*/ DRAIN_CAP_MS + 10);
assert_eq!(anim.committed(later), 28);
}
#[test]
fn inactive_once_floating_counter_drains() {
let now = t0();
let anim = PatchCounterAnim::start(
PatchCounterKind::Added,
/*base*/ 0,
/*delta*/ 2,
now,
);
let before_drain = now + Duration::from_millis(/*millis*/ PULSE_IN_MS + STEP_MS - 10);
let after_drain =
now + Duration::from_millis(/*millis*/ PULSE_IN_MS + STEP_MS * 2 + 10);
assert!(anim.is_active(before_drain));
assert!(!anim.is_active(after_drain));
}
#[test]
fn update_counter_anim_skips_baseline_and_decreases() {
let now = t0();
let mut anim = None;
update_counter_anim(
&mut anim,
PatchCounterKind::Added,
/*current_value*/ 0,
/*next_value*/ 5,
/*had_existing_changes*/ false,
/*animations_enabled*/ true,
now,
);
assert!(anim.is_none());
update_counter_anim(
&mut anim,
PatchCounterKind::Added,
/*current_value*/ 5,
/*next_value*/ 4,
/*had_existing_changes*/ true,
/*animations_enabled*/ true,
now,
);
assert!(anim.is_none());
}
#[test]
fn update_counter_anim_coalesces_active_animation() {
let now = t0();
let mut anim = None;
update_counter_anim(
&mut anim,
PatchCounterKind::Added,
/*current_value*/ 5,
/*next_value*/ 8,
/*had_existing_changes*/ true,
/*animations_enabled*/ true,
now,
);
let mid = now + Duration::from_millis(/*millis*/ PULSE_IN_MS + 100);
update_counter_anim(
&mut anim,
PatchCounterKind::Added,
/*current_value*/ 8,
/*next_value*/ 10,
/*had_existing_changes*/ true,
/*animations_enabled*/ true,
mid,
);
let anim = anim.expect("active animation");
assert_eq!(anim.target(), 10);
assert!(anim.committed(mid) >= 5);
}
}