Skip to main content

chasm_cli/commands/
workspace_cmds.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Workspace listing commands
4
5use 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
50/// List all VS Code workspaces
51pub 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    // Show empty window sessions count (ALL SESSIONS)
84    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
93/// Format file size in human-readable format
94fn 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
106/// List all chat sessions
107pub fn list_sessions(project_path: Option<&str>, show_size: bool, provider: Option<&str>, all_providers: bool) -> Result<()> {
108    // If provider filtering is requested, use the multi-provider approach
109    if provider.is_some() || all_providers {
110        return list_sessions_multi_provider(project_path, show_size, provider, all_providers);
111    }
112
113    // Default behavior: VS Code only (backward compatible)
114    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        // Add empty window sessions (ALL SESSIONS) if no specific project filter
136        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        // Add empty window sessions (ALL SESSIONS) if no specific project filter
211        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
280/// List sessions from multiple providers
281fn list_sessions_multi_provider(
282    project_path: Option<&str>,
283    show_size: bool,
284    provider: Option<&str>,
285    all_providers: bool,
286) -> Result<()> {
287    // Determine which storage paths to scan
288    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            // Get project path from workspace.json
358            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            // Filter by project path if specified
368            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            // List session files
381            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                // Try to get message count from the session file
410                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
466/// Find workspaces by search pattern
467pub fn find_workspaces(pattern: &str) -> Result<()> {
468    let workspaces = discover_workspaces()?;
469
470    // Resolve "." to current directory name
471    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    // Show session paths for each matching workspace
522    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/// Find sessions by search pattern
541#[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            // Check if session matches the pattern
572            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
641/// Optimized session search with filtering
642///
643/// This function is optimized for speed by:
644/// 1. Filtering workspaces first (by name/path)
645/// 2. Filtering by file modification date before reading content
646/// 3. Only parsing JSON when needed
647/// 4. Content search is opt-in (expensive)
648/// 5. Parallel file scanning with rayon
649pub 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    // Parse date filters upfront
669    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    // Determine which storage paths to scan based on provider filter
674    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        // Default to VS Code only
680        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    // Collect workspace directories with minimal I/O
698    // If --all flag is set, don't filter by workspace
699    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                    // Quick check: does chatSessions exist?
721                    let chat_sessions_dir = workspace_dir.join("chatSessions");
722                    if !chat_sessions_dir.exists() {
723                        return None;
724                    }
725
726                    // Parse workspace.json for project path (needed for filtering)
727                    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                    // Apply workspace filter early
740                    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    // Collect all session file paths
776    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    // Process files in parallel
799    let mut results: Vec<_> = session_files
800        .par_iter()
801        .filter_map(|(path, ws_name, provider_name)| {
802            // Date filter using file metadata (very fast)
803            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            // Read file content once
828            let content = match std::fs::read_to_string(path) {
829                Ok(c) => c,
830                Err(_) => return None,
831            };
832
833            // Check for internal message timestamps if --date filter is used
834            if let Some(target) = target_date {
835                // Look for timestamp fields in the JSON content
836                // Timestamps are in milliseconds since epoch
837                let has_matching_timestamp = content
838                    .split("\"timestamp\":")
839                    .skip(1) // Skip first split (before any timestamp)
840                    .any(|part| {
841                        // Extract the numeric value after "timestamp":
842                        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            // Extract title from content
862            let title =
863                extract_title_from_content(&content).unwrap_or_else(|| "Untitled".to_string());
864            let title_lower = title.to_lowercase();
865
866            // Check session ID from filename
867            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            // Check title match
875            let title_matches = !pattern_lower.is_empty() && title_lower.contains(&pattern_lower);
876
877            // Content search if requested
878            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            // Empty pattern matches everything (for listing)
890            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            // Count messages from content (already loaded)
907            let message_count = content.matches("\"message\":").count();
908
909            // Get modification time
910            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    // Sort by modification date (newest first)
943    results.sort_by(|a, b| b.3.cmp(&a.3));
944
945    // Apply limit
946    results.truncate(limit);
947
948    // Check if we have multiple providers to show provider column
949    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
1055/// Extract title from full JSON content (more reliable than header-only)
1056fn extract_title_from_content(content: &str) -> Option<String> {
1057    // Look for "customTitle" first (user-set title)
1058    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    // Fall back to first request's message text
1074    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/// Fast title extraction from JSON header
1093#[allow(dead_code)]
1094fn extract_title_fast(header: &str) -> Option<String> {
1095    extract_title_from_content(header)
1096}
1097
1098/// Truncate string to max length with ellipsis
1099fn 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
1107/// Show workspace details
1108pub 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    // Find workspace by name or hash
1115    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
1189/// Show session details
1190pub 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                // Detect format from file extension
1235                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                // Show first few messages as preview
1272                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
1296/// Get storage paths for agent mode sessions based on provider filter
1297/// Returns (provider_name, storage_path) tuples
1298fn get_agent_storage_paths(provider: Option<&str>) -> Result<Vec<(String, std::path::PathBuf)>> {
1299    let mut paths = Vec::new();
1300
1301    // VS Code path
1302    let vscode_path = crate::workspace::get_workspace_storage_path()?;
1303
1304    // Other provider paths
1305    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            // Default: return VS Code path only for backward compatibility
1314            if vscode_path.exists() {
1315                paths.push(("vscode".to_string(), vscode_path));
1316            }
1317        }
1318        Some("all") => {
1319            // All providers that support agent mode
1320            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                    // Unknown provider - return empty to trigger error message
1394                }
1395            }
1396        }
1397    }
1398
1399    Ok(paths)
1400}
1401
1402/// Get Cursor's workspace storage path
1403fn 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
1451/// Get ClaudeCode's storage path (Anthropic's Claude Code CLI)
1452fn get_claudecode_storage_path() -> Option<std::path::PathBuf> {
1453    #[cfg(target_os = "windows")]
1454    {
1455        // ClaudeCode stores sessions in AppData
1456        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            // Alternative path with different naming
1464            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
1506/// Get OpenCode's storage path (open-source coding assistant)
1507fn 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
1553/// Get OpenClaw's storage path
1554fn 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
1600/// Get Antigravity's storage path
1601fn 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
1647/// List agent mode sessions (chatEditingSessions / Copilot Edits)
1648pub fn list_agents_sessions(
1649    project_path: Option<&str>,
1650    show_size: bool,
1651    provider: Option<&str>,
1652) -> Result<()> {
1653    use colored::*;
1654
1655    // Get storage paths based on provider filter
1656    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            // Get project path from workspace.json
1720            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            // Filter by project path if specified
1730            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            // List agent session directories
1747            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                // Get last modified time and file count
1761                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
1839/// Show agent mode session details
1840pub 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        // Get project path
1859        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        // Filter by project path if specified
1869        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            // List files in the session
1908            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
1937/// Show timeline of session activity with gap visualization
1938pub 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    // Determine which storage paths to scan
1948    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        // Default to VS Code only
1954        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    // Collect all session dates (date -> (chat_count, agent_count, provider))
1974    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            // Get project path
1991            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            // Filter by project path if specified
2001            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            // Scan chatSessions
2018            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            // Scan chatEditingSessions (agent mode) if requested
2035            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    // Find gaps (more than 1 day between sessions)
2092    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    // Show recent activity (last 14 days worth)
2106    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    // Show gaps
2124    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    // Summary
2140    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}