Skip to main content

git_iris/agents/
status_messages.rs

1//! Dynamic status message generation using the fast model
2//!
3//! Generates witty, contextual waiting messages while users wait for
4//! agent operations to complete. Uses fire-and-forget async with hard
5//! timeout to ensure we never block on status messages.
6
7use anyhow::Result;
8use serde::{Deserialize, Serialize};
9use tokio::sync::mpsc;
10use tokio::time::{Duration, timeout};
11
12use crate::agents::provider::{self, DynAgent};
13
14/// Context for generating status messages
15#[derive(Debug, Clone)]
16pub struct StatusContext {
17    /// Type of task being performed
18    pub task_type: String,
19    /// Current branch name
20    pub branch: Option<String>,
21    /// Number of files being analyzed
22    pub file_count: Option<usize>,
23    /// Brief summary of what's happening (e.g., "analyzing commit changes")
24    pub activity: String,
25    /// Actual file names being changed (for richer context)
26    pub files: Vec<String>,
27    /// Whether this is a regeneration (we have more context available)
28    pub is_regeneration: bool,
29    /// Brief description of what's changing (e.g., "auth system, test fixes")
30    pub change_summary: Option<String>,
31    /// On regeneration: hint about current content (e.g., "commit about auth refactor")
32    pub current_content_hint: Option<String>,
33}
34
35impl StatusContext {
36    pub fn new(task_type: &str, activity: &str) -> Self {
37        Self {
38            task_type: task_type.to_string(),
39            branch: None,
40            file_count: None,
41            activity: activity.to_string(),
42            files: Vec::new(),
43            is_regeneration: false,
44            change_summary: None,
45            current_content_hint: None,
46        }
47    }
48
49    pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
50        self.branch = Some(branch.into());
51        self
52    }
53
54    pub fn with_file_count(mut self, count: usize) -> Self {
55        self.file_count = Some(count);
56        self
57    }
58
59    pub fn with_files(mut self, files: Vec<String>) -> Self {
60        self.files = files;
61        self
62    }
63
64    pub fn with_regeneration(mut self, is_regen: bool) -> Self {
65        self.is_regeneration = is_regen;
66        self
67    }
68
69    pub fn with_change_summary(mut self, summary: impl Into<String>) -> Self {
70        self.change_summary = Some(summary.into());
71        self
72    }
73
74    pub fn with_content_hint(mut self, hint: impl Into<String>) -> Self {
75        self.current_content_hint = Some(hint.into());
76        self
77    }
78}
79
80/// A generated status message
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct StatusMessage {
83    /// The witty message to display
84    pub message: String,
85    /// Estimated time context (e.g., "a few seconds", "about 30 seconds")
86    pub time_hint: Option<String>,
87}
88
89impl Default for StatusMessage {
90    fn default() -> Self {
91        Self {
92            message: "Working on it...".to_string(),
93            time_hint: None,
94        }
95    }
96}
97
98/// Capitalize first letter of a string (sentence case)
99fn capitalize_first(s: &str) -> String {
100    let mut chars = s.chars();
101    match chars.next() {
102        None => String::new(),
103        Some(first) => first.to_uppercase().chain(chars).collect(),
104    }
105}
106
107/// Generator for dynamic status messages
108pub struct StatusMessageGenerator {
109    provider: String,
110    fast_model: String,
111    /// API key for the provider (from config)
112    api_key: Option<String>,
113    /// Hard timeout for status message generation (ms)
114    timeout_ms: u64,
115}
116
117impl StatusMessageGenerator {
118    /// Create a new status message generator
119    ///
120    /// # Arguments
121    /// * `provider` - LLM provider name (e.g., "anthropic", "openai")
122    /// * `fast_model` - Model to use for quick generations
123    /// * `api_key` - Optional API key (falls back to env var if not provided)
124    pub fn new(
125        provider: impl Into<String>,
126        fast_model: impl Into<String>,
127        api_key: Option<String>,
128    ) -> Self {
129        Self {
130            provider: provider.into(),
131            fast_model: fast_model.into(),
132            api_key,
133            timeout_ms: 1500, // 1.5 seconds - fast model should respond quickly
134        }
135    }
136
137    /// Set custom timeout in milliseconds
138    pub fn with_timeout_ms(mut self, ms: u64) -> Self {
139        self.timeout_ms = ms;
140        self
141    }
142
143    /// Generate a status message synchronously with timeout
144    ///
145    /// Returns default message if generation fails or times out.
146    pub async fn generate(&self, context: &StatusContext) -> StatusMessage {
147        match timeout(
148            Duration::from_millis(self.timeout_ms),
149            self.generate_internal(context),
150        )
151        .await
152        {
153            Ok(Ok(msg)) => msg,
154            Ok(Err(_)) | Err(_) => Self::default_message(context),
155        }
156    }
157
158    /// Spawn fire-and-forget generation that sends result to channel
159    ///
160    /// This spawns an async task that will send the generated message
161    /// to the provided channel. If generation times out or fails, nothing
162    /// is sent (caller should already have a fallback displayed).
163    pub fn spawn_generation(
164        &self,
165        context: StatusContext,
166        tx: mpsc::UnboundedSender<StatusMessage>,
167    ) {
168        let provider = self.provider.clone();
169        let fast_model = self.fast_model.clone();
170        let api_key = self.api_key.clone();
171        let timeout_ms = self.timeout_ms;
172
173        tokio::spawn(async move {
174            let generator = StatusMessageGenerator {
175                provider,
176                fast_model,
177                api_key,
178                timeout_ms,
179            };
180
181            if let Ok(Ok(msg)) = timeout(
182                Duration::from_millis(timeout_ms),
183                generator.generate_internal(&context),
184            )
185            .await
186            {
187                let _ = tx.send(msg);
188            }
189        });
190    }
191
192    /// Create a channel for receiving status messages
193    pub fn create_channel() -> (
194        mpsc::UnboundedSender<StatusMessage>,
195        mpsc::UnboundedReceiver<StatusMessage>,
196    ) {
197        mpsc::unbounded_channel()
198    }
199
200    /// Build the agent for status message generation
201    fn build_status_agent(
202        provider: &str,
203        fast_model: &str,
204        api_key: Option<&str>,
205    ) -> Result<DynAgent> {
206        let preamble = "You write fun waiting messages for a Git AI named Iris. \
207                        Concise, yet fun and encouraging, add vibes, be clever, not cheesy. \
208                        Capitalize first letter, end with ellipsis. Under 35 chars. No emojis. \
209                        Just the message text, nothing else.";
210
211        match provider {
212            "openai" => {
213                let agent = provider::openai_builder(fast_model, api_key)?
214                    .preamble(preamble)
215                    .max_tokens(50)
216                    .build();
217                Ok(DynAgent::OpenAI(agent))
218            }
219            "anthropic" => {
220                let agent = provider::anthropic_builder(fast_model, api_key)?
221                    .preamble(preamble)
222                    .max_tokens(50)
223                    .build();
224                Ok(DynAgent::Anthropic(agent))
225            }
226            "google" | "gemini" => {
227                let agent = provider::gemini_builder(fast_model, api_key)?
228                    .preamble(preamble)
229                    .max_tokens(50)
230                    .build();
231                Ok(DynAgent::Gemini(agent))
232            }
233            _ => Err(anyhow::anyhow!("Unsupported provider: {}", provider)),
234        }
235    }
236
237    /// Internal generation logic
238    async fn generate_internal(&self, context: &StatusContext) -> Result<StatusMessage> {
239        let prompt = Self::build_prompt(context);
240        tracing::info!(
241            "Building status agent with provider={}, model={}",
242            self.provider,
243            self.fast_model
244        );
245
246        // Build agent synchronously (DynClientBuilder is not Send)
247        // The returned agent IS Send, so we can await after this
248        let agent = match Self::build_status_agent(
249            &self.provider,
250            &self.fast_model,
251            self.api_key.as_deref(),
252        ) {
253            Ok(a) => a,
254            Err(e) => {
255                tracing::warn!("Failed to build status agent: {}", e);
256                return Err(e);
257            }
258        };
259
260        tracing::info!("Prompting status agent...");
261        let response = match agent.prompt(&prompt).await {
262            Ok(r) => r,
263            Err(e) => {
264                tracing::warn!("Status agent prompt failed: {}", e);
265                return Err(anyhow::anyhow!("Prompt failed: {}", e));
266            }
267        };
268
269        let message = capitalize_first(response.trim());
270        tracing::info!(
271            "Status agent response ({} chars): {:?}",
272            message.len(),
273            message
274        );
275
276        // Sanity check - if response is too long or empty, use fallback
277        if message.is_empty() || message.len() > 80 {
278            tracing::info!("Response invalid (empty or too long), using fallback");
279            return Ok(Self::default_message(context));
280        }
281
282        Ok(StatusMessage {
283            message,
284            time_hint: None,
285        })
286    }
287
288    /// Build the prompt for status message generation
289    fn build_prompt(context: &StatusContext) -> String {
290        let mut prompt = String::from("Context:\n");
291
292        prompt.push_str(&format!("Task: {}\n", context.task_type));
293        prompt.push_str(&format!("Activity: {}\n", context.activity));
294
295        if let Some(branch) = &context.branch {
296            prompt.push_str(&format!("Branch: {}\n", branch));
297        }
298
299        if !context.files.is_empty() {
300            let file_list: Vec<&str> = context.files.iter().take(3).map(String::as_str).collect();
301            prompt.push_str(&format!("Files: {}\n", file_list.join(", ")));
302        } else if let Some(count) = context.file_count {
303            prompt.push_str(&format!("File count: {}\n", count));
304        }
305
306        prompt.push_str(
307            "\nYour task is to use the limited context above to generate a fun waiting message \
308             shown to the user while the main task executes. Concise, yet fun and encouraging. \
309             Add fun vibes depending on the context. Be clever. \
310             Capitalize the first letter and end with ellipsis. Under 35 chars. No emojis.\n\n\
311             Just the message:",
312        );
313        prompt
314    }
315
316    /// Get a default message based on context (used as fallback)
317    fn default_message(context: &StatusContext) -> StatusMessage {
318        let message = match context.task_type.as_str() {
319            "commit" => "Crafting your commit message...",
320            "review" => "Analyzing code changes...",
321            "pr" => "Writing PR description...",
322            "changelog" => "Generating changelog...",
323            "release_notes" => "Composing release notes...",
324            "chat" => "Thinking...",
325            "semantic_blame" => "Tracing code origins...",
326            _ => "Working on it...",
327        };
328
329        StatusMessage {
330            message: message.to_string(),
331            time_hint: None,
332        }
333    }
334
335    /// Generate a completion message when a task finishes
336    pub async fn generate_completion(&self, context: &StatusContext) -> StatusMessage {
337        match timeout(
338            Duration::from_millis(self.timeout_ms),
339            self.generate_completion_internal(context),
340        )
341        .await
342        {
343            Ok(Ok(msg)) => msg,
344            Ok(Err(_)) | Err(_) => Self::default_completion(context),
345        }
346    }
347
348    async fn generate_completion_internal(&self, context: &StatusContext) -> Result<StatusMessage> {
349        let prompt = Self::build_completion_prompt(context);
350
351        let agent =
352            Self::build_status_agent(&self.provider, &self.fast_model, self.api_key.as_deref())?;
353        let response = agent.prompt(&prompt).await?;
354        let message = capitalize_first(response.trim());
355
356        if message.is_empty() || message.len() > 80 {
357            return Ok(Self::default_completion(context));
358        }
359
360        Ok(StatusMessage {
361            message,
362            time_hint: None,
363        })
364    }
365
366    fn build_completion_prompt(context: &StatusContext) -> String {
367        let mut prompt = String::from("Task just completed:\n\n");
368        prompt.push_str(&format!("Task: {}\n", context.task_type));
369
370        if let Some(branch) = &context.branch {
371            prompt.push_str(&format!("Branch: {}\n", branch));
372        }
373
374        if let Some(hint) = &context.current_content_hint {
375            prompt.push_str(&format!("Content: {}\n", hint));
376        }
377
378        prompt.push_str(
379            "\nGenerate a brief completion message based on the content above.\n\n\
380             RULES:\n\
381             - Reference the SPECIFIC topic from content above (not generic \"changes\")\n\
382             - Sentence case, under 35 chars, no emojis\n\
383             - Just the message, nothing else:",
384        );
385        prompt
386    }
387
388    fn default_completion(context: &StatusContext) -> StatusMessage {
389        let message = match context.task_type.as_str() {
390            "commit" => "Ready to commit.",
391            "review" => "Review complete.",
392            "pr" => "PR description ready.",
393            "changelog" => "Changelog generated.",
394            "release_notes" => "Release notes ready.",
395            "chat" => "Here you go.",
396            "semantic_blame" => "Origins traced.",
397            _ => "Done.",
398        };
399
400        StatusMessage {
401            message: message.to_string(),
402            time_hint: None,
403        }
404    }
405}
406
407/// Batch of status messages for cycling display
408#[derive(Debug, Clone, Default)]
409pub struct StatusMessageBatch {
410    messages: Vec<StatusMessage>,
411    current_index: usize,
412}
413
414impl StatusMessageBatch {
415    pub fn new() -> Self {
416        Self::default()
417    }
418
419    /// Add a message to the batch
420    pub fn add(&mut self, message: StatusMessage) {
421        self.messages.push(message);
422    }
423
424    /// Get the current message (if any)
425    pub fn current(&self) -> Option<&StatusMessage> {
426        self.messages.get(self.current_index)
427    }
428
429    /// Advance to the next message (cycles back to start)
430    pub fn next(&mut self) {
431        if !self.messages.is_empty() {
432            self.current_index = (self.current_index + 1) % self.messages.len();
433        }
434    }
435
436    /// Check if we have any messages
437    pub fn is_empty(&self) -> bool {
438        self.messages.is_empty()
439    }
440
441    /// Number of messages in batch
442    pub fn len(&self) -> usize {
443        self.messages.len()
444    }
445
446    /// Clear all messages
447    pub fn clear(&mut self) {
448        self.messages.clear();
449        self.current_index = 0;
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn test_status_context_builder() {
459        let ctx = StatusContext::new("commit", "analyzing staged changes")
460            .with_branch("main")
461            .with_file_count(5);
462
463        assert_eq!(ctx.task_type, "commit");
464        assert_eq!(ctx.branch, Some("main".to_string()));
465        assert_eq!(ctx.file_count, Some(5));
466    }
467
468    #[test]
469    fn test_default_messages() {
470        let ctx = StatusContext::new("commit", "test");
471        let msg = StatusMessageGenerator::default_message(&ctx);
472        assert_eq!(msg.message, "Crafting your commit message...");
473
474        let ctx = StatusContext::new("review", "test");
475        let msg = StatusMessageGenerator::default_message(&ctx);
476        assert_eq!(msg.message, "Analyzing code changes...");
477
478        let ctx = StatusContext::new("unknown", "test");
479        let msg = StatusMessageGenerator::default_message(&ctx);
480        assert_eq!(msg.message, "Working on it...");
481    }
482
483    #[test]
484    fn test_message_batch_cycling() {
485        let mut batch = StatusMessageBatch::new();
486        assert!(batch.is_empty());
487        assert!(batch.current().is_none());
488
489        batch.add(StatusMessage {
490            message: "First".to_string(),
491            time_hint: None,
492        });
493        batch.add(StatusMessage {
494            message: "Second".to_string(),
495            time_hint: None,
496        });
497
498        assert_eq!(batch.len(), 2);
499        assert_eq!(
500            batch.current().expect("should have current").message,
501            "First"
502        );
503
504        batch.next();
505        assert_eq!(
506            batch.current().expect("should have current").message,
507            "Second"
508        );
509
510        batch.next();
511        assert_eq!(
512            batch.current().expect("should have current").message,
513            "First"
514        ); // Cycles back
515    }
516
517    #[test]
518    fn test_prompt_building() {
519        let ctx = StatusContext::new("commit", "analyzing staged changes")
520            .with_branch("feature/awesome")
521            .with_file_count(3);
522
523        let prompt = StatusMessageGenerator::build_prompt(&ctx);
524        assert!(prompt.contains("commit"));
525        assert!(prompt.contains("analyzing staged changes"));
526        assert!(prompt.contains("feature/awesome"));
527        assert!(prompt.contains('3'));
528    }
529
530    /// Debug test to evaluate status message quality
531    /// Run with: cargo test `debug_status_messages` -- --ignored --nocapture
532    #[test]
533    #[ignore = "manual debug test for evaluating status message quality"]
534    fn debug_status_messages() {
535        use tokio::runtime::Runtime;
536
537        let rt = Runtime::new().expect("failed to create tokio runtime");
538        rt.block_on(async {
539            // Get provider/model from env or use defaults
540            let provider =
541                std::env::var("IRIS_PROVIDER").unwrap_or_else(|_| "anthropic".to_string());
542            let model = std::env::var("IRIS_MODEL")
543                .unwrap_or_else(|_| "claude-haiku-4-5-20251001".to_string());
544
545            println!("\n{}", "=".repeat(60));
546            println!(
547                "Status Message Debug - Provider: {}, Model: {}",
548                provider, model
549            );
550            println!("{}\n", "=".repeat(60));
551
552            let generator = StatusMessageGenerator::new(&provider, &model, None);
553
554            // Test scenarios
555            let scenarios = [
556                StatusContext::new("commit", "crafting commit message")
557                    .with_branch("main")
558                    .with_files(vec![
559                        "mod.rs".to_string(),
560                        "status_messages.rs".to_string(),
561                        "agent_tasks.rs".to_string(),
562                    ])
563                    .with_file_count(3),
564                StatusContext::new("commit", "crafting commit message")
565                    .with_branch("feature/auth")
566                    .with_files(vec!["auth.rs".to_string(), "login.rs".to_string()])
567                    .with_file_count(2),
568                StatusContext::new("commit", "crafting commit message")
569                    .with_branch("main")
570                    .with_files(vec![
571                        "config.ts".to_string(),
572                        "App.tsx".to_string(),
573                        "hooks.ts".to_string(),
574                    ])
575                    .with_file_count(16)
576                    .with_regeneration(true)
577                    .with_content_hint("refactor: simplify auth flow"),
578                StatusContext::new("review", "analyzing code changes")
579                    .with_branch("pr/123")
580                    .with_files(vec!["reducer.rs".to_string()])
581                    .with_file_count(1),
582                StatusContext::new("pr", "drafting PR description")
583                    .with_branch("feature/dark-mode")
584                    .with_files(vec!["theme.rs".to_string(), "colors.rs".to_string()])
585                    .with_file_count(5),
586            ];
587
588            for (i, ctx) in scenarios.iter().enumerate() {
589                println!("--- Scenario {} ---", i + 1);
590                println!(
591                    "Task: {}, Branch: {:?}, Files: {:?}",
592                    ctx.task_type, ctx.branch, ctx.files
593                );
594                if ctx.is_regeneration {
595                    println!("(Regeneration, hint: {:?})", ctx.current_content_hint);
596                }
597                println!();
598
599                // Generate 5 messages for each scenario
600                for j in 1..=5 {
601                    let msg = generator.generate(ctx).await;
602                    println!("  {}: {}", j, msg.message);
603                }
604                println!();
605            }
606        });
607    }
608}