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 = Self::build_status_agent(
352 &self.provider,
353 &self.fast_model,
354 self.api_key.as_deref(),
355 )?;
356 let response = agent.prompt(&prompt).await?;
357 let message = capitalize_first(response.trim());
358
359 if message.is_empty() || message.len() > 80 {
360 return Ok(Self::default_completion(context));
361 }
362
363 Ok(StatusMessage {
364 message,
365 time_hint: None,
366 })
367 }
368
369 fn build_completion_prompt(context: &StatusContext) -> String {
370 let mut prompt = String::from("Task just completed:\n\n");
371 prompt.push_str(&format!("Task: {}\n", context.task_type));
372
373 if let Some(branch) = &context.branch {
374 prompt.push_str(&format!("Branch: {}\n", branch));
375 }
376
377 if let Some(hint) = &context.current_content_hint {
378 prompt.push_str(&format!("Content: {}\n", hint));
379 }
380
381 prompt.push_str(
382 "\nGenerate a brief completion message based on the content above.\n\n\
383 RULES:\n\
384 - Reference the SPECIFIC topic from content above (not generic \"changes\")\n\
385 - Sentence case, under 35 chars, no emojis\n\
386 - Just the message, nothing else:",
387 );
388 prompt
389 }
390
391 fn default_completion(context: &StatusContext) -> StatusMessage {
392 let message = match context.task_type.as_str() {
393 "commit" => "Ready to commit.",
394 "review" => "Review complete.",
395 "pr" => "PR description ready.",
396 "changelog" => "Changelog generated.",
397 "release_notes" => "Release notes ready.",
398 "chat" => "Here you go.",
399 "semantic_blame" => "Origins traced.",
400 _ => "Done.",
401 };
402
403 StatusMessage {
404 message: message.to_string(),
405 time_hint: None,
406 }
407 }
408}
409
410#[derive(Debug, Clone, Default)]
412pub struct StatusMessageBatch {
413 messages: Vec<StatusMessage>,
414 current_index: usize,
415}
416
417impl StatusMessageBatch {
418 pub fn new() -> Self {
419 Self::default()
420 }
421
422 pub fn add(&mut self, message: StatusMessage) {
424 self.messages.push(message);
425 }
426
427 pub fn current(&self) -> Option<&StatusMessage> {
429 self.messages.get(self.current_index)
430 }
431
432 pub fn next(&mut self) {
434 if !self.messages.is_empty() {
435 self.current_index = (self.current_index + 1) % self.messages.len();
436 }
437 }
438
439 pub fn is_empty(&self) -> bool {
441 self.messages.is_empty()
442 }
443
444 pub fn len(&self) -> usize {
446 self.messages.len()
447 }
448
449 pub fn clear(&mut self) {
451 self.messages.clear();
452 self.current_index = 0;
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn test_status_context_builder() {
462 let ctx = StatusContext::new("commit", "analyzing staged changes")
463 .with_branch("main")
464 .with_file_count(5);
465
466 assert_eq!(ctx.task_type, "commit");
467 assert_eq!(ctx.branch, Some("main".to_string()));
468 assert_eq!(ctx.file_count, Some(5));
469 }
470
471 #[test]
472 fn test_default_messages() {
473 let ctx = StatusContext::new("commit", "test");
474 let msg = StatusMessageGenerator::default_message(&ctx);
475 assert_eq!(msg.message, "Crafting your commit message...");
476
477 let ctx = StatusContext::new("review", "test");
478 let msg = StatusMessageGenerator::default_message(&ctx);
479 assert_eq!(msg.message, "Analyzing code changes...");
480
481 let ctx = StatusContext::new("unknown", "test");
482 let msg = StatusMessageGenerator::default_message(&ctx);
483 assert_eq!(msg.message, "Working on it...");
484 }
485
486 #[test]
487 fn test_message_batch_cycling() {
488 let mut batch = StatusMessageBatch::new();
489 assert!(batch.is_empty());
490 assert!(batch.current().is_none());
491
492 batch.add(StatusMessage {
493 message: "First".to_string(),
494 time_hint: None,
495 });
496 batch.add(StatusMessage {
497 message: "Second".to_string(),
498 time_hint: None,
499 });
500
501 assert_eq!(batch.len(), 2);
502 assert_eq!(batch.current().unwrap().message, "First");
503
504 batch.next();
505 assert_eq!(batch.current().unwrap().message, "Second");
506
507 batch.next();
508 assert_eq!(batch.current().unwrap().message, "First"); }
510
511 #[test]
512 fn test_prompt_building() {
513 let ctx = StatusContext::new("commit", "analyzing staged changes")
514 .with_branch("feature/awesome")
515 .with_file_count(3);
516
517 let prompt = StatusMessageGenerator::build_prompt(&ctx);
518 assert!(prompt.contains("commit"));
519 assert!(prompt.contains("analyzing staged changes"));
520 assert!(prompt.contains("feature/awesome"));
521 assert!(prompt.contains("3"));
522 }
523
524 #[test]
527 #[ignore]
528 fn debug_status_messages() {
529 use tokio::runtime::Runtime;
530
531 let rt = Runtime::new().unwrap();
532 rt.block_on(async {
533 let provider =
535 std::env::var("IRIS_PROVIDER").unwrap_or_else(|_| "anthropic".to_string());
536 let model = std::env::var("IRIS_MODEL")
537 .unwrap_or_else(|_| "claude-haiku-4-5-20251001".to_string());
538
539 println!("\n{}", "=".repeat(60));
540 println!(
541 "Status Message Debug - Provider: {}, Model: {}",
542 provider, model
543 );
544 println!("{}\n", "=".repeat(60));
545
546 let generator = StatusMessageGenerator::new(&provider, &model, None);
547
548 let scenarios = vec![
550 StatusContext::new("commit", "crafting commit message")
551 .with_branch("main")
552 .with_files(vec![
553 "mod.rs".to_string(),
554 "status_messages.rs".to_string(),
555 "agent_tasks.rs".to_string(),
556 ])
557 .with_file_count(3),
558 StatusContext::new("commit", "crafting commit message")
559 .with_branch("feature/auth")
560 .with_files(vec!["auth.rs".to_string(), "login.rs".to_string()])
561 .with_file_count(2),
562 StatusContext::new("commit", "crafting commit message")
563 .with_branch("main")
564 .with_files(vec![
565 "config.ts".to_string(),
566 "App.tsx".to_string(),
567 "hooks.ts".to_string(),
568 ])
569 .with_file_count(16)
570 .with_regeneration(true)
571 .with_content_hint("refactor: simplify auth flow"),
572 StatusContext::new("review", "analyzing code changes")
573 .with_branch("pr/123")
574 .with_files(vec!["reducer.rs".to_string()])
575 .with_file_count(1),
576 StatusContext::new("pr", "drafting PR description")
577 .with_branch("feature/dark-mode")
578 .with_files(vec!["theme.rs".to_string(), "colors.rs".to_string()])
579 .with_file_count(5),
580 ];
581
582 for (i, ctx) in scenarios.iter().enumerate() {
583 println!("--- Scenario {} ---", i + 1);
584 println!(
585 "Task: {}, Branch: {:?}, Files: {:?}",
586 ctx.task_type, ctx.branch, ctx.files
587 );
588 if ctx.is_regeneration {
589 println!("(Regeneration, hint: {:?})", ctx.current_content_hint);
590 }
591 println!();
592
593 for j in 1..=5 {
595 let msg = generator.generate(&ctx).await;
596 println!(" {}: {}", j, msg.message);
597 }
598 println!();
599 }
600 });
601 }
602}