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