chasm_cli/commands/
workspace_cmds.rs

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