1use anyhow::Result;
6use tabled::{settings::Style as TableStyle, Table, Tabled};
7
8use crate::models::Workspace;
9use crate::storage::{read_empty_window_sessions, VsCodeSessionFormat};
10use crate::workspace::discover_workspaces;
11
12#[derive(Tabled)]
13struct WorkspaceRow {
14 #[tabled(rename = "Hash")]
15 hash: String,
16 #[tabled(rename = "Project Path")]
17 project_path: String,
18 #[tabled(rename = "Sessions")]
19 sessions: usize,
20 #[tabled(rename = "Has Chats")]
21 has_chats: String,
22}
23
24#[derive(Tabled)]
25struct SessionRow {
26 #[tabled(rename = "Project Path")]
27 project_path: String,
28 #[tabled(rename = "Session File")]
29 session_file: String,
30 #[tabled(rename = "Last Modified")]
31 last_modified: String,
32 #[tabled(rename = "Messages")]
33 messages: usize,
34}
35
36#[derive(Tabled)]
37struct SessionRowWithSize {
38 #[tabled(rename = "Project Path")]
39 project_path: String,
40 #[tabled(rename = "Session File")]
41 session_file: String,
42 #[tabled(rename = "Last Modified")]
43 last_modified: String,
44 #[tabled(rename = "Messages")]
45 messages: usize,
46 #[tabled(rename = "Size")]
47 size: String,
48}
49
50pub fn list_workspaces() -> Result<()> {
52 let workspaces = discover_workspaces()?;
53
54 if workspaces.is_empty() {
55 println!("No workspaces found.");
56 return Ok(());
57 }
58
59 let rows: Vec<WorkspaceRow> = workspaces
60 .iter()
61 .map(|ws| WorkspaceRow {
62 hash: format!("{}...", &ws.hash[..12.min(ws.hash.len())]),
63 project_path: ws
64 .project_path
65 .clone()
66 .unwrap_or_else(|| "(none)".to_string()),
67 sessions: ws.chat_session_count,
68 has_chats: if ws.has_chat_sessions {
69 "Yes".to_string()
70 } else {
71 "No".to_string()
72 },
73 })
74 .collect();
75
76 let table = Table::new(rows)
77 .with(TableStyle::ascii_rounded())
78 .to_string();
79
80 println!("{}", table);
81 println!("\nTotal workspaces: {}", workspaces.len());
82
83 if let Ok(empty_count) = crate::storage::count_empty_window_sessions() {
85 if empty_count > 0 {
86 println!("Empty window sessions (ALL SESSIONS): {}", empty_count);
87 }
88 }
89
90 Ok(())
91}
92
93fn format_file_size(bytes: u64) -> String {
95 if bytes < 1024 {
96 format!("{} B", bytes)
97 } else if bytes < 1024 * 1024 {
98 format!("{:.1} KB", bytes as f64 / 1024.0)
99 } else if bytes < 1024 * 1024 * 1024 {
100 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
101 } else {
102 format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
103 }
104}
105
106pub fn list_sessions(project_path: Option<&str>, show_size: bool, provider: Option<&str>, all_providers: bool) -> Result<()> {
108 if provider.is_some() || all_providers {
110 return list_sessions_multi_provider(project_path, show_size, provider, all_providers);
111 }
112
113 let workspaces = discover_workspaces()?;
115
116 let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
117 let normalized = crate::workspace::normalize_path(path);
118 workspaces
119 .iter()
120 .filter(|ws| {
121 ws.project_path
122 .as_ref()
123 .map(|p| crate::workspace::normalize_path(p) == normalized)
124 .unwrap_or(false)
125 })
126 .collect()
127 } else {
128 workspaces.iter().collect()
129 };
130
131 if show_size {
132 let mut rows: Vec<SessionRowWithSize> = Vec::new();
133 let mut total_size: u64 = 0;
134
135 if project_path.is_none() {
137 if let Ok(empty_sessions) = read_empty_window_sessions() {
138 for session in empty_sessions {
139 let modified =
140 chrono::DateTime::from_timestamp_millis(session.last_message_date)
141 .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
142 .unwrap_or_else(|| "unknown".to_string());
143
144 let session_id = session.session_id.as_deref().unwrap_or("unknown");
145 rows.push(SessionRowWithSize {
146 project_path: "(ALL SESSIONS)".to_string(),
147 session_file: format!("{}.json", session_id),
148 last_modified: modified,
149 messages: session.request_count(),
150 size: "N/A".to_string(),
151 });
152 }
153 }
154 }
155
156 for ws in &filtered_workspaces {
157 if !ws.has_chat_sessions {
158 continue;
159 }
160
161 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
162
163 for session_with_path in sessions {
164 let metadata = session_with_path.path.metadata().ok();
165 let file_size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
166 total_size += file_size;
167
168 let modified = metadata
169 .and_then(|m| m.modified().ok())
170 .map(|t| {
171 let datetime: chrono::DateTime<chrono::Utc> = t.into();
172 datetime.format("%Y-%m-%d %H:%M").to_string()
173 })
174 .unwrap_or_else(|| "unknown".to_string());
175
176 rows.push(SessionRowWithSize {
177 project_path: ws
178 .project_path
179 .clone()
180 .unwrap_or_else(|| "(none)".to_string()),
181 session_file: session_with_path
182 .path
183 .file_name()
184 .map(|n| n.to_string_lossy().to_string())
185 .unwrap_or_else(|| "unknown".to_string()),
186 last_modified: modified,
187 messages: session_with_path.session.request_count(),
188 size: format_file_size(file_size),
189 });
190 }
191 }
192
193 if rows.is_empty() {
194 println!("No chat sessions found.");
195 return Ok(());
196 }
197
198 let table = Table::new(&rows)
199 .with(TableStyle::ascii_rounded())
200 .to_string();
201 println!("{}", table);
202 println!(
203 "\nTotal sessions: {} ({})",
204 rows.len(),
205 format_file_size(total_size)
206 );
207 } else {
208 let mut rows: Vec<SessionRow> = Vec::new();
209
210 if project_path.is_none() {
212 if let Ok(empty_sessions) = read_empty_window_sessions() {
213 for session in empty_sessions {
214 let modified =
215 chrono::DateTime::from_timestamp_millis(session.last_message_date)
216 .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
217 .unwrap_or_else(|| "unknown".to_string());
218
219 let session_id = session.session_id.as_deref().unwrap_or("unknown");
220 rows.push(SessionRow {
221 project_path: "(ALL SESSIONS)".to_string(),
222 session_file: format!("{}.json", session_id),
223 last_modified: modified,
224 messages: session.request_count(),
225 });
226 }
227 }
228 }
229
230 for ws in &filtered_workspaces {
231 if !ws.has_chat_sessions {
232 continue;
233 }
234
235 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
236
237 for session_with_path in sessions {
238 let modified = session_with_path
239 .path
240 .metadata()
241 .ok()
242 .and_then(|m| m.modified().ok())
243 .map(|t| {
244 let datetime: chrono::DateTime<chrono::Utc> = t.into();
245 datetime.format("%Y-%m-%d %H:%M").to_string()
246 })
247 .unwrap_or_else(|| "unknown".to_string());
248
249 rows.push(SessionRow {
250 project_path: ws
251 .project_path
252 .clone()
253 .unwrap_or_else(|| "(none)".to_string()),
254 session_file: session_with_path
255 .path
256 .file_name()
257 .map(|n| n.to_string_lossy().to_string())
258 .unwrap_or_else(|| "unknown".to_string()),
259 last_modified: modified,
260 messages: session_with_path.session.request_count(),
261 });
262 }
263 }
264
265 if rows.is_empty() {
266 println!("No chat sessions found.");
267 return Ok(());
268 }
269
270 let table = Table::new(&rows)
271 .with(TableStyle::ascii_rounded())
272 .to_string();
273 println!("{}", table);
274 println!("\nTotal sessions: {}", rows.len());
275 }
276
277 Ok(())
278}
279
280fn list_sessions_multi_provider(
282 project_path: Option<&str>,
283 show_size: bool,
284 provider: Option<&str>,
285 all_providers: bool,
286) -> Result<()> {
287 let storage_paths = if all_providers {
289 get_agent_storage_paths(Some("all"))?
290 } else if let Some(p) = provider {
291 get_agent_storage_paths(Some(p))?
292 } else {
293 get_agent_storage_paths(None)?
294 };
295
296 if storage_paths.is_empty() {
297 if let Some(p) = provider {
298 println!("No storage found for provider: {}", p);
299 } else {
300 println!("No workspaces found");
301 }
302 return Ok(());
303 }
304
305 let target_path = project_path.map(|p| crate::workspace::normalize_path(p));
306
307 #[derive(Tabled)]
308 struct SessionRowMulti {
309 #[tabled(rename = "Provider")]
310 provider: String,
311 #[tabled(rename = "Project Path")]
312 project_path: String,
313 #[tabled(rename = "Session File")]
314 session_file: String,
315 #[tabled(rename = "Modified")]
316 last_modified: String,
317 #[tabled(rename = "Msgs")]
318 messages: usize,
319 }
320
321 #[derive(Tabled)]
322 struct SessionRowMultiWithSize {
323 #[tabled(rename = "Provider")]
324 provider: String,
325 #[tabled(rename = "Project Path")]
326 project_path: String,
327 #[tabled(rename = "Session File")]
328 session_file: String,
329 #[tabled(rename = "Modified")]
330 last_modified: String,
331 #[tabled(rename = "Msgs")]
332 messages: usize,
333 #[tabled(rename = "Size")]
334 size: String,
335 }
336
337 let mut rows: Vec<SessionRowMulti> = Vec::new();
338 let mut rows_with_size: Vec<SessionRowMultiWithSize> = Vec::new();
339 let mut total_size: u64 = 0;
340
341 for (provider_name, storage_path) in &storage_paths {
342 if !storage_path.exists() {
343 continue;
344 }
345
346 for entry in std::fs::read_dir(storage_path)?.filter_map(|e| e.ok()) {
347 let workspace_dir = entry.path();
348 if !workspace_dir.is_dir() {
349 continue;
350 }
351
352 let chat_sessions_dir = workspace_dir.join("chatSessions");
353 if !chat_sessions_dir.exists() {
354 continue;
355 }
356
357 let workspace_json = workspace_dir.join("workspace.json");
359 let project = std::fs::read_to_string(&workspace_json)
360 .ok()
361 .and_then(|c| serde_json::from_str::<crate::models::WorkspaceJson>(&c).ok())
362 .and_then(|ws| {
363 ws.folder
364 .map(|f| crate::workspace::decode_workspace_folder(&f))
365 });
366
367 if let Some(ref target) = target_path {
369 if project
370 .as_ref()
371 .map(|p| crate::workspace::normalize_path(p) != *target)
372 .unwrap_or(true)
373 {
374 continue;
375 }
376 }
377
378 let project_display = project.clone().unwrap_or_else(|| "(none)".to_string());
379
380 for session_entry in std::fs::read_dir(&chat_sessions_dir)?.filter_map(|e| e.ok()) {
382 let session_path = session_entry.path();
383 if !session_path.is_file() {
384 continue;
385 }
386
387 let ext = session_path.extension().and_then(|e| e.to_str());
388 if ext != Some("json") && ext != Some("jsonl") {
389 continue;
390 }
391
392 let metadata = session_path.metadata().ok();
393 let file_size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
394 total_size += file_size;
395
396 let modified = metadata
397 .and_then(|m| m.modified().ok())
398 .map(|t| {
399 let datetime: chrono::DateTime<chrono::Utc> = t.into();
400 datetime.format("%Y-%m-%d %H:%M").to_string()
401 })
402 .unwrap_or_else(|| "unknown".to_string());
403
404 let session_file = session_path
405 .file_name()
406 .map(|n| n.to_string_lossy().to_string())
407 .unwrap_or_else(|| "unknown".to_string());
408
409 let messages = std::fs::read_to_string(&session_path)
411 .ok()
412 .map(|c| c.matches("\"message\":").count())
413 .unwrap_or(0);
414
415 if show_size {
416 rows_with_size.push(SessionRowMultiWithSize {
417 provider: provider_name.clone(),
418 project_path: truncate_string(&project_display, 30),
419 session_file: truncate_string(&session_file, 20),
420 last_modified: modified,
421 messages,
422 size: format_file_size(file_size),
423 });
424 } else {
425 rows.push(SessionRowMulti {
426 provider: provider_name.clone(),
427 project_path: truncate_string(&project_display, 30),
428 session_file: truncate_string(&session_file, 20),
429 last_modified: modified,
430 messages,
431 });
432 }
433 }
434 }
435 }
436
437 if show_size {
438 if rows_with_size.is_empty() {
439 println!("No chat sessions found.");
440 return Ok(());
441 }
442 let table = Table::new(&rows_with_size)
443 .with(TableStyle::ascii_rounded())
444 .to_string();
445 println!("{}", table);
446 println!(
447 "\nTotal sessions: {} ({})",
448 rows_with_size.len(),
449 format_file_size(total_size)
450 );
451 } else {
452 if rows.is_empty() {
453 println!("No chat sessions found.");
454 return Ok(());
455 }
456 let table = Table::new(&rows)
457 .with(TableStyle::ascii_rounded())
458 .to_string();
459 println!("{}", table);
460 println!("\nTotal sessions: {}", rows.len());
461 }
462
463 Ok(())
464}
465
466pub fn find_workspaces(pattern: &str) -> Result<()> {
468 let workspaces = discover_workspaces()?;
469
470 let pattern = if pattern == "." {
472 std::env::current_dir()
473 .ok()
474 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
475 .unwrap_or_else(|| pattern.to_string())
476 } else {
477 pattern.to_string()
478 };
479 let pattern_lower = pattern.to_lowercase();
480
481 let matching: Vec<&Workspace> = workspaces
482 .iter()
483 .filter(|ws| {
484 ws.project_path
485 .as_ref()
486 .map(|p| p.to_lowercase().contains(&pattern_lower))
487 .unwrap_or(false)
488 || ws.hash.to_lowercase().contains(&pattern_lower)
489 })
490 .collect();
491
492 if matching.is_empty() {
493 println!("No workspaces found matching '{}'", pattern);
494 return Ok(());
495 }
496
497 let rows: Vec<WorkspaceRow> = matching
498 .iter()
499 .map(|ws| WorkspaceRow {
500 hash: format!("{}...", &ws.hash[..12.min(ws.hash.len())]),
501 project_path: ws
502 .project_path
503 .clone()
504 .unwrap_or_else(|| "(none)".to_string()),
505 sessions: ws.chat_session_count,
506 has_chats: if ws.has_chat_sessions {
507 "Yes".to_string()
508 } else {
509 "No".to_string()
510 },
511 })
512 .collect();
513
514 let table = Table::new(rows)
515 .with(TableStyle::ascii_rounded())
516 .to_string();
517
518 println!("{}", table);
519 println!("\nFound {} matching workspace(s)", matching.len());
520
521 for ws in &matching {
523 if ws.has_chat_sessions {
524 let project = ws.project_path.as_deref().unwrap_or("(none)");
525 println!("\nSessions for {}:", project);
526
527 if let Ok(sessions) =
528 crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)
529 {
530 for session_with_path in sessions {
531 println!(" {}", session_with_path.path.display());
532 }
533 }
534 }
535 }
536
537 Ok(())
538}
539
540#[allow(dead_code)]
542pub fn find_sessions(pattern: &str, project_path: Option<&str>) -> Result<()> {
543 let workspaces = discover_workspaces()?;
544 let pattern_lower = pattern.to_lowercase();
545
546 let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
547 let normalized = crate::workspace::normalize_path(path);
548 workspaces
549 .iter()
550 .filter(|ws| {
551 ws.project_path
552 .as_ref()
553 .map(|p| crate::workspace::normalize_path(p) == normalized)
554 .unwrap_or(false)
555 })
556 .collect()
557 } else {
558 workspaces.iter().collect()
559 };
560
561 let mut rows: Vec<SessionRow> = Vec::new();
562
563 for ws in filtered_workspaces {
564 if !ws.has_chat_sessions {
565 continue;
566 }
567
568 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
569
570 for session_with_path in sessions {
571 let session_id_matches = session_with_path
573 .session
574 .session_id
575 .as_ref()
576 .map(|id| id.to_lowercase().contains(&pattern_lower))
577 .unwrap_or(false);
578 let title_matches = session_with_path
579 .session
580 .title()
581 .to_lowercase()
582 .contains(&pattern_lower);
583 let content_matches = session_with_path.session.requests.iter().any(|r| {
584 r.message
585 .as_ref()
586 .map(|m| {
587 m.text
588 .as_ref()
589 .map(|t| t.to_lowercase().contains(&pattern_lower))
590 .unwrap_or(false)
591 })
592 .unwrap_or(false)
593 });
594
595 if !session_id_matches && !title_matches && !content_matches {
596 continue;
597 }
598
599 let modified = session_with_path
600 .path
601 .metadata()
602 .ok()
603 .and_then(|m| m.modified().ok())
604 .map(|t| {
605 let datetime: chrono::DateTime<chrono::Utc> = t.into();
606 datetime.format("%Y-%m-%d %H:%M").to_string()
607 })
608 .unwrap_or_else(|| "unknown".to_string());
609
610 rows.push(SessionRow {
611 project_path: ws
612 .project_path
613 .clone()
614 .unwrap_or_else(|| "(none)".to_string()),
615 session_file: session_with_path
616 .path
617 .file_name()
618 .map(|n| n.to_string_lossy().to_string())
619 .unwrap_or_else(|| "unknown".to_string()),
620 last_modified: modified,
621 messages: session_with_path.session.request_count(),
622 });
623 }
624 }
625
626 if rows.is_empty() {
627 println!("No sessions found matching '{}'", pattern);
628 return Ok(());
629 }
630
631 let table = Table::new(&rows)
632 .with(TableStyle::ascii_rounded())
633 .to_string();
634
635 println!("{}", table);
636 println!("\nFound {} matching session(s)", rows.len());
637
638 Ok(())
639}
640
641pub fn find_sessions_filtered(
650 pattern: &str,
651 workspace_filter: Option<&str>,
652 title_only: bool,
653 search_content: bool,
654 after: Option<&str>,
655 before: Option<&str>,
656 date: Option<&str>,
657 all_workspaces: bool,
658 provider: Option<&str>,
659 all_providers: bool,
660 limit: usize,
661) -> Result<()> {
662 use chrono::{NaiveDate, Utc};
663 use rayon::prelude::*;
664 use std::sync::atomic::{AtomicUsize, Ordering};
665
666 let pattern_lower = pattern.to_lowercase();
667
668 let after_date = after.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
670 let before_date = before.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
671 let target_date = date.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
672
673 let storage_paths = if all_providers {
675 get_agent_storage_paths(Some("all"))?
676 } else if let Some(p) = provider {
677 get_agent_storage_paths(Some(p))?
678 } else {
679 let vscode_path = crate::workspace::get_workspace_storage_path()?;
681 if vscode_path.exists() {
682 vec![("vscode".to_string(), vscode_path)]
683 } else {
684 vec![]
685 }
686 };
687
688 if storage_paths.is_empty() {
689 if let Some(p) = provider {
690 println!("No storage found for provider: {}", p);
691 } else {
692 println!("No workspaces found");
693 }
694 return Ok(());
695 }
696
697 let ws_filter_lower = if all_workspaces {
700 None
701 } else {
702 workspace_filter.map(|s| s.to_lowercase())
703 };
704
705 let workspace_dirs: Vec<_> = storage_paths
706 .iter()
707 .flat_map(|(provider_name, storage_path)| {
708 if !storage_path.exists() {
709 return vec![];
710 }
711 std::fs::read_dir(storage_path)
712 .into_iter()
713 .flatten()
714 .filter_map(|e| e.ok())
715 .filter(|e| e.path().is_dir())
716 .filter_map(|entry| {
717 let workspace_dir = entry.path();
718 let workspace_json_path = workspace_dir.join("workspace.json");
719
720 let chat_sessions_dir = workspace_dir.join("chatSessions");
722 if !chat_sessions_dir.exists() {
723 return None;
724 }
725
726 let project_path =
728 std::fs::read_to_string(&workspace_json_path)
729 .ok()
730 .and_then(|content| {
731 serde_json::from_str::<crate::models::WorkspaceJson>(&content)
732 .ok()
733 .and_then(|ws| {
734 ws.folder
735 .map(|f| crate::workspace::decode_workspace_folder(&f))
736 })
737 });
738
739 if let Some(ref filter) = ws_filter_lower {
741 let hash = entry.file_name().to_string_lossy().to_lowercase();
742 let path_matches = project_path
743 .as_ref()
744 .map(|p| p.to_lowercase().contains(filter))
745 .unwrap_or(false);
746 if !hash.contains(filter) && !path_matches {
747 return None;
748 }
749 }
750
751 let ws_name = project_path
752 .as_ref()
753 .and_then(|p| std::path::Path::new(p).file_name())
754 .map(|n| n.to_string_lossy().to_string())
755 .unwrap_or_else(|| {
756 entry.file_name().to_string_lossy()[..8.min(entry.file_name().len())]
757 .to_string()
758 });
759
760 Some((chat_sessions_dir, ws_name, provider_name.clone()))
761 })
762 .collect::<Vec<_>>()
763 })
764 .collect();
765
766 if workspace_dirs.is_empty() {
767 if let Some(ws) = workspace_filter {
768 println!("No workspaces found matching '{}'", ws);
769 } else {
770 println!("No workspaces with chat sessions found");
771 }
772 return Ok(());
773 }
774
775 let session_files: Vec<_> = workspace_dirs
777 .iter()
778 .flat_map(|(chat_dir, ws_name, provider_name)| {
779 std::fs::read_dir(chat_dir)
780 .into_iter()
781 .flatten()
782 .filter_map(|e| e.ok())
783 .filter(|e| {
784 e.path()
785 .extension()
786 .map(|ext| ext == "json" || ext == "jsonl")
787 .unwrap_or(false)
788 })
789 .map(|e| (e.path(), ws_name.clone(), provider_name.clone()))
790 .collect::<Vec<_>>()
791 })
792 .collect();
793
794 let total_files = session_files.len();
795 let scanned = AtomicUsize::new(0);
796 let skipped_by_date = AtomicUsize::new(0);
797
798 let mut results: Vec<_> = session_files
800 .par_iter()
801 .filter_map(|(path, ws_name, provider_name)| {
802 if after_date.is_some() || before_date.is_some() {
804 if let Ok(metadata) = path.metadata() {
805 if let Ok(modified) = metadata.modified() {
806 let file_date: chrono::DateTime<Utc> = modified.into();
807 let file_naive = file_date.date_naive();
808
809 if let Some(after) = after_date {
810 if file_naive < after {
811 skipped_by_date.fetch_add(1, Ordering::Relaxed);
812 return None;
813 }
814 }
815 if let Some(before) = before_date {
816 if file_naive > before {
817 skipped_by_date.fetch_add(1, Ordering::Relaxed);
818 return None;
819 }
820 }
821 }
822 }
823 }
824
825 scanned.fetch_add(1, Ordering::Relaxed);
826
827 let content = match std::fs::read_to_string(path) {
829 Ok(c) => c,
830 Err(_) => return None,
831 };
832
833 if let Some(target) = target_date {
835 let has_matching_timestamp = content
838 .split("\"timestamp\":")
839 .skip(1) .any(|part| {
841 let num_str: String = part
843 .chars()
844 .skip_while(|c| c.is_whitespace())
845 .take_while(|c| c.is_ascii_digit())
846 .collect();
847 if let Ok(ts_ms) = num_str.parse::<i64>() {
848 if let Some(dt) = chrono::DateTime::from_timestamp_millis(ts_ms) {
849 return dt.date_naive() == target;
850 }
851 }
852 false
853 });
854
855 if !has_matching_timestamp {
856 skipped_by_date.fetch_add(1, Ordering::Relaxed);
857 return None;
858 }
859 }
860
861 let title =
863 extract_title_from_content(&content).unwrap_or_else(|| "Untitled".to_string());
864 let title_lower = title.to_lowercase();
865
866 let session_id = path
868 .file_stem()
869 .map(|n| n.to_string_lossy().to_string())
870 .unwrap_or_default();
871 let id_matches =
872 !pattern_lower.is_empty() && session_id.to_lowercase().contains(&pattern_lower);
873
874 let title_matches = !pattern_lower.is_empty() && title_lower.contains(&pattern_lower);
876
877 let content_matches = if search_content
879 && !title_only
880 && !id_matches
881 && !title_matches
882 && !pattern_lower.is_empty()
883 {
884 content.to_lowercase().contains(&pattern_lower)
885 } else {
886 false
887 };
888
889 let matches =
891 pattern_lower.is_empty() || id_matches || title_matches || content_matches;
892 if !matches {
893 return None;
894 }
895
896 let match_type = if pattern_lower.is_empty() {
897 ""
898 } else if id_matches {
899 "ID"
900 } else if title_matches {
901 "title"
902 } else {
903 "content"
904 };
905
906 let message_count = content.matches("\"message\":").count();
908
909 let modified = path
911 .metadata()
912 .ok()
913 .and_then(|m| m.modified().ok())
914 .map(|t| {
915 let datetime: chrono::DateTime<chrono::Utc> = t.into();
916 datetime.format("%Y-%m-%d %H:%M").to_string()
917 })
918 .unwrap_or_else(|| "unknown".to_string());
919
920 Some((
921 title,
922 ws_name.clone(),
923 provider_name.clone(),
924 modified,
925 message_count,
926 match_type.to_string(),
927 ))
928 })
929 .collect();
930
931 let scanned_count = scanned.load(Ordering::Relaxed);
932 let skipped_count = skipped_by_date.load(Ordering::Relaxed);
933
934 if results.is_empty() {
935 println!("No sessions found matching '{}'", pattern);
936 if skipped_count > 0 {
937 println!(" ({} sessions skipped due to date filter)", skipped_count);
938 }
939 return Ok(());
940 }
941
942 results.sort_by(|a, b| b.3.cmp(&a.3));
944
945 results.truncate(limit);
947
948 let show_provider_column = all_providers || storage_paths.len() > 1;
950
951 #[derive(Tabled)]
952 struct SearchResultRow {
953 #[tabled(rename = "Title")]
954 title: String,
955 #[tabled(rename = "Workspace")]
956 workspace: String,
957 #[tabled(rename = "Modified")]
958 modified: String,
959 #[tabled(rename = "Msgs")]
960 messages: usize,
961 #[tabled(rename = "Match")]
962 match_type: String,
963 }
964
965 #[derive(Tabled)]
966 struct SearchResultRowWithProvider {
967 #[tabled(rename = "Provider")]
968 provider: String,
969 #[tabled(rename = "Title")]
970 title: String,
971 #[tabled(rename = "Workspace")]
972 workspace: String,
973 #[tabled(rename = "Modified")]
974 modified: String,
975 #[tabled(rename = "Msgs")]
976 messages: usize,
977 #[tabled(rename = "Match")]
978 match_type: String,
979 }
980
981 if show_provider_column {
982 let rows: Vec<SearchResultRowWithProvider> = results
983 .into_iter()
984 .map(
985 |(title, workspace, provider, modified, messages, match_type)| {
986 SearchResultRowWithProvider {
987 provider,
988 title: truncate_string(&title, 35),
989 workspace: truncate_string(&workspace, 15),
990 modified,
991 messages,
992 match_type,
993 }
994 },
995 )
996 .collect();
997
998 let table = Table::new(&rows)
999 .with(TableStyle::ascii_rounded())
1000 .to_string();
1001
1002 println!("{}", table);
1003 println!(
1004 "\nFound {} session(s) (scanned {} of {} files{})",
1005 rows.len(),
1006 scanned_count,
1007 total_files,
1008 if skipped_count > 0 {
1009 format!(", {} skipped by date", skipped_count)
1010 } else {
1011 String::new()
1012 }
1013 );
1014 if rows.len() >= limit {
1015 println!(" (results limited to {}; use --limit to show more)", limit);
1016 }
1017 } else {
1018 let rows: Vec<SearchResultRow> = results
1019 .into_iter()
1020 .map(
1021 |(title, workspace, _provider, modified, messages, match_type)| SearchResultRow {
1022 title: truncate_string(&title, 40),
1023 workspace: truncate_string(&workspace, 20),
1024 modified,
1025 messages,
1026 match_type,
1027 },
1028 )
1029 .collect();
1030
1031 let table = Table::new(&rows)
1032 .with(TableStyle::ascii_rounded())
1033 .to_string();
1034
1035 println!("{}", table);
1036 println!(
1037 "\nFound {} session(s) (scanned {} of {} files{})",
1038 rows.len(),
1039 scanned_count,
1040 total_files,
1041 if skipped_count > 0 {
1042 format!(", {} skipped by date", skipped_count)
1043 } else {
1044 String::new()
1045 }
1046 );
1047 if rows.len() >= limit {
1048 println!(" (results limited to {}; use --limit to show more)", limit);
1049 }
1050 }
1051
1052 Ok(())
1053}
1054
1055fn extract_title_from_content(content: &str) -> Option<String> {
1057 if let Some(start) = content.find("\"customTitle\"") {
1059 if let Some(colon) = content[start..].find(':') {
1060 let after_colon = &content[start + colon + 1..];
1061 let trimmed = after_colon.trim_start();
1062 if let Some(stripped) = trimmed.strip_prefix('"') {
1063 if let Some(end) = stripped.find('"') {
1064 let title = &stripped[..end];
1065 if !title.is_empty() && title != "null" {
1066 return Some(title.to_string());
1067 }
1068 }
1069 }
1070 }
1071 }
1072
1073 if let Some(start) = content.find("\"text\"") {
1075 if let Some(colon) = content[start..].find(':') {
1076 let after_colon = &content[start + colon + 1..];
1077 let trimmed = after_colon.trim_start();
1078 if let Some(stripped) = trimmed.strip_prefix('"') {
1079 if let Some(end) = stripped.find('"') {
1080 let title = &stripped[..end];
1081 if !title.is_empty() && title.len() < 100 {
1082 return Some(title.to_string());
1083 }
1084 }
1085 }
1086 }
1087 }
1088
1089 None
1090}
1091
1092#[allow(dead_code)]
1094fn extract_title_fast(header: &str) -> Option<String> {
1095 extract_title_from_content(header)
1096}
1097
1098fn truncate_string(s: &str, max_len: usize) -> String {
1100 if s.len() <= max_len {
1101 s.to_string()
1102 } else {
1103 format!("{}...", &s[..max_len.saturating_sub(3)])
1104 }
1105}
1106
1107pub fn show_workspace(workspace: &str) -> Result<()> {
1109 use colored::Colorize;
1110
1111 let workspaces = discover_workspaces()?;
1112 let workspace_lower = workspace.to_lowercase();
1113
1114 let matching: Vec<&Workspace> = workspaces
1116 .iter()
1117 .filter(|ws| {
1118 ws.hash.to_lowercase().contains(&workspace_lower)
1119 || ws
1120 .project_path
1121 .as_ref()
1122 .map(|p| p.to_lowercase().contains(&workspace_lower))
1123 .unwrap_or(false)
1124 })
1125 .collect();
1126
1127 if matching.is_empty() {
1128 println!(
1129 "{} No workspace found matching '{}'",
1130 "!".yellow(),
1131 workspace
1132 );
1133 return Ok(());
1134 }
1135
1136 for ws in matching {
1137 println!("\n{}", "=".repeat(60).bright_blue());
1138 println!("{}", "Workspace Details".bright_blue().bold());
1139 println!("{}", "=".repeat(60).bright_blue());
1140
1141 println!("{}: {}", "Hash".bright_white().bold(), ws.hash);
1142 println!(
1143 "{}: {}",
1144 "Path".bright_white().bold(),
1145 ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
1146 );
1147 println!(
1148 "{}: {}",
1149 "Has Sessions".bright_white().bold(),
1150 if ws.has_chat_sessions {
1151 "Yes".green()
1152 } else {
1153 "No".red()
1154 }
1155 );
1156 println!(
1157 "{}: {}",
1158 "Workspace Path".bright_white().bold(),
1159 ws.workspace_path.display()
1160 );
1161
1162 if ws.has_chat_sessions {
1163 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
1164 println!(
1165 "{}: {}",
1166 "Session Count".bright_white().bold(),
1167 sessions.len()
1168 );
1169
1170 if !sessions.is_empty() {
1171 println!("\n{}", "Sessions:".bright_yellow());
1172 for (i, s) in sessions.iter().enumerate() {
1173 let title = s.session.title();
1174 let msg_count = s.session.request_count();
1175 println!(
1176 " {}. {} ({} messages)",
1177 i + 1,
1178 title.bright_cyan(),
1179 msg_count
1180 );
1181 }
1182 }
1183 }
1184 }
1185
1186 Ok(())
1187}
1188
1189pub fn show_session(session_id: &str, project_path: Option<&str>) -> Result<()> {
1191 use colored::Colorize;
1192
1193 let workspaces = discover_workspaces()?;
1194 let session_id_lower = session_id.to_lowercase();
1195
1196 let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
1197 let normalized = crate::workspace::normalize_path(path);
1198 workspaces
1199 .iter()
1200 .filter(|ws| {
1201 ws.project_path
1202 .as_ref()
1203 .map(|p| crate::workspace::normalize_path(p) == normalized)
1204 .unwrap_or(false)
1205 })
1206 .collect()
1207 } else {
1208 workspaces.iter().collect()
1209 };
1210
1211 for ws in filtered_workspaces {
1212 if !ws.has_chat_sessions {
1213 continue;
1214 }
1215
1216 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
1217
1218 for s in sessions {
1219 let filename = s
1220 .path
1221 .file_name()
1222 .map(|n| n.to_string_lossy().to_string())
1223 .unwrap_or_default();
1224
1225 let matches = s
1226 .session
1227 .session_id
1228 .as_ref()
1229 .map(|id| id.to_lowercase().contains(&session_id_lower))
1230 .unwrap_or(false)
1231 || filename.to_lowercase().contains(&session_id_lower);
1232
1233 if matches {
1234 let format = VsCodeSessionFormat::from_path(&s.path);
1236
1237 println!("\n{}", "=".repeat(60).bright_blue());
1238 println!("{}", "Session Details".bright_blue().bold());
1239 println!("{}", "=".repeat(60).bright_blue());
1240
1241 println!(
1242 "{}: {}",
1243 "Title".bright_white().bold(),
1244 s.session.title().bright_cyan()
1245 );
1246 println!("{}: {}", "File".bright_white().bold(), filename);
1247 println!(
1248 "{}: {}",
1249 "Format".bright_white().bold(),
1250 format.to_string().bright_magenta()
1251 );
1252 println!(
1253 "{}: {}",
1254 "Session ID".bright_white().bold(),
1255 s.session
1256 .session_id
1257 .as_ref()
1258 .unwrap_or(&"(none)".to_string())
1259 );
1260 println!(
1261 "{}: {}",
1262 "Messages".bright_white().bold(),
1263 s.session.request_count()
1264 );
1265 println!(
1266 "{}: {}",
1267 "Workspace".bright_white().bold(),
1268 ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
1269 );
1270
1271 println!("\n{}", "Preview:".bright_yellow());
1273 for (i, req) in s.session.requests.iter().take(3).enumerate() {
1274 if let Some(msg) = &req.message {
1275 if let Some(text) = &msg.text {
1276 let preview: String = text.chars().take(100).collect();
1277 let truncated = if text.len() > 100 { "..." } else { "" };
1278 println!(" {}. {}{}", i + 1, preview.dimmed(), truncated);
1279 }
1280 }
1281 }
1282
1283 return Ok(());
1284 }
1285 }
1286 }
1287
1288 println!(
1289 "{} No session found matching '{}'",
1290 "!".yellow(),
1291 session_id
1292 );
1293 Ok(())
1294}
1295
1296fn get_agent_storage_paths(provider: Option<&str>) -> Result<Vec<(String, std::path::PathBuf)>> {
1299 let mut paths = Vec::new();
1300
1301 let vscode_path = crate::workspace::get_workspace_storage_path()?;
1303
1304 let cursor_path = get_cursor_storage_path();
1306 let claudecode_path = get_claudecode_storage_path();
1307 let opencode_path = get_opencode_storage_path();
1308 let openclaw_path = get_openclaw_storage_path();
1309 let antigravity_path = get_antigravity_storage_path();
1310
1311 match provider {
1312 None => {
1313 if vscode_path.exists() {
1315 paths.push(("vscode".to_string(), vscode_path));
1316 }
1317 }
1318 Some("all") => {
1319 if vscode_path.exists() {
1321 paths.push(("vscode".to_string(), vscode_path));
1322 }
1323 if let Some(cp) = cursor_path {
1324 if cp.exists() {
1325 paths.push(("cursor".to_string(), cp));
1326 }
1327 }
1328 if let Some(cc) = claudecode_path {
1329 if cc.exists() {
1330 paths.push(("claudecode".to_string(), cc));
1331 }
1332 }
1333 if let Some(oc) = opencode_path {
1334 if oc.exists() {
1335 paths.push(("opencode".to_string(), oc));
1336 }
1337 }
1338 if let Some(ocl) = openclaw_path {
1339 if ocl.exists() {
1340 paths.push(("openclaw".to_string(), ocl));
1341 }
1342 }
1343 if let Some(ag) = antigravity_path {
1344 if ag.exists() {
1345 paths.push(("antigravity".to_string(), ag));
1346 }
1347 }
1348 }
1349 Some(p) => {
1350 let p_lower = p.to_lowercase();
1351 match p_lower.as_str() {
1352 "vscode" | "vs-code" | "copilot" => {
1353 if vscode_path.exists() {
1354 paths.push(("vscode".to_string(), vscode_path));
1355 }
1356 }
1357 "cursor" => {
1358 if let Some(cp) = cursor_path {
1359 if cp.exists() {
1360 paths.push(("cursor".to_string(), cp));
1361 }
1362 }
1363 }
1364 "claudecode" | "claude-code" | "claude" => {
1365 if let Some(cc) = claudecode_path {
1366 if cc.exists() {
1367 paths.push(("claudecode".to_string(), cc));
1368 }
1369 }
1370 }
1371 "opencode" | "open-code" => {
1372 if let Some(oc) = opencode_path {
1373 if oc.exists() {
1374 paths.push(("opencode".to_string(), oc));
1375 }
1376 }
1377 }
1378 "openclaw" | "open-claw" | "claw" => {
1379 if let Some(ocl) = openclaw_path {
1380 if ocl.exists() {
1381 paths.push(("openclaw".to_string(), ocl));
1382 }
1383 }
1384 }
1385 "antigravity" | "anti-gravity" | "ag" => {
1386 if let Some(ag) = antigravity_path {
1387 if ag.exists() {
1388 paths.push(("antigravity".to_string(), ag));
1389 }
1390 }
1391 }
1392 _ => {
1393 }
1395 }
1396 }
1397 }
1398
1399 Ok(paths)
1400}
1401
1402fn get_cursor_storage_path() -> Option<std::path::PathBuf> {
1404 #[cfg(target_os = "windows")]
1405 {
1406 if let Some(appdata) = dirs::data_dir() {
1407 let cursor_path = appdata.join("Cursor").join("User").join("workspaceStorage");
1408 if cursor_path.exists() {
1409 return Some(cursor_path);
1410 }
1411 }
1412 if let Ok(roaming) = std::env::var("APPDATA") {
1413 let roaming_path = std::path::PathBuf::from(roaming)
1414 .join("Cursor")
1415 .join("User")
1416 .join("workspaceStorage");
1417 if roaming_path.exists() {
1418 return Some(roaming_path);
1419 }
1420 }
1421 }
1422
1423 #[cfg(target_os = "macos")]
1424 {
1425 if let Some(home) = dirs::home_dir() {
1426 let cursor_path = home
1427 .join("Library")
1428 .join("Application Support")
1429 .join("Cursor")
1430 .join("User")
1431 .join("workspaceStorage");
1432 if cursor_path.exists() {
1433 return Some(cursor_path);
1434 }
1435 }
1436 }
1437
1438 #[cfg(target_os = "linux")]
1439 {
1440 if let Some(config) = dirs::config_dir() {
1441 let cursor_path = config.join("Cursor").join("User").join("workspaceStorage");
1442 if cursor_path.exists() {
1443 return Some(cursor_path);
1444 }
1445 }
1446 }
1447
1448 None
1449}
1450
1451fn get_claudecode_storage_path() -> Option<std::path::PathBuf> {
1453 #[cfg(target_os = "windows")]
1454 {
1455 if let Ok(appdata) = std::env::var("APPDATA") {
1457 let claude_path = std::path::PathBuf::from(&appdata)
1458 .join("claude-code")
1459 .join("sessions");
1460 if claude_path.exists() {
1461 return Some(claude_path);
1462 }
1463 let alt_path = std::path::PathBuf::from(&appdata)
1465 .join("ClaudeCode")
1466 .join("workspaceStorage");
1467 if alt_path.exists() {
1468 return Some(alt_path);
1469 }
1470 }
1471 if let Some(local) = dirs::data_local_dir() {
1472 let local_path = local.join("ClaudeCode").join("sessions");
1473 if local_path.exists() {
1474 return Some(local_path);
1475 }
1476 }
1477 }
1478
1479 #[cfg(target_os = "macos")]
1480 {
1481 if let Some(home) = dirs::home_dir() {
1482 let claude_path = home
1483 .join("Library")
1484 .join("Application Support")
1485 .join("claude-code")
1486 .join("sessions");
1487 if claude_path.exists() {
1488 return Some(claude_path);
1489 }
1490 }
1491 }
1492
1493 #[cfg(target_os = "linux")]
1494 {
1495 if let Some(config) = dirs::config_dir() {
1496 let claude_path = config.join("claude-code").join("sessions");
1497 if claude_path.exists() {
1498 return Some(claude_path);
1499 }
1500 }
1501 }
1502
1503 None
1504}
1505
1506fn get_opencode_storage_path() -> Option<std::path::PathBuf> {
1508 #[cfg(target_os = "windows")]
1509 {
1510 if let Ok(appdata) = std::env::var("APPDATA") {
1511 let opencode_path = std::path::PathBuf::from(&appdata)
1512 .join("OpenCode")
1513 .join("workspaceStorage");
1514 if opencode_path.exists() {
1515 return Some(opencode_path);
1516 }
1517 }
1518 if let Some(local) = dirs::data_local_dir() {
1519 let local_path = local.join("OpenCode").join("sessions");
1520 if local_path.exists() {
1521 return Some(local_path);
1522 }
1523 }
1524 }
1525
1526 #[cfg(target_os = "macos")]
1527 {
1528 if let Some(home) = dirs::home_dir() {
1529 let opencode_path = home
1530 .join("Library")
1531 .join("Application Support")
1532 .join("OpenCode")
1533 .join("workspaceStorage");
1534 if opencode_path.exists() {
1535 return Some(opencode_path);
1536 }
1537 }
1538 }
1539
1540 #[cfg(target_os = "linux")]
1541 {
1542 if let Some(config) = dirs::config_dir() {
1543 let opencode_path = config.join("opencode").join("workspaceStorage");
1544 if opencode_path.exists() {
1545 return Some(opencode_path);
1546 }
1547 }
1548 }
1549
1550 None
1551}
1552
1553fn get_openclaw_storage_path() -> Option<std::path::PathBuf> {
1555 #[cfg(target_os = "windows")]
1556 {
1557 if let Ok(appdata) = std::env::var("APPDATA") {
1558 let openclaw_path = std::path::PathBuf::from(&appdata)
1559 .join("OpenClaw")
1560 .join("workspaceStorage");
1561 if openclaw_path.exists() {
1562 return Some(openclaw_path);
1563 }
1564 }
1565 if let Some(local) = dirs::data_local_dir() {
1566 let local_path = local.join("OpenClaw").join("sessions");
1567 if local_path.exists() {
1568 return Some(local_path);
1569 }
1570 }
1571 }
1572
1573 #[cfg(target_os = "macos")]
1574 {
1575 if let Some(home) = dirs::home_dir() {
1576 let openclaw_path = home
1577 .join("Library")
1578 .join("Application Support")
1579 .join("OpenClaw")
1580 .join("workspaceStorage");
1581 if openclaw_path.exists() {
1582 return Some(openclaw_path);
1583 }
1584 }
1585 }
1586
1587 #[cfg(target_os = "linux")]
1588 {
1589 if let Some(config) = dirs::config_dir() {
1590 let openclaw_path = config.join("openclaw").join("workspaceStorage");
1591 if openclaw_path.exists() {
1592 return Some(openclaw_path);
1593 }
1594 }
1595 }
1596
1597 None
1598}
1599
1600fn get_antigravity_storage_path() -> Option<std::path::PathBuf> {
1602 #[cfg(target_os = "windows")]
1603 {
1604 if let Ok(appdata) = std::env::var("APPDATA") {
1605 let antigrav_path = std::path::PathBuf::from(&appdata)
1606 .join("Antigravity")
1607 .join("workspaceStorage");
1608 if antigrav_path.exists() {
1609 return Some(antigrav_path);
1610 }
1611 }
1612 if let Some(local) = dirs::data_local_dir() {
1613 let local_path = local.join("Antigravity").join("sessions");
1614 if local_path.exists() {
1615 return Some(local_path);
1616 }
1617 }
1618 }
1619
1620 #[cfg(target_os = "macos")]
1621 {
1622 if let Some(home) = dirs::home_dir() {
1623 let antigrav_path = home
1624 .join("Library")
1625 .join("Application Support")
1626 .join("Antigravity")
1627 .join("workspaceStorage");
1628 if antigrav_path.exists() {
1629 return Some(antigrav_path);
1630 }
1631 }
1632 }
1633
1634 #[cfg(target_os = "linux")]
1635 {
1636 if let Some(config) = dirs::config_dir() {
1637 let antigrav_path = config.join("antigravity").join("workspaceStorage");
1638 if antigrav_path.exists() {
1639 return Some(antigrav_path);
1640 }
1641 }
1642 }
1643
1644 None
1645}
1646
1647pub fn list_agents_sessions(
1649 project_path: Option<&str>,
1650 show_size: bool,
1651 provider: Option<&str>,
1652) -> Result<()> {
1653 use colored::*;
1654
1655 let storage_paths = get_agent_storage_paths(provider)?;
1657
1658 if storage_paths.is_empty() {
1659 if let Some(p) = provider {
1660 println!("No storage found for provider: {}", p);
1661 println!("\nSupported providers: vscode, cursor, claudecode, opencode, openclaw, antigravity");
1662 } else {
1663 println!("No workspaces found");
1664 }
1665 return Ok(());
1666 }
1667
1668 #[derive(Tabled)]
1669 struct AgentSessionRow {
1670 #[tabled(rename = "Provider")]
1671 provider: String,
1672 #[tabled(rename = "Project")]
1673 project: String,
1674 #[tabled(rename = "Session ID")]
1675 session_id: String,
1676 #[tabled(rename = "Last Modified")]
1677 last_modified: String,
1678 #[tabled(rename = "Files")]
1679 file_count: usize,
1680 }
1681
1682 #[derive(Tabled)]
1683 struct AgentSessionRowWithSize {
1684 #[tabled(rename = "Provider")]
1685 provider: String,
1686 #[tabled(rename = "Project")]
1687 project: String,
1688 #[tabled(rename = "Session ID")]
1689 session_id: String,
1690 #[tabled(rename = "Last Modified")]
1691 last_modified: String,
1692 #[tabled(rename = "Files")]
1693 file_count: usize,
1694 #[tabled(rename = "Size")]
1695 size: String,
1696 }
1697
1698 let target_path = project_path.map(|p| crate::workspace::normalize_path(p));
1699 let mut total_size: u64 = 0;
1700 let mut rows_with_size: Vec<AgentSessionRowWithSize> = Vec::new();
1701 let mut rows: Vec<AgentSessionRow> = Vec::new();
1702
1703 for (provider_name, storage_path) in &storage_paths {
1704 if !storage_path.exists() {
1705 continue;
1706 }
1707
1708 for entry in std::fs::read_dir(storage_path)?.filter_map(|e| e.ok()) {
1709 let workspace_dir = entry.path();
1710 if !workspace_dir.is_dir() {
1711 continue;
1712 }
1713
1714 let agent_sessions_dir = workspace_dir.join("chatEditingSessions");
1715 if !agent_sessions_dir.exists() {
1716 continue;
1717 }
1718
1719 let workspace_json = workspace_dir.join("workspace.json");
1721 let project = std::fs::read_to_string(&workspace_json)
1722 .ok()
1723 .and_then(|c| serde_json::from_str::<crate::models::WorkspaceJson>(&c).ok())
1724 .and_then(|ws| {
1725 ws.folder
1726 .map(|f| crate::workspace::decode_workspace_folder(&f))
1727 });
1728
1729 if let Some(ref target) = target_path {
1731 if project
1732 .as_ref()
1733 .map(|p| crate::workspace::normalize_path(p) != *target)
1734 .unwrap_or(true)
1735 {
1736 continue;
1737 }
1738 }
1739
1740 let project_name = project
1741 .as_ref()
1742 .and_then(|p| std::path::Path::new(p).file_name())
1743 .map(|n| n.to_string_lossy().to_string())
1744 .unwrap_or_else(|| entry.file_name().to_string_lossy()[..8].to_string());
1745
1746 for session_entry in std::fs::read_dir(&agent_sessions_dir)?.filter_map(|e| e.ok()) {
1748 let session_dir = session_entry.path();
1749 if !session_dir.is_dir() {
1750 continue;
1751 }
1752
1753 let session_id = session_entry.file_name().to_string_lossy().to_string();
1754 let short_id = if session_id.len() > 8 {
1755 format!("{}...", &session_id[..8])
1756 } else {
1757 session_id.clone()
1758 };
1759
1760 let mut last_mod = std::time::SystemTime::UNIX_EPOCH;
1762 let mut file_count = 0;
1763 let mut session_size: u64 = 0;
1764
1765 if let Ok(files) = std::fs::read_dir(&session_dir) {
1766 for file in files.filter_map(|f| f.ok()) {
1767 file_count += 1;
1768 if let Ok(meta) = file.metadata() {
1769 session_size += meta.len();
1770 if let Ok(mod_time) = meta.modified() {
1771 if mod_time > last_mod {
1772 last_mod = mod_time;
1773 }
1774 }
1775 }
1776 }
1777 }
1778
1779 total_size += session_size;
1780
1781 let modified = if last_mod != std::time::SystemTime::UNIX_EPOCH {
1782 let datetime: chrono::DateTime<chrono::Utc> = last_mod.into();
1783 datetime.format("%Y-%m-%d %H:%M").to_string()
1784 } else {
1785 "unknown".to_string()
1786 };
1787
1788 if show_size {
1789 rows_with_size.push(AgentSessionRowWithSize {
1790 provider: provider_name.clone(),
1791 project: project_name.clone(),
1792 session_id: short_id,
1793 last_modified: modified,
1794 file_count,
1795 size: format_file_size(session_size),
1796 });
1797 } else {
1798 rows.push(AgentSessionRow {
1799 provider: provider_name.clone(),
1800 project: project_name.clone(),
1801 session_id: short_id,
1802 last_modified: modified,
1803 file_count,
1804 });
1805 }
1806 }
1807 }
1808 }
1809
1810 if show_size {
1811 if rows_with_size.is_empty() {
1812 println!("No agent mode sessions found.");
1813 return Ok(());
1814 }
1815 let table = Table::new(&rows_with_size)
1816 .with(TableStyle::ascii_rounded())
1817 .to_string();
1818 println!("{}", table);
1819 println!(
1820 "\nTotal agent sessions: {} ({})",
1821 rows_with_size.len(),
1822 format_file_size(total_size)
1823 );
1824 } else {
1825 if rows.is_empty() {
1826 println!("No agent mode sessions found.");
1827 return Ok(());
1828 }
1829 let table = Table::new(&rows)
1830 .with(TableStyle::ascii_rounded())
1831 .to_string();
1832 println!("{}", table);
1833 println!("\nTotal agent sessions: {}", rows.len());
1834 }
1835
1836 Ok(())
1837}
1838
1839pub fn show_agent_session(session_id: &str, project_path: Option<&str>) -> Result<()> {
1841 use colored::*;
1842
1843 let storage_path = crate::workspace::get_workspace_storage_path()?;
1844 let session_id_lower = session_id.to_lowercase();
1845 let target_path = project_path.map(|p| crate::workspace::normalize_path(p));
1846
1847 for entry in std::fs::read_dir(&storage_path)?.filter_map(|e| e.ok()) {
1848 let workspace_dir = entry.path();
1849 if !workspace_dir.is_dir() {
1850 continue;
1851 }
1852
1853 let agent_sessions_dir = workspace_dir.join("chatEditingSessions");
1854 if !agent_sessions_dir.exists() {
1855 continue;
1856 }
1857
1858 let workspace_json = workspace_dir.join("workspace.json");
1860 let project = std::fs::read_to_string(&workspace_json)
1861 .ok()
1862 .and_then(|c| serde_json::from_str::<crate::models::WorkspaceJson>(&c).ok())
1863 .and_then(|ws| {
1864 ws.folder
1865 .map(|f| crate::workspace::decode_workspace_folder(&f))
1866 });
1867
1868 if let Some(ref target) = target_path {
1870 if project
1871 .as_ref()
1872 .map(|p| crate::workspace::normalize_path(p) != *target)
1873 .unwrap_or(true)
1874 {
1875 continue;
1876 }
1877 }
1878
1879 for session_entry in std::fs::read_dir(&agent_sessions_dir)?.filter_map(|e| e.ok()) {
1880 let full_id = session_entry.file_name().to_string_lossy().to_string();
1881 if !full_id.to_lowercase().contains(&session_id_lower) {
1882 continue;
1883 }
1884
1885 let session_dir = session_entry.path();
1886
1887 println!("\n{}", "=".repeat(60).bright_blue());
1888 println!("{}", "Agent Session Details".bright_blue().bold());
1889 println!("{}", "=".repeat(60).bright_blue());
1890
1891 println!(
1892 "{}: {}",
1893 "Session ID".bright_white().bold(),
1894 full_id.bright_cyan()
1895 );
1896 println!(
1897 "{}: {}",
1898 "Project".bright_white().bold(),
1899 project.as_deref().unwrap_or("(none)")
1900 );
1901 println!(
1902 "{}: {}",
1903 "Path".bright_white().bold(),
1904 session_dir.display()
1905 );
1906
1907 println!("\n{}", "Session Files:".bright_yellow());
1909 let mut total_size: u64 = 0;
1910 if let Ok(files) = std::fs::read_dir(&session_dir) {
1911 for file in files.filter_map(|f| f.ok()) {
1912 let path = file.path();
1913 let name = file.file_name().to_string_lossy().to_string();
1914 let size = file.metadata().map(|m| m.len()).unwrap_or(0);
1915 total_size += size;
1916 println!(" {} ({})", name.dimmed(), format_file_size(size));
1917 }
1918 }
1919 println!(
1920 "\n{}: {}",
1921 "Total Size".bright_white().bold(),
1922 format_file_size(total_size)
1923 );
1924
1925 return Ok(());
1926 }
1927 }
1928
1929 println!(
1930 "{} No agent session found matching '{}'",
1931 "!".yellow(),
1932 session_id
1933 );
1934 Ok(())
1935}
1936
1937pub fn show_timeline(
1939 project_path: Option<&str>,
1940 include_agents: bool,
1941 provider: Option<&str>,
1942 all_providers: bool,
1943) -> Result<()> {
1944 use colored::*;
1945 use std::collections::BTreeMap;
1946
1947 let storage_paths = if all_providers {
1949 get_agent_storage_paths(Some("all"))?
1950 } else if let Some(p) = provider {
1951 get_agent_storage_paths(Some(p))?
1952 } else {
1953 let vscode_path = crate::workspace::get_workspace_storage_path()?;
1955 if vscode_path.exists() {
1956 vec![("vscode".to_string(), vscode_path)]
1957 } else {
1958 vec![]
1959 }
1960 };
1961
1962 if storage_paths.is_empty() {
1963 if let Some(p) = provider {
1964 println!("No storage found for provider: {}", p);
1965 } else {
1966 println!("No workspaces found");
1967 }
1968 return Ok(());
1969 }
1970
1971 let target_path = project_path.map(|p| crate::workspace::normalize_path(p));
1972
1973 let mut date_activity: BTreeMap<chrono::NaiveDate, (usize, usize)> = BTreeMap::new();
1975 let mut project_name = String::new();
1976 let mut providers_scanned: Vec<String> = Vec::new();
1977
1978 for (provider_name, storage_path) in &storage_paths {
1979 if !storage_path.exists() {
1980 continue;
1981 }
1982 providers_scanned.push(provider_name.clone());
1983
1984 for entry in std::fs::read_dir(storage_path)?.filter_map(|e| e.ok()) {
1985 let workspace_dir = entry.path();
1986 if !workspace_dir.is_dir() {
1987 continue;
1988 }
1989
1990 let workspace_json = workspace_dir.join("workspace.json");
1992 let project = std::fs::read_to_string(&workspace_json)
1993 .ok()
1994 .and_then(|c| serde_json::from_str::<crate::models::WorkspaceJson>(&c).ok())
1995 .and_then(|ws| {
1996 ws.folder
1997 .map(|f| crate::workspace::decode_workspace_folder(&f))
1998 });
1999
2000 if let Some(ref target) = target_path {
2002 if project
2003 .as_ref()
2004 .map(|p| crate::workspace::normalize_path(p) != *target)
2005 .unwrap_or(true)
2006 {
2007 continue;
2008 }
2009 if project_name.is_empty() {
2010 project_name = std::path::Path::new(target)
2011 .file_name()
2012 .map(|n| n.to_string_lossy().to_string())
2013 .unwrap_or_else(|| target.clone());
2014 }
2015 }
2016
2017 let chat_sessions_dir = workspace_dir.join("chatSessions");
2019 if chat_sessions_dir.exists() {
2020 if let Ok(files) = std::fs::read_dir(&chat_sessions_dir) {
2021 for file in files.filter_map(|f| f.ok()) {
2022 if let Ok(meta) = file.metadata() {
2023 if let Ok(modified) = meta.modified() {
2024 let datetime: chrono::DateTime<chrono::Utc> = modified.into();
2025 let date = datetime.date_naive();
2026 let entry = date_activity.entry(date).or_insert((0, 0));
2027 entry.0 += 1;
2028 }
2029 }
2030 }
2031 }
2032 }
2033
2034 if include_agents {
2036 let agent_sessions_dir = workspace_dir.join("chatEditingSessions");
2037 if agent_sessions_dir.exists() {
2038 if let Ok(dirs) = std::fs::read_dir(&agent_sessions_dir) {
2039 for dir in dirs.filter_map(|d| d.ok()) {
2040 if let Ok(meta) = dir.metadata() {
2041 if let Ok(modified) = meta.modified() {
2042 let datetime: chrono::DateTime<chrono::Utc> = modified.into();
2043 let date = datetime.date_naive();
2044 let entry = date_activity.entry(date).or_insert((0, 0));
2045 entry.1 += 1;
2046 }
2047 }
2048 }
2049 }
2050 }
2051 }
2052 }
2053 }
2054
2055 if date_activity.is_empty() {
2056 println!("No session activity found.");
2057 return Ok(());
2058 }
2059
2060 let title = if project_name.is_empty() {
2061 "All Workspaces".to_string()
2062 } else {
2063 project_name
2064 };
2065
2066 let provider_info = if providers_scanned.len() > 1 || all_providers {
2067 format!(" ({})", providers_scanned.join(", "))
2068 } else {
2069 String::new()
2070 };
2071
2072 println!(
2073 "\n{} Session Timeline: {}{}",
2074 "[*]".blue(),
2075 title.cyan(),
2076 provider_info.dimmed()
2077 );
2078 println!("{}", "=".repeat(60));
2079
2080 let dates: Vec<_> = date_activity.keys().collect();
2081 let first_date = **dates.first().unwrap();
2082 let last_date = **dates.last().unwrap();
2083
2084 println!(
2085 "Range: {} to {}",
2086 first_date.format("%Y-%m-%d"),
2087 last_date.format("%Y-%m-%d")
2088 );
2089 println!();
2090
2091 let mut gaps: Vec<(chrono::NaiveDate, chrono::NaiveDate, i64)> = Vec::new();
2093 let mut prev_date: Option<chrono::NaiveDate> = None;
2094
2095 for date in dates.iter() {
2096 if let Some(prev) = prev_date {
2097 let diff = (**date - prev).num_days();
2098 if diff > 1 {
2099 gaps.push((prev, **date, diff));
2100 }
2101 }
2102 prev_date = Some(**date);
2103 }
2104
2105 println!("{}", "Recent Activity:".bright_yellow());
2107 let recent_dates: Vec<_> = date_activity.iter().rev().take(14).collect();
2108 for (date, (chats, agents)) in recent_dates.iter().rev() {
2109 let chat_bar = "█".repeat((*chats).min(20));
2110 let agent_bar = if include_agents && *agents > 0 {
2111 format!(" {}", "▓".repeat((*agents).min(10)).bright_magenta())
2112 } else {
2113 String::new()
2114 };
2115 println!(
2116 " {} │ {}{}",
2117 date.format("%Y-%m-%d"),
2118 chat_bar.bright_green(),
2119 agent_bar
2120 );
2121 }
2122
2123 if !gaps.is_empty() {
2125 println!("\n{}", "Gaps (>1 day):".bright_red());
2126 for (start, end, days) in gaps.iter().take(10) {
2127 println!(
2128 " {} → {} ({} days)",
2129 start.format("%Y-%m-%d"),
2130 end.format("%Y-%m-%d"),
2131 days
2132 );
2133 }
2134 if gaps.len() > 10 {
2135 println!(" ... and {} more gaps", gaps.len() - 10);
2136 }
2137 }
2138
2139 let total_chats: usize = date_activity.values().map(|(c, _)| c).sum();
2141 let total_agents: usize = date_activity.values().map(|(_, a)| a).sum();
2142 let total_days = date_activity.len();
2143 let total_gap_days: i64 = gaps.iter().map(|(_, _, d)| d - 1).sum();
2144
2145 println!("\n{}", "Summary:".bright_white().bold());
2146 println!(" Active days: {}", total_days);
2147 println!(" Chat sessions: {}", total_chats);
2148 if include_agents {
2149 println!(" Agent sessions: {}", total_agents);
2150 }
2151 println!(" Total gap days: {}", total_gap_days);
2152
2153 if include_agents {
2154 println!(
2155 "\n{} {} = chat, {} = agent",
2156 "Legend:".dimmed(),
2157 "█".bright_green(),
2158 "▓".bright_magenta()
2159 );
2160 }
2161
2162 Ok(())
2163}