Skip to main content

chasm_cli/commands/
workspace_cmds.rs

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