Skip to main content

cove_cli/sidebar/
state.rs

1// ── State detection for Claude session windows ──
2//
3// Reads Cove event files written by Claude Code hooks to determine sidebar state.
4// Each Claude session has an event file at ~/.cove/events/{session_id}.jsonl.
5// The sidebar matches events to tmux windows by comparing the event's `pane_id`
6// (from $TMUX_PANE) to each window's tmux pane ID. This correctly handles
7// multiple sessions in the same working directory.
8
9use std::collections::HashMap;
10use std::fs;
11use std::io::{BufRead, Seek, SeekFrom};
12use std::path::Path;
13
14use serde::Deserialize;
15
16use crate::events;
17use crate::tmux;
18
19// ── Types ──
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum WindowState {
23    /// New session, no hook events fired yet.
24    Fresh,
25    /// Claude is generating output.
26    Working,
27    /// Claude is waiting for user to answer a question.
28    Asking,
29    /// Claude is waiting for the user to approve a tool use.
30    Waiting,
31    /// Claude finished answering — waiting for next user message.
32    Idle,
33    /// Claude process exited — shell prompt visible.
34    Done,
35}
36
37#[derive(Deserialize)]
38struct EventEntry {
39    state: String,
40    #[allow(dead_code)]
41    cwd: String,
42    /// Tmux pane ID (e.g. "%0") — used to match events to windows.
43    #[serde(default)]
44    pane_id: String,
45    ts: u64,
46}
47
48// ── Helpers ──
49
50/// Read the last line of a file efficiently.
51/// Returns None if the file is empty or unreadable.
52pub fn read_last_line(path: &Path) -> Option<String> {
53    let file = fs::File::open(path).ok()?;
54    let len = file.metadata().ok()?.len();
55    if len == 0 {
56        return None;
57    }
58
59    // Read last 1KB — event lines are ~80 bytes, so this is more than enough
60    let tail_start = len.saturating_sub(1024);
61    let mut reader = std::io::BufReader::new(file);
62    reader.seek(SeekFrom::Start(tail_start)).ok()?;
63
64    // If we seeked mid-line, skip the partial first line
65    if tail_start > 0 {
66        let mut discard = String::new();
67        let _ = reader.read_line(&mut discard);
68    }
69
70    let mut last = None;
71    let mut line = String::new();
72    loop {
73        line.clear();
74        match reader.read_line(&mut line) {
75            Ok(0) => break,
76            Ok(_) => {
77                let trimmed = line.trim();
78                if !trimmed.is_empty() {
79                    last = Some(trimmed.to_string());
80                }
81            }
82            Err(_) => break,
83        }
84    }
85
86    last
87}
88
89/// Load the latest event from each event file in the events directory.
90/// Returns a map of pane_id → state, keeping only the highest-timestamp entry
91/// per pane_id. This deduplicates across multiple files that share a recycled
92/// pane ID, ensuring the current session's events always win.
93pub fn load_latest_events(dir: &Path) -> HashMap<String, String> {
94    let entries = match fs::read_dir(dir) {
95        Ok(e) => e,
96        Err(_) => return HashMap::new(),
97    };
98
99    // Track (state, timestamp) per pane_id — keep highest timestamp
100    let mut best: HashMap<String, (String, u64)> = HashMap::new();
101    for entry in entries.flatten() {
102        let path = entry.path();
103        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
104            continue;
105        }
106        if let Some(line) = read_last_line(&path) {
107            if let Ok(event) = serde_json::from_str::<EventEntry>(&line) {
108                if !event.pane_id.is_empty() {
109                    let replace = best
110                        .get(&event.pane_id)
111                        .is_none_or(|(_, prev_ts)| event.ts > *prev_ts);
112                    if replace {
113                        best.insert(event.pane_id, (event.state, event.ts));
114                    }
115                }
116            }
117        }
118    }
119
120    best.into_iter().map(|(k, (state, _))| (k, state)).collect()
121}
122
123pub fn state_from_str(s: &str) -> WindowState {
124    match s {
125        "working" => WindowState::Working,
126        "asking" => WindowState::Asking,
127        "waiting" => WindowState::Waiting,
128        "idle" => WindowState::Idle,
129        _ => WindowState::Fresh,
130    }
131}
132
133// ── Public API ──
134
135/// Remove event files whose last event matches the given pane_id.
136/// Called when a new window is created to prevent stale events (from a previous
137/// session that used the same recycled tmux pane_id) from contaminating state.
138pub fn purge_events_for_pane(pane_id: &str) {
139    purge_events_for_pane_in(&events::events_dir(), pane_id);
140}
141
142/// Remove event files whose last event matches the given pane_id from the given directory.
143pub fn purge_events_for_pane_in(dir: &Path, pane_id: &str) {
144    let entries = match fs::read_dir(dir) {
145        Ok(e) => e,
146        Err(_) => return,
147    };
148
149    for entry in entries.flatten() {
150        let path = entry.path();
151        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
152            continue;
153        }
154        if let Some(line) = read_last_line(&path) {
155            if let Ok(event) = serde_json::from_str::<EventEntry>(&line) {
156                if event.pane_id == pane_id {
157                    let _ = fs::remove_file(&path);
158                }
159            }
160        }
161    }
162}
163
164pub struct StateDetector {
165    pane_ids: HashMap<u32, String>,
166}
167
168impl Default for StateDetector {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174impl StateDetector {
175    pub fn new() -> Self {
176        Self {
177            pane_ids: HashMap::new(),
178        }
179    }
180
181    /// Get the tmux pane_id (e.g. "%5") for a window's Claude pane.
182    pub fn pane_id(&self, window_index: u32) -> Option<&str> {
183        self.pane_ids.get(&window_index).map(String::as_str)
184    }
185
186    /// Detect the state of each window. Returns a map from window_index to state.
187    pub fn detect(&mut self, windows: &[tmux::WindowInfo]) -> HashMap<u32, WindowState> {
188        let mut states = HashMap::new();
189
190        // Get foreground commands + pane IDs for all panes in one tmux call
191        let pane_infos: Vec<tmux::PaneInfo> = tmux::list_pane_commands().unwrap_or_default();
192
193        // Store pane_ids so context manager can look them up
194        self.pane_ids = pane_infos
195            .iter()
196            .map(|p| (p.window_index, p.pane_id.clone()))
197            .collect();
198
199        let pane_cmds: HashMap<u32, &str> = pane_infos
200            .iter()
201            .map(|p| (p.window_index, p.command.as_str()))
202            .collect();
203        let pane_ids: HashMap<u32, &str> = pane_infos
204            .iter()
205            .map(|p| (p.window_index, p.pane_id.as_str()))
206            .collect();
207
208        // Load all latest events once per detect cycle
209        let events = load_latest_events(&events::events_dir());
210
211        for win in windows {
212            let cmd = pane_cmds.get(&win.index).copied().unwrap_or("zsh");
213
214            // Shell prompt means Claude exited
215            if cmd == "zsh" || cmd == "bash" || cmd == "fish" {
216                states.insert(win.index, WindowState::Done);
217                continue;
218            }
219
220            // Match event by pane_id — each tmux pane has a unique ID like "%0"
221            let win_pane_id = pane_ids.get(&win.index).copied().unwrap_or("");
222            let state = match events.get(win_pane_id) {
223                Some(state_str) => state_from_str(state_str),
224                None => WindowState::Fresh,
225            };
226
227            states.insert(win.index, state);
228        }
229
230        states
231    }
232}
233
234// ── Tests ──
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use std::io::Write;
240
241    #[test]
242    fn test_read_last_line_single() {
243        let dir = tempfile::tempdir().unwrap();
244        let path = dir.path().join("test.jsonl");
245        let mut f = fs::File::create(&path).unwrap();
246        writeln!(f, r#"{{"state":"working","cwd":"/tmp","ts":1000}}"#).unwrap();
247
248        let line = read_last_line(&path).unwrap();
249        assert!(line.contains(r#""state":"working""#));
250    }
251
252    #[test]
253    fn test_read_last_line_multiple() {
254        let dir = tempfile::tempdir().unwrap();
255        let path = dir.path().join("test.jsonl");
256        let mut f = fs::File::create(&path).unwrap();
257        writeln!(f, r#"{{"state":"working","cwd":"/tmp","ts":1000}}"#).unwrap();
258        writeln!(f, r#"{{"state":"idle","cwd":"/tmp","ts":1001}}"#).unwrap();
259
260        let line = read_last_line(&path).unwrap();
261        assert!(line.contains(r#""state":"idle""#));
262    }
263
264    #[test]
265    fn test_read_last_line_empty() {
266        let dir = tempfile::tempdir().unwrap();
267        let path = dir.path().join("test.jsonl");
268        fs::File::create(&path).unwrap();
269
270        assert!(read_last_line(&path).is_none());
271    }
272
273    #[test]
274    fn test_read_last_line_missing() {
275        let path = Path::new("/nonexistent/test.jsonl");
276        assert!(read_last_line(path).is_none());
277    }
278
279    #[test]
280    fn test_load_latest_events() {
281        let dir = tempfile::tempdir().unwrap();
282
283        let mut f1 = fs::File::create(dir.path().join("session-a.jsonl")).unwrap();
284        writeln!(
285            f1,
286            r#"{{"state":"working","cwd":"/project-a","pane_id":"%0","ts":1000}}"#
287        )
288        .unwrap();
289        writeln!(
290            f1,
291            r#"{{"state":"idle","cwd":"/project-a","pane_id":"%0","ts":1001}}"#
292        )
293        .unwrap();
294
295        let mut f2 = fs::File::create(dir.path().join("session-b.jsonl")).unwrap();
296        writeln!(
297            f2,
298            r#"{{"state":"asking","cwd":"/project-b","pane_id":"%3","ts":2000}}"#
299        )
300        .unwrap();
301
302        let events = load_latest_events(dir.path());
303        assert_eq!(events.len(), 2);
304        assert_eq!(events["%0"], "idle");
305        assert_eq!(events["%3"], "asking");
306    }
307
308    #[test]
309    fn test_same_cwd_different_panes() {
310        let dir = tempfile::tempdir().unwrap();
311
312        // Two sessions in the same cwd but different panes
313        let mut f1 = fs::File::create(dir.path().join("session-a.jsonl")).unwrap();
314        writeln!(
315            f1,
316            r#"{{"state":"working","cwd":"/same/dir","pane_id":"%0","ts":1000}}"#
317        )
318        .unwrap();
319
320        let mut f2 = fs::File::create(dir.path().join("session-b.jsonl")).unwrap();
321        writeln!(
322            f2,
323            r#"{{"state":"idle","cwd":"/same/dir","pane_id":"%3","ts":1000}}"#
324        )
325        .unwrap();
326
327        let events = load_latest_events(dir.path());
328        assert_eq!(events.len(), 2);
329
330        // Each should match to its own pane, not cross-contaminate
331        assert_eq!(events["%0"], "working");
332        assert_eq!(events["%3"], "idle");
333    }
334
335    #[test]
336    fn test_load_latest_events_deduplicates_by_timestamp() {
337        let dir = tempfile::tempdir().unwrap();
338
339        // Stale file with pane_id %0 and older timestamp
340        let mut f1 = fs::File::create(dir.path().join("stale-session.jsonl")).unwrap();
341        writeln!(
342            f1,
343            r#"{{"state":"idle","cwd":"/old","pane_id":"%0","ts":1000}}"#
344        )
345        .unwrap();
346
347        // Current file with pane_id %0 and newer timestamp
348        let mut f2 = fs::File::create(dir.path().join("current-session.jsonl")).unwrap();
349        writeln!(
350            f2,
351            r#"{{"state":"working","cwd":"/new","pane_id":"%0","ts":2000}}"#
352        )
353        .unwrap();
354
355        let events = load_latest_events(dir.path());
356        assert_eq!(events.len(), 1);
357        // Newer timestamp wins — "working" from ts:2000 beats "idle" from ts:1000
358        assert_eq!(events["%0"], "working");
359    }
360
361    #[test]
362    fn test_events_without_pane_id_ignored() {
363        let dir = tempfile::tempdir().unwrap();
364
365        // Old-format event without pane_id should be skipped
366        let mut f = fs::File::create(dir.path().join("old-session.jsonl")).unwrap();
367        writeln!(f, r#"{{"state":"working","cwd":"/project","ts":1000}}"#).unwrap();
368
369        let events = load_latest_events(dir.path());
370        assert!(events.is_empty());
371    }
372
373    #[test]
374    fn test_load_latest_events_empty_dir() {
375        let dir = tempfile::tempdir().unwrap();
376        let events = load_latest_events(dir.path());
377        assert!(events.is_empty());
378    }
379
380    #[test]
381    fn test_load_latest_events_missing_dir() {
382        let events = load_latest_events(Path::new("/nonexistent/events"));
383        assert!(events.is_empty());
384    }
385
386    #[test]
387    fn test_state_from_str() {
388        assert_eq!(state_from_str("working"), WindowState::Working);
389        assert_eq!(state_from_str("idle"), WindowState::Idle);
390        assert_eq!(state_from_str("asking"), WindowState::Asking);
391        assert_eq!(state_from_str("waiting"), WindowState::Waiting);
392        assert_eq!(state_from_str("unknown"), WindowState::Fresh);
393    }
394
395    #[test]
396    fn test_purge_events_for_pane() {
397        let dir = tempfile::tempdir().unwrap();
398
399        // Stale event with pane_id %3 — should be removed
400        let mut f1 = fs::File::create(dir.path().join("old-session.jsonl")).unwrap();
401        writeln!(
402            f1,
403            r#"{{"state":"asking","cwd":"/project","pane_id":"%3","ts":1000}}"#
404        )
405        .unwrap();
406
407        // Active event with pane_id %0 — should be kept
408        let mut f2 = fs::File::create(dir.path().join("active-session.jsonl")).unwrap();
409        writeln!(
410            f2,
411            r#"{{"state":"idle","cwd":"/project","pane_id":"%0","ts":2000}}"#
412        )
413        .unwrap();
414
415        // Another stale event with pane_id %3 — should be removed
416        let mut f3 = fs::File::create(dir.path().join("another-old.jsonl")).unwrap();
417        writeln!(
418            f3,
419            r#"{{"state":"idle","cwd":"/other","pane_id":"%3","ts":500}}"#
420        )
421        .unwrap();
422
423        purge_events_for_pane_in(dir.path(), "%3");
424
425        // Only the %0 file should remain
426        let remaining: Vec<_> = fs::read_dir(dir.path())
427            .unwrap()
428            .flatten()
429            .filter(|e| e.path().extension().and_then(|e| e.to_str()) == Some("jsonl"))
430            .collect();
431        assert_eq!(remaining.len(), 1);
432        assert!(
433            remaining[0]
434                .path()
435                .file_name()
436                .unwrap()
437                .to_str()
438                .unwrap()
439                .contains("active-session")
440        );
441    }
442}