Skip to main content

chasm/commands/
workspace_cmds.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
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(
108    project_path: Option<&str>,
109    show_size: bool,
110    provider: Option<&str>,
111    all_providers: bool,
112) -> Result<()> {
113    // If provider filtering is requested, use the multi-provider approach
114    if provider.is_some() || all_providers {
115        return list_sessions_multi_provider(project_path, show_size, provider, all_providers);
116    }
117
118    // Default behavior: VS Code only (backward compatible)
119    let workspaces = discover_workspaces()?;
120
121    let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
122        let normalized = crate::workspace::normalize_path(path);
123        workspaces
124            .iter()
125            .filter(|ws| {
126                ws.project_path
127                    .as_ref()
128                    .map(|p| crate::workspace::normalize_path(p) == normalized)
129                    .unwrap_or(false)
130            })
131            .collect()
132    } else {
133        workspaces.iter().collect()
134    };
135
136    if show_size {
137        let mut rows: Vec<SessionRowWithSize> = Vec::new();
138        let mut total_size: u64 = 0;
139
140        // Add empty window sessions (ALL SESSIONS) if no specific project filter
141        if project_path.is_none() {
142            if let Ok(empty_sessions) = read_empty_window_sessions() {
143                for session in empty_sessions {
144                    let modified =
145                        chrono::DateTime::from_timestamp_millis(session.last_message_date)
146                            .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
147                            .unwrap_or_else(|| "unknown".to_string());
148
149                    let session_id = session.session_id.as_deref().unwrap_or("unknown");
150                    rows.push(SessionRowWithSize {
151                        project_path: "(ALL SESSIONS)".to_string(),
152                        session_file: format!("{}.json", session_id),
153                        last_modified: modified,
154                        messages: session.request_count(),
155                        size: "N/A".to_string(),
156                    });
157                }
158            }
159        }
160
161        for ws in &filtered_workspaces {
162            if !ws.has_chat_sessions {
163                continue;
164            }
165
166            let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
167
168            for session_with_path in sessions {
169                let metadata = session_with_path.path.metadata().ok();
170                let file_size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
171                total_size += file_size;
172
173                let modified = metadata
174                    .and_then(|m| m.modified().ok())
175                    .map(|t| {
176                        let datetime: chrono::DateTime<chrono::Utc> = t.into();
177                        datetime.format("%Y-%m-%d %H:%M").to_string()
178                    })
179                    .unwrap_or_else(|| "unknown".to_string());
180
181                rows.push(SessionRowWithSize {
182                    project_path: ws
183                        .project_path
184                        .clone()
185                        .unwrap_or_else(|| "(none)".to_string()),
186                    session_file: session_with_path
187                        .path
188                        .file_name()
189                        .map(|n| n.to_string_lossy().to_string())
190                        .unwrap_or_else(|| "unknown".to_string()),
191                    last_modified: modified,
192                    messages: session_with_path.session.request_count(),
193                    size: format_file_size(file_size),
194                });
195            }
196        }
197
198        if rows.is_empty() {
199            println!("No chat sessions found.");
200            return Ok(());
201        }
202
203        let table = Table::new(&rows)
204            .with(TableStyle::ascii_rounded())
205            .to_string();
206        println!("{}", table);
207        println!(
208            "\nTotal sessions: {} ({})",
209            rows.len(),
210            format_file_size(total_size)
211        );
212    } else {
213        let mut rows: Vec<SessionRow> = Vec::new();
214
215        // Add empty window sessions (ALL SESSIONS) if no specific project filter
216        if project_path.is_none() {
217            if let Ok(empty_sessions) = read_empty_window_sessions() {
218                for session in empty_sessions {
219                    let modified =
220                        chrono::DateTime::from_timestamp_millis(session.last_message_date)
221                            .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
222                            .unwrap_or_else(|| "unknown".to_string());
223
224                    let session_id = session.session_id.as_deref().unwrap_or("unknown");
225                    rows.push(SessionRow {
226                        project_path: "(ALL SESSIONS)".to_string(),
227                        session_file: format!("{}.json", session_id),
228                        last_modified: modified,
229                        messages: session.request_count(),
230                    });
231                }
232            }
233        }
234
235        for ws in &filtered_workspaces {
236            if !ws.has_chat_sessions {
237                continue;
238            }
239
240            let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
241
242            for session_with_path in sessions {
243                let modified = session_with_path
244                    .path
245                    .metadata()
246                    .ok()
247                    .and_then(|m| m.modified().ok())
248                    .map(|t| {
249                        let datetime: chrono::DateTime<chrono::Utc> = t.into();
250                        datetime.format("%Y-%m-%d %H:%M").to_string()
251                    })
252                    .unwrap_or_else(|| "unknown".to_string());
253
254                rows.push(SessionRow {
255                    project_path: ws
256                        .project_path
257                        .clone()
258                        .unwrap_or_else(|| "(none)".to_string()),
259                    session_file: session_with_path
260                        .path
261                        .file_name()
262                        .map(|n| n.to_string_lossy().to_string())
263                        .unwrap_or_else(|| "unknown".to_string()),
264                    last_modified: modified,
265                    messages: session_with_path.session.request_count(),
266                });
267            }
268        }
269
270        if rows.is_empty() {
271            println!("No chat sessions found.");
272            return Ok(());
273        }
274
275        let table = Table::new(&rows)
276            .with(TableStyle::ascii_rounded())
277            .to_string();
278        println!("{}", table);
279        println!("\nTotal sessions: {}", rows.len());
280    }
281
282    Ok(())
283}
284
285/// List sessions from multiple providers
286fn list_sessions_multi_provider(
287    project_path: Option<&str>,
288    show_size: bool,
289    provider: Option<&str>,
290    all_providers: bool,
291) -> Result<()> {
292    // Determine which storage paths to scan
293    let storage_paths = if all_providers {
294        get_agent_storage_paths(Some("all"))?
295    } else if let Some(p) = provider {
296        get_agent_storage_paths(Some(p))?
297    } else {
298        get_agent_storage_paths(None)?
299    };
300
301    if storage_paths.is_empty() {
302        if let Some(p) = provider {
303            println!("No storage found for provider: {}", p);
304        } else {
305            println!("No workspaces found");
306        }
307        return Ok(());
308    }
309
310    let target_path = project_path.map(crate::workspace::normalize_path);
311
312    #[derive(Tabled)]
313    struct SessionRowMulti {
314        #[tabled(rename = "Provider")]
315        provider: String,
316        #[tabled(rename = "Project Path")]
317        project_path: String,
318        #[tabled(rename = "Session File")]
319        session_file: String,
320        #[tabled(rename = "Modified")]
321        last_modified: String,
322        #[tabled(rename = "Msgs")]
323        messages: usize,
324    }
325
326    #[derive(Tabled)]
327    struct SessionRowMultiWithSize {
328        #[tabled(rename = "Provider")]
329        provider: String,
330        #[tabled(rename = "Project Path")]
331        project_path: String,
332        #[tabled(rename = "Session File")]
333        session_file: String,
334        #[tabled(rename = "Modified")]
335        last_modified: String,
336        #[tabled(rename = "Msgs")]
337        messages: usize,
338        #[tabled(rename = "Size")]
339        size: String,
340    }
341
342    let mut rows: Vec<SessionRowMulti> = Vec::new();
343    let mut rows_with_size: Vec<SessionRowMultiWithSize> = Vec::new();
344    let mut total_size: u64 = 0;
345
346    for (provider_name, storage_path) in &storage_paths {
347        if !storage_path.exists() {
348            continue;
349        }
350
351        for entry in std::fs::read_dir(storage_path)?.filter_map(|e| e.ok()) {
352            let workspace_dir = entry.path();
353            if !workspace_dir.is_dir() {
354                continue;
355            }
356
357            let chat_sessions_dir = workspace_dir.join("chatSessions");
358            if !chat_sessions_dir.exists() {
359                continue;
360            }
361
362            // Get project path from workspace.json
363            let workspace_json = workspace_dir.join("workspace.json");
364            let project = std::fs::read_to_string(&workspace_json)
365                .ok()
366                .and_then(|c| serde_json::from_str::<crate::models::WorkspaceJson>(&c).ok())
367                .and_then(|ws| {
368                    ws.folder
369                        .map(|f| crate::workspace::decode_workspace_folder(&f))
370                });
371
372            // Filter by project path if specified
373            if let Some(ref target) = target_path {
374                if project
375                    .as_ref()
376                    .map(|p| crate::workspace::normalize_path(p) != *target)
377                    .unwrap_or(true)
378                {
379                    continue;
380                }
381            }
382
383            let project_display = project.clone().unwrap_or_else(|| "(none)".to_string());
384
385            // List session files
386            for session_entry in std::fs::read_dir(&chat_sessions_dir)?.filter_map(|e| e.ok()) {
387                let session_path = session_entry.path();
388                if !session_path.is_file() {
389                    continue;
390                }
391
392                let ext = session_path.extension().and_then(|e| e.to_str());
393                if ext != Some("json") && ext != Some("jsonl") {
394                    continue;
395                }
396
397                let metadata = session_path.metadata().ok();
398                let file_size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
399                total_size += file_size;
400
401                let modified = metadata
402                    .and_then(|m| m.modified().ok())
403                    .map(|t| {
404                        let datetime: chrono::DateTime<chrono::Utc> = t.into();
405                        datetime.format("%Y-%m-%d %H:%M").to_string()
406                    })
407                    .unwrap_or_else(|| "unknown".to_string());
408
409                let session_file = session_path
410                    .file_name()
411                    .map(|n| n.to_string_lossy().to_string())
412                    .unwrap_or_else(|| "unknown".to_string());
413
414                // Try to get message count from the session file
415                let messages = std::fs::read_to_string(&session_path)
416                    .ok()
417                    .map(|c| c.matches("\"message\":").count())
418                    .unwrap_or(0);
419
420                if show_size {
421                    rows_with_size.push(SessionRowMultiWithSize {
422                        provider: provider_name.clone(),
423                        project_path: truncate_string(&project_display, 30),
424                        session_file: truncate_string(&session_file, 20),
425                        last_modified: modified,
426                        messages,
427                        size: format_file_size(file_size),
428                    });
429                } else {
430                    rows.push(SessionRowMulti {
431                        provider: provider_name.clone(),
432                        project_path: truncate_string(&project_display, 30),
433                        session_file: truncate_string(&session_file, 20),
434                        last_modified: modified,
435                        messages,
436                    });
437                }
438            }
439        }
440    }
441
442    if show_size {
443        if rows_with_size.is_empty() {
444            println!("No chat sessions found.");
445            return Ok(());
446        }
447        let table = Table::new(&rows_with_size)
448            .with(TableStyle::ascii_rounded())
449            .to_string();
450        println!("{}", table);
451        println!(
452            "\nTotal sessions: {} ({})",
453            rows_with_size.len(),
454            format_file_size(total_size)
455        );
456    } else {
457        if rows.is_empty() {
458            println!("No chat sessions found.");
459            return Ok(());
460        }
461        let table = Table::new(&rows)
462            .with(TableStyle::ascii_rounded())
463            .to_string();
464        println!("{}", table);
465        println!("\nTotal sessions: {}", rows.len());
466    }
467
468    Ok(())
469}
470
471/// Find workspaces by search pattern
472pub fn find_workspaces(pattern: &str) -> Result<()> {
473    let workspaces = discover_workspaces()?;
474
475    // Resolve "." to current directory name
476    let pattern = if pattern == "." {
477        std::env::current_dir()
478            .ok()
479            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
480            .unwrap_or_else(|| pattern.to_string())
481    } else {
482        pattern.to_string()
483    };
484    let pattern_lower = pattern.to_lowercase();
485
486    let matching: Vec<&Workspace> = workspaces
487        .iter()
488        .filter(|ws| {
489            ws.project_path
490                .as_ref()
491                .map(|p| p.to_lowercase().contains(&pattern_lower))
492                .unwrap_or(false)
493                || ws.hash.to_lowercase().contains(&pattern_lower)
494        })
495        .collect();
496
497    if matching.is_empty() {
498        println!("No workspaces found matching '{}'", pattern);
499        return Ok(());
500    }
501
502    let rows: Vec<WorkspaceRow> = matching
503        .iter()
504        .map(|ws| WorkspaceRow {
505            hash: format!("{}...", &ws.hash[..12.min(ws.hash.len())]),
506            project_path: ws
507                .project_path
508                .clone()
509                .unwrap_or_else(|| "(none)".to_string()),
510            sessions: ws.chat_session_count,
511            has_chats: if ws.has_chat_sessions {
512                "Yes".to_string()
513            } else {
514                "No".to_string()
515            },
516        })
517        .collect();
518
519    let table = Table::new(rows)
520        .with(TableStyle::ascii_rounded())
521        .to_string();
522
523    println!("{}", table);
524    println!("\nFound {} matching workspace(s)", matching.len());
525
526    // Show session paths for each matching workspace
527    for ws in &matching {
528        if ws.has_chat_sessions {
529            let project = ws.project_path.as_deref().unwrap_or("(none)");
530            println!("\nSessions for {}:", project);
531
532            if let Ok(sessions) =
533                crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)
534            {
535                for session_with_path in sessions {
536                    println!("  {}", session_with_path.path.display());
537                }
538            }
539        }
540    }
541
542    Ok(())
543}
544
545/// Find sessions by search pattern
546#[allow(dead_code)]
547pub fn find_sessions(pattern: &str, project_path: Option<&str>) -> Result<()> {
548    let workspaces = discover_workspaces()?;
549    let pattern_lower = pattern.to_lowercase();
550
551    let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
552        let normalized = crate::workspace::normalize_path(path);
553        workspaces
554            .iter()
555            .filter(|ws| {
556                ws.project_path
557                    .as_ref()
558                    .map(|p| crate::workspace::normalize_path(p) == normalized)
559                    .unwrap_or(false)
560            })
561            .collect()
562    } else {
563        workspaces.iter().collect()
564    };
565
566    let mut rows: Vec<SessionRow> = Vec::new();
567
568    for ws in filtered_workspaces {
569        if !ws.has_chat_sessions {
570            continue;
571        }
572
573        let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
574
575        for session_with_path in sessions {
576            // Check if session matches the pattern
577            let session_id_matches = session_with_path
578                .session
579                .session_id
580                .as_ref()
581                .map(|id| id.to_lowercase().contains(&pattern_lower))
582                .unwrap_or(false);
583            let title_matches = session_with_path
584                .session
585                .title()
586                .to_lowercase()
587                .contains(&pattern_lower);
588            let content_matches = session_with_path.session.requests.iter().any(|r| {
589                r.message
590                    .as_ref()
591                    .map(|m| {
592                        m.text
593                            .as_ref()
594                            .map(|t| t.to_lowercase().contains(&pattern_lower))
595                            .unwrap_or(false)
596                    })
597                    .unwrap_or(false)
598            });
599
600            if !session_id_matches && !title_matches && !content_matches {
601                continue;
602            }
603
604            let modified = session_with_path
605                .path
606                .metadata()
607                .ok()
608                .and_then(|m| m.modified().ok())
609                .map(|t| {
610                    let datetime: chrono::DateTime<chrono::Utc> = t.into();
611                    datetime.format("%Y-%m-%d %H:%M").to_string()
612                })
613                .unwrap_or_else(|| "unknown".to_string());
614
615            rows.push(SessionRow {
616                project_path: ws
617                    .project_path
618                    .clone()
619                    .unwrap_or_else(|| "(none)".to_string()),
620                session_file: session_with_path
621                    .path
622                    .file_name()
623                    .map(|n| n.to_string_lossy().to_string())
624                    .unwrap_or_else(|| "unknown".to_string()),
625                last_modified: modified,
626                messages: session_with_path.session.request_count(),
627            });
628        }
629    }
630
631    if rows.is_empty() {
632        println!("No sessions found matching '{}'", pattern);
633        return Ok(());
634    }
635
636    let table = Table::new(&rows)
637        .with(TableStyle::ascii_rounded())
638        .to_string();
639
640    println!("{}", table);
641    println!("\nFound {} matching session(s)", rows.len());
642
643    Ok(())
644}
645
646/// Read only the first `max_bytes` of a file as a string.
647/// Returns None if the file cannot be read.
648fn read_file_header(path: &std::path::Path, max_bytes: usize) -> Option<String> {
649    use std::io::Read;
650    let file = std::fs::File::open(path).ok()?;
651    let mut reader = std::io::BufReader::new(file);
652    let mut buffer = vec![0u8; max_bytes];
653    let bytes_read = reader.read(&mut buffer).ok()?;
654    buffer.truncate(bytes_read);
655    String::from_utf8(buffer).ok()
656}
657
658/// Case-insensitive substring search without allocating a lowercased copy.
659/// Uses byte-level comparison for ASCII patterns (which covers all common search terms).
660fn contains_case_insensitive(haystack: &str, needle_lower: &str) -> bool {
661    if needle_lower.is_empty() {
662        return true;
663    }
664    let needle_bytes = needle_lower.as_bytes();
665    let haystack_bytes = haystack.as_bytes();
666    if needle_bytes.len() > haystack_bytes.len() {
667        return false;
668    }
669    // Sliding window byte comparison with ASCII lowering
670    'outer: for i in 0..=(haystack_bytes.len() - needle_bytes.len()) {
671        for j in 0..needle_bytes.len() {
672            if haystack_bytes[i + j].to_ascii_lowercase() != needle_bytes[j] {
673                continue 'outer;
674            }
675        }
676        return true;
677    }
678    false
679}
680
681/// Optimized session search with filtering
682///
683/// This function is optimized for speed by:
684/// 1. Filtering workspaces first (by name/path)
685/// 2. Filtering by file modification date before reading content
686/// 3. Title-only search reads only first 4KB of each file (10-100x faster)
687/// 4. Case-insensitive search avoids String::to_lowercase() allocation
688/// 5. Content search is opt-in (expensive)
689/// 6. Parallel file scanning with rayon
690pub fn find_sessions_filtered(
691    pattern: &str,
692    workspace_filter: Option<&str>,
693    title_only: bool,
694    search_content: bool,
695    after: Option<&str>,
696    before: Option<&str>,
697    date: Option<&str>,
698    all_workspaces: bool,
699    provider: Option<&str>,
700    all_providers: bool,
701    limit: usize,
702) -> Result<()> {
703    use chrono::{NaiveDate, Utc};
704    use rayon::prelude::*;
705    use std::sync::atomic::{AtomicUsize, Ordering};
706
707    let pattern_lower = pattern.to_lowercase();
708
709    // Parse date filters upfront
710    let after_date = after.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
711    let before_date = before.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
712    let target_date = date.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
713
714    // Determine which storage paths to scan based on provider filter
715    let storage_paths = if all_providers {
716        get_agent_storage_paths(Some("all"))?
717    } else if let Some(p) = provider {
718        get_agent_storage_paths(Some(p))?
719    } else {
720        // Default to VS Code only
721        let vscode_path = crate::workspace::get_workspace_storage_path()?;
722        if vscode_path.exists() {
723            vec![("vscode".to_string(), vscode_path)]
724        } else {
725            vec![]
726        }
727    };
728
729    if storage_paths.is_empty() {
730        if let Some(p) = provider {
731            println!("No storage found for provider: {}", p);
732        } else {
733            println!("No workspaces found");
734        }
735        return Ok(());
736    }
737
738    // Collect workspace directories with minimal I/O
739    // If --all flag is set, don't filter by workspace
740    let ws_filter_lower = if all_workspaces {
741        None
742    } else {
743        workspace_filter.map(|s| s.to_lowercase())
744    };
745
746    let workspace_dirs: Vec<_> = storage_paths
747        .iter()
748        .flat_map(|(provider_name, storage_path)| {
749            if !storage_path.exists() {
750                return vec![];
751            }
752            std::fs::read_dir(storage_path)
753                .into_iter()
754                .flatten()
755                .filter_map(|e| e.ok())
756                .filter(|e| e.path().is_dir())
757                .filter_map(|entry| {
758                    let workspace_dir = entry.path();
759                    let workspace_json_path = workspace_dir.join("workspace.json");
760
761                    // Quick check: does chatSessions exist?
762                    let chat_sessions_dir = workspace_dir.join("chatSessions");
763                    if !chat_sessions_dir.exists() {
764                        return None;
765                    }
766
767                    // Parse workspace.json for project path (needed for filtering)
768                    let project_path =
769                        std::fs::read_to_string(&workspace_json_path)
770                            .ok()
771                            .and_then(|content| {
772                                serde_json::from_str::<crate::models::WorkspaceJson>(&content)
773                                    .ok()
774                                    .and_then(|ws| {
775                                        ws.folder
776                                            .map(|f| crate::workspace::decode_workspace_folder(&f))
777                                    })
778                            });
779
780                    // Apply workspace filter early
781                    if let Some(ref filter) = ws_filter_lower {
782                        let hash = entry.file_name().to_string_lossy().to_lowercase();
783                        let path_matches = project_path
784                            .as_ref()
785                            .map(|p| p.to_lowercase().contains(filter))
786                            .unwrap_or(false);
787                        if !hash.contains(filter) && !path_matches {
788                            return None;
789                        }
790                    }
791
792                    let ws_name = project_path
793                        .as_ref()
794                        .and_then(|p| std::path::Path::new(p).file_name())
795                        .map(|n| n.to_string_lossy().to_string())
796                        .unwrap_or_else(|| {
797                            entry.file_name().to_string_lossy()[..8.min(entry.file_name().len())]
798                                .to_string()
799                        });
800
801                    Some((chat_sessions_dir, ws_name, provider_name.clone()))
802                })
803                .collect::<Vec<_>>()
804        })
805        .collect();
806
807    if workspace_dirs.is_empty() {
808        if let Some(ws) = workspace_filter {
809            println!("No workspaces found matching '{}'", ws);
810        } else {
811            println!("No workspaces with chat sessions found");
812        }
813        return Ok(());
814    }
815
816    // Collect all session file paths
817    let session_files: Vec<_> = workspace_dirs
818        .iter()
819        .flat_map(|(chat_dir, ws_name, provider_name)| {
820            std::fs::read_dir(chat_dir)
821                .into_iter()
822                .flatten()
823                .filter_map(|e| e.ok())
824                .filter(|e| {
825                    e.path()
826                        .extension()
827                        .map(|ext| ext == "json" || ext == "jsonl")
828                        .unwrap_or(false)
829                })
830                .map(|e| (e.path(), ws_name.clone(), provider_name.clone()))
831                .collect::<Vec<_>>()
832        })
833        .collect();
834
835    let total_files = session_files.len();
836    let scanned = AtomicUsize::new(0);
837    let skipped_by_date = AtomicUsize::new(0);
838
839    // Process files in parallel
840    let mut results: Vec<_> = session_files
841        .par_iter()
842        .filter_map(|(path, ws_name, provider_name)| {
843            // Date filter using file metadata (very fast)
844            if after_date.is_some() || before_date.is_some() {
845                if let Ok(metadata) = path.metadata() {
846                    if let Ok(modified) = metadata.modified() {
847                        let file_date: chrono::DateTime<Utc> = modified.into();
848                        let file_naive = file_date.date_naive();
849
850                        if let Some(after) = after_date {
851                            if file_naive < after {
852                                skipped_by_date.fetch_add(1, Ordering::Relaxed);
853                                return None;
854                            }
855                        }
856                        if let Some(before) = before_date {
857                            if file_naive > before {
858                                skipped_by_date.fetch_add(1, Ordering::Relaxed);
859                                return None;
860                            }
861                        }
862                    }
863                }
864            }
865
866            scanned.fetch_add(1, Ordering::Relaxed);
867
868            // Optimization: for title-only search (no --content flag), read only
869            // the first 4KB of the file to extract the title. This is 10-100x
870            // faster for large session files (which can be megabytes).
871            let (title, content_for_search) = if title_only || !search_content {
872                // Fast path: only need the title
873                let header = match read_file_header(path, 4096) {
874                    Some(h) => h,
875                    None => return None,
876                };
877                let title =
878                    extract_title_from_content(&header).unwrap_or_else(|| "Untitled".to_string());
879                (title, None)
880            } else {
881                // Full read path: need content for search
882                let content = match std::fs::read_to_string(path) {
883                    Ok(c) => c,
884                    Err(_) => return None,
885                };
886
887                // Check for internal message timestamps if --date filter is used
888                if let Some(target) = target_date {
889                    let has_matching_timestamp =
890                        content.split("\"timestamp\":").skip(1).any(|part| {
891                            let num_str: String = part
892                                .chars()
893                                .skip_while(|c| c.is_whitespace())
894                                .take_while(|c| c.is_ascii_digit())
895                                .collect();
896                            if let Ok(ts_ms) = num_str.parse::<i64>() {
897                                if let Some(dt) = chrono::DateTime::from_timestamp_millis(ts_ms) {
898                                    return dt.date_naive() == target;
899                                }
900                            }
901                            false
902                        });
903
904                    if !has_matching_timestamp {
905                        skipped_by_date.fetch_add(1, Ordering::Relaxed);
906                        return None;
907                    }
908                }
909
910                let title =
911                    extract_title_from_content(&content).unwrap_or_else(|| "Untitled".to_string());
912                (title, Some(content))
913            };
914
915            let title_lower = title.to_lowercase();
916
917            // Check session ID from filename
918            let session_id = path
919                .file_stem()
920                .map(|n| n.to_string_lossy().to_string())
921                .unwrap_or_default();
922            let id_matches =
923                !pattern_lower.is_empty() && session_id.to_lowercase().contains(&pattern_lower);
924
925            // Check title match
926            let title_matches = !pattern_lower.is_empty() && title_lower.contains(&pattern_lower);
927
928            // Content search if requested (uses pre-loaded content_for_search)
929            let content_matches = if search_content
930                && !title_only
931                && !id_matches
932                && !title_matches
933                && !pattern_lower.is_empty()
934            {
935                if let Some(ref content) = content_for_search {
936                    // Use case-insensitive byte search for speed
937                    contains_case_insensitive(content, &pattern_lower)
938                } else {
939                    // Content wasn't loaded (title-only mode), do a lazy read
940                    match std::fs::read_to_string(path) {
941                        Ok(c) => contains_case_insensitive(&c, &pattern_lower),
942                        Err(_) => false,
943                    }
944                }
945            } else {
946                false
947            };
948
949            // Empty pattern matches everything (for listing)
950            let matches =
951                pattern_lower.is_empty() || id_matches || title_matches || content_matches;
952            if !matches {
953                return None;
954            }
955
956            let match_type = if pattern_lower.is_empty() {
957                ""
958            } else if id_matches {
959                "ID"
960            } else if title_matches {
961                "title"
962            } else {
963                "content"
964            };
965
966            // Count messages from content if available, otherwise estimate from file size
967            let message_count = if let Some(ref content) = content_for_search {
968                content.matches("\"message\":").count()
969            } else {
970                // Estimate from file size (avoid reading full file just for count)
971                path.metadata()
972                    .ok()
973                    .map(|m| {
974                        // Rough estimate: ~500 bytes per message on average
975                        (m.len() / 500).max(1) as usize
976                    })
977                    .unwrap_or(0)
978            };
979
980            // Get modification time
981            let modified = path
982                .metadata()
983                .ok()
984                .and_then(|m| m.modified().ok())
985                .map(|t| {
986                    let datetime: chrono::DateTime<chrono::Utc> = t.into();
987                    datetime.format("%Y-%m-%d %H:%M").to_string()
988                })
989                .unwrap_or_else(|| "unknown".to_string());
990
991            Some((
992                title,
993                ws_name.clone(),
994                provider_name.clone(),
995                modified,
996                message_count,
997                match_type.to_string(),
998            ))
999        })
1000        .collect();
1001
1002    let scanned_count = scanned.load(Ordering::Relaxed);
1003    let skipped_count = skipped_by_date.load(Ordering::Relaxed);
1004
1005    if results.is_empty() {
1006        println!("No sessions found matching '{}'", pattern);
1007        if skipped_count > 0 {
1008            println!("  ({} sessions skipped due to date filter)", skipped_count);
1009        }
1010        return Ok(());
1011    }
1012
1013    // Sort by modification date (newest first)
1014    results.sort_by(|a, b| b.3.cmp(&a.3));
1015
1016    // Apply limit
1017    results.truncate(limit);
1018
1019    // Check if we have multiple providers to show provider column
1020    let show_provider_column = all_providers || storage_paths.len() > 1;
1021
1022    #[derive(Tabled)]
1023    struct SearchResultRow {
1024        #[tabled(rename = "Title")]
1025        title: String,
1026        #[tabled(rename = "Workspace")]
1027        workspace: String,
1028        #[tabled(rename = "Modified")]
1029        modified: String,
1030        #[tabled(rename = "Msgs")]
1031        messages: usize,
1032        #[tabled(rename = "Match")]
1033        match_type: String,
1034    }
1035
1036    #[derive(Tabled)]
1037    struct SearchResultRowWithProvider {
1038        #[tabled(rename = "Provider")]
1039        provider: String,
1040        #[tabled(rename = "Title")]
1041        title: String,
1042        #[tabled(rename = "Workspace")]
1043        workspace: String,
1044        #[tabled(rename = "Modified")]
1045        modified: String,
1046        #[tabled(rename = "Msgs")]
1047        messages: usize,
1048        #[tabled(rename = "Match")]
1049        match_type: String,
1050    }
1051
1052    if show_provider_column {
1053        let rows: Vec<SearchResultRowWithProvider> = results
1054            .into_iter()
1055            .map(
1056                |(title, workspace, provider, modified, messages, match_type)| {
1057                    SearchResultRowWithProvider {
1058                        provider,
1059                        title: truncate_string(&title, 35),
1060                        workspace: truncate_string(&workspace, 15),
1061                        modified,
1062                        messages,
1063                        match_type,
1064                    }
1065                },
1066            )
1067            .collect();
1068
1069        let table = Table::new(&rows)
1070            .with(TableStyle::ascii_rounded())
1071            .to_string();
1072
1073        println!("{}", table);
1074        println!(
1075            "\nFound {} session(s) (scanned {} of {} files{})",
1076            rows.len(),
1077            scanned_count,
1078            total_files,
1079            if skipped_count > 0 {
1080                format!(", {} skipped by date", skipped_count)
1081            } else {
1082                String::new()
1083            }
1084        );
1085        if rows.len() >= limit {
1086            println!("  (results limited to {}; use --limit to show more)", limit);
1087        }
1088    } else {
1089        let rows: Vec<SearchResultRow> = results
1090            .into_iter()
1091            .map(
1092                |(title, workspace, _provider, modified, messages, match_type)| SearchResultRow {
1093                    title: truncate_string(&title, 40),
1094                    workspace: truncate_string(&workspace, 20),
1095                    modified,
1096                    messages,
1097                    match_type,
1098                },
1099            )
1100            .collect();
1101
1102        let table = Table::new(&rows)
1103            .with(TableStyle::ascii_rounded())
1104            .to_string();
1105
1106        println!("{}", table);
1107        println!(
1108            "\nFound {} session(s) (scanned {} of {} files{})",
1109            rows.len(),
1110            scanned_count,
1111            total_files,
1112            if skipped_count > 0 {
1113                format!(", {} skipped by date", skipped_count)
1114            } else {
1115                String::new()
1116            }
1117        );
1118        if rows.len() >= limit {
1119            println!("  (results limited to {}; use --limit to show more)", limit);
1120        }
1121    }
1122
1123    Ok(())
1124}
1125
1126/// Extract title from full JSON content (more reliable than header-only)
1127fn extract_title_from_content(content: &str) -> Option<String> {
1128    // Look for "customTitle" first (user-set title)
1129    if let Some(start) = content.find("\"customTitle\"") {
1130        if let Some(colon) = content[start..].find(':') {
1131            let after_colon = &content[start + colon + 1..];
1132            let trimmed = after_colon.trim_start();
1133            if let Some(stripped) = trimmed.strip_prefix('"') {
1134                if let Some(end) = stripped.find('"') {
1135                    let title = &stripped[..end];
1136                    if !title.is_empty() && title != "null" {
1137                        return Some(title.to_string());
1138                    }
1139                }
1140            }
1141        }
1142    }
1143
1144    // Fall back to first request's message text
1145    if let Some(start) = content.find("\"text\"") {
1146        if let Some(colon) = content[start..].find(':') {
1147            let after_colon = &content[start + colon + 1..];
1148            let trimmed = after_colon.trim_start();
1149            if let Some(stripped) = trimmed.strip_prefix('"') {
1150                if let Some(end) = stripped.find('"') {
1151                    let title = &stripped[..end];
1152                    if !title.is_empty() && title.len() < 100 {
1153                        return Some(title.to_string());
1154                    }
1155                }
1156            }
1157        }
1158    }
1159
1160    None
1161}
1162
1163/// Fast title extraction from JSON header
1164#[allow(dead_code)]
1165fn extract_title_fast(header: &str) -> Option<String> {
1166    extract_title_from_content(header)
1167}
1168
1169/// Truncate string to max length with ellipsis
1170fn truncate_string(s: &str, max_len: usize) -> String {
1171    if s.len() <= max_len {
1172        s.to_string()
1173    } else {
1174        format!("{}...", &s[..max_len.saturating_sub(3)])
1175    }
1176}
1177
1178/// Show workspace details
1179pub fn show_workspace(workspace: &str) -> Result<()> {
1180    use colored::Colorize;
1181
1182    let workspaces = discover_workspaces()?;
1183    let workspace_lower = workspace.to_lowercase();
1184
1185    // Find workspace by name or hash
1186    let matching: Vec<&Workspace> = workspaces
1187        .iter()
1188        .filter(|ws| {
1189            ws.hash.to_lowercase().contains(&workspace_lower)
1190                || ws
1191                    .project_path
1192                    .as_ref()
1193                    .map(|p| p.to_lowercase().contains(&workspace_lower))
1194                    .unwrap_or(false)
1195        })
1196        .collect();
1197
1198    if matching.is_empty() {
1199        println!(
1200            "{} No workspace found matching '{}'",
1201            "!".yellow(),
1202            workspace
1203        );
1204        return Ok(());
1205    }
1206
1207    for ws in matching {
1208        println!("\n{}", "=".repeat(60).bright_blue());
1209        println!("{}", "Workspace Details".bright_blue().bold());
1210        println!("{}", "=".repeat(60).bright_blue());
1211
1212        println!("{}: {}", "Hash".bright_white().bold(), ws.hash);
1213        println!(
1214            "{}: {}",
1215            "Path".bright_white().bold(),
1216            ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
1217        );
1218        println!(
1219            "{}: {}",
1220            "Has Sessions".bright_white().bold(),
1221            if ws.has_chat_sessions {
1222                "Yes".green()
1223            } else {
1224                "No".red()
1225            }
1226        );
1227        println!(
1228            "{}: {}",
1229            "Workspace Path".bright_white().bold(),
1230            ws.workspace_path.display()
1231        );
1232
1233        if ws.has_chat_sessions {
1234            let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
1235            println!(
1236                "{}: {}",
1237                "Session Count".bright_white().bold(),
1238                sessions.len()
1239            );
1240
1241            if !sessions.is_empty() {
1242                println!("\n{}", "Sessions:".bright_yellow());
1243                for (i, s) in sessions.iter().enumerate() {
1244                    let title = s.session.title();
1245                    let msg_count = s.session.request_count();
1246                    println!(
1247                        "  {}. {} ({} messages)",
1248                        i + 1,
1249                        title.bright_cyan(),
1250                        msg_count
1251                    );
1252                }
1253            }
1254        }
1255    }
1256
1257    Ok(())
1258}
1259
1260/// Show session details
1261pub fn show_session(session_id: &str, project_path: Option<&str>) -> Result<()> {
1262    use colored::Colorize;
1263
1264    let workspaces = discover_workspaces()?;
1265    let session_id_lower = session_id.to_lowercase();
1266
1267    let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
1268        let normalized = crate::workspace::normalize_path(path);
1269        workspaces
1270            .iter()
1271            .filter(|ws| {
1272                ws.project_path
1273                    .as_ref()
1274                    .map(|p| crate::workspace::normalize_path(p) == normalized)
1275                    .unwrap_or(false)
1276            })
1277            .collect()
1278    } else {
1279        workspaces.iter().collect()
1280    };
1281
1282    for ws in filtered_workspaces {
1283        if !ws.has_chat_sessions {
1284            continue;
1285        }
1286
1287        let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
1288
1289        for s in sessions {
1290            let filename = s
1291                .path
1292                .file_name()
1293                .map(|n| n.to_string_lossy().to_string())
1294                .unwrap_or_default();
1295
1296            let matches = s
1297                .session
1298                .session_id
1299                .as_ref()
1300                .map(|id| id.to_lowercase().contains(&session_id_lower))
1301                .unwrap_or(false)
1302                || filename.to_lowercase().contains(&session_id_lower);
1303
1304            if matches {
1305                // Detect format from file extension
1306                let format = VsCodeSessionFormat::from_path(&s.path);
1307
1308                println!("\n{}", "=".repeat(60).bright_blue());
1309                println!("{}", "Session Details".bright_blue().bold());
1310                println!("{}", "=".repeat(60).bright_blue());
1311
1312                println!(
1313                    "{}: {}",
1314                    "Title".bright_white().bold(),
1315                    s.session.title().bright_cyan()
1316                );
1317                println!("{}: {}", "File".bright_white().bold(), filename);
1318                println!(
1319                    "{}: {}",
1320                    "Format".bright_white().bold(),
1321                    format.to_string().bright_magenta()
1322                );
1323                println!(
1324                    "{}: {}",
1325                    "Session ID".bright_white().bold(),
1326                    s.session
1327                        .session_id
1328                        .as_ref()
1329                        .unwrap_or(&"(none)".to_string())
1330                );
1331                println!(
1332                    "{}: {}",
1333                    "Messages".bright_white().bold(),
1334                    s.session.request_count()
1335                );
1336                println!(
1337                    "{}: {}",
1338                    "Workspace".bright_white().bold(),
1339                    ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
1340                );
1341
1342                // Show first few messages as preview
1343                println!("\n{}", "Preview:".bright_yellow());
1344                for (i, req) in s.session.requests.iter().take(3).enumerate() {
1345                    if let Some(msg) = &req.message {
1346                        if let Some(text) = &msg.text {
1347                            let preview: String = text.chars().take(100).collect();
1348                            let truncated = if text.len() > 100 { "..." } else { "" };
1349                            println!("  {}. {}{}", i + 1, preview.dimmed(), truncated);
1350                        }
1351                    }
1352                }
1353
1354                return Ok(());
1355            }
1356        }
1357    }
1358
1359    println!(
1360        "{} No session found matching '{}'",
1361        "!".yellow(),
1362        session_id
1363    );
1364    Ok(())
1365}
1366
1367/// Get storage paths for agent mode sessions based on provider filter
1368/// Returns (provider_name, storage_path) tuples
1369fn get_agent_storage_paths(provider: Option<&str>) -> Result<Vec<(String, std::path::PathBuf)>> {
1370    let mut paths = Vec::new();
1371
1372    // VS Code path
1373    let vscode_path = crate::workspace::get_workspace_storage_path()?;
1374
1375    // Other provider paths
1376    let cursor_path = get_cursor_storage_path();
1377    let claudecode_path = get_claudecode_storage_path();
1378    let opencode_path = get_opencode_storage_path();
1379    let openclaw_path = get_openclaw_storage_path();
1380    let antigravity_path = get_antigravity_storage_path();
1381    let codexcli_path = get_codexcli_storage_path();
1382    let droidcli_path = get_droidcli_storage_path();
1383    let geminicli_path = get_geminicli_storage_path();
1384
1385    match provider {
1386        None => {
1387            // Default: return VS Code path only for backward compatibility
1388            if vscode_path.exists() {
1389                paths.push(("vscode".to_string(), vscode_path));
1390            }
1391        }
1392        Some("all") => {
1393            // All providers that support agent mode
1394            if vscode_path.exists() {
1395                paths.push(("vscode".to_string(), vscode_path));
1396            }
1397            if let Some(cp) = cursor_path {
1398                if cp.exists() {
1399                    paths.push(("cursor".to_string(), cp));
1400                }
1401            }
1402            if let Some(cc) = claudecode_path {
1403                if cc.exists() {
1404                    paths.push(("claudecode".to_string(), cc));
1405                }
1406            }
1407            if let Some(oc) = opencode_path {
1408                if oc.exists() {
1409                    paths.push(("opencode".to_string(), oc));
1410                }
1411            }
1412            if let Some(ocl) = openclaw_path {
1413                if ocl.exists() {
1414                    paths.push(("openclaw".to_string(), ocl));
1415                }
1416            }
1417            if let Some(ag) = antigravity_path {
1418                if ag.exists() {
1419                    paths.push(("antigravity".to_string(), ag));
1420                }
1421            }
1422            if let Some(cx) = codexcli_path {
1423                if cx.exists() {
1424                    paths.push(("codexcli".to_string(), cx));
1425                }
1426            }
1427            if let Some(dr) = droidcli_path {
1428                if dr.exists() {
1429                    paths.push(("droidcli".to_string(), dr));
1430                }
1431            }
1432            if let Some(gc) = geminicli_path {
1433                if gc.exists() {
1434                    paths.push(("geminicli".to_string(), gc));
1435                }
1436            }
1437        }
1438        Some(p) => {
1439            let p_lower = p.to_lowercase();
1440            match p_lower.as_str() {
1441                "vscode" | "vs-code" | "copilot" => {
1442                    if vscode_path.exists() {
1443                        paths.push(("vscode".to_string(), vscode_path));
1444                    }
1445                }
1446                "cursor" => {
1447                    if let Some(cp) = cursor_path {
1448                        if cp.exists() {
1449                            paths.push(("cursor".to_string(), cp));
1450                        }
1451                    }
1452                }
1453                "claudecode" | "claude-code" | "claude" => {
1454                    if let Some(cc) = claudecode_path {
1455                        if cc.exists() {
1456                            paths.push(("claudecode".to_string(), cc));
1457                        }
1458                    }
1459                }
1460                "opencode" | "open-code" => {
1461                    if let Some(oc) = opencode_path {
1462                        if oc.exists() {
1463                            paths.push(("opencode".to_string(), oc));
1464                        }
1465                    }
1466                }
1467                "openclaw" | "open-claw" | "claw" => {
1468                    if let Some(ocl) = openclaw_path {
1469                        if ocl.exists() {
1470                            paths.push(("openclaw".to_string(), ocl));
1471                        }
1472                    }
1473                }
1474                "antigravity" | "anti-gravity" | "ag" => {
1475                    if let Some(ag) = antigravity_path {
1476                        if ag.exists() {
1477                            paths.push(("antigravity".to_string(), ag));
1478                        }
1479                    }
1480                }
1481                "codexcli" | "codex-cli" | "codex" => {
1482                    if let Some(cx) = codexcli_path {
1483                        if cx.exists() {
1484                            paths.push(("codexcli".to_string(), cx));
1485                        }
1486                    }
1487                }
1488                "droidcli" | "droid-cli" | "droid" | "factory" => {
1489                    if let Some(dr) = droidcli_path {
1490                        if dr.exists() {
1491                            paths.push(("droidcli".to_string(), dr));
1492                        }
1493                    }
1494                }
1495                "geminicli" | "gemini-cli" => {
1496                    if let Some(gc) = geminicli_path {
1497                        if gc.exists() {
1498                            paths.push(("geminicli".to_string(), gc));
1499                        }
1500                    }
1501                }
1502                _ => {
1503                    // Unknown provider - return empty to trigger error message
1504                }
1505            }
1506        }
1507    }
1508
1509    Ok(paths)
1510}
1511
1512/// Get Cursor's workspace storage path
1513fn get_cursor_storage_path() -> Option<std::path::PathBuf> {
1514    #[cfg(target_os = "windows")]
1515    {
1516        if let Some(appdata) = dirs::data_dir() {
1517            let cursor_path = appdata.join("Cursor").join("User").join("workspaceStorage");
1518            if cursor_path.exists() {
1519                return Some(cursor_path);
1520            }
1521        }
1522        if let Ok(roaming) = std::env::var("APPDATA") {
1523            let roaming_path = std::path::PathBuf::from(roaming)
1524                .join("Cursor")
1525                .join("User")
1526                .join("workspaceStorage");
1527            if roaming_path.exists() {
1528                return Some(roaming_path);
1529            }
1530        }
1531    }
1532
1533    #[cfg(target_os = "macos")]
1534    {
1535        if let Some(home) = dirs::home_dir() {
1536            let cursor_path = home
1537                .join("Library")
1538                .join("Application Support")
1539                .join("Cursor")
1540                .join("User")
1541                .join("workspaceStorage");
1542            if cursor_path.exists() {
1543                return Some(cursor_path);
1544            }
1545        }
1546    }
1547
1548    #[cfg(target_os = "linux")]
1549    {
1550        if let Some(config) = dirs::config_dir() {
1551            let cursor_path = config.join("Cursor").join("User").join("workspaceStorage");
1552            if cursor_path.exists() {
1553                return Some(cursor_path);
1554            }
1555        }
1556    }
1557
1558    None
1559}
1560
1561/// Get ClaudeCode's storage path (Anthropic's Claude Code CLI)
1562fn get_claudecode_storage_path() -> Option<std::path::PathBuf> {
1563    #[cfg(target_os = "windows")]
1564    {
1565        // ClaudeCode stores sessions in AppData
1566        if let Ok(appdata) = std::env::var("APPDATA") {
1567            let claude_path = std::path::PathBuf::from(&appdata)
1568                .join("claude-code")
1569                .join("sessions");
1570            if claude_path.exists() {
1571                return Some(claude_path);
1572            }
1573            // Alternative path with different naming
1574            let alt_path = std::path::PathBuf::from(&appdata)
1575                .join("ClaudeCode")
1576                .join("workspaceStorage");
1577            if alt_path.exists() {
1578                return Some(alt_path);
1579            }
1580        }
1581        if let Some(local) = dirs::data_local_dir() {
1582            let local_path = local.join("ClaudeCode").join("sessions");
1583            if local_path.exists() {
1584                return Some(local_path);
1585            }
1586        }
1587    }
1588
1589    #[cfg(target_os = "macos")]
1590    {
1591        if let Some(home) = dirs::home_dir() {
1592            let claude_path = home
1593                .join("Library")
1594                .join("Application Support")
1595                .join("claude-code")
1596                .join("sessions");
1597            if claude_path.exists() {
1598                return Some(claude_path);
1599            }
1600        }
1601    }
1602
1603    #[cfg(target_os = "linux")]
1604    {
1605        if let Some(config) = dirs::config_dir() {
1606            let claude_path = config.join("claude-code").join("sessions");
1607            if claude_path.exists() {
1608                return Some(claude_path);
1609            }
1610        }
1611    }
1612
1613    None
1614}
1615
1616/// Get OpenCode's storage path (open-source coding assistant)
1617fn get_opencode_storage_path() -> Option<std::path::PathBuf> {
1618    #[cfg(target_os = "windows")]
1619    {
1620        if let Ok(appdata) = std::env::var("APPDATA") {
1621            let opencode_path = std::path::PathBuf::from(&appdata)
1622                .join("OpenCode")
1623                .join("workspaceStorage");
1624            if opencode_path.exists() {
1625                return Some(opencode_path);
1626            }
1627        }
1628        if let Some(local) = dirs::data_local_dir() {
1629            let local_path = local.join("OpenCode").join("sessions");
1630            if local_path.exists() {
1631                return Some(local_path);
1632            }
1633        }
1634    }
1635
1636    #[cfg(target_os = "macos")]
1637    {
1638        if let Some(home) = dirs::home_dir() {
1639            let opencode_path = home
1640                .join("Library")
1641                .join("Application Support")
1642                .join("OpenCode")
1643                .join("workspaceStorage");
1644            if opencode_path.exists() {
1645                return Some(opencode_path);
1646            }
1647        }
1648    }
1649
1650    #[cfg(target_os = "linux")]
1651    {
1652        if let Some(config) = dirs::config_dir() {
1653            let opencode_path = config.join("opencode").join("workspaceStorage");
1654            if opencode_path.exists() {
1655                return Some(opencode_path);
1656            }
1657        }
1658    }
1659
1660    None
1661}
1662
1663/// Get OpenClaw's storage path
1664fn get_openclaw_storage_path() -> Option<std::path::PathBuf> {
1665    #[cfg(target_os = "windows")]
1666    {
1667        if let Ok(appdata) = std::env::var("APPDATA") {
1668            let openclaw_path = std::path::PathBuf::from(&appdata)
1669                .join("OpenClaw")
1670                .join("workspaceStorage");
1671            if openclaw_path.exists() {
1672                return Some(openclaw_path);
1673            }
1674        }
1675        if let Some(local) = dirs::data_local_dir() {
1676            let local_path = local.join("OpenClaw").join("sessions");
1677            if local_path.exists() {
1678                return Some(local_path);
1679            }
1680        }
1681    }
1682
1683    #[cfg(target_os = "macos")]
1684    {
1685        if let Some(home) = dirs::home_dir() {
1686            let openclaw_path = home
1687                .join("Library")
1688                .join("Application Support")
1689                .join("OpenClaw")
1690                .join("workspaceStorage");
1691            if openclaw_path.exists() {
1692                return Some(openclaw_path);
1693            }
1694        }
1695    }
1696
1697    #[cfg(target_os = "linux")]
1698    {
1699        if let Some(config) = dirs::config_dir() {
1700            let openclaw_path = config.join("openclaw").join("workspaceStorage");
1701            if openclaw_path.exists() {
1702                return Some(openclaw_path);
1703            }
1704        }
1705    }
1706
1707    None
1708}
1709
1710/// Get Antigravity's storage path
1711fn get_antigravity_storage_path() -> Option<std::path::PathBuf> {
1712    #[cfg(target_os = "windows")]
1713    {
1714        if let Ok(appdata) = std::env::var("APPDATA") {
1715            let antigrav_path = std::path::PathBuf::from(&appdata)
1716                .join("Antigravity")
1717                .join("workspaceStorage");
1718            if antigrav_path.exists() {
1719                return Some(antigrav_path);
1720            }
1721        }
1722        if let Some(local) = dirs::data_local_dir() {
1723            let local_path = local.join("Antigravity").join("sessions");
1724            if local_path.exists() {
1725                return Some(local_path);
1726            }
1727        }
1728    }
1729
1730    #[cfg(target_os = "macos")]
1731    {
1732        if let Some(home) = dirs::home_dir() {
1733            let antigrav_path = home
1734                .join("Library")
1735                .join("Application Support")
1736                .join("Antigravity")
1737                .join("workspaceStorage");
1738            if antigrav_path.exists() {
1739                return Some(antigrav_path);
1740            }
1741        }
1742    }
1743
1744    #[cfg(target_os = "linux")]
1745    {
1746        if let Some(config) = dirs::config_dir() {
1747            let antigrav_path = config.join("antigravity").join("workspaceStorage");
1748            if antigrav_path.exists() {
1749                return Some(antigrav_path);
1750            }
1751        }
1752    }
1753
1754    None
1755}
1756
1757/// Get Codex CLI's storage path (OpenAI Codex CLI)
1758/// Stores JSONL session files in ~/.codex/sessions/
1759fn get_codexcli_storage_path() -> Option<std::path::PathBuf> {
1760    if let Some(home) = dirs::home_dir() {
1761        let codex_path = home.join(".codex").join("sessions");
1762        if codex_path.exists() {
1763            return Some(codex_path);
1764        }
1765    }
1766
1767    #[cfg(target_os = "windows")]
1768    {
1769        if let Ok(appdata) = std::env::var("APPDATA") {
1770            let codex_path = std::path::PathBuf::from(&appdata)
1771                .join("codex")
1772                .join("sessions");
1773            if codex_path.exists() {
1774                return Some(codex_path);
1775            }
1776        }
1777        if let Some(local) = dirs::data_local_dir() {
1778            let local_path = local.join("codex").join("sessions");
1779            if local_path.exists() {
1780                return Some(local_path);
1781            }
1782        }
1783    }
1784
1785    None
1786}
1787
1788/// Get Droid CLI's storage path (Factory Droid CLI)
1789/// Stores JSONL session files in ~/.factory/sessions/
1790fn get_droidcli_storage_path() -> Option<std::path::PathBuf> {
1791    if let Some(home) = dirs::home_dir() {
1792        let droid_path = home.join(".factory").join("sessions");
1793        if droid_path.exists() {
1794            return Some(droid_path);
1795        }
1796    }
1797
1798    #[cfg(target_os = "windows")]
1799    {
1800        if let Ok(appdata) = std::env::var("APPDATA") {
1801            let droid_path = std::path::PathBuf::from(&appdata)
1802                .join("factory")
1803                .join("sessions");
1804            if droid_path.exists() {
1805                return Some(droid_path);
1806            }
1807        }
1808        if let Some(local) = dirs::data_local_dir() {
1809            let local_path = local.join("factory").join("sessions");
1810            if local_path.exists() {
1811                return Some(local_path);
1812            }
1813        }
1814    }
1815
1816    None
1817}
1818
1819/// Get Gemini CLI's storage path (Google Gemini CLI)
1820/// Stores JSON session files in ~/.gemini/tmp/
1821fn get_geminicli_storage_path() -> Option<std::path::PathBuf> {
1822    if let Some(home) = dirs::home_dir() {
1823        let gemini_path = home.join(".gemini").join("tmp");
1824        if gemini_path.exists() {
1825            return Some(gemini_path);
1826        }
1827    }
1828
1829    #[cfg(target_os = "windows")]
1830    {
1831        if let Ok(appdata) = std::env::var("APPDATA") {
1832            let gemini_path = std::path::PathBuf::from(&appdata)
1833                .join("gemini")
1834                .join("tmp");
1835            if gemini_path.exists() {
1836                return Some(gemini_path);
1837            }
1838        }
1839        if let Some(local) = dirs::data_local_dir() {
1840            let local_path = local.join("gemini").join("tmp");
1841            if local_path.exists() {
1842                return Some(local_path);
1843            }
1844        }
1845    }
1846
1847    None
1848}
1849
1850/// List agent mode sessions (chatEditingSessions / Copilot Edits)
1851pub fn list_agents_sessions(
1852    project_path: Option<&str>,
1853    show_size: bool,
1854    provider: Option<&str>,
1855) -> Result<()> {
1856    // Get storage paths based on provider filter
1857    let storage_paths = get_agent_storage_paths(provider)?;
1858
1859    if storage_paths.is_empty() {
1860        if let Some(p) = provider {
1861            println!("No storage found for provider: {}", p);
1862            println!("\nSupported providers: vscode, cursor, claudecode, opencode, openclaw, antigravity, codexcli, droidcli, geminicli");
1863        } else {
1864            println!("No workspaces found");
1865        }
1866        return Ok(());
1867    }
1868
1869    #[derive(Tabled)]
1870    struct AgentSessionRow {
1871        #[tabled(rename = "Provider")]
1872        provider: String,
1873        #[tabled(rename = "Project")]
1874        project: String,
1875        #[tabled(rename = "Session ID")]
1876        session_id: String,
1877        #[tabled(rename = "Last Modified")]
1878        last_modified: String,
1879        #[tabled(rename = "Files")]
1880        file_count: usize,
1881    }
1882
1883    #[derive(Tabled)]
1884    struct AgentSessionRowWithSize {
1885        #[tabled(rename = "Provider")]
1886        provider: String,
1887        #[tabled(rename = "Project")]
1888        project: String,
1889        #[tabled(rename = "Session ID")]
1890        session_id: String,
1891        #[tabled(rename = "Last Modified")]
1892        last_modified: String,
1893        #[tabled(rename = "Files")]
1894        file_count: usize,
1895        #[tabled(rename = "Size")]
1896        size: String,
1897    }
1898
1899    let target_path = project_path.map(crate::workspace::normalize_path);
1900    let mut total_size: u64 = 0;
1901    let mut rows_with_size: Vec<AgentSessionRowWithSize> = Vec::new();
1902    let mut rows: Vec<AgentSessionRow> = Vec::new();
1903
1904    for (provider_name, storage_path) in &storage_paths {
1905        if !storage_path.exists() {
1906            continue;
1907        }
1908
1909        for entry in std::fs::read_dir(storage_path)?.filter_map(|e| e.ok()) {
1910            let workspace_dir = entry.path();
1911            if !workspace_dir.is_dir() {
1912                continue;
1913            }
1914
1915            let agent_sessions_dir = workspace_dir.join("chatEditingSessions");
1916            if !agent_sessions_dir.exists() {
1917                continue;
1918            }
1919
1920            // Get project path from workspace.json
1921            let workspace_json = workspace_dir.join("workspace.json");
1922            let project = std::fs::read_to_string(&workspace_json)
1923                .ok()
1924                .and_then(|c| serde_json::from_str::<crate::models::WorkspaceJson>(&c).ok())
1925                .and_then(|ws| {
1926                    ws.folder
1927                        .map(|f| crate::workspace::decode_workspace_folder(&f))
1928                });
1929
1930            // Filter by project path if specified
1931            if let Some(ref target) = target_path {
1932                if project
1933                    .as_ref()
1934                    .map(|p| crate::workspace::normalize_path(p) != *target)
1935                    .unwrap_or(true)
1936                {
1937                    continue;
1938                }
1939            }
1940
1941            let project_name = project
1942                .as_ref()
1943                .and_then(|p| std::path::Path::new(p).file_name())
1944                .map(|n| n.to_string_lossy().to_string())
1945                .unwrap_or_else(|| entry.file_name().to_string_lossy()[..8].to_string());
1946
1947            // List agent session directories
1948            for session_entry in std::fs::read_dir(&agent_sessions_dir)?.filter_map(|e| e.ok()) {
1949                let session_dir = session_entry.path();
1950                if !session_dir.is_dir() {
1951                    continue;
1952                }
1953
1954                let session_id = session_entry.file_name().to_string_lossy().to_string();
1955                let short_id = if session_id.len() > 8 {
1956                    format!("{}...", &session_id[..8])
1957                } else {
1958                    session_id.clone()
1959                };
1960
1961                // Get last modified time and file count
1962                let mut last_mod = std::time::SystemTime::UNIX_EPOCH;
1963                let mut file_count = 0;
1964                let mut session_size: u64 = 0;
1965
1966                if let Ok(files) = std::fs::read_dir(&session_dir) {
1967                    for file in files.filter_map(|f| f.ok()) {
1968                        file_count += 1;
1969                        if let Ok(meta) = file.metadata() {
1970                            session_size += meta.len();
1971                            if let Ok(mod_time) = meta.modified() {
1972                                if mod_time > last_mod {
1973                                    last_mod = mod_time;
1974                                }
1975                            }
1976                        }
1977                    }
1978                }
1979
1980                total_size += session_size;
1981
1982                let modified = if last_mod != std::time::SystemTime::UNIX_EPOCH {
1983                    let datetime: chrono::DateTime<chrono::Utc> = last_mod.into();
1984                    datetime.format("%Y-%m-%d %H:%M").to_string()
1985                } else {
1986                    "unknown".to_string()
1987                };
1988
1989                if show_size {
1990                    rows_with_size.push(AgentSessionRowWithSize {
1991                        provider: provider_name.clone(),
1992                        project: project_name.clone(),
1993                        session_id: short_id,
1994                        last_modified: modified,
1995                        file_count,
1996                        size: format_file_size(session_size),
1997                    });
1998                } else {
1999                    rows.push(AgentSessionRow {
2000                        provider: provider_name.clone(),
2001                        project: project_name.clone(),
2002                        session_id: short_id,
2003                        last_modified: modified,
2004                        file_count,
2005                    });
2006                }
2007            }
2008        }
2009    }
2010
2011    if show_size {
2012        if rows_with_size.is_empty() {
2013            println!("No agent mode sessions found.");
2014            return Ok(());
2015        }
2016        let table = Table::new(&rows_with_size)
2017            .with(TableStyle::ascii_rounded())
2018            .to_string();
2019        println!("{}", table);
2020        println!(
2021            "\nTotal agent sessions: {} ({})",
2022            rows_with_size.len(),
2023            format_file_size(total_size)
2024        );
2025    } else {
2026        if rows.is_empty() {
2027            println!("No agent mode sessions found.");
2028            return Ok(());
2029        }
2030        let table = Table::new(&rows)
2031            .with(TableStyle::ascii_rounded())
2032            .to_string();
2033        println!("{}", table);
2034        println!("\nTotal agent sessions: {}", rows.len());
2035    }
2036
2037    Ok(())
2038}
2039
2040/// Show agent mode session details
2041pub fn show_agent_session(session_id: &str, project_path: Option<&str>) -> Result<()> {
2042    use colored::*;
2043
2044    let storage_path = crate::workspace::get_workspace_storage_path()?;
2045    let session_id_lower = session_id.to_lowercase();
2046    let target_path = project_path.map(crate::workspace::normalize_path);
2047
2048    for entry in std::fs::read_dir(&storage_path)?.filter_map(|e| e.ok()) {
2049        let workspace_dir = entry.path();
2050        if !workspace_dir.is_dir() {
2051            continue;
2052        }
2053
2054        let agent_sessions_dir = workspace_dir.join("chatEditingSessions");
2055        if !agent_sessions_dir.exists() {
2056            continue;
2057        }
2058
2059        // Get project path
2060        let workspace_json = workspace_dir.join("workspace.json");
2061        let project = std::fs::read_to_string(&workspace_json)
2062            .ok()
2063            .and_then(|c| serde_json::from_str::<crate::models::WorkspaceJson>(&c).ok())
2064            .and_then(|ws| {
2065                ws.folder
2066                    .map(|f| crate::workspace::decode_workspace_folder(&f))
2067            });
2068
2069        // Filter by project path if specified
2070        if let Some(ref target) = target_path {
2071            if project
2072                .as_ref()
2073                .map(|p| crate::workspace::normalize_path(p) != *target)
2074                .unwrap_or(true)
2075            {
2076                continue;
2077            }
2078        }
2079
2080        for session_entry in std::fs::read_dir(&agent_sessions_dir)?.filter_map(|e| e.ok()) {
2081            let full_id = session_entry.file_name().to_string_lossy().to_string();
2082            if !full_id.to_lowercase().contains(&session_id_lower) {
2083                continue;
2084            }
2085
2086            let session_dir = session_entry.path();
2087
2088            println!("\n{}", "=".repeat(60).bright_blue());
2089            println!("{}", "Agent Session Details".bright_blue().bold());
2090            println!("{}", "=".repeat(60).bright_blue());
2091
2092            println!(
2093                "{}: {}",
2094                "Session ID".bright_white().bold(),
2095                full_id.bright_cyan()
2096            );
2097            println!(
2098                "{}: {}",
2099                "Project".bright_white().bold(),
2100                project.as_deref().unwrap_or("(none)")
2101            );
2102            println!(
2103                "{}: {}",
2104                "Path".bright_white().bold(),
2105                session_dir.display()
2106            );
2107
2108            // List files in the session
2109            println!("\n{}", "Session Files:".bright_yellow());
2110            let mut total_size: u64 = 0;
2111            if let Ok(files) = std::fs::read_dir(&session_dir) {
2112                for file in files.filter_map(|f| f.ok()) {
2113                    let _path = file.path();
2114                    let name = file.file_name().to_string_lossy().to_string();
2115                    let size = file.metadata().map(|m| m.len()).unwrap_or(0);
2116                    total_size += size;
2117                    println!("  {} ({})", name.dimmed(), format_file_size(size));
2118                }
2119            }
2120            println!(
2121                "\n{}: {}",
2122                "Total Size".bright_white().bold(),
2123                format_file_size(total_size)
2124            );
2125
2126            return Ok(());
2127        }
2128    }
2129
2130    println!(
2131        "{} No agent session found matching '{}'",
2132        "!".yellow(),
2133        session_id
2134    );
2135    Ok(())
2136}
2137
2138/// Show timeline of session activity with gap visualization
2139pub fn show_timeline(
2140    project_path: Option<&str>,
2141    include_agents: bool,
2142    provider: Option<&str>,
2143    all_providers: bool,
2144) -> Result<()> {
2145    use colored::*;
2146    use std::collections::BTreeMap;
2147
2148    // Determine which storage paths to scan
2149    let storage_paths = if all_providers {
2150        get_agent_storage_paths(Some("all"))?
2151    } else if let Some(p) = provider {
2152        get_agent_storage_paths(Some(p))?
2153    } else {
2154        // Default to VS Code only
2155        let vscode_path = crate::workspace::get_workspace_storage_path()?;
2156        if vscode_path.exists() {
2157            vec![("vscode".to_string(), vscode_path)]
2158        } else {
2159            vec![]
2160        }
2161    };
2162
2163    if storage_paths.is_empty() {
2164        if let Some(p) = provider {
2165            println!("No storage found for provider: {}", p);
2166        } else {
2167            println!("No workspaces found");
2168        }
2169        return Ok(());
2170    }
2171
2172    let target_path = project_path.map(crate::workspace::normalize_path);
2173
2174    // Collect all session dates (date -> (chat_count, agent_count, provider))
2175    let mut date_activity: BTreeMap<chrono::NaiveDate, (usize, usize)> = BTreeMap::new();
2176    let mut project_name = String::new();
2177    let mut providers_scanned: Vec<String> = Vec::new();
2178
2179    for (provider_name, storage_path) in &storage_paths {
2180        if !storage_path.exists() {
2181            continue;
2182        }
2183        providers_scanned.push(provider_name.clone());
2184
2185        for entry in std::fs::read_dir(storage_path)?.filter_map(|e| e.ok()) {
2186            let workspace_dir = entry.path();
2187            if !workspace_dir.is_dir() {
2188                continue;
2189            }
2190
2191            // Get project path
2192            let workspace_json = workspace_dir.join("workspace.json");
2193            let project = std::fs::read_to_string(&workspace_json)
2194                .ok()
2195                .and_then(|c| serde_json::from_str::<crate::models::WorkspaceJson>(&c).ok())
2196                .and_then(|ws| {
2197                    ws.folder
2198                        .map(|f| crate::workspace::decode_workspace_folder(&f))
2199                });
2200
2201            // Filter by project path if specified
2202            if let Some(ref target) = target_path {
2203                if project
2204                    .as_ref()
2205                    .map(|p| crate::workspace::normalize_path(p) != *target)
2206                    .unwrap_or(true)
2207                {
2208                    continue;
2209                }
2210                if project_name.is_empty() {
2211                    project_name = std::path::Path::new(target)
2212                        .file_name()
2213                        .map(|n| n.to_string_lossy().to_string())
2214                        .unwrap_or_else(|| target.clone());
2215                }
2216            }
2217
2218            // Scan chatSessions
2219            let chat_sessions_dir = workspace_dir.join("chatSessions");
2220            if chat_sessions_dir.exists() {
2221                if let Ok(files) = std::fs::read_dir(&chat_sessions_dir) {
2222                    for file in files.filter_map(|f| f.ok()) {
2223                        if let Ok(meta) = file.metadata() {
2224                            if let Ok(modified) = meta.modified() {
2225                                let datetime: chrono::DateTime<chrono::Utc> = modified.into();
2226                                let date = datetime.date_naive();
2227                                let entry = date_activity.entry(date).or_insert((0, 0));
2228                                entry.0 += 1;
2229                            }
2230                        }
2231                    }
2232                }
2233            }
2234
2235            // Scan chatEditingSessions (agent mode) if requested
2236            if include_agents {
2237                let agent_sessions_dir = workspace_dir.join("chatEditingSessions");
2238                if agent_sessions_dir.exists() {
2239                    if let Ok(dirs) = std::fs::read_dir(&agent_sessions_dir) {
2240                        for dir in dirs.filter_map(|d| d.ok()) {
2241                            if let Ok(meta) = dir.metadata() {
2242                                if let Ok(modified) = meta.modified() {
2243                                    let datetime: chrono::DateTime<chrono::Utc> = modified.into();
2244                                    let date = datetime.date_naive();
2245                                    let entry = date_activity.entry(date).or_insert((0, 0));
2246                                    entry.1 += 1;
2247                                }
2248                            }
2249                        }
2250                    }
2251                }
2252            }
2253        }
2254    }
2255
2256    if date_activity.is_empty() {
2257        println!("No session activity found.");
2258        return Ok(());
2259    }
2260
2261    let title = if project_name.is_empty() {
2262        "All Workspaces".to_string()
2263    } else {
2264        project_name
2265    };
2266
2267    let provider_info = if providers_scanned.len() > 1 || all_providers {
2268        format!(" ({})", providers_scanned.join(", "))
2269    } else {
2270        String::new()
2271    };
2272
2273    println!(
2274        "\n{} Session Timeline: {}{}",
2275        "[*]".blue(),
2276        title.cyan(),
2277        provider_info.dimmed()
2278    );
2279    println!("{}", "=".repeat(60));
2280
2281    let dates: Vec<_> = date_activity.keys().collect();
2282    let first_date = **dates.first().unwrap();
2283    let last_date = **dates.last().unwrap();
2284
2285    println!(
2286        "Range: {} to {}",
2287        first_date.format("%Y-%m-%d"),
2288        last_date.format("%Y-%m-%d")
2289    );
2290    println!();
2291
2292    // Find gaps (more than 1 day between sessions)
2293    let mut gaps: Vec<(chrono::NaiveDate, chrono::NaiveDate, i64)> = Vec::new();
2294    let mut prev_date: Option<chrono::NaiveDate> = None;
2295
2296    for date in dates.iter() {
2297        if let Some(prev) = prev_date {
2298            let diff = (**date - prev).num_days();
2299            if diff > 1 {
2300                gaps.push((prev, **date, diff));
2301            }
2302        }
2303        prev_date = Some(**date);
2304    }
2305
2306    // Show recent activity (last 14 days worth)
2307    println!("{}", "Recent Activity:".bright_yellow());
2308    let recent_dates: Vec<_> = date_activity.iter().rev().take(14).collect();
2309    for (date, (chats, agents)) in recent_dates.iter().rev() {
2310        let chat_bar = "█".repeat((*chats).min(20));
2311        let agent_bar = if include_agents && *agents > 0 {
2312            format!(" {}", "▓".repeat((*agents).min(10)).bright_magenta())
2313        } else {
2314            String::new()
2315        };
2316        println!(
2317            "  {} │ {}{}",
2318            date.format("%Y-%m-%d"),
2319            chat_bar.bright_green(),
2320            agent_bar
2321        );
2322    }
2323
2324    // Show gaps
2325    if !gaps.is_empty() {
2326        println!("\n{}", "Gaps (>1 day):".bright_red());
2327        for (start, end, days) in gaps.iter().take(10) {
2328            println!(
2329                "  {} → {} ({} days)",
2330                start.format("%Y-%m-%d"),
2331                end.format("%Y-%m-%d"),
2332                days
2333            );
2334        }
2335        if gaps.len() > 10 {
2336            println!("  ... and {} more gaps", gaps.len() - 10);
2337        }
2338    }
2339
2340    // Summary
2341    let total_chats: usize = date_activity.values().map(|(c, _)| c).sum();
2342    let total_agents: usize = date_activity.values().map(|(_, a)| a).sum();
2343    let total_days = date_activity.len();
2344    let total_gap_days: i64 = gaps.iter().map(|(_, _, d)| d - 1).sum();
2345
2346    println!("\n{}", "Summary:".bright_white().bold());
2347    println!("  Active days: {}", total_days);
2348    println!("  Chat sessions: {}", total_chats);
2349    if include_agents {
2350        println!("  Agent sessions: {}", total_agents);
2351    }
2352    println!("  Total gap days: {}", total_gap_days);
2353
2354    if include_agents {
2355        println!(
2356            "\n{} {} = chat, {} = agent",
2357            "Legend:".dimmed(),
2358            "█".bright_green(),
2359            "▓".bright_magenta()
2360        );
2361    }
2362
2363    Ok(())
2364}
2365
2366/// Show the VS Code session index (state.vscdb) for a workspace
2367pub fn show_index(project_path: Option<&str>, all: bool) -> Result<()> {
2368    if all {
2369        return show_index_all();
2370    }
2371
2372    use colored::Colorize;
2373    use tabled::{settings::Style as TableStyle, Table, Tabled};
2374
2375    let path = crate::commands::register::resolve_path(project_path);
2376    let path_str = path.to_string_lossy().to_string();
2377
2378    println!(
2379        "{} Session index for: {}",
2380        "[CSM]".cyan().bold(),
2381        path.display()
2382    );
2383
2384    let (ws_id, ws_path, _folder) = crate::workspace::find_workspace_by_path(&path_str)?
2385        .ok_or_else(|| crate::error::CsmError::WorkspaceNotFound(path.display().to_string()))?;
2386
2387    let db_path = crate::storage::get_workspace_storage_db(&ws_id)?;
2388    let index = crate::storage::read_chat_session_index(&db_path)?;
2389
2390    println!(
2391        "   Workspace: {} ({})",
2392        ws_id.bright_yellow(),
2393        ws_path.display()
2394    );
2395    println!(
2396        "   Index version: {}, entries: {}\n",
2397        index.version,
2398        index.entries.len()
2399    );
2400
2401    #[derive(Tabled)]
2402    struct IndexRow {
2403        #[tabled(rename = "Session ID")]
2404        session_id: String,
2405        #[tabled(rename = "Title")]
2406        title: String,
2407        #[tabled(rename = "isEmpty")]
2408        is_empty: String,
2409        #[tabled(rename = "Last Message")]
2410        last_message: String,
2411        #[tabled(rename = "ResponseState")]
2412        response_state: String,
2413        #[tabled(rename = "Location")]
2414        location: String,
2415    }
2416
2417    let mut rows: Vec<IndexRow> = Vec::new();
2418    for (_, entry) in &index.entries {
2419        let last_msg = if entry.last_message_date > 0 {
2420            let secs = entry.last_message_date / 1000;
2421            chrono::DateTime::from_timestamp(secs, 0)
2422                .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
2423                .unwrap_or_else(|| entry.last_message_date.to_string())
2424        } else {
2425            "0".to_string()
2426        };
2427
2428        let state = match entry.last_response_state {
2429            0 => "Pending",
2430            1 => "Complete",
2431            2 => "Cancelled",
2432            3 => "Failed",
2433            4 => "NeedsInput",
2434            _ => "Unknown",
2435        };
2436
2437        rows.push(IndexRow {
2438            session_id: entry.session_id[..12.min(entry.session_id.len())].to_string(),
2439            title: if entry.title.len() > 40 {
2440                format!("{}...", &entry.title[..37])
2441            } else {
2442                entry.title.clone()
2443            },
2444            is_empty: if entry.is_empty {
2445                "true".red().to_string()
2446            } else {
2447                "false".green().to_string()
2448            },
2449            last_message: last_msg,
2450            response_state: state.to_string(),
2451            location: entry.initial_location.clone(),
2452        });
2453    }
2454
2455    // Sort by last message date descending
2456    rows.sort_by(|a, b| b.last_message.cmp(&a.last_message));
2457
2458    let table = Table::new(&rows)
2459        .with(TableStyle::ascii_rounded())
2460        .to_string();
2461    println!("{}", table);
2462
2463    // Also check for files on disk not in index
2464    let chat_dir = ws_path.join("chatSessions");
2465    if chat_dir.exists() {
2466        let mut disk_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2467        for entry in std::fs::read_dir(&chat_dir)? {
2468            let entry = entry?;
2469            let p = entry.path();
2470            if p.extension()
2471                .map(crate::storage::is_session_file_extension)
2472                .unwrap_or(false)
2473            {
2474                if let Some(stem) = p.file_stem() {
2475                    disk_ids.insert(stem.to_string_lossy().to_string());
2476                }
2477            }
2478        }
2479        let indexed_ids: std::collections::HashSet<String> =
2480            index.entries.keys().cloned().collect();
2481        let orphaned: Vec<_> = disk_ids.difference(&indexed_ids).collect();
2482        let stale: Vec<_> = indexed_ids.difference(&disk_ids).collect();
2483
2484        if !orphaned.is_empty() {
2485            println!(
2486                "\n{} {} session(s) on disk but NOT in index (orphaned):",
2487                "[!]".yellow(),
2488                orphaned.len()
2489            );
2490            for id in &orphaned {
2491                println!("   {}", id.red());
2492            }
2493        }
2494        if !stale.is_empty() {
2495            println!(
2496                "\n{} {} index entries with NO file on disk (stale):",
2497                "[!]".yellow(),
2498                stale.len()
2499            );
2500            for id in &stale {
2501                println!("   {}", id.red());
2502            }
2503        }
2504        if orphaned.is_empty() && stale.is_empty() {
2505            println!("\n{} Index is in sync with files on disk.", "[OK]".green());
2506        }
2507    }
2508
2509    Ok(())
2510}
2511
2512/// Show index summary for all workspaces with chat sessions
2513fn show_index_all() -> Result<()> {
2514    use colored::Colorize;
2515
2516    println!(
2517        "{} Scanning all workspace indexes...\n",
2518        "[CSM]".cyan().bold(),
2519    );
2520
2521    let workspaces = crate::workspace::discover_workspaces()?;
2522    let ws_with_sessions: Vec<_> = workspaces
2523        .iter()
2524        .filter(|w| w.has_chat_sessions && w.chat_session_count > 0)
2525        .collect();
2526
2527    if ws_with_sessions.is_empty() {
2528        println!("{} No workspaces with chat sessions found.", "[!]".yellow());
2529        return Ok(());
2530    }
2531
2532    let mut total_entries = 0usize;
2533    let mut total_non_empty = 0usize;
2534    let mut total_orphaned = 0usize;
2535    let mut total_stale = 0usize;
2536    let mut _sync_ok = 0usize;
2537    let mut sync_issues = 0usize;
2538
2539    for (i, ws) in ws_with_sessions.iter().enumerate() {
2540        let display_name = ws
2541            .project_path
2542            .as_deref()
2543            .unwrap_or(&ws.hash);
2544
2545        let db_path = match crate::storage::get_workspace_storage_db(&ws.hash) {
2546            Ok(p) => p,
2547            Err(_) => {
2548                println!(
2549                    "[{}/{}] {} {} — {} no state.vscdb",
2550                    i + 1,
2551                    ws_with_sessions.len(),
2552                    display_name.cyan(),
2553                    "".dimmed(),
2554                    "[!]".yellow()
2555                );
2556                continue;
2557            }
2558        };
2559
2560        let index = match crate::storage::read_chat_session_index(&db_path) {
2561            Ok(idx) => idx,
2562            Err(_) => {
2563                println!(
2564                    "[{}/{}] {} — {} no index in state.vscdb",
2565                    i + 1,
2566                    ws_with_sessions.len(),
2567                    display_name.cyan(),
2568                    "[!]".yellow()
2569                );
2570                continue;
2571            }
2572        };
2573
2574        let non_empty = index.entries.values().filter(|e| !e.is_empty).count();
2575        total_entries += index.entries.len();
2576        total_non_empty += non_empty;
2577
2578        // Check sync status
2579        let chat_dir = ws.workspace_path.join("chatSessions");
2580        let mut orphaned = 0usize;
2581        let mut stale = 0usize;
2582        if chat_dir.exists() {
2583            let mut disk_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2584            if let Ok(entries) = std::fs::read_dir(&chat_dir) {
2585                for entry in entries.flatten() {
2586                    let p = entry.path();
2587                    if p.extension()
2588                        .map(crate::storage::is_session_file_extension)
2589                        .unwrap_or(false)
2590                    {
2591                        if let Some(stem) = p.file_stem() {
2592                            disk_ids.insert(stem.to_string_lossy().to_string());
2593                        }
2594                    }
2595                }
2596            }
2597            let indexed_ids: std::collections::HashSet<String> =
2598                index.entries.keys().cloned().collect();
2599            orphaned = disk_ids.difference(&indexed_ids).count();
2600            stale = indexed_ids.difference(&disk_ids).count();
2601        }
2602        total_orphaned += orphaned;
2603        total_stale += stale;
2604
2605        let status = if orphaned == 0 && stale == 0 {
2606            _sync_ok += 1;
2607            "[OK]".green().to_string()
2608        } else {
2609            sync_issues += 1;
2610            format!(
2611                "{}{}",
2612                if orphaned > 0 {
2613                    format!("{} orphaned ", orphaned).yellow().to_string()
2614                } else {
2615                    String::new()
2616                },
2617                if stale > 0 {
2618                    format!("{} stale", stale).yellow().to_string()
2619                } else {
2620                    String::new()
2621                }
2622            )
2623        };
2624
2625        println!(
2626            "[{:>3}/{}] {} — {} entries ({} with content) {}",
2627            i + 1,
2628            ws_with_sessions.len(),
2629            display_name.cyan(),
2630            index.entries.len(),
2631            non_empty.to_string().green(),
2632            status
2633        );
2634    }
2635
2636    println!(
2637        "\n{} {} workspaces, {} index entries ({} with content)",
2638        "[OK]".green().bold(),
2639        ws_with_sessions.len().to_string().cyan(),
2640        total_entries.to_string().cyan(),
2641        total_non_empty.to_string().green()
2642    );
2643    if sync_issues > 0 {
2644        println!(
2645            "   {} {}/{} workspaces have sync issues ({} orphaned, {} stale)",
2646            "[!]".yellow(),
2647            sync_issues,
2648            ws_with_sessions.len(),
2649            total_orphaned,
2650            total_stale
2651        );
2652    } else {
2653        println!(
2654            "   {} All indexes in sync with files on disk.",
2655            "[OK]".green()
2656        );
2657    }
2658
2659    Ok(())
2660}