git_iris/agents/
status.rs

1use crate::messages::ColoredMessage;
2use std::sync::{Arc, Mutex};
3use std::time::{Duration, Instant};
4
5/// Safely truncate a string at a character boundary
6fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str {
7    if s.len() <= max_bytes {
8        return s;
9    }
10    let mut end = max_bytes;
11    while end > 0 && !s.is_char_boundary(end) {
12        end -= 1;
13    }
14    &s[..end]
15}
16
17/// Status phases for the Iris agent
18#[derive(Debug, Clone, PartialEq)]
19pub enum IrisPhase {
20    Initializing,
21    Planning,
22    ToolExecution { tool_name: String, reason: String },
23    PlanExpansion,
24    Synthesis,
25    Analysis,
26    Generation,
27    Completed,
28    Error(String),
29}
30
31/// Token counting information for live updates
32#[derive(Debug, Clone, Default)]
33pub struct TokenMetrics {
34    pub input_tokens: u32,
35    pub output_tokens: u32,
36    pub total_tokens: u32,
37    pub tokens_per_second: f32,
38    pub estimated_remaining: Option<u32>,
39}
40
41/// Status tracker for Iris agent operations with dynamic messages and live token counting
42#[derive(Debug, Clone)]
43pub struct IrisStatus {
44    pub phase: IrisPhase,
45    pub message: String,
46    /// Theme token for color resolution (e.g., "accent.secondary", "success")
47    pub token: &'static str,
48    pub started_at: Instant,
49    pub current_step: usize,
50    pub total_steps: Option<usize>,
51    pub tokens: TokenMetrics,
52    pub is_streaming: bool,
53}
54
55impl IrisStatus {
56    pub fn new() -> Self {
57        Self {
58            phase: IrisPhase::Initializing,
59            message: "🤖 Initializing...".to_string(),
60            token: "accent.secondary",
61            started_at: Instant::now(),
62            current_step: 0,
63            total_steps: None,
64            tokens: TokenMetrics::default(),
65            is_streaming: false,
66        }
67    }
68
69    /// Create a dynamic status with LLM-generated message (constrained to 80 chars)
70    pub fn dynamic(phase: IrisPhase, message: String, step: usize, total: Option<usize>) -> Self {
71        let token = match phase {
72            IrisPhase::Initializing | IrisPhase::PlanExpansion => "accent.secondary",
73            IrisPhase::Planning => "accent.deep",
74            IrisPhase::ToolExecution { .. } | IrisPhase::Completed => "success",
75            IrisPhase::Synthesis => "accent.tertiary",
76            IrisPhase::Analysis => "warning",
77            IrisPhase::Generation => "text.primary",
78            IrisPhase::Error(_) => "error",
79        };
80
81        // Constrain message to 80 characters as requested
82        let constrained_message = if message.len() > 80 {
83            format!("{}...", truncate_at_char_boundary(&message, 77))
84        } else {
85            message
86        };
87
88        Self {
89            phase,
90            message: constrained_message,
91            token,
92            started_at: Instant::now(),
93            current_step: step,
94            total_steps: total,
95            tokens: TokenMetrics::default(),
96            is_streaming: false,
97        }
98    }
99
100    /// Create dynamic streaming status with live token counting
101    pub fn streaming(
102        message: String,
103        tokens: TokenMetrics,
104        step: usize,
105        total: Option<usize>,
106    ) -> Self {
107        // Constrain message to 80 characters
108        let constrained_message = if message.len() > 80 {
109            format!("{}...", truncate_at_char_boundary(&message, 77))
110        } else {
111            message
112        };
113
114        Self {
115            phase: IrisPhase::Generation,
116            message: constrained_message,
117            token: "text.primary",
118            started_at: Instant::now(),
119            current_step: step,
120            total_steps: total,
121            tokens,
122            is_streaming: true,
123        }
124    }
125
126    /// Update token metrics during streaming
127    pub fn update_tokens(&mut self, tokens: TokenMetrics) {
128        self.tokens = tokens;
129
130        // Update tokens per second based on elapsed time
131        let elapsed = self.started_at.elapsed().as_secs_f32();
132        if elapsed > 0.0 {
133            #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
134            {
135                self.tokens.tokens_per_second = self.tokens.output_tokens as f32 / elapsed;
136            }
137        }
138    }
139
140    /// Create error status
141    pub fn error(error: &str) -> Self {
142        let constrained_message = if error.len() > 35 {
143            format!("❌ {}...", truncate_at_char_boundary(error, 32))
144        } else {
145            format!("❌ {error}")
146        };
147
148        Self {
149            phase: IrisPhase::Error(error.to_string()),
150            message: constrained_message,
151            token: "error",
152            started_at: Instant::now(),
153            current_step: 0,
154            total_steps: None,
155            tokens: TokenMetrics::default(),
156            is_streaming: false,
157        }
158    }
159
160    /// Create completed status
161    pub fn completed() -> Self {
162        Self {
163            phase: IrisPhase::Completed,
164            message: "🎉 Done!".to_string(),
165            token: "success",
166            started_at: Instant::now(),
167            current_step: 0,
168            total_steps: None,
169            tokens: TokenMetrics::default(),
170            is_streaming: false,
171        }
172    }
173
174    pub fn duration(&self) -> Duration {
175        self.started_at.elapsed()
176    }
177
178    #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
179    pub fn progress_percentage(&self) -> f32 {
180        if let Some(total) = self.total_steps {
181            (self.current_step as f32 / total as f32) * 100.0
182        } else {
183            0.0
184        }
185    }
186
187    /// Format status for display - clean and minimal
188    pub fn format_for_display(&self) -> String {
189        // Just the message - clean and elegant
190        self.message.clone()
191    }
192}
193
194impl Default for IrisStatus {
195    fn default() -> Self {
196        Self::new()
197    }
198}
199
200/// Global status tracker for Iris agent
201pub struct IrisStatusTracker {
202    status: Arc<Mutex<IrisStatus>>,
203}
204
205impl IrisStatusTracker {
206    pub fn new() -> Self {
207        Self {
208            status: Arc::new(Mutex::new(IrisStatus::new())),
209        }
210    }
211
212    /// Update status with dynamic message
213    pub fn update(&self, status: IrisStatus) {
214        crate::log_debug!(
215            "📋 Status: Updating to phase: {:?}, message: '{}'",
216            status.phase,
217            status.message
218        );
219        if let Ok(mut current_status) = self.status.lock() {
220            *current_status = status;
221            crate::log_debug!("📋 Status: Update completed successfully");
222        } else {
223            crate::log_debug!("📋 Status: ⚠️ Failed to acquire status lock");
224        }
225    }
226
227    /// Update with dynamic LLM-generated message
228    pub fn update_dynamic(
229        &self,
230        phase: IrisPhase,
231        message: String,
232        step: usize,
233        total: Option<usize>,
234    ) {
235        crate::log_debug!(
236            "🎯 Status: Dynamic update - phase: {:?}, message: '{}', step: {}/{:?}",
237            phase,
238            message,
239            step,
240            total
241        );
242        self.update(IrisStatus::dynamic(phase, message, step, total));
243    }
244
245    /// Update streaming status with token metrics
246    pub fn update_streaming(
247        &self,
248        message: String,
249        tokens: TokenMetrics,
250        step: usize,
251        total: Option<usize>,
252    ) {
253        self.update(IrisStatus::streaming(message, tokens, step, total));
254    }
255
256    /// Update only token metrics for current status
257    pub fn update_tokens(&self, tokens: TokenMetrics) {
258        if let Ok(mut status) = self.status.lock() {
259            status.update_tokens(tokens);
260        }
261    }
262
263    pub fn get_current(&self) -> IrisStatus {
264        self.status.lock().map_or_else(
265            |_| IrisStatus::error("Status lock poisoned"),
266            |guard| guard.clone(),
267        )
268    }
269
270    pub fn get_for_spinner(&self) -> ColoredMessage {
271        let status = self.get_current();
272        ColoredMessage {
273            text: status.format_for_display(),
274            token: status.token,
275        }
276    }
277
278    /// Set error status
279    pub fn error(&self, error: &str) {
280        self.update(IrisStatus::error(error));
281    }
282
283    /// Set completed status
284    pub fn completed(&self) {
285        self.update(IrisStatus::completed());
286    }
287}
288
289impl Default for IrisStatusTracker {
290    fn default() -> Self {
291        Self::new()
292    }
293}
294
295/// Global instance of the Iris status tracker
296pub static IRIS_STATUS: std::sync::LazyLock<IrisStatusTracker> =
297    std::sync::LazyLock::new(IrisStatusTracker::new);
298
299/// Global flag to track if agent mode is enabled (enabled by default)
300pub static AGENT_MODE_ENABLED: std::sync::LazyLock<std::sync::Arc<std::sync::atomic::AtomicBool>> =
301    std::sync::LazyLock::new(|| std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)));
302
303/// Enable agent mode globally
304pub fn enable_agent_mode() {
305    AGENT_MODE_ENABLED.store(true, std::sync::atomic::Ordering::Relaxed);
306}
307
308/// Check if agent mode is enabled
309pub fn is_agent_mode_enabled() -> bool {
310    AGENT_MODE_ENABLED.load(std::sync::atomic::Ordering::Relaxed)
311}
312
313/// Helper macros for dynamic status updates with LLM messages
314#[macro_export]
315macro_rules! iris_status_dynamic {
316    ($phase:expr, $message:expr, $step:expr) => {
317        $crate::agents::status::IRIS_STATUS.update_dynamic(
318            $phase,
319            $message.to_string(),
320            $step,
321            None,
322        );
323    };
324    ($phase:expr, $message:expr, $step:expr, $total:expr) => {
325        $crate::agents::status::IRIS_STATUS.update_dynamic(
326            $phase,
327            $message.to_string(),
328            $step,
329            Some($total),
330        );
331    };
332}
333
334#[macro_export]
335macro_rules! iris_status_streaming {
336    ($message:expr, $tokens:expr) => {
337        $crate::agents::status::IRIS_STATUS.update_streaming(
338            $message.to_string(),
339            $tokens,
340            0,
341            None,
342        );
343    };
344    ($message:expr, $tokens:expr, $step:expr, $total:expr) => {
345        $crate::agents::status::IRIS_STATUS.update_streaming(
346            $message.to_string(),
347            $tokens,
348            $step,
349            Some($total),
350        );
351    };
352}
353
354#[macro_export]
355macro_rules! iris_status_tokens {
356    ($tokens:expr) => {
357        $crate::agents::status::IRIS_STATUS.update_tokens($tokens);
358    };
359}
360
361#[macro_export]
362macro_rules! iris_status_error {
363    ($error:expr) => {
364        $crate::agents::status::IRIS_STATUS.error($error);
365    };
366}
367
368#[macro_export]
369macro_rules! iris_status_completed {
370    () => {
371        $crate::agents::status::IRIS_STATUS.completed();
372    };
373}