chasm_cli/commands/
workspace_cmds.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Workspace listing commands
4
5use anyhow::Result;
6use colored::Colorize;
7use tabled::{settings::Style, Table, Tabled};
8
9use crate::models::Workspace;
10use crate::storage::read_empty_window_sessions;
11use crate::workspace::discover_workspaces;
12
13#[derive(Tabled)]
14struct WorkspaceRow {
15    #[tabled(rename = "Hash")]
16    hash: String,
17    #[tabled(rename = "Project Path")]
18    project_path: String,
19    #[tabled(rename = "Sessions")]
20    sessions: String,
21    #[tabled(rename = "Has Chats")]
22    has_chats: String,
23}
24
25#[derive(Tabled)]
26struct SessionRow {
27    #[tabled(rename = "Project Path")]
28    project_path: String,
29    #[tabled(rename = "Session File")]
30    session_file: String,
31    #[tabled(rename = "Last Modified")]
32    last_modified: String,
33    #[tabled(rename = "Messages")]
34    messages: usize,
35}
36
37/// List all VS Code workspaces
38pub fn list_workspaces() -> Result<()> {
39    let workspaces = discover_workspaces()?;
40
41    if workspaces.is_empty() {
42        println!("{} No workspaces found.", "[!]".yellow());
43        return Ok(());
44    }
45
46    let rows: Vec<WorkspaceRow> = workspaces
47        .iter()
48        .map(|ws| WorkspaceRow {
49            hash: format!(
50                "{}",
51                format!("{}...", &ws.hash[..12.min(ws.hash.len())]).cyan()
52            ),
53            project_path: ws
54                .project_path
55                .clone()
56                .unwrap_or_else(|| "(none)".to_string()),
57            sessions: format!("{}", ws.chat_session_count.to_string().green()),
58            has_chats: if ws.has_chat_sessions {
59                format!("{}", "Yes".green())
60            } else {
61                format!("{}", "No".red())
62            },
63        })
64        .collect();
65
66    let table = Table::new(rows).with(Style::ascii_rounded()).to_string();
67    println!("{}", table);
68    println!(
69        "\n{} Total workspaces: {}",
70        "[=]".blue(),
71        workspaces.len().to_string().yellow()
72    );
73
74    // Show empty window sessions count (ALL SESSIONS)
75    if let Ok(empty_count) = crate::storage::count_empty_window_sessions() {
76        if empty_count > 0 {
77            println!(
78                "{} Empty window sessions (ALL SESSIONS): {}",
79                "[i]".cyan(),
80                empty_count.to_string().yellow()
81            );
82        }
83    }
84
85    Ok(())
86}
87
88/// List all chat sessions
89pub fn list_sessions(project_path: Option<&str>) -> Result<()> {
90    let workspaces = discover_workspaces()?;
91
92    let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
93        let normalized = crate::workspace::normalize_path(path);
94        workspaces
95            .iter()
96            .filter(|ws| {
97                ws.project_path
98                    .as_ref()
99                    .map(|p| crate::workspace::normalize_path(p) == normalized)
100                    .unwrap_or(false)
101            })
102            .collect()
103    } else {
104        workspaces.iter().collect()
105    };
106
107    let mut rows: Vec<SessionRow> = Vec::new();
108
109    // Add empty window sessions (ALL SESSIONS) if no specific project filter
110    if project_path.is_none() {
111        if let Ok(empty_sessions) = read_empty_window_sessions() {
112            for session in empty_sessions {
113                let modified = chrono::DateTime::from_timestamp_millis(session.last_message_date)
114                    .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
115                    .unwrap_or_else(|| "unknown".to_string());
116
117                let session_id = session.session_id.as_deref().unwrap_or("unknown");
118                rows.push(SessionRow {
119                    project_path: "(ALL SESSIONS)".to_string(),
120                    session_file: format!("{}.json", session_id),
121                    last_modified: modified,
122                    messages: session.request_count(),
123                });
124            }
125        }
126    }
127
128    for ws in filtered_workspaces {
129        if !ws.has_chat_sessions {
130            continue;
131        }
132
133        let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
134
135        for session_with_path in sessions {
136            let modified = session_with_path
137                .path
138                .metadata()
139                .ok()
140                .and_then(|m| m.modified().ok())
141                .map(|t| {
142                    let datetime: chrono::DateTime<chrono::Utc> = t.into();
143                    datetime.format("%Y-%m-%d %H:%M").to_string()
144                })
145                .unwrap_or_else(|| "unknown".to_string());
146
147            rows.push(SessionRow {
148                project_path: ws
149                    .project_path
150                    .clone()
151                    .unwrap_or_else(|| "(none)".to_string()),
152                session_file: session_with_path
153                    .path
154                    .file_name()
155                    .map(|n| n.to_string_lossy().to_string())
156                    .unwrap_or_else(|| "unknown".to_string()),
157                last_modified: modified,
158                messages: session_with_path.session.request_count(),
159            });
160        }
161    }
162
163    if rows.is_empty() {
164        println!("{} No chat sessions found.", "[!]".yellow());
165        return Ok(());
166    }
167
168    let table = Table::new(&rows).with(Style::ascii_rounded()).to_string();
169
170    println!("{}", table.dimmed());
171    println!(
172        "\n{} Total sessions: {}",
173        "[=]".blue(),
174        rows.len().to_string().yellow()
175    );
176
177    Ok(())
178}
179
180/// Find workspaces by search pattern
181pub fn find_workspaces(pattern: &str) -> Result<()> {
182    let workspaces = discover_workspaces()?;
183
184    // Resolve "." to current directory name
185    let pattern = if pattern == "." {
186        std::env::current_dir()
187            .ok()
188            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
189            .unwrap_or_else(|| pattern.to_string())
190    } else {
191        pattern.to_string()
192    };
193    let pattern_lower = pattern.to_lowercase();
194
195    let matching: Vec<&Workspace> = workspaces
196        .iter()
197        .filter(|ws| {
198            ws.project_path
199                .as_ref()
200                .map(|p| p.to_lowercase().contains(&pattern_lower))
201                .unwrap_or(false)
202                || ws.hash.to_lowercase().contains(&pattern_lower)
203        })
204        .collect();
205
206    if matching.is_empty() {
207        println!(
208            "{} No workspaces found matching '{}'",
209            "[!]".yellow(),
210            pattern.cyan()
211        );
212        return Ok(());
213    }
214
215    let rows: Vec<WorkspaceRow> = matching
216        .iter()
217        .map(|ws| WorkspaceRow {
218            hash: format!(
219                "{}",
220                format!("{}...", &ws.hash[..12.min(ws.hash.len())]).cyan()
221            ),
222            project_path: ws
223                .project_path
224                .clone()
225                .unwrap_or_else(|| "(none)".to_string()),
226            sessions: format!("{}", ws.chat_session_count.to_string().green()),
227            has_chats: if ws.has_chat_sessions {
228                format!("{}", "Yes".green())
229            } else {
230                format!("{}", "No".red())
231            },
232        })
233        .collect();
234
235    let table = Table::new(rows).with(Style::ascii_rounded()).to_string();
236
237    println!("{}", table);
238    println!(
239        "\n{} Found {} matching workspace(s)",
240        "[=]".blue(),
241        matching.len().to_string().yellow()
242    );
243
244    // Show session paths for each matching workspace
245    for ws in &matching {
246        if ws.has_chat_sessions {
247            let project = ws.project_path.as_deref().unwrap_or("(none)");
248            println!("\nSessions for {}:", project);
249
250            if let Ok(sessions) =
251                crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)
252            {
253                for session_with_path in sessions {
254                    println!("  {}", session_with_path.path.display());
255                }
256            }
257        }
258    }
259
260    Ok(())
261}
262
263/// Find sessions by search pattern
264#[allow(dead_code)]
265pub fn find_sessions(pattern: &str, project_path: Option<&str>) -> Result<()> {
266    let workspaces = discover_workspaces()?;
267    let pattern_lower = pattern.to_lowercase();
268
269    let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
270        let normalized = crate::workspace::normalize_path(path);
271        workspaces
272            .iter()
273            .filter(|ws| {
274                ws.project_path
275                    .as_ref()
276                    .map(|p| crate::workspace::normalize_path(p) == normalized)
277                    .unwrap_or(false)
278            })
279            .collect()
280    } else {
281        workspaces.iter().collect()
282    };
283
284    let mut rows: Vec<SessionRow> = Vec::new();
285
286    for ws in filtered_workspaces {
287        if !ws.has_chat_sessions {
288            continue;
289        }
290
291        let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
292
293        for session_with_path in sessions {
294            // Check if session matches the pattern
295            let session_id_matches = session_with_path
296                .session
297                .session_id
298                .as_ref()
299                .map(|id| id.to_lowercase().contains(&pattern_lower))
300                .unwrap_or(false);
301            let title_matches = session_with_path
302                .session
303                .title()
304                .to_lowercase()
305                .contains(&pattern_lower);
306            let content_matches = session_with_path.session.requests.iter().any(|r| {
307                r.message
308                    .as_ref()
309                    .map(|m| {
310                        m.text
311                            .as_ref()
312                            .map(|t| t.to_lowercase().contains(&pattern_lower))
313                            .unwrap_or(false)
314                    })
315                    .unwrap_or(false)
316            });
317
318            if !session_id_matches && !title_matches && !content_matches {
319                continue;
320            }
321
322            let modified = session_with_path
323                .path
324                .metadata()
325                .ok()
326                .and_then(|m| m.modified().ok())
327                .map(|t| {
328                    let datetime: chrono::DateTime<chrono::Utc> = t.into();
329                    datetime.format("%Y-%m-%d %H:%M").to_string()
330                })
331                .unwrap_or_else(|| "unknown".to_string());
332
333            rows.push(SessionRow {
334                project_path: ws
335                    .project_path
336                    .clone()
337                    .unwrap_or_else(|| "(none)".to_string()),
338                session_file: session_with_path
339                    .path
340                    .file_name()
341                    .map(|n| n.to_string_lossy().to_string())
342                    .unwrap_or_else(|| "unknown".to_string()),
343                last_modified: modified,
344                messages: session_with_path.session.request_count(),
345            });
346        }
347    }
348
349    if rows.is_empty() {
350        println!("No sessions found matching '{}'", pattern);
351        return Ok(());
352    }
353
354    let table = Table::new(&rows).with(Style::ascii_rounded()).to_string();
355
356    println!("{}", table);
357    println!(
358        "\n{} Found {} matching session(s)",
359        "[=]".blue(),
360        rows.len().to_string().yellow()
361    );
362
363    Ok(())
364}
365
366/// Optimized session search with filtering
367///
368/// This function is optimized for speed by:
369/// 1. Filtering workspaces first (by name/path)
370/// 2. Filtering by file modification date before reading content
371/// 3. Only parsing JSON when needed
372/// 4. Content search is opt-in (expensive)
373/// 5. Parallel file scanning with rayon
374pub fn find_sessions_filtered(
375    pattern: &str,
376    workspace_filter: Option<&str>,
377    title_only: bool,
378    search_content: bool,
379    after: Option<&str>,
380    before: Option<&str>,
381    limit: usize,
382) -> Result<()> {
383    use chrono::{NaiveDate, Utc};
384    use rayon::prelude::*;
385    use std::sync::atomic::{AtomicUsize, Ordering};
386
387    let pattern_lower = pattern.to_lowercase();
388
389    // Parse date filters upfront
390    let after_date = after.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
391    let before_date = before.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
392
393    // Get workspace storage path directly - avoid full discovery if filtering
394    let storage_path = crate::workspace::get_workspace_storage_path()?;
395    if !storage_path.exists() {
396        println!("No workspaces found");
397        return Ok(());
398    }
399
400    // Collect workspace directories with minimal I/O
401    let ws_filter_lower = workspace_filter.map(|s| s.to_lowercase());
402
403    let workspace_dirs: Vec<_> = std::fs::read_dir(&storage_path)?
404        .filter_map(|e| e.ok())
405        .filter(|e| e.path().is_dir())
406        .filter_map(|entry| {
407            let workspace_dir = entry.path();
408            let workspace_json_path = workspace_dir.join("workspace.json");
409
410            // Quick check: does chatSessions exist?
411            let chat_sessions_dir = workspace_dir.join("chatSessions");
412            if !chat_sessions_dir.exists() {
413                return None;
414            }
415
416            // Parse workspace.json for project path (needed for filtering)
417            let project_path =
418                std::fs::read_to_string(&workspace_json_path)
419                    .ok()
420                    .and_then(|content| {
421                        serde_json::from_str::<crate::models::WorkspaceJson>(&content)
422                            .ok()
423                            .and_then(|ws| {
424                                ws.folder
425                                    .map(|f| crate::workspace::decode_workspace_folder(&f))
426                            })
427                    });
428
429            // Apply workspace filter early
430            if let Some(ref filter) = ws_filter_lower {
431                let hash = entry.file_name().to_string_lossy().to_lowercase();
432                let path_matches = project_path
433                    .as_ref()
434                    .map(|p| p.to_lowercase().contains(filter))
435                    .unwrap_or(false);
436                if !hash.contains(filter) && !path_matches {
437                    return None;
438                }
439            }
440
441            let ws_name = project_path
442                .as_ref()
443                .and_then(|p| std::path::Path::new(p).file_name())
444                .map(|n| n.to_string_lossy().to_string())
445                .unwrap_or_else(|| {
446                    entry.file_name().to_string_lossy()[..8.min(entry.file_name().len())]
447                        .to_string()
448                });
449
450            Some((chat_sessions_dir, ws_name))
451        })
452        .collect();
453
454    if workspace_dirs.is_empty() {
455        if let Some(ws) = workspace_filter {
456            println!("No workspaces found matching '{}'", ws);
457        } else {
458            println!("No workspaces with chat sessions found");
459        }
460        return Ok(());
461    }
462
463    // Collect all session file paths
464    let session_files: Vec<_> = workspace_dirs
465        .iter()
466        .flat_map(|(chat_dir, ws_name)| {
467            std::fs::read_dir(chat_dir)
468                .into_iter()
469                .flatten()
470                .filter_map(|e| e.ok())
471                .filter(|e| {
472                    e.path()
473                        .extension()
474                        .map(|ext| ext == "json")
475                        .unwrap_or(false)
476                })
477                .map(|e| (e.path(), ws_name.clone()))
478                .collect::<Vec<_>>()
479        })
480        .collect();
481
482    let total_files = session_files.len();
483    let scanned = AtomicUsize::new(0);
484    let skipped_by_date = AtomicUsize::new(0);
485
486    // Process files in parallel
487    let mut results: Vec<_> = session_files
488        .par_iter()
489        .filter_map(|(path, ws_name)| {
490            // Date filter using file metadata (very fast)
491            if after_date.is_some() || before_date.is_some() {
492                if let Ok(metadata) = path.metadata() {
493                    if let Ok(modified) = metadata.modified() {
494                        let file_date: chrono::DateTime<Utc> = modified.into();
495                        let file_naive = file_date.date_naive();
496
497                        if let Some(after) = after_date {
498                            if file_naive < after {
499                                skipped_by_date.fetch_add(1, Ordering::Relaxed);
500                                return None;
501                            }
502                        }
503                        if let Some(before) = before_date {
504                            if file_naive > before {
505                                skipped_by_date.fetch_add(1, Ordering::Relaxed);
506                                return None;
507                            }
508                        }
509                    }
510                }
511            }
512
513            scanned.fetch_add(1, Ordering::Relaxed);
514
515            // Read file content once
516            let content = match std::fs::read_to_string(path) {
517                Ok(c) => c,
518                Err(_) => return None,
519            };
520
521            // Extract title from content
522            let title =
523                extract_title_from_content(&content).unwrap_or_else(|| "Untitled".to_string());
524            let title_lower = title.to_lowercase();
525
526            // Check session ID from filename
527            let session_id = path
528                .file_stem()
529                .map(|n| n.to_string_lossy().to_string())
530                .unwrap_or_default();
531            let id_matches =
532                !pattern_lower.is_empty() && session_id.to_lowercase().contains(&pattern_lower);
533
534            // Check title match
535            let title_matches = !pattern_lower.is_empty() && title_lower.contains(&pattern_lower);
536
537            // Content search if requested
538            let content_matches = if search_content
539                && !title_only
540                && !id_matches
541                && !title_matches
542                && !pattern_lower.is_empty()
543            {
544                content.to_lowercase().contains(&pattern_lower)
545            } else {
546                false
547            };
548
549            // Empty pattern matches everything (for listing)
550            let matches =
551                pattern_lower.is_empty() || id_matches || title_matches || content_matches;
552            if !matches {
553                return None;
554            }
555
556            let match_type = if pattern_lower.is_empty() {
557                ""
558            } else if id_matches {
559                "ID"
560            } else if title_matches {
561                "title"
562            } else {
563                "content"
564            };
565
566            // Count messages from content (already loaded)
567            let message_count = content.matches("\"message\":").count();
568
569            // Get modification time
570            let modified = path
571                .metadata()
572                .ok()
573                .and_then(|m| m.modified().ok())
574                .map(|t| {
575                    let datetime: chrono::DateTime<chrono::Utc> = t.into();
576                    datetime.format("%Y-%m-%d %H:%M").to_string()
577                })
578                .unwrap_or_else(|| "unknown".to_string());
579
580            Some((
581                title,
582                ws_name.clone(),
583                modified,
584                message_count,
585                match_type.to_string(),
586            ))
587        })
588        .collect();
589
590    let scanned_count = scanned.load(Ordering::Relaxed);
591    let skipped_count = skipped_by_date.load(Ordering::Relaxed);
592
593    if results.is_empty() {
594        println!("No sessions found matching '{}'", pattern);
595        if skipped_count > 0 {
596            println!("  ({} sessions skipped due to date filter)", skipped_count);
597        }
598        return Ok(());
599    }
600
601    // Sort by modification date (newest first)
602    results.sort_by(|a, b| b.2.cmp(&a.2));
603
604    // Apply limit
605    results.truncate(limit);
606
607    #[derive(Tabled)]
608    struct SearchResultRow {
609        #[tabled(rename = "Title")]
610        title: String,
611        #[tabled(rename = "Workspace")]
612        workspace: String,
613        #[tabled(rename = "Modified")]
614        modified: String,
615        #[tabled(rename = "Msgs")]
616        messages: usize,
617        #[tabled(rename = "Match")]
618        match_type: String,
619    }
620
621    let rows: Vec<SearchResultRow> = results
622        .into_iter()
623        .map(
624            |(title, workspace, modified, messages, match_type)| SearchResultRow {
625                title: truncate_string(&title, 40),
626                workspace: truncate_string(&workspace, 20),
627                modified,
628                messages,
629                match_type,
630            },
631        )
632        .collect();
633
634    let table = Table::new(&rows).with(Style::ascii_rounded()).to_string();
635
636    println!("{}", table);
637    println!(
638        "\nFound {} session(s) (scanned {} of {} files{})",
639        rows.len(),
640        scanned_count,
641        total_files,
642        if skipped_count > 0 {
643            format!(", {} skipped by date", skipped_count)
644        } else {
645            String::new()
646        }
647    );
648    if rows.len() >= limit {
649        println!("  (results limited to {}; use --limit to show more)", limit);
650    }
651
652    Ok(())
653}
654
655/// Extract title from full JSON content (more reliable than header-only)
656fn extract_title_from_content(content: &str) -> Option<String> {
657    // Look for "customTitle" first (user-set title)
658    if let Some(start) = content.find("\"customTitle\"") {
659        if let Some(colon) = content[start..].find(':') {
660            let after_colon = &content[start + colon + 1..];
661            let trimmed = after_colon.trim_start();
662            if let Some(stripped) = trimmed.strip_prefix('"') {
663                if let Some(end) = stripped.find('"') {
664                    let title = &stripped[..end];
665                    if !title.is_empty() && title != "null" {
666                        return Some(title.to_string());
667                    }
668                }
669            }
670        }
671    }
672
673    // Fall back to first request's message text
674    if let Some(start) = content.find("\"text\"") {
675        if let Some(colon) = content[start..].find(':') {
676            let after_colon = &content[start + colon + 1..];
677            let trimmed = after_colon.trim_start();
678            if let Some(stripped) = trimmed.strip_prefix('"') {
679                if let Some(end) = stripped.find('"') {
680                    let title = &stripped[..end];
681                    if !title.is_empty() && title.len() < 100 {
682                        return Some(title.to_string());
683                    }
684                }
685            }
686        }
687    }
688
689    None
690}
691
692/// Fast title extraction from JSON header
693#[allow(dead_code)]
694fn extract_title_fast(header: &str) -> Option<String> {
695    extract_title_from_content(header)
696}
697
698/// Truncate string to max length with ellipsis
699fn truncate_string(s: &str, max_len: usize) -> String {
700    if s.len() <= max_len {
701        s.to_string()
702    } else {
703        format!("{}...", &s[..max_len.saturating_sub(3)])
704    }
705}
706
707/// Show workspace details
708pub fn show_workspace(workspace: &str) -> Result<()> {
709    use colored::Colorize;
710
711    let workspaces = discover_workspaces()?;
712    let workspace_lower = workspace.to_lowercase();
713
714    // Find workspace by name or hash
715    let matching: Vec<&Workspace> = workspaces
716        .iter()
717        .filter(|ws| {
718            ws.hash.to_lowercase().contains(&workspace_lower)
719                || ws
720                    .project_path
721                    .as_ref()
722                    .map(|p| p.to_lowercase().contains(&workspace_lower))
723                    .unwrap_or(false)
724        })
725        .collect();
726
727    if matching.is_empty() {
728        println!(
729            "{} No workspace found matching '{}'",
730            "!".yellow(),
731            workspace
732        );
733        return Ok(());
734    }
735
736    for ws in matching {
737        println!("\n{}", "=".repeat(60).bright_blue());
738        println!("{}", "Workspace Details".bright_blue().bold());
739        println!("{}", "=".repeat(60).bright_blue());
740
741        println!("{}: {}", "Hash".bright_white().bold(), ws.hash);
742        println!(
743            "{}: {}",
744            "Path".bright_white().bold(),
745            ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
746        );
747        println!(
748            "{}: {}",
749            "Has Sessions".bright_white().bold(),
750            if ws.has_chat_sessions {
751                "Yes".green()
752            } else {
753                "No".red()
754            }
755        );
756        println!(
757            "{}: {}",
758            "Workspace Path".bright_white().bold(),
759            ws.workspace_path.display()
760        );
761
762        if ws.has_chat_sessions {
763            let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
764            println!(
765                "{}: {}",
766                "Session Count".bright_white().bold(),
767                sessions.len()
768            );
769
770            if !sessions.is_empty() {
771                println!("\n{}", "Sessions:".bright_yellow());
772                for (i, s) in sessions.iter().enumerate() {
773                    let title = s.session.title();
774                    let msg_count = s.session.request_count();
775                    println!(
776                        "  {}. {} ({} messages)",
777                        i + 1,
778                        title.bright_cyan(),
779                        msg_count
780                    );
781                }
782            }
783        }
784    }
785
786    Ok(())
787}
788
789/// Show session details
790pub fn show_session(session_id: &str, project_path: Option<&str>) -> Result<()> {
791    use colored::Colorize;
792
793    let workspaces = discover_workspaces()?;
794    let session_id_lower = session_id.to_lowercase();
795
796    let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
797        let normalized = crate::workspace::normalize_path(path);
798        workspaces
799            .iter()
800            .filter(|ws| {
801                ws.project_path
802                    .as_ref()
803                    .map(|p| crate::workspace::normalize_path(p) == normalized)
804                    .unwrap_or(false)
805            })
806            .collect()
807    } else {
808        workspaces.iter().collect()
809    };
810
811    for ws in filtered_workspaces {
812        if !ws.has_chat_sessions {
813            continue;
814        }
815
816        let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
817
818        for s in sessions {
819            let filename = s
820                .path
821                .file_name()
822                .map(|n| n.to_string_lossy().to_string())
823                .unwrap_or_default();
824
825            let matches = s
826                .session
827                .session_id
828                .as_ref()
829                .map(|id| id.to_lowercase().contains(&session_id_lower))
830                .unwrap_or(false)
831                || filename.to_lowercase().contains(&session_id_lower);
832
833            if matches {
834                println!("\n{}", "=".repeat(60).bright_blue());
835                println!("{}", "Session Details".bright_blue().bold());
836                println!("{}", "=".repeat(60).bright_blue());
837
838                println!(
839                    "{}: {}",
840                    "Title".bright_white().bold(),
841                    s.session.title().bright_cyan()
842                );
843                println!("{}: {}", "File".bright_white().bold(), filename);
844                println!(
845                    "{}: {}",
846                    "Session ID".bright_white().bold(),
847                    s.session
848                        .session_id
849                        .as_ref()
850                        .unwrap_or(&"(none)".to_string())
851                );
852                println!(
853                    "{}: {}",
854                    "Messages".bright_white().bold(),
855                    s.session.request_count()
856                );
857                println!(
858                    "{}: {}",
859                    "Workspace".bright_white().bold(),
860                    ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
861                );
862
863                // Show first few messages as preview
864                println!("\n{}", "Preview:".bright_yellow());
865                for (i, req) in s.session.requests.iter().take(3).enumerate() {
866                    if let Some(msg) = &req.message {
867                        if let Some(text) = &msg.text {
868                            let preview: String = text.chars().take(100).collect();
869                            let truncated = if text.len() > 100 { "..." } else { "" };
870                            println!("  {}. {}{}", i + 1, preview.dimmed(), truncated);
871                        }
872                    }
873                }
874
875                return Ok(());
876            }
877        }
878    }
879
880    println!(
881        "{} No session found matching '{}'",
882        "!".yellow(),
883        session_id
884    );
885    Ok(())
886}