1use std::collections::{HashMap, HashSet};
8use std::fs::{self, OpenOptions};
9use std::io::Write as _;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::sync::mpsc;
13use std::thread;
14use std::time::{Duration, Instant};
15
16use crate::events;
17use crate::sidebar::state::WindowState;
18use crate::tmux::WindowInfo;
19
20type GeneratorFn = Arc<dyn Fn(&str, &str) -> Result<String, String> + Send + Sync>;
23
24pub struct ContextManager {
25 contexts: HashMap<String, String>,
26 in_flight: HashSet<String>,
27 failed: HashMap<String, Instant>,
28 errors: HashMap<String, String>,
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 prev_states: HashMap<String, WindowState>,
36}
37
38const 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
45const CONVERSATION_BUDGET: usize = 6000;
47
48const MESSAGE_TRUNCATE: usize = 300;
50
51const API_TIMEOUT: Duration = Duration::from_secs(30);
52const RETRY_COOLDOWN: Duration = Duration::from_secs(30);
53
54fn 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
68impl 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 errors: HashMap::new(),
84 tx,
85 rx,
86 generator: Arc::new(generate_context),
87 prev_selected_name: None,
88 prev_states: HashMap::new(),
89 }
90 }
91
92 pub fn with_generator(
93 generator_fn: impl Fn(&str, &str) -> Result<String, String> + Send + Sync + 'static,
94 ) -> Self {
95 let (tx, rx) = mpsc::channel();
96 Self {
97 contexts: HashMap::new(),
98 in_flight: HashSet::new(),
99 failed: HashMap::new(),
100 errors: HashMap::new(),
101 tx,
102 rx,
103 generator: Arc::new(generator_fn),
104 prev_selected_name: None,
105 prev_states: HashMap::new(),
106 }
107 }
108
109 pub fn drain(&mut self) {
111 while let Ok((name, result)) = self.rx.try_recv() {
112 self.in_flight.remove(&name);
113 match result {
114 Ok(context) => {
115 self.errors.remove(&name);
116 self.contexts.insert(name, context);
117 }
118 Err(reason) => {
119 log_context(&format!("failed for {name}: {reason}"));
120 self.errors.insert(name.clone(), reason);
121 self.failed.insert(name, Instant::now());
122 }
123 }
124 }
125 }
126
127 pub fn get(&self, name: &str) -> Option<&str> {
129 self.contexts.get(name).map(String::as_str)
130 }
131
132 pub fn get_error(&self, name: &str) -> Option<&str> {
134 self.errors.get(name).map(String::as_str)
135 }
136
137 pub fn is_loading(&self, name: &str) -> bool {
139 self.in_flight.contains(name)
140 }
141
142 pub fn tick(
149 &mut self,
150 windows: &[WindowInfo],
151 states: &HashMap<u32, WindowState>,
152 selected: usize,
153 pane_id_for: &impl Fn(u32) -> Option<String>,
154 cwd_for: &impl Fn(u32) -> Option<String>,
155 ) {
156 self.drain();
158
159 if let Some(win) = windows.get(selected) {
163 let state = states
164 .get(&win.index)
165 .copied()
166 .unwrap_or(WindowState::Fresh);
167 let prev = self
168 .prev_states
169 .get(&win.name)
170 .copied()
171 .unwrap_or(WindowState::Fresh);
172
173 if prev == WindowState::Working && is_settled(state) {
174 self.contexts.remove(&win.name);
175 self.failed.remove(&win.name);
176 }
177
178 self.prev_states.insert(win.name.clone(), state);
179 }
180
181 if let Some(win) = windows.get(selected) {
186 let state = states
187 .get(&win.index)
188 .copied()
189 .unwrap_or(WindowState::Fresh);
190 if is_settled(state) {
191 let pane_id = pane_id_for(win.index).unwrap_or_default();
192 let cwd = cwd_for(win.index).unwrap_or_else(|| win.pane_path.clone());
193 self.request(&win.name, &cwd, &pane_id);
194 }
195 }
196
197 let current_name = windows.get(selected).map(|w| w.name.clone());
199 if current_name != self.prev_selected_name {
200 if let Some(ref prev_name) = self.prev_selected_name {
202 if let Some(prev_win) = windows.iter().find(|w| w.name == *prev_name) {
203 let state = states
204 .get(&prev_win.index)
205 .copied()
206 .unwrap_or(WindowState::Fresh);
207 if is_settled(state) {
208 let pane_id = pane_id_for(prev_win.index).unwrap_or_default();
209 let cwd =
210 cwd_for(prev_win.index).unwrap_or_else(|| prev_win.pane_path.clone());
211 self.refresh(&prev_win.name, &cwd, &pane_id);
212 }
213 }
214 }
215 self.prev_selected_name = current_name;
216 }
217 }
218
219 pub fn request(&mut self, name: &str, cwd: &str, pane_id: &str) {
221 if self.contexts.contains_key(name) || self.in_flight.contains(name) {
222 return;
223 }
224 if let Some(failed_at) = self.failed.get(name) {
225 if failed_at.elapsed() < RETRY_COOLDOWN {
226 return;
227 }
228 }
229 self.failed.remove(name);
230 self.spawn(name, cwd, pane_id);
231 }
232
233 pub fn refresh(&mut self, name: &str, cwd: &str, pane_id: &str) {
235 if self.in_flight.contains(name) {
236 return;
237 }
238 self.contexts.remove(name);
239 self.failed.remove(name);
240 self.errors.remove(name);
241 self.spawn(name, cwd, pane_id);
242 }
243
244 fn spawn(&mut self, name: &str, cwd: &str, pane_id: &str) {
245 self.in_flight.insert(name.to_string());
246 let tx = self.tx.clone();
247 let name = name.to_string();
248 let cwd = cwd.to_string();
249 let pane_id = pane_id.to_string();
250 let generator = Arc::clone(&self.generator);
251 thread::spawn(move || {
252 let result = generator(&cwd, &pane_id);
253 let _ = tx.send((name, result));
254 });
255 }
256}
257
258fn is_settled(state: WindowState) -> bool {
262 matches!(
263 state,
264 WindowState::Idle | WindowState::Asking | WindowState::Waiting
265 )
266}
267
268fn claude_project_dir(cwd: &str) -> PathBuf {
272 let home = std::env::var("HOME").unwrap_or_default();
273 let project_key = cwd.replace('/', "-");
274 PathBuf::from(home)
275 .join(".claude")
276 .join("projects")
277 .join(project_key)
278}
279
280fn find_session_file(cwd: &str, pane_id: &str) -> Option<PathBuf> {
282 let session_id = match events::find_session_id(pane_id) {
283 Some(id) => id,
284 None => {
285 log_context(&format!("no session_id for pane_id={pane_id}"));
286 return None;
287 }
288 };
289 let project_dir = claude_project_dir(cwd);
290 let path = project_dir.join(format!("{session_id}.jsonl"));
291 if path.exists() {
292 Some(path)
293 } else {
294 log_context(&format!("JSONL not found: {}", path.display()));
295 None
296 }
297}
298
299fn extract_conversation(path: &Path) -> Option<String> {
305 let content = match fs::read_to_string(path) {
306 Ok(c) => c,
307 Err(e) => {
308 log_context(&format!(
309 "read session file failed: {}: {e}",
310 path.display()
311 ));
312 return None;
313 }
314 };
315 let mut messages = Vec::new();
316
317 for line in content.lines() {
318 let entry: serde_json::Value = match serde_json::from_str(line) {
319 Ok(v) => v,
320 Err(_) => continue,
321 };
322
323 let entry_type = match entry.get("type").and_then(|t| t.as_str()) {
324 Some(t) => t,
325 None => continue,
326 };
327
328 if entry_type != "user" && entry_type != "assistant" {
329 continue;
330 }
331
332 let message = match entry.get("message") {
333 Some(m) => m,
334 None => continue,
335 };
336
337 let role = message
338 .get("role")
339 .and_then(|r| r.as_str())
340 .unwrap_or(entry_type);
341
342 let content_arr = match message.get("content").and_then(|c| c.as_array()) {
343 Some(arr) => arr,
344 None => continue,
345 };
346
347 for item in content_arr {
348 if item.get("type").and_then(|t| t.as_str()) != Some("text") {
350 continue;
351 }
352 if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
353 let text = text.trim();
354 if text.is_empty() {
355 continue;
356 }
357 let truncated = if text.chars().count() > MESSAGE_TRUNCATE {
358 let t: String = text.chars().take(MESSAGE_TRUNCATE).collect();
359 format!("{t}\u{2026}")
360 } else {
361 text.to_string()
362 };
363 let label = if role == "user" { "User" } else { "Assistant" };
364 messages.push(format!("{label}: {truncated}"));
365 }
366 }
367 }
368
369 if messages.is_empty() {
370 return None;
371 }
372
373 let mut kept = Vec::new();
375 let mut total_len = 0;
376 for msg in messages.iter().rev() {
377 if total_len + msg.len() + 2 > CONVERSATION_BUDGET {
378 break;
379 }
380 total_len += msg.len() + 2;
381 kept.push(msg.as_str());
382 }
383 kept.reverse();
384
385 Some(kept.join("\n\n"))
386}
387
388fn generate_context(cwd: &str, pane_id: &str) -> Result<String, String> {
391 let session_path = find_session_file(cwd, pane_id)
392 .ok_or_else(|| format!("no session file: cwd={cwd} pane_id={pane_id}"))?;
393 let conversation = extract_conversation(&session_path)
394 .ok_or_else(|| format!("no conversation: path={}", session_path.display()))?;
395
396 let prompt = format!("{SUMMARY_PROMPT}\n\nConversation:\n{conversation}");
397 call_ollama(&prompt)
398}
399
400const OLLAMA_DEFAULT_MODEL: &str = "llama3.2";
401
402fn call_ollama(prompt: &str) -> Result<String, String> {
403 let model =
404 std::env::var("COVE_OLLAMA_MODEL").unwrap_or_else(|_| OLLAMA_DEFAULT_MODEL.to_string());
405
406 let config = ureq::Agent::config_builder()
407 .timeout_global(Some(API_TIMEOUT))
408 .build();
409 let agent: ureq::Agent = config.into();
410
411 let body = serde_json::json!({
412 "model": model,
413 "messages": [{"role": "user", "content": prompt}],
414 "stream": false
415 });
416
417 let mut response = agent
418 .post("http://localhost:11434/api/chat")
419 .send_json(&body)
420 .map_err(|e| {
421 if e.to_string().contains("Connection refused") {
422 "Ollama not connected".to_string()
423 } else {
424 format!("Ollama request failed: {e}")
425 }
426 })?;
427
428 let response_text = response
429 .body_mut()
430 .read_to_string()
431 .map_err(|e| format!("read response failed: {e}"))?;
432
433 let parsed: serde_json::Value =
434 serde_json::from_str(&response_text).map_err(|e| format!("parse response failed: {e}"))?;
435
436 let text = parsed
437 .get("message")
438 .and_then(|m| m.get("content"))
439 .and_then(|t| t.as_str())
440 .ok_or_else(|| "no text in Ollama response".to_string())?;
441
442 let text = text.trim().to_string();
443 if text.is_empty() {
444 Err("Ollama returned empty text".to_string())
445 } else {
446 Ok(text)
447 }
448}
449
450#[cfg(test)]
453mod tests {
454 use super::*;
455 use std::io::Write;
456
457 fn write_session_jsonl(dir: &Path, session_id: &str, messages: &[(&str, &str)]) -> PathBuf {
458 let path = dir.join(format!("{session_id}.jsonl"));
459 let mut f = fs::File::create(&path).unwrap();
460 for (role, text) in messages {
461 let entry = serde_json::json!({
462 "type": role,
463 "message": {
464 "role": role,
465 "content": [{"type": "text", "text": text}]
466 }
467 });
468 writeln!(f, "{}", serde_json::to_string(&entry).unwrap()).unwrap();
469 }
470 path
471 }
472
473 #[test]
474 fn test_extract_conversation_basic() {
475 let dir = tempfile::tempdir().unwrap();
476 let path = write_session_jsonl(
477 dir.path(),
478 "test",
479 &[
480 ("user", "Fix the login bug"),
481 ("assistant", "I'll look into the auth module"),
482 ("user", "Also check the session handling"),
483 ],
484 );
485
486 let result = extract_conversation(&path).unwrap();
487 assert!(result.contains("User: Fix the login bug"));
488 assert!(result.contains("Assistant: I'll look into the auth module"));
489 assert!(result.contains("User: Also check the session handling"));
490 }
491
492 #[test]
493 fn test_extract_conversation_skips_non_text() {
494 let dir = tempfile::tempdir().unwrap();
495 let path = dir.path().join("test.jsonl");
496 let mut f = fs::File::create(&path).unwrap();
497
498 writeln!(
500 f,
501 r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"hello"}}]}}}}"#
502 )
503 .unwrap();
504 writeln!(f, r#"{{"type":"progress","data":{{"hook":"test"}}}}"#).unwrap();
506 writeln!(
508 f,
509 r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"tool_use","name":"Bash"}}]}}}}"#
510 )
511 .unwrap();
512
513 let result = extract_conversation(&path).unwrap();
514 assert!(result.contains("User: hello"));
515 assert!(!result.contains("progress"));
516 assert!(!result.contains("Bash"));
517 }
518
519 #[test]
520 fn test_extract_conversation_truncates_long_messages() {
521 let dir = tempfile::tempdir().unwrap();
522 let long_text = "a".repeat(500);
523 let path = write_session_jsonl(dir.path(), "test", &[("user", &long_text)]);
524
525 let result = extract_conversation(&path).unwrap();
526 assert!(result.chars().count() < 500);
528 assert!(result.ends_with('\u{2026}'));
529 }
530
531 #[test]
532 fn test_extract_conversation_empty_file() {
533 let dir = tempfile::tempdir().unwrap();
534 let path = dir.path().join("empty.jsonl");
535 fs::File::create(&path).unwrap();
536
537 assert!(extract_conversation(&path).is_none());
538 }
539
540 #[test]
541 fn test_extract_conversation_respects_budget() {
542 let dir = tempfile::tempdir().unwrap();
543 let msg = "x".repeat(MESSAGE_TRUNCATE - 10);
544 let messages: Vec<(&str, &str)> = (0..100).map(|_| ("user", msg.as_str())).collect();
545 let path = write_session_jsonl(dir.path(), "test", &messages);
546
547 let result = extract_conversation(&path).unwrap();
548 assert!(result.len() <= CONVERSATION_BUDGET + 200); }
550
551 #[test]
552 fn test_claude_project_dir() {
553 let dir = claude_project_dir("/Users/test/workspace/myproject");
554 assert!(
555 dir.to_str()
556 .unwrap()
557 .ends_with("/.claude/projects/-Users-test-workspace-myproject")
558 );
559 }
560
561 #[test]
562 fn test_find_session_id_from_events() {
563 let dir = tempfile::tempdir().unwrap();
564 let events_path = dir.path().join("abc-123.jsonl");
565 let mut f = fs::File::create(&events_path).unwrap();
566 writeln!(
567 f,
568 r#"{{"state":"working","cwd":"/tmp","pane_id":"%5","ts":1000}}"#
569 )
570 .unwrap();
571 writeln!(
572 f,
573 r#"{{"state":"idle","cwd":"/tmp","pane_id":"%5","ts":1001}}"#
574 )
575 .unwrap();
576
577 let content = fs::read_to_string(&events_path).unwrap();
579 let last_line = content
580 .lines()
581 .rev()
582 .find(|l| !l.trim().is_empty())
583 .unwrap();
584 let event: serde_json::Value = serde_json::from_str(last_line).unwrap();
585 assert_eq!(event.get("pane_id").and_then(|v| v.as_str()), Some("%5"));
586 }
587
588 use std::sync::Mutex;
594
595 fn mock_generator() -> (
597 impl Fn(&str, &str) -> Result<String, String> + Send + Sync + 'static,
598 Arc<Mutex<Vec<(String, String)>>>,
599 ) {
600 let calls: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
601 let calls_clone = Arc::clone(&calls);
602 let generator = move |cwd: &str, pane_id: &str| -> Result<String, String> {
603 calls_clone
604 .lock()
605 .unwrap()
606 .push((cwd.to_string(), pane_id.to_string()));
607 Ok(format!("context for {pane_id}"))
608 };
609 (generator, calls)
610 }
611
612 fn win(index: u32, name: &str) -> WindowInfo {
613 WindowInfo {
614 index,
615 name: name.to_string(),
616 is_active: false,
617 pane_path: format!("/project/{name}"),
618 }
619 }
620
621 fn pane_ids(map: &HashMap<u32, String>) -> impl Fn(u32) -> Option<String> + '_ {
622 move |idx| map.get(&idx).cloned()
623 }
624
625 fn no_cwd(_idx: u32) -> Option<String> {
626 None
627 }
628
629 fn drain_with_wait(mgr: &mut ContextManager) {
631 thread::sleep(Duration::from_millis(50));
634 mgr.drain();
635 }
636
637 #[test]
642 fn test_new_session_no_context_action() {
643 let (generator, calls) = mock_generator();
644 let mut mgr = ContextManager::with_generator(generator);
645
646 let windows = vec![win(1, "session-1")];
647 let states: HashMap<u32, WindowState> = [(1, WindowState::Fresh)].into_iter().collect();
648 let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
649
650 mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
652
653 assert!(calls.lock().unwrap().is_empty());
655 assert!(!mgr.is_loading("session-1"));
656 assert!(mgr.get("session-1").is_none());
657 }
658
659 #[test]
664 fn test_switch_between_fresh_sessions_no_context_action() {
665 let (generator, calls) = mock_generator();
666 let mut mgr = ContextManager::with_generator(generator);
667
668 let windows = vec![win(1, "session-1"), win(2, "session-2")];
669 let states: HashMap<u32, WindowState> = [(1, WindowState::Fresh), (2, WindowState::Fresh)]
670 .into_iter()
671 .collect();
672 let panes: HashMap<u32, String> =
673 [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
674
675 mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
677 assert!(calls.lock().unwrap().is_empty());
678
679 mgr.tick(&windows, &states, 1, &pane_ids(&panes), &no_cwd);
681
682 assert!(calls.lock().unwrap().is_empty());
684 assert!(!mgr.is_loading("session-1"));
685 assert!(!mgr.is_loading("session-2"));
686 assert!(mgr.get("session-1").is_none());
687 assert!(mgr.get("session-2").is_none());
688 }
689
690 #[test]
696 fn test_switch_from_active_to_fresh_fires_context_for_previous() {
697 let (generator, calls) = mock_generator();
698 let mut mgr = ContextManager::with_generator(generator);
699
700 let windows = vec![win(1, "active-session"), win(2, "new-session")];
701 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle), (2, WindowState::Fresh)]
702 .into_iter()
703 .collect();
704 let panes: HashMap<u32, String> =
705 [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
706
707 mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
709 drain_with_wait(&mut mgr);
710
711 let call_count_after_tick1 = calls.lock().unwrap().len();
713 assert!(
714 call_count_after_tick1 > 0,
715 "should request context for Idle session"
716 );
717 assert!(
718 calls.lock().unwrap().iter().any(|(_, pid)| pid == "%0"),
719 "should request for pane %0"
720 );
721
722 calls.lock().unwrap().clear();
724 mgr.tick(&windows, &states, 1, &pane_ids(&panes), &no_cwd);
725 drain_with_wait(&mut mgr);
727
728 let tick2_calls = calls.lock().unwrap().clone();
731 assert!(
732 tick2_calls.iter().any(|(_, pid)| pid == "%0"),
733 "should refresh context for previous active session"
734 );
735 assert!(
736 !tick2_calls.iter().any(|(_, pid)| pid == "%1"),
737 "should NOT request context for Fresh new session"
738 );
739
740 assert!(!mgr.is_loading("new-session"));
742 assert!(mgr.get("new-session").is_none());
743 }
744
745 #[test]
750 fn test_switch_back_while_pending_shows_loading() {
751 let barrier = Arc::new(std::sync::Barrier::new(2));
753 let barrier_clone = Arc::clone(&barrier);
754 let slow_generator = move |_cwd: &str, _pane_id: &str| -> Result<String, String> {
755 barrier_clone.wait();
756 Ok("generated context".to_string())
757 };
758 let mut mgr = ContextManager::with_generator(slow_generator);
759
760 let windows = vec![win(1, "active-session"), win(2, "new-session")];
761 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle), (2, WindowState::Fresh)]
762 .into_iter()
763 .collect();
764 let panes: HashMap<u32, String> =
765 [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
766
767 mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
769 assert!(
771 mgr.is_loading("active-session"),
772 "should be loading while generator is running"
773 );
774
775 mgr.tick(&windows, &states, 1, &pane_ids(&panes), &no_cwd);
778
779 mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
781 assert!(
782 mgr.is_loading("active-session"),
783 "should still be loading when switching back"
784 );
785 assert!(
786 mgr.get("active-session").is_none(),
787 "context should not be available yet"
788 );
789
790 barrier.wait();
792 drain_with_wait(&mut mgr);
793
794 assert!(
796 !mgr.is_loading("active-session"),
797 "should not be loading after drain"
798 );
799 assert_eq!(
800 mgr.get("active-session"),
801 Some("generated context"),
802 "context should be populated after drain"
803 );
804 }
805
806 #[test]
813 fn test_working_state_does_not_fire_context() {
814 let (generator, calls) = mock_generator();
815 let mut mgr = ContextManager::with_generator(generator);
816
817 let windows = vec![win(1, "session-1")];
818 let states: HashMap<u32, WindowState> = [(1, WindowState::Working)].into_iter().collect();
819 let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
820
821 mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
823
824 assert!(
826 calls.lock().unwrap().is_empty(),
827 "should not fire context during Working"
828 );
829 assert!(!mgr.is_loading("session-1"));
830 assert!(mgr.get("session-1").is_none());
831 }
832
833 #[test]
840 fn test_full_user_flow_fresh_working_idle() {
841 let (generator, calls) = mock_generator();
842 let mut mgr = ContextManager::with_generator(generator);
843
844 let windows = vec![win(1, "my-session")];
845 let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
846
847 let states_fresh: HashMap<u32, WindowState> =
849 [(1, WindowState::Fresh)].into_iter().collect();
850 mgr.tick(&windows, &states_fresh, 0, &pane_ids(&panes), &no_cwd);
851 assert!(calls.lock().unwrap().is_empty(), "Fresh: no generator call");
852 assert!(mgr.get("my-session").is_none(), "Fresh: no context");
853 assert!(!mgr.is_loading("my-session"), "Fresh: not loading");
854
855 let states_working: HashMap<u32, WindowState> =
857 [(1, WindowState::Working)].into_iter().collect();
858 mgr.tick(&windows, &states_working, 0, &pane_ids(&panes), &no_cwd);
859 assert!(
860 calls.lock().unwrap().is_empty(),
861 "Working: no generator call"
862 );
863 assert!(mgr.get("my-session").is_none(), "Working: no context");
864 assert!(!mgr.is_loading("my-session"), "Working: not loading");
865
866 let states_idle: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
868 mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
869
870 assert!(
872 mgr.is_loading("my-session"),
873 "Idle: loading while in-flight"
874 );
875
876 drain_with_wait(&mut mgr);
878
879 assert_eq!(
881 calls.lock().unwrap().len(),
882 1,
883 "Idle: generator called once"
884 );
885
886 assert!(
888 !mgr.is_loading("my-session"),
889 "Idle: not loading after drain"
890 );
891 assert_eq!(
892 mgr.get("my-session"),
893 Some("context for %0"),
894 "Idle: context populated"
895 );
896 }
897
898 #[test]
903 fn test_working_to_idle_invalidates_cache() {
904 let (generator, calls) = mock_generator();
905 let mut mgr = ContextManager::with_generator(generator);
906
907 let windows = vec![win(1, "my-session")];
908 let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
909
910 let states_idle: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
912 mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
913 drain_with_wait(&mut mgr);
914 assert_eq!(mgr.get("my-session"), Some("context for %0"));
915 assert_eq!(calls.lock().unwrap().len(), 1);
916
917 let states_working: HashMap<u32, WindowState> =
919 [(1, WindowState::Working)].into_iter().collect();
920 mgr.tick(&windows, &states_working, 0, &pane_ids(&panes), &no_cwd);
921 assert_eq!(mgr.get("my-session"), Some("context for %0"));
923
924 calls.lock().unwrap().clear();
927 mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
928
929 assert!(
931 mgr.is_loading("my-session"),
932 "should re-request after Working → Idle transition"
933 );
934
935 drain_with_wait(&mut mgr);
936
937 assert_eq!(
939 calls.lock().unwrap().len(),
940 1,
941 "should have called generator after Working → Idle transition"
942 );
943 assert_eq!(
944 mgr.get("my-session"),
945 Some("context for %0"),
946 "fresh context should be available"
947 );
948 }
949
950 #[test]
956 fn test_failed_generator_cooldown_cleared_on_transition() {
957 let call_count = Arc::new(Mutex::new(0u32));
958 let call_count_clone = Arc::clone(&call_count);
959 let generator = move |_cwd: &str, _pane_id: &str| -> Result<String, String> {
961 let mut count = call_count_clone.lock().unwrap();
962 *count += 1;
963 if *count == 1 {
964 Err("simulated failure".to_string())
965 } else {
966 Ok("generated context".to_string())
967 }
968 };
969 let mut mgr = ContextManager::with_generator(generator);
970
971 let windows = vec![win(1, "my-session")];
972 let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
973
974 let states_idle: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
976 mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
977 drain_with_wait(&mut mgr);
978 assert!(mgr.get("my-session").is_none(), "first attempt should fail");
979 assert_eq!(*call_count.lock().unwrap(), 1);
980
981 mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
983 drain_with_wait(&mut mgr);
984 assert_eq!(
985 *call_count.lock().unwrap(),
986 1,
987 "cooldown should block retry"
988 );
989
990 let states_working: HashMap<u32, WindowState> =
992 [(1, WindowState::Working)].into_iter().collect();
993 mgr.tick(&windows, &states_working, 0, &pane_ids(&panes), &no_cwd);
994 mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
995 drain_with_wait(&mut mgr);
996
997 assert_eq!(
999 *call_count.lock().unwrap(),
1000 2,
1001 "should retry after state transition"
1002 );
1003 assert_eq!(
1004 mgr.get("my-session"),
1005 Some("generated context"),
1006 "second attempt should succeed"
1007 );
1008 }
1009}