use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
static NEXT_OPERATION: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProgressOperation {
id: u64,
started_at: Instant,
}
impl ProgressOperation {
pub fn start() -> Self {
Self {
id: NEXT_OPERATION.fetch_add(1, Ordering::Relaxed),
started_at: Instant::now(),
}
}
pub fn id(self) -> u64 {
self.id
}
pub fn started_at(self) -> Instant {
self.started_at
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProgressPhase {
Initializing,
PreparingContext,
SavingCheckpoint,
WaitingForModel,
Processing,
ReceivingResponse,
Retrying,
CheckingPermissions,
WaitingForApproval,
RunningTools,
CheckingToolOutput,
Compacting {
estimated_input_tokens: Option<usize>,
started_at: Instant,
},
}
impl ProgressPhase {
pub fn label(self) -> &'static str {
match self {
Self::Initializing => "Initializing session",
Self::PreparingContext => "Preparing context",
Self::SavingCheckpoint => "Saving checkpoint",
Self::WaitingForModel => "Waiting for model",
Self::Processing => "Processing",
Self::ReceivingResponse => "Receiving response",
Self::Retrying => "Retrying request",
Self::CheckingPermissions => "Checking permissions",
Self::WaitingForApproval => "Waiting for approval",
Self::RunningTools => "Running tools",
Self::CheckingToolOutput => "Checking tool output...",
Self::Compacting { .. } => "Compacting context",
}
}
pub fn is_animated(self) -> bool {
self != Self::WaitingForApproval
}
pub fn elapsed_secs(self, operation: ProgressOperation, now: Instant) -> u64 {
let started_at = match self {
Self::Compacting { started_at, .. } => started_at,
_ => operation.started_at(),
};
now.saturating_duration_since(started_at).as_secs()
}
pub fn format(self, elapsed_secs: u64) -> String {
if let Self::Compacting { estimated_input_tokens, .. } = self {
let estimate = estimated_input_tokens
.map(|tokens| format!(" · ~{} input tokens", crate::format_token_count(tokens)))
.unwrap_or_default();
return if elapsed_secs == 0 {
format!("{}{}", self.label(), estimate)
} else {
format!("{}{} · {elapsed_secs}s", self.label(), estimate)
};
}
if elapsed_secs == 0 {
self.label().to_string()
} else {
format!("{} · {elapsed_secs}s", self.label())
}
}
pub fn format_for_width(self, elapsed_secs: u64, width: usize) -> String {
let formatted = self.format(elapsed_secs);
if formatted.chars().count() <= width {
return formatted;
}
let Self::Compacting { estimated_input_tokens, .. } = self else {
return formatted;
};
let compact = match estimated_input_tokens {
Some(tokens) => format!("Compact ~{} · {elapsed_secs}s", crate::format_token_count(tokens)),
None => format!("Compact · {elapsed_secs}s"),
};
if compact.chars().count() <= width {
return compact;
}
match estimated_input_tokens {
Some(tokens) => format!("Compact ~{} {elapsed_secs}s", crate::format_token_count(tokens)),
None => compact,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProgressUpdate {
Begin {
operation: ProgressOperation,
phase: ProgressPhase,
},
Phase {
operation: ProgressOperation,
phase: ProgressPhase,
},
Finish {
operation: ProgressOperation,
},
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn compaction_phase_uses_its_own_elapsed_clock() {
let operation = ProgressOperation::start();
let started_at = operation.started_at() + Duration::from_secs(10);
let phase = ProgressPhase::Compacting { estimated_input_tokens: Some(12_000), started_at };
let now = started_at + Duration::from_secs(74);
assert_eq!(phase.elapsed_secs(operation, now), 74);
assert_eq!(ProgressPhase::WaitingForModel.elapsed_secs(operation, now), 84);
}
}
#[derive(Debug, Default)]
pub struct ProgressTracker {
latest_id: u64,
active: Option<(ProgressOperation, ProgressPhase)>,
}
impl ProgressTracker {
pub fn apply(&mut self, update: ProgressUpdate) -> bool {
match update {
ProgressUpdate::Begin { operation, phase } if operation.id() > self.latest_id => {
self.latest_id = operation.id();
self.active = Some((operation, phase));
true
}
ProgressUpdate::Phase { operation, phase }
if self.active.is_some_and(|(current, old)| current == operation && old != phase) =>
{
self.active = Some((operation, phase));
true
}
ProgressUpdate::Finish { operation } if self.active.is_some_and(|(current, _)| current == operation) => {
self.active = None;
true
}
_ => false,
}
}
#[must_use]
pub fn phase(&self) -> Option<ProgressPhase> {
self.active.map(|(_, phase)| phase)
}
}
#[cfg(test)]
mod tracker_tests {
use super::*;
#[test]
fn stale_updates_cannot_replace_or_finish_newer_progress() {
let old = ProgressOperation::start();
let new = ProgressOperation::start();
let mut tracker = ProgressTracker::default();
assert!(tracker.apply(ProgressUpdate::Begin { operation: old, phase: ProgressPhase::Processing }));
assert!(tracker.apply(ProgressUpdate::Begin {
operation: new,
phase: ProgressPhase::WaitingForModel
}));
assert!(!tracker.apply(ProgressUpdate::Finish { operation: old }));
assert!(!tracker.apply(ProgressUpdate::Phase { operation: old, phase: ProgressPhase::RunningTools }));
assert_eq!(tracker.phase(), Some(ProgressPhase::WaitingForModel));
assert!(tracker.apply(ProgressUpdate::Finish { operation: new }));
assert!(!tracker.apply(ProgressUpdate::Begin { operation: old, phase: ProgressPhase::Processing }));
assert_eq!(tracker.phase(), None);
}
}