Skip to main content

tmx/agents/
classify.rs

1//! Classify Claude/Codex's UI state from a tmux pane snapshot.
2//!
3//! We can't query Claude Code's internal state directly, so we read what
4//! the user sees: the visible portion of the tmux pane. The Claude TUI is
5//! distinctive enough that a tail-of-pane regex hits each state cleanly:
6//!
7//!   * **AwaitingDecision** — selection / permission dialog up. Most
8//!     visible cue is the universal "Enter to select · ↑/↓ to navigate ·
9//!     Esc to cancel" hint at the bottom.
10//!   * **Working** — Claude is composing or running a tool. The bottom
11//!     line carries an "esc to interrupt" hint and the input box is
12//!     replaced by an active spinner glyph (✻/✱/✷/✸/✹/✺).
13//!   * **Ready** — Claude is parked at the chat prompt. The hallmark is a
14//!     bare `❯ ` line followed by the model footer.
15//!   * **Unknown** — pane has no Claude UI markers we recognise. Caller
16//!     decides how to map that (typically `Idle`).
17//!
18//! Patterns intentionally bias toward false negatives over false
19//! positives — when in doubt we'd rather show "idle" than mis-claim
20//! "decision".
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ClaudeUiState {
24    Working,
25    AwaitingDecision,
26    Ready,
27    Unknown,
28}
29
30/// Spinner glyphs Claude Code uses in its "thinking" lines. Presence of
31/// any in the last few lines is a strong working signal when paired with
32/// the absence of a free `❯ ` prompt.
33const SPINNER_CHARS: &[char] = &['✱', '✻', '✷', '✸', '✹', '✺'];
34
35/// Strings that unambiguously signal an active selection / permission
36/// dialog blocking on the user. Match wins over everything else.
37const DECISION_MARKERS: &[&str] = &[
38    "Enter to select",
39    "to navigate ·",
40    "Do you want to proceed",
41    "Allow this tool",
42];
43
44/// Strings that signal Claude is mid-turn and the input prompt is
45/// suppressed. The "esc to interrupt" hint is the most reliable one
46/// because it only appears while a turn is in flight.
47const WORKING_MARKERS: &[&str] = &["esc to interrupt"];
48
49/// Classify the visible pane content. Looks at roughly the last 30 lines —
50/// enough to cover the input box plus footer without scanning the entire
51/// scrollback.
52pub fn classify(pane_content: &str) -> ClaudeUiState {
53    let tail = tail_lines(pane_content, 30);
54
55    if DECISION_MARKERS.iter().any(|m| tail.contains(m)) {
56        return ClaudeUiState::AwaitingDecision;
57    }
58
59    if WORKING_MARKERS.iter().any(|m| tail.contains(m)) {
60        return ClaudeUiState::Working;
61    }
62
63    if has_ready_prompt(&tail) {
64        return ClaudeUiState::Ready;
65    }
66
67    // Spinner without an "esc to interrupt" hint is rare but possible —
68    // treat it as working too. Done after the explicit hint check so we
69    // don't classify decorative sparkles in a chat message as working.
70    if tail.chars().any(|c| SPINNER_CHARS.contains(&c)) && !has_ready_prompt(&tail) {
71        return ClaudeUiState::Working;
72    }
73
74    ClaudeUiState::Unknown
75}
76
77/// True when the tail shows a free `❯` prompt — i.e. a line whose first
78/// non-space character is `❯` and the next non-`❯` character (if any) is
79/// whitespace or end-of-line. Rejects `❯ 1.` style (selection cursor on a
80/// numbered list), since that belongs to AwaitingDecision.
81fn has_ready_prompt(tail: &str) -> bool {
82    tail.lines().any(|line| {
83        let mut chars = line.trim_start().chars();
84        if chars.next() != Some('❯') {
85            return false;
86        }
87        match chars.next() {
88            None => true, // bare "❯"
89            Some(c) if c.is_whitespace() => !chars
90                .find(|c| !c.is_whitespace())
91                .is_some_and(|c| c.is_ascii_digit()),
92            _ => false,
93        }
94    })
95}
96
97fn tail_lines(s: &str, n: usize) -> String {
98    let lines: Vec<&str> = s.lines().collect();
99    let start = lines.len().saturating_sub(n);
100    lines[start..].join("\n")
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn classify_ready_state_from_real_capture() {
109        let pane = "\
110  /cross-review now, or (c) build anti-pattern hook.
111
112─────────────────────────────────────────────
113❯
114─────────────────────────────────────────────
115   Opus 4.7 (1M context) dotfiles master | +1008-68
116   ━━━━╌╌╌╌╌╌╌╌╌╌╌ 32% | $19.08 | 6h14m | 5h14% 7d35%
117  ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents
118                                                             new task? /clear
119";
120        assert_eq!(classify(pane), ClaudeUiState::Ready);
121    }
122
123    #[test]
124    fn classify_decision_state_from_real_capture() {
125        let pane = "\
126  3. 스크린샷 공유해주면 확인 가능
127  4. Type something.
128───────────────────────────────────────────────────────────
129  5. Chat about this
130
131Enter to select · ↑/↓ to navigate · Esc to cancel
132";
133        assert_eq!(classify(pane), ClaudeUiState::AwaitingDecision);
134    }
135
136    #[test]
137    fn classify_working_state_with_interrupt_hint() {
138        let pane = "\
139✱ Baking the response...
140(esc to interrupt)
141";
142        assert_eq!(classify(pane), ClaudeUiState::Working);
143    }
144
145    #[test]
146    fn classify_working_state_with_spinner_only() {
147        // No interrupt hint but a spinner is present and no ❯ prompt.
148        let pane = "\
149✻ Baked for 1m 26s
150  ⎿  Some intermediate output
151";
152        assert_eq!(classify(pane), ClaudeUiState::Working);
153    }
154
155    #[test]
156    fn classify_unknown_for_plain_shell() {
157        let pane = "\
158$ ls
159file1  file2  file3
160$
161";
162        assert_eq!(classify(pane), ClaudeUiState::Unknown);
163    }
164
165    #[test]
166    fn has_ready_prompt_rejects_numbered_selection_cursor() {
167        let tail = "❯ 1. First option\n  2. Second option";
168        assert!(!has_ready_prompt(tail));
169    }
170
171    #[test]
172    fn has_ready_prompt_accepts_empty_chat_prompt() {
173        assert!(has_ready_prompt("❯ "));
174        assert!(has_ready_prompt("  ❯ "));
175        assert!(has_ready_prompt("❯ some draft text"));
176    }
177
178    #[test]
179    fn classify_decision_wins_over_spinner() {
180        // A pane that happens to have a ✻ in a chat message but is
181        // actively showing a decision should still classify as decision.
182        let pane = "\
183  ✻ Mentioned in a message
184  3. Option three
185Enter to select · ↑/↓ to navigate · Esc to cancel
186";
187        assert_eq!(classify(pane), ClaudeUiState::AwaitingDecision);
188    }
189
190    #[test]
191    fn tail_lines_returns_last_n() {
192        let s = "a\nb\nc\nd\ne";
193        assert_eq!(tail_lines(s, 2), "d\ne");
194        assert_eq!(tail_lines(s, 10), "a\nb\nc\nd\ne");
195    }
196}