1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ClaudeUiState {
24 Working,
25 AwaitingDecision,
26 Ready,
27 Unknown,
28}
29
30const SPINNER_CHARS: &[char] = &['✱', '✻', '✷', '✸', '✹', '✺'];
34
35const DECISION_MARKERS: &[&str] = &[
38 "Enter to select",
39 "to navigate ·",
40 "Do you want to proceed",
41 "Allow this tool",
42];
43
44const WORKING_MARKERS: &[&str] = &["esc to interrupt"];
48
49pub 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 if tail.chars().any(|c| SPINNER_CHARS.contains(&c)) && !has_ready_prompt(&tail) {
71 return ClaudeUiState::Working;
72 }
73
74 ClaudeUiState::Unknown
75}
76
77fn 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, 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 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 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}