Skip to main content

cove_cli/sidebar/
context.rs

1// ── Background context generation for sessions ──
2//
3// Reads Claude session JSONL files directly, extracts conversation text,
4// then calls `claude -p` with a compact prompt (no session resume).
5// This avoids the ~600KB context reload that `claude -c` would trigger.
6// Results flow back via mpsc channel so the sidebar event loop never blocks.
7
8use std::collections::{HashMap, HashSet};
9use std::fs::{self, OpenOptions};
10use std::io::Write as _;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13use std::sync::Arc;
14use std::sync::mpsc;
15use std::time::{Duration, Instant};
16use std::{io, thread};
17
18use crate::events;
19use crate::sidebar::state::WindowState;
20use crate::tmux::WindowInfo;
21
22// ── Types ──
23
24type GeneratorFn = Arc<dyn Fn(&str, &str) -> Option<String> + Send + Sync>;
25
26pub struct ContextManager {
27    contexts: HashMap<String, String>,
28    in_flight: HashSet<String>,
29    failed: HashMap<String, Instant>,
30    tx: mpsc::Sender<(String, Result<String, String>)>,
31    rx: mpsc::Receiver<(String, Result<String, String>)>,
32    generator: GeneratorFn,
33    prev_selected_name: Option<String>,
34    /// Previous state per window — used to detect transitions and invalidate cache.
35    prev_states: HashMap<String, WindowState>,
36}
37
38// ── Constants ──
39
40const SUMMARY_PROMPT: &str = "\
41Summarize the overall goal and current state of this conversation in 1-2 sentences. \
42What is the user trying to accomplish, and where are things at? \
43Output only the summary, nothing else.";
44
45/// Max chars of conversation text to include in the prompt.
46const CONVERSATION_BUDGET: usize = 6000;
47
48/// Max chars per individual message (truncate long code blocks, etc.).
49const MESSAGE_TRUNCATE: usize = 300;
50
51const SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(30);
52const RETRY_COOLDOWN: Duration = Duration::from_secs(30);
53
54// ── Diagnostic Logging ──
55
56fn log_context(msg: &str) {
57    let home = std::env::var("HOME").unwrap_or_default();
58    let path = PathBuf::from(home).join(".cove").join("context.log");
59    if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
60        let ts = std::time::SystemTime::now()
61            .duration_since(std::time::UNIX_EPOCH)
62            .unwrap_or_default()
63            .as_secs();
64        let _ = writeln!(f, "[{ts}] {msg}");
65    }
66}
67
68// ── Public API ──
69
70impl Default for ContextManager {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl ContextManager {
77    pub fn new() -> Self {
78        let (tx, rx) = mpsc::channel();
79        Self {
80            contexts: HashMap::new(),
81            in_flight: HashSet::new(),
82            failed: HashMap::new(),
83            tx,
84            rx,
85            generator: Arc::new(generate_context),
86            prev_selected_name: None,
87            prev_states: HashMap::new(),
88        }
89    }
90
91    pub fn with_generator(
92        generator_fn: impl Fn(&str, &str) -> Option<String> + Send + Sync + 'static,
93    ) -> Self {
94        let (tx, rx) = mpsc::channel();
95        Self {
96            contexts: HashMap::new(),
97            in_flight: HashSet::new(),
98            failed: HashMap::new(),
99            tx,
100            rx,
101            generator: Arc::new(generator_fn),
102            prev_selected_name: None,
103            prev_states: HashMap::new(),
104        }
105    }
106
107    /// Drain completed context results from background threads.
108    pub fn drain(&mut self) {
109        while let Ok((name, result)) = self.rx.try_recv() {
110            self.in_flight.remove(&name);
111            match result {
112                Ok(context) => {
113                    self.contexts.insert(name, context);
114                }
115                Err(reason) => {
116                    log_context(&format!("failed for {name}: {reason}"));
117                    self.failed.insert(name, Instant::now());
118                }
119            }
120        }
121    }
122
123    /// Get the context for a window, if available.
124    pub fn get(&self, name: &str) -> Option<&str> {
125        self.contexts.get(name).map(String::as_str)
126    }
127
128    /// Whether a context request is currently running for this window.
129    pub fn is_loading(&self, name: &str) -> bool {
130        self.in_flight.contains(name)
131    }
132
133    /// Run one tick of context orchestration.
134    ///
135    /// - Drains completed results from background threads
136    /// - Invalidates cache on state transitions (Working → settled)
137    /// - Requests context for the selected window (lazy — only when settled)
138    /// - On selection change: refreshes old session (if settled), requests new session
139    pub fn tick(
140        &mut self,
141        windows: &[WindowInfo],
142        states: &HashMap<u32, WindowState>,
143        selected: usize,
144        pane_id_for: &impl Fn(u32) -> Option<String>,
145        cwd_for: &impl Fn(u32) -> Option<String>,
146    ) {
147        // Drain completed context results first so they're available this tick
148        self.drain();
149
150        // Detect state transitions and invalidate stale context.
151        // When a session transitions from Working to a settled state (Idle/Asking/Waiting),
152        // the conversation has new content — clear cache so fresh context is generated.
153        if let Some(win) = windows.get(selected) {
154            let state = states
155                .get(&win.index)
156                .copied()
157                .unwrap_or(WindowState::Fresh);
158            let prev = self
159                .prev_states
160                .get(&win.name)
161                .copied()
162                .unwrap_or(WindowState::Fresh);
163
164            if prev == WindowState::Working && is_settled(state) {
165                self.contexts.remove(&win.name);
166                self.failed.remove(&win.name);
167            }
168
169            self.prev_states.insert(win.name.clone(), state);
170        }
171
172        // Request context for the selected window only (lazy generation).
173        // Only fire for settled states — Working is too early (conversation incomplete,
174        // subprocess may fail), and failures trigger a 30s retry cooldown that blocks
175        // subsequent attempts when the session actually reaches Idle.
176        if let Some(win) = windows.get(selected) {
177            let state = states
178                .get(&win.index)
179                .copied()
180                .unwrap_or(WindowState::Fresh);
181            if is_settled(state) {
182                let pane_id = pane_id_for(win.index).unwrap_or_default();
183                let cwd = cwd_for(win.index).unwrap_or_else(|| win.pane_path.clone());
184                self.request(&win.name, &cwd, &pane_id);
185            }
186        }
187
188        // Track selection changes and manage context generation
189        let current_name = windows.get(selected).map(|w| w.name.clone());
190        if current_name != self.prev_selected_name {
191            // Refresh context for old session — only if it's in a settled state
192            if let Some(ref prev_name) = self.prev_selected_name {
193                if let Some(prev_win) = windows.iter().find(|w| w.name == *prev_name) {
194                    let state = states
195                        .get(&prev_win.index)
196                        .copied()
197                        .unwrap_or(WindowState::Fresh);
198                    if is_settled(state) {
199                        let pane_id = pane_id_for(prev_win.index).unwrap_or_default();
200                        let cwd =
201                            cwd_for(prev_win.index).unwrap_or_else(|| prev_win.pane_path.clone());
202                        self.refresh(&prev_win.name, &cwd, &pane_id);
203                    }
204                }
205            }
206            self.prev_selected_name = current_name;
207        }
208    }
209
210    /// Request context generation for a window (no-op if cached, in flight, or within retry cooldown).
211    pub fn request(&mut self, name: &str, cwd: &str, pane_id: &str) {
212        if self.contexts.contains_key(name) || self.in_flight.contains(name) {
213            return;
214        }
215        if let Some(failed_at) = self.failed.get(name) {
216            if failed_at.elapsed() < RETRY_COOLDOWN {
217                return;
218            }
219        }
220        self.failed.remove(name);
221        self.spawn(name, cwd, pane_id);
222    }
223
224    /// Force-refresh context for a window (clears cache/failed, respects in_flight).
225    pub fn refresh(&mut self, name: &str, cwd: &str, pane_id: &str) {
226        if self.in_flight.contains(name) {
227            return;
228        }
229        self.contexts.remove(name);
230        self.failed.remove(name);
231        self.spawn(name, cwd, pane_id);
232    }
233
234    fn spawn(&mut self, name: &str, cwd: &str, pane_id: &str) {
235        self.in_flight.insert(name.to_string());
236        let tx = self.tx.clone();
237        let name = name.to_string();
238        let cwd = cwd.to_string();
239        let pane_id = pane_id.to_string();
240        let generator = Arc::clone(&self.generator);
241        thread::spawn(move || {
242            let result = match generator(&cwd, &pane_id) {
243                Some(ctx) => Ok(ctx),
244                None => Err("generator returned None".to_string()),
245            };
246            let _ = tx.send((name, result));
247        });
248    }
249}
250
251/// A settled state means Claude has finished a turn and the conversation has content
252/// worth summarizing. Only generate context in these states — not during Working
253/// (conversation incomplete) or Fresh/Done (no activity / session ended).
254fn is_settled(state: WindowState) -> bool {
255    matches!(
256        state,
257        WindowState::Idle | WindowState::Asking | WindowState::Waiting
258    )
259}
260
261// ── Session Lookup ──
262
263/// Derive the Claude project directory from a cwd.
264fn claude_project_dir(cwd: &str) -> PathBuf {
265    let home = std::env::var("HOME").unwrap_or_default();
266    let project_key = cwd.replace('/', "-");
267    PathBuf::from(home)
268        .join(".claude")
269        .join("projects")
270        .join(project_key)
271}
272
273/// Find the Claude session JSONL file for a given pane.
274fn find_session_file(cwd: &str, pane_id: &str) -> Option<PathBuf> {
275    let session_id = match events::find_session_id(pane_id) {
276        Some(id) => id,
277        None => {
278            log_context(&format!("no session_id for pane_id={pane_id}"));
279            return None;
280        }
281    };
282    let project_dir = claude_project_dir(cwd);
283    let path = project_dir.join(format!("{session_id}.jsonl"));
284    if path.exists() {
285        Some(path)
286    } else {
287        log_context(&format!("JSONL not found: {}", path.display()));
288        None
289    }
290}
291
292// ── JSONL Parsing ──
293
294/// Extract conversation text from a Claude session JSONL file.
295/// Returns a compact representation of user/assistant messages, truncated
296/// to fit within CONVERSATION_BUDGET.
297fn extract_conversation(path: &Path) -> Option<String> {
298    let content = match fs::read_to_string(path) {
299        Ok(c) => c,
300        Err(e) => {
301            log_context(&format!(
302                "read session file failed: {}: {e}",
303                path.display()
304            ));
305            return None;
306        }
307    };
308    let mut messages = Vec::new();
309
310    for line in content.lines() {
311        let entry: serde_json::Value = match serde_json::from_str(line) {
312            Ok(v) => v,
313            Err(_) => continue,
314        };
315
316        let entry_type = match entry.get("type").and_then(|t| t.as_str()) {
317            Some(t) => t,
318            None => continue,
319        };
320
321        if entry_type != "user" && entry_type != "assistant" {
322            continue;
323        }
324
325        let message = match entry.get("message") {
326            Some(m) => m,
327            None => continue,
328        };
329
330        let role = message
331            .get("role")
332            .and_then(|r| r.as_str())
333            .unwrap_or(entry_type);
334
335        let content_arr = match message.get("content").and_then(|c| c.as_array()) {
336            Some(arr) => arr,
337            None => continue,
338        };
339
340        for item in content_arr {
341            // Only extract text content — skip images, tool_use, tool_result
342            if item.get("type").and_then(|t| t.as_str()) != Some("text") {
343                continue;
344            }
345            if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
346                let text = text.trim();
347                if text.is_empty() {
348                    continue;
349                }
350                let truncated = if text.chars().count() > MESSAGE_TRUNCATE {
351                    let t: String = text.chars().take(MESSAGE_TRUNCATE).collect();
352                    format!("{t}\u{2026}")
353                } else {
354                    text.to_string()
355                };
356                let label = if role == "user" { "User" } else { "Assistant" };
357                messages.push(format!("{label}: {truncated}"));
358            }
359        }
360    }
361
362    if messages.is_empty() {
363        return None;
364    }
365
366    // Keep recent messages, truncated to fit within budget
367    let mut kept = Vec::new();
368    let mut total_len = 0;
369    for msg in messages.iter().rev() {
370        if total_len + msg.len() + 2 > CONVERSATION_BUDGET {
371            break;
372        }
373        total_len += msg.len() + 2;
374        kept.push(msg.as_str());
375    }
376    kept.reverse();
377
378    Some(kept.join("\n\n"))
379}
380
381// ── Context Generation ──
382
383fn generate_context(cwd: &str, pane_id: &str) -> Option<String> {
384    let session_path = match find_session_file(cwd, pane_id) {
385        Some(p) => p,
386        None => {
387            log_context(&format!("no session file: cwd={cwd} pane_id={pane_id}"));
388            return None;
389        }
390    };
391    let conversation = match extract_conversation(&session_path) {
392        Some(c) => c,
393        None => {
394            log_context(&format!("no conversation: path={}", session_path.display()));
395            return None;
396        }
397    };
398
399    let prompt = format!("{SUMMARY_PROMPT}\n\nConversation:\n{conversation}");
400
401    // Fresh claude -p call — no -c, no session resume, no large context reload.
402    // Must clear CLAUDECODE env var to avoid "nested session" detection,
403    // since the sidebar itself runs inside a Claude Code session.
404    let mut child = match Command::new("claude")
405        .args(["-p", &prompt, "--max-turns", "1", "--model", "haiku"])
406        .current_dir(cwd)
407        .env_remove("CLAUDECODE")
408        .stdout(Stdio::piped())
409        .stderr(Stdio::null())
410        .spawn()
411    {
412        Ok(c) => c,
413        Err(e) => {
414            log_context(&format!("claude spawn failed: {e}"));
415            return None;
416        }
417    };
418
419    let deadline = Instant::now() + SUBPROCESS_TIMEOUT;
420    let status = loop {
421        match child.try_wait() {
422            Ok(Some(status)) => break status,
423            Ok(None) => {
424                if Instant::now() >= deadline {
425                    let _ = child.kill();
426                    let _ = child.wait();
427                    log_context("claude timed out after 30s");
428                    return None;
429                }
430                thread::sleep(Duration::from_millis(200));
431            }
432            Err(e) => {
433                log_context(&format!("claude try_wait failed: {e}"));
434                return None;
435            }
436        }
437    };
438
439    if !status.success() {
440        log_context(&format!("claude exited {}", status));
441        return None;
442    }
443
444    let mut stdout = child.stdout.take()?;
445    let mut text = String::new();
446    if let Err(e) = io::Read::read_to_string(&mut stdout, &mut text) {
447        log_context(&format!("read claude stdout failed: {e}"));
448        return None;
449    }
450    let text = text.trim().to_string();
451    if text.is_empty() {
452        log_context("claude returned empty output");
453        None
454    } else {
455        Some(text)
456    }
457}
458
459// ── Tests ──
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use std::io::Write;
465
466    fn write_session_jsonl(dir: &Path, session_id: &str, messages: &[(&str, &str)]) -> PathBuf {
467        let path = dir.join(format!("{session_id}.jsonl"));
468        let mut f = fs::File::create(&path).unwrap();
469        for (role, text) in messages {
470            let entry = serde_json::json!({
471                "type": role,
472                "message": {
473                    "role": role,
474                    "content": [{"type": "text", "text": text}]
475                }
476            });
477            writeln!(f, "{}", serde_json::to_string(&entry).unwrap()).unwrap();
478        }
479        path
480    }
481
482    #[test]
483    fn test_extract_conversation_basic() {
484        let dir = tempfile::tempdir().unwrap();
485        let path = write_session_jsonl(
486            dir.path(),
487            "test",
488            &[
489                ("user", "Fix the login bug"),
490                ("assistant", "I'll look into the auth module"),
491                ("user", "Also check the session handling"),
492            ],
493        );
494
495        let result = extract_conversation(&path).unwrap();
496        assert!(result.contains("User: Fix the login bug"));
497        assert!(result.contains("Assistant: I'll look into the auth module"));
498        assert!(result.contains("User: Also check the session handling"));
499    }
500
501    #[test]
502    fn test_extract_conversation_skips_non_text() {
503        let dir = tempfile::tempdir().unwrap();
504        let path = dir.path().join("test.jsonl");
505        let mut f = fs::File::create(&path).unwrap();
506
507        // User message with text
508        writeln!(
509            f,
510            r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"hello"}}]}}}}"#
511        )
512        .unwrap();
513        // Progress entry (should be skipped)
514        writeln!(f, r#"{{"type":"progress","data":{{"hook":"test"}}}}"#).unwrap();
515        // Assistant with tool_use (should be skipped)
516        writeln!(
517            f,
518            r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"tool_use","name":"Bash"}}]}}}}"#
519        )
520        .unwrap();
521
522        let result = extract_conversation(&path).unwrap();
523        assert!(result.contains("User: hello"));
524        assert!(!result.contains("progress"));
525        assert!(!result.contains("Bash"));
526    }
527
528    #[test]
529    fn test_extract_conversation_truncates_long_messages() {
530        let dir = tempfile::tempdir().unwrap();
531        let long_text = "a".repeat(500);
532        let path = write_session_jsonl(dir.path(), "test", &[("user", &long_text)]);
533
534        let result = extract_conversation(&path).unwrap();
535        // Should be truncated to MESSAGE_TRUNCATE + "…"
536        assert!(result.chars().count() < 500);
537        assert!(result.ends_with('\u{2026}'));
538    }
539
540    #[test]
541    fn test_extract_conversation_empty_file() {
542        let dir = tempfile::tempdir().unwrap();
543        let path = dir.path().join("empty.jsonl");
544        fs::File::create(&path).unwrap();
545
546        assert!(extract_conversation(&path).is_none());
547    }
548
549    #[test]
550    fn test_extract_conversation_respects_budget() {
551        let dir = tempfile::tempdir().unwrap();
552        let msg = "x".repeat(MESSAGE_TRUNCATE - 10);
553        let messages: Vec<(&str, &str)> = (0..100).map(|_| ("user", msg.as_str())).collect();
554        let path = write_session_jsonl(dir.path(), "test", &messages);
555
556        let result = extract_conversation(&path).unwrap();
557        assert!(result.len() <= CONVERSATION_BUDGET + 200); // some slack for labels
558    }
559
560    #[test]
561    fn test_claude_project_dir() {
562        let dir = claude_project_dir("/Users/test/workspace/myproject");
563        assert!(
564            dir.to_str()
565                .unwrap()
566                .ends_with("/.claude/projects/-Users-test-workspace-myproject")
567        );
568    }
569
570    #[test]
571    fn test_find_session_id_from_events() {
572        let dir = tempfile::tempdir().unwrap();
573        let events_path = dir.path().join("abc-123.jsonl");
574        let mut f = fs::File::create(&events_path).unwrap();
575        writeln!(
576            f,
577            r#"{{"state":"working","cwd":"/tmp","pane_id":"%5","ts":1000}}"#
578        )
579        .unwrap();
580        writeln!(
581            f,
582            r#"{{"state":"idle","cwd":"/tmp","pane_id":"%5","ts":1001}}"#
583        )
584        .unwrap();
585
586        // Test the lookup logic directly (can't use find_session_id since it reads ~/.cove)
587        let content = fs::read_to_string(&events_path).unwrap();
588        let last_line = content
589            .lines()
590            .rev()
591            .find(|l| !l.trim().is_empty())
592            .unwrap();
593        let event: serde_json::Value = serde_json::from_str(last_line).unwrap();
594        assert_eq!(event.get("pane_id").and_then(|v| v.as_str()), Some("%5"));
595    }
596
597    // ── Context lifecycle integration tests ──
598    //
599    // These test the orchestration logic in tick(): when context generation
600    // fires (or doesn't) based on session state and selection changes.
601
602    use std::sync::Mutex;
603
604    /// Build a mock generator that tracks calls and returns controlled results.
605    fn mock_generator() -> (
606        impl Fn(&str, &str) -> Option<String> + Send + Sync + 'static,
607        Arc<Mutex<Vec<(String, String)>>>,
608    ) {
609        let calls: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
610        let calls_clone = Arc::clone(&calls);
611        let generator = move |cwd: &str, pane_id: &str| -> Option<String> {
612            calls_clone
613                .lock()
614                .unwrap()
615                .push((cwd.to_string(), pane_id.to_string()));
616            Some(format!("context for {pane_id}"))
617        };
618        (generator, calls)
619    }
620
621    fn win(index: u32, name: &str) -> WindowInfo {
622        WindowInfo {
623            index,
624            name: name.to_string(),
625            is_active: false,
626            pane_path: format!("/project/{name}"),
627        }
628    }
629
630    fn pane_ids(map: &HashMap<u32, String>) -> impl Fn(u32) -> Option<String> + '_ {
631        move |idx| map.get(&idx).cloned()
632    }
633
634    fn no_cwd(_idx: u32) -> Option<String> {
635        None
636    }
637
638    /// Drain results by waiting briefly for background threads to complete.
639    fn drain_with_wait(mgr: &mut ContextManager) {
640        // Background threads are fast (mock generator returns immediately),
641        // but we need a brief pause for the thread to send its result.
642        thread::sleep(Duration::from_millis(50));
643        mgr.drain();
644    }
645
646    // ── Scenario 1: New session → no context action ──
647    //
648    // A brand-new session (Fresh state) should not trigger any context
649    // generation. No "loading…", no failed generation.
650    #[test]
651    fn test_new_session_no_context_action() {
652        let (generator, calls) = mock_generator();
653        let mut mgr = ContextManager::with_generator(generator);
654
655        let windows = vec![win(1, "session-1")];
656        let states: HashMap<u32, WindowState> = [(1, WindowState::Fresh)].into_iter().collect();
657        let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
658
659        // Run a tick with the Fresh session selected
660        mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
661
662        // No generation should have been spawned
663        assert!(calls.lock().unwrap().is_empty());
664        assert!(!mgr.is_loading("session-1"));
665        assert!(mgr.get("session-1").is_none());
666    }
667
668    // ── Scenario 2: Switch from new (no prompt) session to another new session → no context action ──
669    //
670    // Both sessions are Fresh. Switching between them should not trigger
671    // context generation for either session.
672    #[test]
673    fn test_switch_between_fresh_sessions_no_context_action() {
674        let (generator, calls) = mock_generator();
675        let mut mgr = ContextManager::with_generator(generator);
676
677        let windows = vec![win(1, "session-1"), win(2, "session-2")];
678        let states: HashMap<u32, WindowState> = [(1, WindowState::Fresh), (2, WindowState::Fresh)]
679            .into_iter()
680            .collect();
681        let panes: HashMap<u32, String> =
682            [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
683
684        // Tick 1: session-1 selected (Fresh)
685        mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
686        assert!(calls.lock().unwrap().is_empty());
687
688        // Tick 2: switch to session-2 (also Fresh)
689        mgr.tick(&windows, &states, 1, &pane_ids(&panes), &no_cwd);
690
691        // No generation for either session
692        assert!(calls.lock().unwrap().is_empty());
693        assert!(!mgr.is_loading("session-1"));
694        assert!(!mgr.is_loading("session-2"));
695        assert!(mgr.get("session-1").is_none());
696        assert!(mgr.get("session-2").is_none());
697    }
698
699    // ── Scenario 3: Switch from active session to new session → fire context for previous ──
700    //
701    // When switching away from a session that has had at least 1 conversation
702    // turn (not Fresh), context generation should fire for that session.
703    // The new Fresh session should NOT get context generated.
704    #[test]
705    fn test_switch_from_active_to_fresh_fires_context_for_previous() {
706        let (generator, calls) = mock_generator();
707        let mut mgr = ContextManager::with_generator(generator);
708
709        let windows = vec![win(1, "active-session"), win(2, "new-session")];
710        let states: HashMap<u32, WindowState> = [(1, WindowState::Idle), (2, WindowState::Fresh)]
711            .into_iter()
712            .collect();
713        let panes: HashMap<u32, String> =
714            [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
715
716        // Tick 1: active-session selected (Idle — has had activity)
717        mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
718        drain_with_wait(&mut mgr);
719
720        // The prefetch loop should have requested context for the active session
721        let call_count_after_tick1 = calls.lock().unwrap().len();
722        assert!(
723            call_count_after_tick1 > 0,
724            "should request context for Idle session"
725        );
726        assert!(
727            calls.lock().unwrap().iter().any(|(_, pid)| pid == "%0"),
728            "should request for pane %0"
729        );
730
731        // Tick 2: switch to new-session (Fresh)
732        calls.lock().unwrap().clear();
733        mgr.tick(&windows, &states, 1, &pane_ids(&panes), &no_cwd);
734        // Wait for background thread to complete so calls are recorded
735        drain_with_wait(&mut mgr);
736
737        // Should fire refresh for old active-session (pane %0)
738        // Should NOT fire for new-session (pane %1) since it's Fresh
739        let tick2_calls = calls.lock().unwrap().clone();
740        assert!(
741            tick2_calls.iter().any(|(_, pid)| pid == "%0"),
742            "should refresh context for previous active session"
743        );
744        assert!(
745            !tick2_calls.iter().any(|(_, pid)| pid == "%1"),
746            "should NOT request context for Fresh new session"
747        );
748
749        // new-session should have no loading state or context
750        assert!(!mgr.is_loading("new-session"));
751        assert!(mgr.get("new-session").is_none());
752    }
753
754    // ── Scenario 4: Switch back to previous session while context is pending → loading state ──
755    //
756    // After switching away from an active session (triggering refresh),
757    // switching back before the generation completes should show loading state.
758    #[test]
759    fn test_switch_back_while_pending_shows_loading() {
760        // Use a slow generator that blocks until signaled
761        let barrier = Arc::new(std::sync::Barrier::new(2));
762        let barrier_clone = Arc::clone(&barrier);
763        let slow_generator = move |_cwd: &str, _pane_id: &str| -> Option<String> {
764            barrier_clone.wait();
765            Some("generated context".to_string())
766        };
767        let mut mgr = ContextManager::with_generator(slow_generator);
768
769        let windows = vec![win(1, "active-session"), win(2, "new-session")];
770        let states: HashMap<u32, WindowState> = [(1, WindowState::Idle), (2, WindowState::Fresh)]
771            .into_iter()
772            .collect();
773        let panes: HashMap<u32, String> =
774            [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
775
776        // Tick 1: active-session selected — starts context generation (blocked on barrier)
777        mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
778        // Context is in-flight but blocked, so is_loading should be true
779        assert!(
780            mgr.is_loading("active-session"),
781            "should be loading while generator is running"
782        );
783
784        // Tick 2: switch to new-session — triggers refresh for active-session
785        // But active-session is still in-flight (blocked), so refresh is a no-op
786        mgr.tick(&windows, &states, 1, &pane_ids(&panes), &no_cwd);
787
788        // Tick 3: switch back to active-session — should still show loading
789        mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
790        assert!(
791            mgr.is_loading("active-session"),
792            "should still be loading when switching back"
793        );
794        assert!(
795            mgr.get("active-session").is_none(),
796            "context should not be available yet"
797        );
798
799        // Unblock the generator
800        barrier.wait();
801        drain_with_wait(&mut mgr);
802
803        // Now context should be available
804        assert!(
805            !mgr.is_loading("active-session"),
806            "should not be loading after drain"
807        );
808        assert_eq!(
809            mgr.get("active-session"),
810            Some("generated context"),
811            "context should be populated after drain"
812        );
813    }
814
815    // ── Scenario 5: Working state should NOT fire context generation ──
816    //
817    // During Working state, the conversation is incomplete — Claude hasn't
818    // responded yet. Firing the generator here risks failure (session file not
819    // ready) which triggers a 30s retry cooldown, blocking context generation
820    // when the session eventually reaches Idle.
821    #[test]
822    fn test_working_state_does_not_fire_context() {
823        let (generator, calls) = mock_generator();
824        let mut mgr = ContextManager::with_generator(generator);
825
826        let windows = vec![win(1, "session-1")];
827        let states: HashMap<u32, WindowState> = [(1, WindowState::Working)].into_iter().collect();
828        let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
829
830        // Tick with Working state selected
831        mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
832
833        // Generator should NOT fire during Working
834        assert!(
835            calls.lock().unwrap().is_empty(),
836            "should not fire context during Working"
837        );
838        assert!(!mgr.is_loading("session-1"));
839        assert!(mgr.get("session-1").is_none());
840    }
841
842    // ── Scenario 6: Full user flow — Fresh → Working → Idle ──
843    //
844    // Simulates the exact user experience:
845    // 1. Session starts (Fresh) → no context
846    // 2. User asks a question (Working) → no context fires
847    // 3. Claude responds (Idle) → context fires and resolves
848    #[test]
849    fn test_full_user_flow_fresh_working_idle() {
850        let (generator, calls) = mock_generator();
851        let mut mgr = ContextManager::with_generator(generator);
852
853        let windows = vec![win(1, "my-session")];
854        let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
855
856        // Phase 1: Fresh — no context activity
857        let states_fresh: HashMap<u32, WindowState> =
858            [(1, WindowState::Fresh)].into_iter().collect();
859        mgr.tick(&windows, &states_fresh, 0, &pane_ids(&panes), &no_cwd);
860        assert!(calls.lock().unwrap().is_empty(), "Fresh: no generator call");
861        assert!(mgr.get("my-session").is_none(), "Fresh: no context");
862        assert!(!mgr.is_loading("my-session"), "Fresh: not loading");
863
864        // Phase 2: Working — still no context activity
865        let states_working: HashMap<u32, WindowState> =
866            [(1, WindowState::Working)].into_iter().collect();
867        mgr.tick(&windows, &states_working, 0, &pane_ids(&panes), &no_cwd);
868        assert!(
869            calls.lock().unwrap().is_empty(),
870            "Working: no generator call"
871        );
872        assert!(mgr.get("my-session").is_none(), "Working: no context");
873        assert!(!mgr.is_loading("my-session"), "Working: not loading");
874
875        // Phase 3: Idle — context fires
876        let states_idle: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
877        mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
878
879        // spawn() sets in_flight synchronously before the thread runs
880        assert!(
881            mgr.is_loading("my-session"),
882            "Idle: loading while in-flight"
883        );
884
885        // Let background thread complete and drain results
886        drain_with_wait(&mut mgr);
887
888        // Generator should have been called (check after drain — calls is populated in thread)
889        assert_eq!(
890            calls.lock().unwrap().len(),
891            1,
892            "Idle: generator called once"
893        );
894
895        // Context should now be available
896        assert!(
897            !mgr.is_loading("my-session"),
898            "Idle: not loading after drain"
899        );
900        assert_eq!(
901            mgr.get("my-session"),
902            Some("context for %0"),
903            "Idle: context populated"
904        );
905    }
906
907    // ── Scenario 7: State transition invalidates stale cache ──
908    //
909    // After context is cached for a session, a new Working → Idle transition
910    // should invalidate the cache so fresh context is generated.
911    #[test]
912    fn test_working_to_idle_invalidates_cache() {
913        let (generator, calls) = mock_generator();
914        let mut mgr = ContextManager::with_generator(generator);
915
916        let windows = vec![win(1, "my-session")];
917        let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
918
919        // First round: Idle → generates and caches context
920        let states_idle: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
921        mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
922        drain_with_wait(&mut mgr);
923        assert_eq!(mgr.get("my-session"), Some("context for %0"));
924        assert_eq!(calls.lock().unwrap().len(), 1);
925
926        // Second round: user asks another question → Working
927        let states_working: HashMap<u32, WindowState> =
928            [(1, WindowState::Working)].into_iter().collect();
929        mgr.tick(&windows, &states_working, 0, &pane_ids(&panes), &no_cwd);
930        // Context still cached from first round (not cleared during Working)
931        assert_eq!(mgr.get("my-session"), Some("context for %0"));
932
933        // Third round: Claude responds → Idle again
934        // The Working → Idle transition should invalidate the cache
935        calls.lock().unwrap().clear();
936        mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
937
938        // spawn() is synchronous — should be in-flight (cache was cleared by transition)
939        assert!(
940            mgr.is_loading("my-session"),
941            "should re-request after Working → Idle transition"
942        );
943
944        drain_with_wait(&mut mgr);
945
946        // Generator should have been called again
947        assert_eq!(
948            calls.lock().unwrap().len(),
949            1,
950            "should have called generator after Working → Idle transition"
951        );
952        assert_eq!(
953            mgr.get("my-session"),
954            Some("context for %0"),
955            "fresh context should be available"
956        );
957    }
958
959    // ── Scenario 8: Failed generator during Idle retries correctly ──
960    //
961    // Verifies the 30s cooldown behavior: if the generator fails during Idle,
962    // the retry cooldown prevents immediate re-requests (as expected).
963    // But a state transition (Working → Idle) should clear the cooldown.
964    #[test]
965    fn test_failed_generator_cooldown_cleared_on_transition() {
966        let call_count = Arc::new(Mutex::new(0u32));
967        let call_count_clone = Arc::clone(&call_count);
968        // Generator fails on first call, succeeds on second
969        let generator = move |_cwd: &str, _pane_id: &str| -> Option<String> {
970            let mut count = call_count_clone.lock().unwrap();
971            *count += 1;
972            if *count == 1 {
973                None // fail first time
974            } else {
975                Some("generated context".to_string())
976            }
977        };
978        let mut mgr = ContextManager::with_generator(generator);
979
980        let windows = vec![win(1, "my-session")];
981        let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
982
983        // Tick 1: Idle → generator fires and fails → enters 30s cooldown
984        let states_idle: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
985        mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
986        drain_with_wait(&mut mgr);
987        assert!(mgr.get("my-session").is_none(), "first attempt should fail");
988        assert_eq!(*call_count.lock().unwrap(), 1);
989
990        // Tick 2: Still Idle → cooldown blocks retry
991        mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
992        drain_with_wait(&mut mgr);
993        assert_eq!(
994            *call_count.lock().unwrap(),
995            1,
996            "cooldown should block retry"
997        );
998
999        // Simulate new activity: Working → Idle transition clears cooldown
1000        let states_working: HashMap<u32, WindowState> =
1001            [(1, WindowState::Working)].into_iter().collect();
1002        mgr.tick(&windows, &states_working, 0, &pane_ids(&panes), &no_cwd);
1003        mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
1004        drain_with_wait(&mut mgr);
1005
1006        // Should have retried and succeeded
1007        assert_eq!(
1008            *call_count.lock().unwrap(),
1009            2,
1010            "should retry after state transition"
1011        );
1012        assert_eq!(
1013            mgr.get("my-session"),
1014            Some("generated context"),
1015            "second attempt should succeed"
1016        );
1017    }
1018}