1use 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
22type 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 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 SUBPROCESS_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 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 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 pub fn get(&self, name: &str) -> Option<&str> {
125 self.contexts.get(name).map(String::as_str)
126 }
127
128 pub fn is_loading(&self, name: &str) -> bool {
130 self.in_flight.contains(name)
131 }
132
133 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 self.drain();
149
150 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 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 let current_name = windows.get(selected).map(|w| w.name.clone());
190 if current_name != self.prev_selected_name {
191 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 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 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
251fn is_settled(state: WindowState) -> bool {
255 matches!(
256 state,
257 WindowState::Idle | WindowState::Asking | WindowState::Waiting
258 )
259}
260
261fn 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
273fn 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
292fn 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 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 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
381fn 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 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#[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 writeln!(
509 f,
510 r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"hello"}}]}}}}"#
511 )
512 .unwrap();
513 writeln!(f, r#"{{"type":"progress","data":{{"hook":"test"}}}}"#).unwrap();
515 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 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); }
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 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 use std::sync::Mutex;
603
604 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 fn drain_with_wait(mgr: &mut ContextManager) {
640 thread::sleep(Duration::from_millis(50));
643 mgr.drain();
644 }
645
646 #[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 mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
661
662 assert!(calls.lock().unwrap().is_empty());
664 assert!(!mgr.is_loading("session-1"));
665 assert!(mgr.get("session-1").is_none());
666 }
667
668 #[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 mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
686 assert!(calls.lock().unwrap().is_empty());
687
688 mgr.tick(&windows, &states, 1, &pane_ids(&panes), &no_cwd);
690
691 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 #[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 mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
718 drain_with_wait(&mut mgr);
719
720 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 calls.lock().unwrap().clear();
733 mgr.tick(&windows, &states, 1, &pane_ids(&panes), &no_cwd);
734 drain_with_wait(&mut mgr);
736
737 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 assert!(!mgr.is_loading("new-session"));
751 assert!(mgr.get("new-session").is_none());
752 }
753
754 #[test]
759 fn test_switch_back_while_pending_shows_loading() {
760 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 mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
778 assert!(
780 mgr.is_loading("active-session"),
781 "should be loading while generator is running"
782 );
783
784 mgr.tick(&windows, &states, 1, &pane_ids(&panes), &no_cwd);
787
788 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 barrier.wait();
801 drain_with_wait(&mut mgr);
802
803 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 #[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 mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
832
833 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 #[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 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 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 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 assert!(
881 mgr.is_loading("my-session"),
882 "Idle: loading while in-flight"
883 );
884
885 drain_with_wait(&mut mgr);
887
888 assert_eq!(
890 calls.lock().unwrap().len(),
891 1,
892 "Idle: generator called once"
893 );
894
895 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 #[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 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 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 assert_eq!(mgr.get("my-session"), Some("context for %0"));
932
933 calls.lock().unwrap().clear();
936 mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
937
938 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 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 #[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 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 } 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 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 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 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 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}