1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ScreenVerdict {
11 AgentIdle,
13 AgentWorking,
15 ContextExhausted,
17 Unknown,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum AgentType {
28 Claude,
29 Codex,
30 Kiro,
31 Generic,
32}
33
34impl std::str::FromStr for AgentType {
35 type Err = String;
36 fn from_str(s: &str) -> Result<Self, Self::Err> {
37 match s.to_lowercase().as_str() {
38 "claude" => Ok(Self::Claude),
39 "codex" => Ok(Self::Codex),
40 "kiro" => Ok(Self::Kiro),
41 "generic" | "bash" | "shell" => Ok(Self::Generic),
42 _ => Err(format!("unknown agent type: {s}")),
43 }
44 }
45}
46
47impl std::fmt::Display for AgentType {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 match self {
50 Self::Claude => write!(f, "claude"),
51 Self::Codex => write!(f, "codex"),
52 Self::Kiro => write!(f, "kiro"),
53 Self::Generic => write!(f, "generic"),
54 }
55 }
56}
57
58pub fn classify(agent_type: AgentType, screen: &vt100::Screen) -> ScreenVerdict {
64 let content = screen.contents();
65 if content.trim().is_empty() {
66 return ScreenVerdict::Unknown;
67 }
68
69 if detect_context_exhausted(&content) {
71 return ScreenVerdict::ContextExhausted;
72 }
73
74 match agent_type {
75 AgentType::Claude => classify_claude(&content),
76 AgentType::Codex => classify_codex(&content),
77 AgentType::Kiro => classify_kiro(&content),
78 AgentType::Generic => classify_generic(&content),
79 }
80}
81
82const EXHAUSTION_PATTERNS: &[&str] = &[
87 "context window exceeded",
88 "context window is full",
89 "conversation is too long",
90 "maximum context length",
91 "context limit reached",
92 "truncated due to context limit",
93 "input exceeds the model",
94 "prompt is too long",
95];
96
97fn detect_context_exhausted(content: &str) -> bool {
98 let lower = content.to_lowercase();
99 EXHAUSTION_PATTERNS.iter().any(|p| lower.contains(p))
100}
101
102const CLAUDE_PROMPT_CHARS: &[char] = &['\u{276F}']; const CLAUDE_SPINNER_CHARS: &[char] = &[
111 '\u{00B7}', '\u{2722}', '\u{2733}', '\u{2736}', '\u{273B}', '\u{273D}', ];
118
119fn classify_claude(content: &str) -> ScreenVerdict {
120 let lines: Vec<&str> = content.lines().collect();
121 let recent_raw: Vec<&str> = lines.iter().rev().take(6).copied().collect();
122
123 let has_interrupt_footer = recent_raw.iter().any(|line| {
125 let trimmed = line.trim().to_lowercase();
126 trimmed.contains("esc to interrupt")
127 || trimmed.contains("esc to inter")
128 || trimmed.contains("esc to in\u{2026}")
129 || trimmed.contains("esc to in...")
130 });
131
132 if has_interrupt_footer {
133 return ScreenVerdict::AgentWorking;
134 }
135
136 let recent_nonempty: Vec<&str> = lines
138 .iter()
139 .rev()
140 .filter(|l| !l.trim().is_empty())
141 .take(12)
142 .copied()
143 .collect();
144
145 for line in &recent_nonempty {
146 if looks_like_claude_spinner(line) {
147 return ScreenVerdict::AgentWorking;
148 }
149 }
150
151 for line in &recent_nonempty {
153 let trimmed = line.trim();
154 for &prompt_char in CLAUDE_PROMPT_CHARS {
155 if trimmed.starts_with(prompt_char) {
156 let after = &trimmed[prompt_char.len_utf8()..];
157 if after.is_empty() || after.starts_with(|c: char| c.is_whitespace()) {
158 return ScreenVerdict::AgentIdle;
159 }
160 }
161 }
162 }
163
164 ScreenVerdict::Unknown
165}
166
167fn looks_like_claude_spinner(line: &str) -> bool {
168 let trimmed = line.trim();
169 if trimmed.is_empty() {
170 return false;
171 }
172 let first = trimmed.chars().next().unwrap();
173 CLAUDE_SPINNER_CHARS.contains(&first)
174 && (trimmed.contains('\u{2026}') || trimmed.contains("(thinking"))
175}
176
177fn classify_codex(content: &str) -> ScreenVerdict {
182 let lines: Vec<&str> = content.lines().collect();
183 let recent_nonempty: Vec<&str> = lines
184 .iter()
185 .rev()
186 .filter(|l| !l.trim().is_empty())
187 .take(12)
188 .copied()
189 .collect();
190
191 for line in &recent_nonempty {
193 let trimmed = line.trim();
194 if trimmed.starts_with('\u{203A}')
195 && (trimmed.len() <= '\u{203A}'.len_utf8()
196 || trimmed['\u{203A}'.len_utf8()..].starts_with(|c: char| c.is_whitespace()))
197 {
198 return ScreenVerdict::AgentIdle;
199 }
200 }
201
202 ScreenVerdict::Unknown
203}
204
205fn classify_kiro(content: &str) -> ScreenVerdict {
210 let lines: Vec<&str> = content.lines().collect();
211 let recent_nonempty: Vec<&str> = lines
212 .iter()
213 .rev()
214 .filter(|l| !l.trim().is_empty())
215 .take(12)
216 .copied()
217 .collect();
218
219 for line in &recent_nonempty {
221 let lower = line.to_lowercase();
222 if (lower.contains("kiro") || lower.contains("agent"))
223 && (lower.contains("thinking")
224 || lower.contains("planning")
225 || lower.contains("applying")
226 || lower.contains("working"))
227 {
228 return ScreenVerdict::AgentWorking;
229 }
230 }
231
232 for line in &recent_nonempty {
234 let trimmed = line.trim();
235 if trimmed == ">"
236 || trimmed.ends_with("> ")
237 || trimmed.to_lowercase().starts_with("kiro>")
238 || trimmed.to_lowercase().starts_with("kiro >")
239 {
240 return ScreenVerdict::AgentIdle;
241 }
242 }
243
244 ScreenVerdict::Unknown
245}
246
247fn classify_generic(content: &str) -> ScreenVerdict {
252 let lines: Vec<&str> = content.lines().collect();
253 let recent_nonempty: Vec<&str> = lines
254 .iter()
255 .rev()
256 .filter(|l| !l.trim().is_empty())
257 .take(6)
258 .copied()
259 .collect();
260
261 for line in &recent_nonempty {
262 let trimmed = line.trim();
263 if trimmed.ends_with("$ ")
265 || trimmed.ends_with('$')
266 || trimmed.ends_with("% ")
267 || trimmed.ends_with('%')
268 || trimmed.ends_with("> ")
269 || trimmed.ends_with('>')
270 {
271 return ScreenVerdict::AgentIdle;
272 }
273 }
274
275 ScreenVerdict::Unknown
276}
277
278#[cfg(test)]
283mod tests {
284 use super::*;
285
286 fn make_screen(content: &str) -> vt100::Parser {
287 let mut parser = vt100::Parser::new(24, 80, 0);
288 parser.process(content.as_bytes());
289 parser
290 }
291
292 #[test]
295 fn claude_idle_prompt() {
296 let parser = make_screen("Some output\n\n\u{276F} ");
297 assert_eq!(
298 classify(AgentType::Claude, parser.screen()),
299 ScreenVerdict::AgentIdle
300 );
301 }
302
303 #[test]
304 fn claude_idle_bare_prompt() {
305 let parser = make_screen("Some output\n\n\u{276F}");
306 assert_eq!(
307 classify(AgentType::Claude, parser.screen()),
308 ScreenVerdict::AgentIdle
309 );
310 }
311
312 #[test]
313 fn claude_working_spinner() {
314 let parser = make_screen("\u{00B7} Thinking\u{2026}\n");
315 assert_eq!(
316 classify(AgentType::Claude, parser.screen()),
317 ScreenVerdict::AgentWorking
318 );
319 }
320
321 #[test]
322 fn claude_working_interrupt_footer() {
323 let parser = make_screen("Some output\nesc to interrupt\n");
324 assert_eq!(
325 classify(AgentType::Claude, parser.screen()),
326 ScreenVerdict::AgentWorking
327 );
328 }
329
330 #[test]
331 fn claude_working_interrupt_truncated() {
332 let parser = make_screen("Some output\nesc to inter\n");
333 assert_eq!(
334 classify(AgentType::Claude, parser.screen()),
335 ScreenVerdict::AgentWorking
336 );
337 }
338
339 #[test]
340 fn claude_context_exhausted() {
341 let parser = make_screen("Error: context window is full\n\u{276F} ");
342 assert_eq!(
343 classify(AgentType::Claude, parser.screen()),
344 ScreenVerdict::ContextExhausted
345 );
346 }
347
348 #[test]
351 fn codex_idle_prompt() {
352 let parser = make_screen("Done.\n\n\u{203A} ");
353 assert_eq!(
354 classify(AgentType::Codex, parser.screen()),
355 ScreenVerdict::AgentIdle
356 );
357 }
358
359 #[test]
360 fn codex_idle_bare_prompt() {
361 let parser = make_screen("Done.\n\n\u{203A}");
362 assert_eq!(
363 classify(AgentType::Codex, parser.screen()),
364 ScreenVerdict::AgentIdle
365 );
366 }
367
368 #[test]
369 fn codex_unknown_no_prompt() {
370 let parser = make_screen("Running something...\n");
371 assert_eq!(
372 classify(AgentType::Codex, parser.screen()),
373 ScreenVerdict::Unknown
374 );
375 }
376
377 #[test]
380 fn kiro_idle_prompt() {
381 let parser = make_screen("Result\nKiro> ");
382 assert_eq!(
383 classify(AgentType::Kiro, parser.screen()),
384 ScreenVerdict::AgentIdle
385 );
386 }
387
388 #[test]
389 fn kiro_idle_bare_gt() {
390 let parser = make_screen("Result\n>");
391 assert_eq!(
392 classify(AgentType::Kiro, parser.screen()),
393 ScreenVerdict::AgentIdle
394 );
395 }
396
397 #[test]
398 fn kiro_working() {
399 let parser = make_screen("Kiro is thinking...\n");
400 assert_eq!(
401 classify(AgentType::Kiro, parser.screen()),
402 ScreenVerdict::AgentWorking
403 );
404 }
405
406 #[test]
407 fn kiro_working_agent_planning() {
408 let parser = make_screen("Agent is planning...\n");
409 assert_eq!(
410 classify(AgentType::Kiro, parser.screen()),
411 ScreenVerdict::AgentWorking
412 );
413 }
414
415 #[test]
418 fn generic_shell_prompt_dollar() {
419 let parser = make_screen("user@host:~$ ");
420 assert_eq!(
421 classify(AgentType::Generic, parser.screen()),
422 ScreenVerdict::AgentIdle
423 );
424 }
425
426 #[test]
427 fn generic_shell_prompt_percent() {
428 let parser = make_screen("user@host:~% ");
429 assert_eq!(
430 classify(AgentType::Generic, parser.screen()),
431 ScreenVerdict::AgentIdle
432 );
433 }
434
435 #[test]
436 fn generic_shell_prompt_gt() {
437 let parser = make_screen("prompt> ");
438 assert_eq!(
439 classify(AgentType::Generic, parser.screen()),
440 ScreenVerdict::AgentIdle
441 );
442 }
443
444 #[test]
445 fn generic_empty_unknown() {
446 let parser = make_screen("");
447 assert_eq!(
448 classify(AgentType::Generic, parser.screen()),
449 ScreenVerdict::Unknown
450 );
451 }
452
453 #[test]
456 fn exhaustion_all_types() {
457 for agent_type in [
458 AgentType::Claude,
459 AgentType::Codex,
460 AgentType::Kiro,
461 AgentType::Generic,
462 ] {
463 let parser = make_screen("Error: conversation is too long to continue\n$ ");
464 assert_eq!(
465 classify(agent_type, parser.screen()),
466 ScreenVerdict::ContextExhausted,
467 "failed for {agent_type}",
468 );
469 }
470 }
471
472 #[test]
473 fn exhaustion_maximum_context_length() {
474 let parser = make_screen("Error: maximum context length exceeded\n$ ");
475 assert_eq!(
476 classify(AgentType::Generic, parser.screen()),
477 ScreenVerdict::ContextExhausted
478 );
479 }
480
481 #[test]
482 fn agent_type_from_str() {
483 assert_eq!("claude".parse::<AgentType>().unwrap(), AgentType::Claude);
484 assert_eq!("CODEX".parse::<AgentType>().unwrap(), AgentType::Codex);
485 assert_eq!("Kiro".parse::<AgentType>().unwrap(), AgentType::Kiro);
486 assert_eq!("generic".parse::<AgentType>().unwrap(), AgentType::Generic);
487 assert_eq!("bash".parse::<AgentType>().unwrap(), AgentType::Generic);
488 assert_eq!("shell".parse::<AgentType>().unwrap(), AgentType::Generic);
489 assert!("unknown".parse::<AgentType>().is_err());
490 }
491
492 #[test]
493 fn agent_type_display() {
494 assert_eq!(AgentType::Claude.to_string(), "claude");
495 assert_eq!(AgentType::Codex.to_string(), "codex");
496 assert_eq!(AgentType::Kiro.to_string(), "kiro");
497 assert_eq!(AgentType::Generic.to_string(), "generic");
498 }
499
500 #[test]
501 fn all_exhaustion_patterns_trigger() {
502 for pattern in EXHAUSTION_PATTERNS {
503 let parser = make_screen(&format!("Error: {pattern}\n$ "));
504 assert_eq!(
505 classify(AgentType::Generic, parser.screen()),
506 ScreenVerdict::ContextExhausted,
507 "pattern '{pattern}' did not trigger exhaustion",
508 );
509 }
510 }
511}