1use std::collections::{HashMap, HashSet};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::sync::Arc;
13use std::sync::mpsc;
14use std::time::{Duration, Instant};
15use std::{io, thread};
16
17use crate::events;
18use crate::sidebar::state::WindowState;
19use crate::tmux::WindowInfo;
20
21type GeneratorFn = Arc<dyn Fn(&str, &str) -> Option<String> + Send + Sync>;
24
25pub struct ContextManager {
26 contexts: HashMap<String, String>,
27 in_flight: HashSet<String>,
28 failed: HashMap<String, Instant>,
29 tx: mpsc::Sender<(String, String)>,
30 rx: mpsc::Receiver<(String, String)>,
31 generator: GeneratorFn,
32 prev_selected_name: Option<String>,
33}
34
35const SUMMARY_PROMPT: &str = "\
38Summarize the overall goal and current state of this conversation in 1-2 sentences. \
39What is the user trying to accomplish, and where are things at? \
40Output only the summary, nothing else.";
41
42const CONVERSATION_BUDGET: usize = 6000;
44
45const MESSAGE_TRUNCATE: usize = 300;
47
48const SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(30);
49const RETRY_COOLDOWN: Duration = Duration::from_secs(30);
50
51impl Default for ContextManager {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59impl ContextManager {
60 pub fn new() -> Self {
61 let (tx, rx) = mpsc::channel();
62 Self {
63 contexts: HashMap::new(),
64 in_flight: HashSet::new(),
65 failed: HashMap::new(),
66 tx,
67 rx,
68 generator: Arc::new(generate_context),
69 prev_selected_name: None,
70 }
71 }
72
73 pub fn with_generator(
74 generator_fn: impl Fn(&str, &str) -> Option<String> + Send + Sync + 'static,
75 ) -> Self {
76 let (tx, rx) = mpsc::channel();
77 Self {
78 contexts: HashMap::new(),
79 in_flight: HashSet::new(),
80 failed: HashMap::new(),
81 tx,
82 rx,
83 generator: Arc::new(generator_fn),
84 prev_selected_name: None,
85 }
86 }
87
88 pub fn drain(&mut self) {
90 while let Ok((name, context)) = self.rx.try_recv() {
91 self.in_flight.remove(&name);
92 if context.is_empty() {
93 self.failed.insert(name, Instant::now());
94 } else {
95 self.contexts.insert(name, context);
96 }
97 }
98 }
99
100 pub fn get(&self, name: &str) -> Option<&str> {
102 self.contexts.get(name).map(String::as_str)
103 }
104
105 pub fn is_loading(&self, name: &str) -> bool {
107 self.in_flight.contains(name)
108 }
109
110 pub fn tick(
116 &mut self,
117 windows: &[WindowInfo],
118 states: &HashMap<u32, WindowState>,
119 selected: usize,
120 pane_id_for: &impl Fn(u32) -> Option<String>,
121 ) {
122 for win in windows {
124 let state = states
125 .get(&win.index)
126 .copied()
127 .unwrap_or(WindowState::Fresh);
128 if state != WindowState::Fresh {
129 let pane_id = pane_id_for(win.index).unwrap_or_default();
130 self.request(&win.name, &win.pane_path, &pane_id);
131 }
132 }
133
134 self.drain();
136
137 let current_name = windows.get(selected).map(|w| w.name.clone());
139 if current_name != self.prev_selected_name {
140 if let Some(ref prev_name) = self.prev_selected_name {
142 if let Some(prev_win) = windows.iter().find(|w| w.name == *prev_name) {
143 let state = states
144 .get(&prev_win.index)
145 .copied()
146 .unwrap_or(WindowState::Fresh);
147 if state != WindowState::Fresh {
148 let pane_id = pane_id_for(prev_win.index).unwrap_or_default();
149 self.refresh(&prev_win.name, &prev_win.pane_path, &pane_id);
150 }
151 }
152 }
153 if let Some(win) = windows.get(selected) {
155 let state = states
156 .get(&win.index)
157 .copied()
158 .unwrap_or(WindowState::Fresh);
159 if state != WindowState::Fresh {
160 let pane_id = pane_id_for(win.index).unwrap_or_default();
161 self.request(&win.name, &win.pane_path, &pane_id);
162 }
163 }
164 self.prev_selected_name = current_name;
165 }
166 }
167
168 pub fn request(&mut self, name: &str, cwd: &str, pane_id: &str) {
170 if self.contexts.contains_key(name) || self.in_flight.contains(name) {
171 return;
172 }
173 if let Some(failed_at) = self.failed.get(name) {
174 if failed_at.elapsed() < RETRY_COOLDOWN {
175 return;
176 }
177 }
178 self.failed.remove(name);
179 self.spawn(name, cwd, pane_id);
180 }
181
182 pub fn refresh(&mut self, name: &str, cwd: &str, pane_id: &str) {
184 if self.in_flight.contains(name) {
185 return;
186 }
187 self.contexts.remove(name);
188 self.failed.remove(name);
189 self.spawn(name, cwd, pane_id);
190 }
191
192 fn spawn(&mut self, name: &str, cwd: &str, pane_id: &str) {
193 self.in_flight.insert(name.to_string());
194 let tx = self.tx.clone();
195 let name = name.to_string();
196 let cwd = cwd.to_string();
197 let pane_id = pane_id.to_string();
198 let generator = Arc::clone(&self.generator);
199 thread::spawn(move || {
200 let context = generator(&cwd, &pane_id).unwrap_or_default();
201 let _ = tx.send((name, context));
202 });
203 }
204}
205
206fn claude_project_dir(cwd: &str) -> PathBuf {
210 let home = std::env::var("HOME").unwrap_or_default();
211 let project_key = cwd.replace('/', "-");
212 PathBuf::from(home)
213 .join(".claude")
214 .join("projects")
215 .join(project_key)
216}
217
218fn find_session_file(cwd: &str, pane_id: &str) -> Option<PathBuf> {
220 let session_id = events::find_session_id(pane_id)?;
221 let project_dir = claude_project_dir(cwd);
222 let path = project_dir.join(format!("{session_id}.jsonl"));
223 if path.exists() { Some(path) } else { None }
224}
225
226fn extract_conversation(path: &Path) -> Option<String> {
232 let content = fs::read_to_string(path).ok()?;
233 let mut messages = Vec::new();
234
235 for line in content.lines() {
236 let entry: serde_json::Value = match serde_json::from_str(line) {
237 Ok(v) => v,
238 Err(_) => continue,
239 };
240
241 let entry_type = match entry.get("type").and_then(|t| t.as_str()) {
242 Some(t) => t,
243 None => continue,
244 };
245
246 if entry_type != "user" && entry_type != "assistant" {
247 continue;
248 }
249
250 let message = match entry.get("message") {
251 Some(m) => m,
252 None => continue,
253 };
254
255 let role = message
256 .get("role")
257 .and_then(|r| r.as_str())
258 .unwrap_or(entry_type);
259
260 let content_arr = match message.get("content").and_then(|c| c.as_array()) {
261 Some(arr) => arr,
262 None => continue,
263 };
264
265 for item in content_arr {
266 if item.get("type").and_then(|t| t.as_str()) != Some("text") {
268 continue;
269 }
270 if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
271 let text = text.trim();
272 if text.is_empty() {
273 continue;
274 }
275 let truncated = if text.chars().count() > MESSAGE_TRUNCATE {
276 let t: String = text.chars().take(MESSAGE_TRUNCATE).collect();
277 format!("{t}\u{2026}")
278 } else {
279 text.to_string()
280 };
281 let label = if role == "user" { "User" } else { "Assistant" };
282 messages.push(format!("{label}: {truncated}"));
283 }
284 }
285 }
286
287 if messages.is_empty() {
288 return None;
289 }
290
291 let mut kept = Vec::new();
293 let mut total_len = 0;
294 for msg in messages.iter().rev() {
295 if total_len + msg.len() + 2 > CONVERSATION_BUDGET {
296 break;
297 }
298 total_len += msg.len() + 2;
299 kept.push(msg.as_str());
300 }
301 kept.reverse();
302
303 Some(kept.join("\n\n"))
304}
305
306fn generate_context(cwd: &str, pane_id: &str) -> Option<String> {
309 let session_path = find_session_file(cwd, pane_id)?;
310 let conversation = extract_conversation(&session_path)?;
311
312 let prompt = format!("{SUMMARY_PROMPT}\n\nConversation:\n{conversation}");
313
314 let mut child = Command::new("claude")
318 .args(["-p", &prompt, "--max-turns", "1", "--model", "haiku"])
319 .current_dir(cwd)
320 .env_remove("CLAUDECODE")
321 .stdout(Stdio::piped())
322 .stderr(Stdio::null())
323 .spawn()
324 .ok()?;
325
326 let deadline = Instant::now() + SUBPROCESS_TIMEOUT;
327 let status = loop {
328 match child.try_wait() {
329 Ok(Some(status)) => break status,
330 Ok(None) => {
331 if Instant::now() >= deadline {
332 let _ = child.kill();
333 let _ = child.wait();
334 return None;
335 }
336 thread::sleep(Duration::from_millis(200));
337 }
338 Err(_) => return None,
339 }
340 };
341
342 if !status.success() {
343 return None;
344 }
345
346 let mut stdout = child.stdout.take()?;
347 let mut text = String::new();
348 io::Read::read_to_string(&mut stdout, &mut text).ok()?;
349 let text = text.trim().to_string();
350 if text.is_empty() { None } else { Some(text) }
351}
352
353#[cfg(test)]
356mod tests {
357 use super::*;
358 use std::io::Write;
359
360 fn write_session_jsonl(dir: &Path, session_id: &str, messages: &[(&str, &str)]) -> PathBuf {
361 let path = dir.join(format!("{session_id}.jsonl"));
362 let mut f = fs::File::create(&path).unwrap();
363 for (role, text) in messages {
364 let entry = serde_json::json!({
365 "type": role,
366 "message": {
367 "role": role,
368 "content": [{"type": "text", "text": text}]
369 }
370 });
371 writeln!(f, "{}", serde_json::to_string(&entry).unwrap()).unwrap();
372 }
373 path
374 }
375
376 #[test]
377 fn test_extract_conversation_basic() {
378 let dir = tempfile::tempdir().unwrap();
379 let path = write_session_jsonl(
380 dir.path(),
381 "test",
382 &[
383 ("user", "Fix the login bug"),
384 ("assistant", "I'll look into the auth module"),
385 ("user", "Also check the session handling"),
386 ],
387 );
388
389 let result = extract_conversation(&path).unwrap();
390 assert!(result.contains("User: Fix the login bug"));
391 assert!(result.contains("Assistant: I'll look into the auth module"));
392 assert!(result.contains("User: Also check the session handling"));
393 }
394
395 #[test]
396 fn test_extract_conversation_skips_non_text() {
397 let dir = tempfile::tempdir().unwrap();
398 let path = dir.path().join("test.jsonl");
399 let mut f = fs::File::create(&path).unwrap();
400
401 writeln!(
403 f,
404 r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"hello"}}]}}}}"#
405 )
406 .unwrap();
407 writeln!(f, r#"{{"type":"progress","data":{{"hook":"test"}}}}"#).unwrap();
409 writeln!(
411 f,
412 r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"tool_use","name":"Bash"}}]}}}}"#
413 )
414 .unwrap();
415
416 let result = extract_conversation(&path).unwrap();
417 assert!(result.contains("User: hello"));
418 assert!(!result.contains("progress"));
419 assert!(!result.contains("Bash"));
420 }
421
422 #[test]
423 fn test_extract_conversation_truncates_long_messages() {
424 let dir = tempfile::tempdir().unwrap();
425 let long_text = "a".repeat(500);
426 let path = write_session_jsonl(dir.path(), "test", &[("user", &long_text)]);
427
428 let result = extract_conversation(&path).unwrap();
429 assert!(result.chars().count() < 500);
431 assert!(result.ends_with('\u{2026}'));
432 }
433
434 #[test]
435 fn test_extract_conversation_empty_file() {
436 let dir = tempfile::tempdir().unwrap();
437 let path = dir.path().join("empty.jsonl");
438 fs::File::create(&path).unwrap();
439
440 assert!(extract_conversation(&path).is_none());
441 }
442
443 #[test]
444 fn test_extract_conversation_respects_budget() {
445 let dir = tempfile::tempdir().unwrap();
446 let msg = "x".repeat(MESSAGE_TRUNCATE - 10);
447 let messages: Vec<(&str, &str)> = (0..100).map(|_| ("user", msg.as_str())).collect();
448 let path = write_session_jsonl(dir.path(), "test", &messages);
449
450 let result = extract_conversation(&path).unwrap();
451 assert!(result.len() <= CONVERSATION_BUDGET + 200); }
453
454 #[test]
455 fn test_claude_project_dir() {
456 let dir = claude_project_dir("/Users/test/workspace/myproject");
457 assert!(
458 dir.to_str()
459 .unwrap()
460 .ends_with("/.claude/projects/-Users-test-workspace-myproject")
461 );
462 }
463
464 #[test]
465 fn test_find_session_id_from_events() {
466 let dir = tempfile::tempdir().unwrap();
467 let events_path = dir.path().join("abc-123.jsonl");
468 let mut f = fs::File::create(&events_path).unwrap();
469 writeln!(
470 f,
471 r#"{{"state":"working","cwd":"/tmp","pane_id":"%5","ts":1000}}"#
472 )
473 .unwrap();
474 writeln!(
475 f,
476 r#"{{"state":"idle","cwd":"/tmp","pane_id":"%5","ts":1001}}"#
477 )
478 .unwrap();
479
480 let content = fs::read_to_string(&events_path).unwrap();
482 let last_line = content
483 .lines()
484 .rev()
485 .find(|l| !l.trim().is_empty())
486 .unwrap();
487 let event: serde_json::Value = serde_json::from_str(last_line).unwrap();
488 assert_eq!(event.get("pane_id").and_then(|v| v.as_str()), Some("%5"));
489 }
490
491 use std::sync::Mutex;
497
498 fn mock_generator() -> (
500 impl Fn(&str, &str) -> Option<String> + Send + Sync + 'static,
501 Arc<Mutex<Vec<(String, String)>>>,
502 ) {
503 let calls: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
504 let calls_clone = Arc::clone(&calls);
505 let generator = move |cwd: &str, pane_id: &str| -> Option<String> {
506 calls_clone
507 .lock()
508 .unwrap()
509 .push((cwd.to_string(), pane_id.to_string()));
510 Some(format!("context for {pane_id}"))
511 };
512 (generator, calls)
513 }
514
515 fn win(index: u32, name: &str) -> WindowInfo {
516 WindowInfo {
517 index,
518 name: name.to_string(),
519 is_active: false,
520 pane_path: format!("/project/{name}"),
521 }
522 }
523
524 fn pane_ids(map: &HashMap<u32, String>) -> impl Fn(u32) -> Option<String> + '_ {
525 move |idx| map.get(&idx).cloned()
526 }
527
528 fn drain_with_wait(mgr: &mut ContextManager) {
530 thread::sleep(Duration::from_millis(50));
533 mgr.drain();
534 }
535
536 #[test]
541 fn test_new_session_no_context_action() {
542 let (generator, calls) = mock_generator();
543 let mut mgr = ContextManager::with_generator(generator);
544
545 let windows = vec![win(1, "session-1")];
546 let states: HashMap<u32, WindowState> = [(1, WindowState::Fresh)].into_iter().collect();
547 let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
548
549 mgr.tick(&windows, &states, 0, &pane_ids(&panes));
551
552 assert!(calls.lock().unwrap().is_empty());
554 assert!(!mgr.is_loading("session-1"));
555 assert!(mgr.get("session-1").is_none());
556 }
557
558 #[test]
563 fn test_switch_between_fresh_sessions_no_context_action() {
564 let (generator, calls) = mock_generator();
565 let mut mgr = ContextManager::with_generator(generator);
566
567 let windows = vec![win(1, "session-1"), win(2, "session-2")];
568 let states: HashMap<u32, WindowState> = [(1, WindowState::Fresh), (2, WindowState::Fresh)]
569 .into_iter()
570 .collect();
571 let panes: HashMap<u32, String> =
572 [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
573
574 mgr.tick(&windows, &states, 0, &pane_ids(&panes));
576 assert!(calls.lock().unwrap().is_empty());
577
578 mgr.tick(&windows, &states, 1, &pane_ids(&panes));
580
581 assert!(calls.lock().unwrap().is_empty());
583 assert!(!mgr.is_loading("session-1"));
584 assert!(!mgr.is_loading("session-2"));
585 assert!(mgr.get("session-1").is_none());
586 assert!(mgr.get("session-2").is_none());
587 }
588
589 #[test]
595 fn test_switch_from_active_to_fresh_fires_context_for_previous() {
596 let (generator, calls) = mock_generator();
597 let mut mgr = ContextManager::with_generator(generator);
598
599 let windows = vec![win(1, "active-session"), win(2, "new-session")];
600 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle), (2, WindowState::Fresh)]
601 .into_iter()
602 .collect();
603 let panes: HashMap<u32, String> =
604 [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
605
606 mgr.tick(&windows, &states, 0, &pane_ids(&panes));
608 drain_with_wait(&mut mgr);
609
610 let call_count_after_tick1 = calls.lock().unwrap().len();
612 assert!(
613 call_count_after_tick1 > 0,
614 "should request context for Idle session"
615 );
616 assert!(
617 calls.lock().unwrap().iter().any(|(_, pid)| pid == "%0"),
618 "should request for pane %0"
619 );
620
621 calls.lock().unwrap().clear();
623 mgr.tick(&windows, &states, 1, &pane_ids(&panes));
624 drain_with_wait(&mut mgr);
626
627 let tick2_calls = calls.lock().unwrap().clone();
630 assert!(
631 tick2_calls.iter().any(|(_, pid)| pid == "%0"),
632 "should refresh context for previous active session"
633 );
634 assert!(
635 !tick2_calls.iter().any(|(_, pid)| pid == "%1"),
636 "should NOT request context for Fresh new session"
637 );
638
639 assert!(!mgr.is_loading("new-session"));
641 assert!(mgr.get("new-session").is_none());
642 }
643
644 #[test]
649 fn test_switch_back_while_pending_shows_loading() {
650 let barrier = Arc::new(std::sync::Barrier::new(2));
652 let barrier_clone = Arc::clone(&barrier);
653 let slow_generator = move |_cwd: &str, _pane_id: &str| -> Option<String> {
654 barrier_clone.wait();
655 Some("generated context".to_string())
656 };
657 let mut mgr = ContextManager::with_generator(slow_generator);
658
659 let windows = vec![win(1, "active-session"), win(2, "new-session")];
660 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle), (2, WindowState::Fresh)]
661 .into_iter()
662 .collect();
663 let panes: HashMap<u32, String> =
664 [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
665
666 mgr.tick(&windows, &states, 0, &pane_ids(&panes));
668 assert!(
670 mgr.is_loading("active-session"),
671 "should be loading while generator is running"
672 );
673
674 mgr.tick(&windows, &states, 1, &pane_ids(&panes));
677
678 mgr.tick(&windows, &states, 0, &pane_ids(&panes));
680 assert!(
681 mgr.is_loading("active-session"),
682 "should still be loading when switching back"
683 );
684 assert!(
685 mgr.get("active-session").is_none(),
686 "context should not be available yet"
687 );
688
689 barrier.wait();
691 drain_with_wait(&mut mgr);
692
693 assert!(
695 !mgr.is_loading("active-session"),
696 "should not be loading after drain"
697 );
698 assert_eq!(
699 mgr.get("active-session"),
700 Some("generated context"),
701 "context should be populated after drain"
702 );
703 }
704}