Skip to main content

batty_cli/shim/
classifier.rs

1//! State classifiers: determine agent state from virtual screen content.
2//!
3//! Each agent type (Claude, Codex, Kiro, Generic) has different prompt
4//! patterns, spinner indicators, and context exhaustion messages.
5
6use serde::{Deserialize, Serialize};
7
8/// What the classifier thinks the agent is doing.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ScreenVerdict {
11    /// Agent is at its input prompt, waiting for a message.
12    AgentIdle,
13    /// Agent is actively processing (producing output).
14    AgentWorking,
15    /// Agent reported conversation/context too large.
16    ContextExhausted,
17    /// Can't determine — keep previous state.
18    Unknown,
19}
20
21/// Agent type selector for the shim classifier.
22///
23/// This operates on vt100::Screen content, independent of the AgentType
24/// in src/agent/ which works with tmux capture.
25#[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
58// ---------------------------------------------------------------------------
59// Classifier dispatch
60// ---------------------------------------------------------------------------
61
62/// Classify screen content based on agent type.
63pub 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    // Context exhaustion check (common across all types)
70    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
82// ---------------------------------------------------------------------------
83// Context exhaustion (shared)
84// ---------------------------------------------------------------------------
85
86const 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
102// ---------------------------------------------------------------------------
103// Claude Code classifier
104// ---------------------------------------------------------------------------
105
106/// Claude Code prompt characters.
107const CLAUDE_PROMPT_CHARS: &[char] = &['\u{276F}']; // ❯
108
109/// Claude spinner prefixes.
110const CLAUDE_SPINNER_CHARS: &[char] = &[
111    '\u{00B7}', // ·
112    '\u{2722}', // ✢
113    '\u{2733}', // ✳
114    '\u{2736}', // ✶
115    '\u{273B}', // ✻
116    '\u{273D}', // ✽
117];
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    // "esc to interrupt" means Claude is actively working
124    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    // Check for spinner in recent non-empty lines
137    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    // Check for idle prompt: ❯ followed by whitespace or EOL
152    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
177// ---------------------------------------------------------------------------
178// Codex classifier
179// ---------------------------------------------------------------------------
180
181fn 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    // Codex prompt: › followed by whitespace or EOL
192    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
205// ---------------------------------------------------------------------------
206// Kiro classifier
207// ---------------------------------------------------------------------------
208
209fn 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    // Check for working indicators first
220    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    // Kiro prompts: Kiro>, kiro>, Kiro >, kiro >, or bare >
233    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
247// ---------------------------------------------------------------------------
248// Generic classifier (bash / shell / REPL)
249// ---------------------------------------------------------------------------
250
251fn 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        // Shell prompts: ends with "$ " or "$", or "% " or "%", or "> " or ">"
264        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// ---------------------------------------------------------------------------
279// Tests
280// ---------------------------------------------------------------------------
281
282#[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    // -- Claude --
293
294    #[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    // -- Codex --
349
350    #[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    // -- Kiro --
378
379    #[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    // -- Generic --
416
417    #[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    // -- Shared --
454
455    #[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}