1use anyhow::Result;
8use serde::{Deserialize, Serialize};
9use tokio::sync::mpsc;
10use tokio::time::{Duration, timeout};
11
12use crate::agents::provider::{self, DynAgent};
13
14#[derive(Debug, Clone)]
16pub struct StatusContext {
17 pub task_type: String,
19 pub branch: Option<String>,
21 pub file_count: Option<usize>,
23 pub activity: String,
25 pub files: Vec<String>,
27 pub is_regeneration: bool,
29 pub change_summary: Option<String>,
31 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#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct StatusMessage {
83 pub message: String,
85 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
98fn 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
107pub struct StatusMessageGenerator {
109 provider: String,
110 fast_model: String,
111 api_key: Option<String>,
113 timeout_ms: u64,
115}
116
117impl StatusMessageGenerator {
118 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, }
135 }
136
137 pub fn with_timeout_ms(mut self, ms: u64) -> Self {
139 self.timeout_ms = ms;
140 self
141 }
142
143 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 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 pub fn create_channel() -> (
194 mpsc::UnboundedSender<StatusMessage>,
195 mpsc::UnboundedReceiver<StatusMessage>,
196 ) {
197 mpsc::unbounded_channel()
198 }
199
200 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 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 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 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 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 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 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#[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 pub fn add(&mut self, message: StatusMessage) {
421 self.messages.push(message);
422 }
423
424 pub fn current(&self) -> Option<&StatusMessage> {
426 self.messages.get(self.current_index)
427 }
428
429 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 pub fn is_empty(&self) -> bool {
438 self.messages.is_empty()
439 }
440
441 pub fn len(&self) -> usize {
443 self.messages.len()
444 }
445
446 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 ); }
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 #[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 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 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 for j in 1..=5 {
601 let msg = generator.generate(ctx).await;
602 println!(" {}: {}", j, msg.message);
603 }
604 println!();
605 }
606 });
607 }
608}