vtcode_commons/ui_protocol/
progress.rs1use std::sync::atomic::{AtomicU64, Ordering};
2use std::time::Instant;
3
4static NEXT_OPERATION: AtomicU64 = AtomicU64::new(1);
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct ProgressOperation {
9 id: u64,
10 started_at: Instant,
11}
12
13impl ProgressOperation {
14 pub fn start() -> Self {
15 Self {
16 id: NEXT_OPERATION.fetch_add(1, Ordering::Relaxed),
17 started_at: Instant::now(),
18 }
19 }
20
21 pub fn id(self) -> u64 {
22 self.id
23 }
24
25 pub fn started_at(self) -> Instant {
26 self.started_at
27 }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ProgressPhase {
33 Initializing,
34 PreparingContext,
35 SavingCheckpoint,
36 WaitingForModel,
37 Processing,
38 ReceivingResponse,
39 Retrying,
40 CheckingPermissions,
41 WaitingForApproval,
42 RunningTools,
43 CheckingToolOutput,
44 Compacting {
45 estimated_input_tokens: Option<usize>,
46 started_at: Instant,
47 },
48}
49
50impl ProgressPhase {
51 pub fn label(self) -> &'static str {
52 match self {
53 Self::Initializing => "Initializing session",
54 Self::PreparingContext => "Preparing context",
55 Self::SavingCheckpoint => "Saving checkpoint",
56 Self::WaitingForModel => "Waiting for model",
57 Self::Processing => "Processing",
58 Self::ReceivingResponse => "Receiving response",
59 Self::Retrying => "Retrying request",
60 Self::CheckingPermissions => "Checking permissions",
61 Self::WaitingForApproval => "Waiting for approval",
62 Self::RunningTools => "Running tools",
63 Self::CheckingToolOutput => "Checking tool output...",
64 Self::Compacting { .. } => "Compacting context",
65 }
66 }
67
68 pub fn is_animated(self) -> bool {
69 self != Self::WaitingForApproval
70 }
71
72 pub fn elapsed_secs(self, operation: ProgressOperation, now: Instant) -> u64 {
75 let started_at = match self {
76 Self::Compacting { started_at, .. } => started_at,
77 _ => operation.started_at(),
78 };
79 now.saturating_duration_since(started_at).as_secs()
80 }
81
82 pub fn format(self, elapsed_secs: u64) -> String {
83 if let Self::Compacting { estimated_input_tokens, .. } = self {
84 let estimate = estimated_input_tokens
85 .map(|tokens| format!(" · ~{} input tokens", crate::format_token_count(tokens)))
86 .unwrap_or_default();
87 return if elapsed_secs == 0 {
88 format!("{}{}", self.label(), estimate)
89 } else {
90 format!("{}{} · {elapsed_secs}s", self.label(), estimate)
91 };
92 }
93 if elapsed_secs == 0 {
94 self.label().to_string()
95 } else {
96 format!("{} · {elapsed_secs}s", self.label())
97 }
98 }
99
100 pub fn format_for_width(self, elapsed_secs: u64, width: usize) -> String {
103 let formatted = self.format(elapsed_secs);
104 if formatted.chars().count() <= width {
105 return formatted;
106 }
107
108 let Self::Compacting { estimated_input_tokens, .. } = self else {
109 return formatted;
110 };
111 let compact = match estimated_input_tokens {
112 Some(tokens) => format!("Compact ~{} · {elapsed_secs}s", crate::format_token_count(tokens)),
113 None => format!("Compact · {elapsed_secs}s"),
114 };
115 if compact.chars().count() <= width {
116 return compact;
117 }
118 match estimated_input_tokens {
121 Some(tokens) => format!("Compact ~{} {elapsed_secs}s", crate::format_token_count(tokens)),
122 None => compact,
123 }
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum ProgressUpdate {
129 Begin {
130 operation: ProgressOperation,
131 phase: ProgressPhase,
132 },
133 Phase {
134 operation: ProgressOperation,
135 phase: ProgressPhase,
136 },
137 Finish {
138 operation: ProgressOperation,
139 },
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use std::time::Duration;
146
147 #[test]
148 fn compaction_phase_uses_its_own_elapsed_clock() {
149 let operation = ProgressOperation::start();
150 let started_at = operation.started_at() + Duration::from_secs(10);
151 let phase = ProgressPhase::Compacting { estimated_input_tokens: Some(12_000), started_at };
152 let now = started_at + Duration::from_secs(74);
153
154 assert_eq!(phase.elapsed_secs(operation, now), 74);
155 assert_eq!(ProgressPhase::WaitingForModel.elapsed_secs(operation, now), 84);
156 }
157}
158
159#[derive(Debug, Default)]
161pub struct ProgressTracker {
162 latest_id: u64,
163 active: Option<(ProgressOperation, ProgressPhase)>,
164}
165
166impl ProgressTracker {
167 pub fn apply(&mut self, update: ProgressUpdate) -> bool {
169 match update {
170 ProgressUpdate::Begin { operation, phase } if operation.id() > self.latest_id => {
171 self.latest_id = operation.id();
172 self.active = Some((operation, phase));
173 true
174 }
175 ProgressUpdate::Phase { operation, phase }
176 if self.active.is_some_and(|(current, old)| current == operation && old != phase) =>
177 {
178 self.active = Some((operation, phase));
179 true
180 }
181 ProgressUpdate::Finish { operation } if self.active.is_some_and(|(current, _)| current == operation) => {
182 self.active = None;
183 true
184 }
185 _ => false,
186 }
187 }
188
189 #[must_use]
190 pub fn phase(&self) -> Option<ProgressPhase> {
191 self.active.map(|(_, phase)| phase)
192 }
193}
194
195#[cfg(test)]
196mod tracker_tests {
197 use super::*;
198
199 #[test]
200 fn stale_updates_cannot_replace_or_finish_newer_progress() {
201 let old = ProgressOperation::start();
202 let new = ProgressOperation::start();
203 let mut tracker = ProgressTracker::default();
204 assert!(tracker.apply(ProgressUpdate::Begin { operation: old, phase: ProgressPhase::Processing }));
205 assert!(tracker.apply(ProgressUpdate::Begin {
206 operation: new,
207 phase: ProgressPhase::WaitingForModel
208 }));
209 assert!(!tracker.apply(ProgressUpdate::Finish { operation: old }));
210 assert!(!tracker.apply(ProgressUpdate::Phase { operation: old, phase: ProgressPhase::RunningTools }));
211 assert_eq!(tracker.phase(), Some(ProgressPhase::WaitingForModel));
212 assert!(tracker.apply(ProgressUpdate::Finish { operation: new }));
213 assert!(!tracker.apply(ProgressUpdate::Begin { operation: old, phase: ProgressPhase::Processing }));
214 assert_eq!(tracker.phase(), None);
215 }
216}