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