Skip to main content

chasm_cli/commands/
history.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! History commands (show, fetch, merge)
4
5use anyhow::{Context, Result};
6use chrono::{DateTime, Utc};
7use colored::*;
8use std::path::Path;
9use uuid::Uuid;
10
11use crate::models::{ChatRequest, ChatSession};
12use crate::storage::{
13    add_session_to_index, backup_workspace_sessions, get_workspace_storage_db, is_vscode_running,
14    register_all_sessions_from_directory,
15};
16use crate::workspace::{
17    discover_workspaces, find_all_workspaces_for_project, find_workspace_by_path,
18    get_chat_sessions_from_workspace, normalize_path,
19};
20
21/// Show all chat sessions across workspaces for current project
22pub fn history_show(project_path: Option<&str>) -> Result<()> {
23    // Resolve the project path, handling "." specially
24    let project_path = match project_path {
25        Some(".") | None => std::env::current_dir()
26            .map(|p| p.to_string_lossy().to_string())
27            .unwrap_or_else(|_| ".".to_string()),
28        Some(p) => {
29            // If it's a relative path, try to resolve it
30            let path = Path::new(p);
31            if path.is_relative() {
32                std::env::current_dir()
33                    .map(|cwd| cwd.join(path).to_string_lossy().to_string())
34                    .unwrap_or_else(|_| p.to_string())
35            } else {
36                p.to_string()
37            }
38        }
39    };
40
41    let project_name = Path::new(&project_path)
42        .file_name()
43        .map(|n| n.to_string_lossy().to_string())
44        .unwrap_or_else(|| project_path.clone());
45
46    println!(
47        "\n{} Chat History for: {}",
48        "[*]".blue(),
49        project_name.cyan()
50    );
51    println!("{}", "=".repeat(70));
52
53    // Find all workspaces for this project
54    let all_workspaces = find_all_workspaces_for_project(&project_name)?;
55
56    if all_workspaces.is_empty() {
57        println!(
58            "\n{} No workspaces found matching '{}'",
59            "[!]".yellow(),
60            project_name
61        );
62        return Ok(());
63    }
64
65    // Find current workspace
66    let current_ws = find_workspace_by_path(&project_path)?;
67    let current_ws_id = current_ws.as_ref().map(|(id, _, _)| id.clone());
68
69    let mut total_sessions = 0;
70    let mut total_requests = 0;
71
72    for (ws_id, ws_dir, folder_path, last_mod) in &all_workspaces {
73        let is_current = current_ws_id.as_ref() == Some(ws_id);
74        let marker = if is_current { "-> " } else { "   " };
75        let label = if is_current {
76            " (current)".green().to_string()
77        } else {
78            "".to_string()
79        };
80
81        let mod_date: DateTime<Utc> = (*last_mod).into();
82        let mod_str = mod_date.format("%Y-%m-%d %H:%M").to_string();
83
84        let sessions = get_chat_sessions_from_workspace(ws_dir)?;
85
86        println!(
87            "\n{}Workspace: {}...{}",
88            marker.cyan(),
89            &ws_id[..16.min(ws_id.len())],
90            label
91        );
92        println!("   Path: {}", folder_path.as_deref().unwrap_or("(none)"));
93        println!("   Modified: {}", mod_str);
94        println!("   Sessions: {}", sessions.len());
95
96        if !sessions.is_empty() {
97            for session_with_path in &sessions {
98                let session = &session_with_path.session;
99                let title = session.title();
100                let request_count = session.request_count();
101
102                // Get timestamp range
103                let date_range = if let Some((first, last)) = session.timestamp_range() {
104                    let first_date = timestamp_to_date(first);
105                    let last_date = timestamp_to_date(last);
106                    if first_date == last_date {
107                        first_date
108                    } else {
109                        format!("{} -> {}", first_date, last_date)
110                    }
111                } else {
112                    "empty".to_string()
113                };
114
115                println!(
116                    "     {} {:<40} ({:3} msgs) [{}]",
117                    "[-]".blue(),
118                    truncate(&title, 40),
119                    request_count,
120                    date_range
121                );
122
123                total_requests += request_count;
124                total_sessions += 1;
125            }
126        }
127    }
128
129    println!("\n{}", "=".repeat(70));
130    println!(
131        "Total: {} sessions, {} messages across {} workspace(s)",
132        total_sessions,
133        total_requests,
134        all_workspaces.len()
135    );
136
137    Ok(())
138}
139
140/// Fetch chat sessions from other workspaces into current workspace
141pub fn history_fetch(project_path: Option<&str>, force: bool, no_register: bool) -> Result<()> {
142    // Resolve the path - canonicalize handles "." and relative paths
143    let project_path = match project_path {
144        Some(p) => {
145            let path = Path::new(p);
146            path.canonicalize()
147                .map(|p| p.to_string_lossy().to_string())
148                .unwrap_or_else(|_| p.to_string())
149        }
150        None => std::env::current_dir()
151            .map(|p| p.to_string_lossy().to_string())
152            .unwrap_or_else(|_| ".".to_string()),
153    };
154
155    let project_name = Path::new(&project_path)
156        .file_name()
157        .map(|n| n.to_string_lossy().to_string())
158        .unwrap_or_else(|| project_path.clone());
159
160    println!(
161        "\n{} Fetching Chat History for: {}",
162        "[<]".blue(),
163        project_name.cyan()
164    );
165    println!("{}", "=".repeat(70));
166
167    // Find current workspace
168    let current_ws = find_workspace_by_path(&project_path)?
169        .context("Current workspace not found. Make sure the project is opened in VS Code")?;
170    let (current_ws_id, current_ws_dir, _) = current_ws;
171
172    // Find all workspaces for this project
173    let all_workspaces = find_all_workspaces_for_project(&project_name)?;
174    let historical_workspaces: Vec<_> = all_workspaces
175        .into_iter()
176        .filter(|(id, _, _, _)| *id != current_ws_id)
177        .collect();
178
179    if historical_workspaces.is_empty() {
180        println!(
181            "{} No historical workspaces found for '{}'",
182            "[!]".yellow(),
183            project_name
184        );
185        println!("   Only the current workspace exists.");
186        return Ok(());
187    }
188
189    println!(
190        "Found {} historical workspace(s)\n",
191        historical_workspaces.len()
192    );
193
194    // Create chatSessions directory
195    let chat_sessions_dir = current_ws_dir.join("chatSessions");
196    std::fs::create_dir_all(&chat_sessions_dir)?;
197
198    let mut fetched_count = 0;
199    let mut skipped_count = 0;
200
201    for (_, ws_dir, _, _) in &historical_workspaces {
202        let sessions = get_chat_sessions_from_workspace(ws_dir)?;
203
204        for session_with_path in sessions {
205            // Get session ID from filename if not in data
206            let session_id = session_with_path
207                .session
208                .session_id
209                .clone()
210                .unwrap_or_else(|| {
211                    session_with_path
212                        .path
213                        .file_stem()
214                        .map(|s| s.to_string_lossy().to_string())
215                        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
216                });
217            let dest_file = chat_sessions_dir.join(format!("{}.json", session_id));
218
219            if dest_file.exists() && !force {
220                println!(
221                    "   {} Skipped (exists): {}...",
222                    "[>]".yellow(),
223                    &session_id[..16.min(session_id.len())]
224                );
225                skipped_count += 1;
226            } else {
227                std::fs::copy(&session_with_path.path, &dest_file)?;
228                let title = session_with_path.session.title();
229                println!(
230                    "   {} Fetched: {} ({}...)",
231                    "[OK]".green(),
232                    truncate(&title, 40),
233                    &session_id[..16.min(session_id.len())]
234                );
235                fetched_count += 1;
236            }
237        }
238    }
239
240    println!("\n{}", "=".repeat(70));
241    println!("Fetched: {} sessions", fetched_count);
242    if skipped_count > 0 {
243        println!("Skipped: {} (use --force to overwrite)", skipped_count);
244    }
245
246    // Register sessions in VS Code index
247    if fetched_count > 0 && !no_register {
248        println!(
249            "\n{} Registering sessions in VS Code index...",
250            "[#]".blue()
251        );
252
253        if is_vscode_running() && !force {
254            println!(
255                "{} VS Code is running. Sessions may not appear until restart.",
256                "[!]".yellow()
257            );
258            println!("   Run 'csm history fetch --force' after closing VS Code to register.");
259        } else {
260            let registered =
261                register_all_sessions_from_directory(&current_ws_id, &chat_sessions_dir, true)?;
262            println!(
263                "{} Registered {} sessions in index",
264                "[OK]".green(),
265                registered
266            );
267        }
268    }
269
270    println!(
271        "\n{} Reload VS Code (Ctrl+R) and check Chat history dropdown",
272        "[i]".cyan()
273    );
274
275    Ok(())
276}
277
278/// Merge all chat sessions into a single unified chat ordered by timestamp
279pub fn history_merge(
280    project_path: Option<&str>,
281    title: Option<&str>,
282    force: bool,
283    no_backup: bool,
284) -> Result<()> {
285    let project_path = project_path.map(|p| p.to_string()).unwrap_or_else(|| {
286        std::env::current_dir()
287            .map(|p| p.to_string_lossy().to_string())
288            .unwrap_or_else(|_| ".".to_string())
289    });
290
291    let project_name = Path::new(&project_path)
292        .file_name()
293        .map(|n| n.to_string_lossy().to_string())
294        .unwrap_or_else(|| project_path.clone());
295
296    println!(
297        "\n{} Merging Chat History for: {}",
298        "[M]".blue(),
299        project_name.cyan()
300    );
301    println!("{}", "=".repeat(70));
302
303    // Find current workspace
304    let current_ws = find_workspace_by_path(&project_path)?
305        .context("Current workspace not found. Make sure the project is opened in VS Code")?;
306    let (current_ws_id, current_ws_dir, _) = current_ws;
307
308    // Find all workspaces for this project
309    let all_workspaces = find_all_workspaces_for_project(&project_name)?;
310
311    // Collect ALL sessions from ALL workspaces
312    println!(
313        "\n{} Collecting sessions from {} workspace(s)...",
314        "[D]".blue(),
315        all_workspaces.len()
316    );
317
318    let mut all_sessions = Vec::new();
319    for (ws_id, ws_dir, _, _) in &all_workspaces {
320        let sessions = get_chat_sessions_from_workspace(ws_dir)?;
321        if !sessions.is_empty() {
322            println!(
323                "   {} {}... ({} sessions)",
324                "[d]".blue(),
325                &ws_id[..16.min(ws_id.len())],
326                sessions.len()
327            );
328            all_sessions.extend(sessions);
329        }
330    }
331
332    if all_sessions.is_empty() {
333        println!("\n{} No chat sessions found in any workspace", "[X]".red());
334        return Ok(());
335    }
336
337    println!("\n   Total: {} sessions collected", all_sessions.len());
338
339    // Collect all requests with timestamps
340    println!("\n{} Extracting and sorting messages...", "[*]".blue());
341
342    let mut all_requests: Vec<ChatRequest> = Vec::new();
343    for session_with_path in &all_sessions {
344        let session = &session_with_path.session;
345        let session_title = session.title();
346
347        for req in &session.requests {
348            let mut req = req.clone();
349            // Add source session info
350            req.source_session = Some(session_title.clone());
351            if req.timestamp.is_some() {
352                all_requests.push(req);
353            }
354        }
355    }
356
357    if all_requests.is_empty() {
358        println!("\n{} No messages found in any session", "[X]".red());
359        return Ok(());
360    }
361
362    // Sort by timestamp
363    all_requests.sort_by_key(|r| r.timestamp.unwrap_or(0));
364
365    // Get timeline info
366    let first_time = all_requests.first().and_then(|r| r.timestamp).unwrap_or(0);
367    let last_time = all_requests.last().and_then(|r| r.timestamp).unwrap_or(0);
368
369    let first_date = timestamp_to_date(first_time);
370    let last_date = timestamp_to_date(last_time);
371    let days_span = if first_time > 0 && last_time > 0 {
372        (last_time - first_time) / (1000 * 60 * 60 * 24)
373    } else {
374        0
375    };
376
377    println!("   Messages: {}", all_requests.len());
378    println!(
379        "   Timeline: {} -> {} ({} days)",
380        first_date, last_date, days_span
381    );
382
383    // Create merged session
384    println!("\n{} Creating merged session...", "[+]".blue());
385
386    let merged_session_id = Uuid::new_v4().to_string();
387    let merged_title = title.map(|t| t.to_string()).unwrap_or_else(|| {
388        format!(
389            "Merged History ({} sessions, {} days)",
390            all_sessions.len(),
391            days_span
392        )
393    });
394
395    let merged_session = ChatSession {
396        version: 3,
397        session_id: Some(merged_session_id.clone()),
398        creation_date: first_time,
399        last_message_date: last_time,
400        is_imported: false,
401        initial_location: "panel".to_string(),
402        custom_title: Some(merged_title.clone()),
403        requester_username: Some("User".to_string()),
404        requester_avatar_icon_uri: None, // Optional - VS Code will use default
405        responder_username: Some("GitHub Copilot".to_string()),
406        responder_avatar_icon_uri: Some(serde_json::json!({"id": "copilot"})),
407        requests: all_requests.clone(),
408    };
409
410    // Create backup if requested
411    let chat_sessions_dir = current_ws_dir.join("chatSessions");
412
413    if !no_backup {
414        if let Some(backup_dir) = backup_workspace_sessions(&current_ws_dir)? {
415            println!(
416                "   {} Backup: {}",
417                "[B]".blue(),
418                backup_dir.file_name().unwrap().to_string_lossy()
419            );
420        }
421    }
422
423    // Write merged session
424    std::fs::create_dir_all(&chat_sessions_dir)?;
425    let merged_file = chat_sessions_dir.join(format!("{}.json", merged_session_id));
426
427    let json = serde_json::to_string_pretty(&merged_session)?;
428    std::fs::write(&merged_file, json)?;
429
430    println!(
431        "   {} File: {}",
432        "[F]".blue(),
433        merged_file.file_name().unwrap().to_string_lossy()
434    );
435
436    // Register in VS Code index
437    println!("\n{} Registering in VS Code index...", "[#]".blue());
438
439    if is_vscode_running() && !force {
440        println!(
441            "{} VS Code is running. Close it and run again, or use --force",
442            "[!]".yellow()
443        );
444    } else {
445        let db_path = get_workspace_storage_db(&current_ws_id)?;
446        add_session_to_index(
447            &db_path,
448            &merged_session_id,
449            &merged_title,
450            last_time,
451            false,
452            "panel",
453            false,
454        )?;
455        println!("   {} Registered in index", "[OK]".green());
456    }
457
458    println!("\n{}", "=".repeat(70));
459    println!("{} MERGE COMPLETE!", "[OK]".green().bold());
460    println!("\n{} Summary:", "[=]".blue());
461    println!("   - Sessions merged: {}", all_sessions.len());
462    println!("   - Total messages: {}", all_requests.len());
463    println!("   - Timeline: {} days", days_span);
464    println!("   - Title: {}", merged_title);
465
466    println!("\n{} Next Steps:", "[i]".cyan());
467    println!("   1. Reload VS Code (Ctrl+R)");
468    println!("   2. Open Chat history dropdown");
469    println!("   3. Select: '{}'", merged_title);
470
471    Ok(())
472}
473
474/// Merge chat sessions from workspaces matching a name pattern
475pub fn merge_by_workspace_name(
476    workspace_name: &str,
477    title: Option<&str>,
478    target_path: Option<&str>,
479    force: bool,
480    no_backup: bool,
481) -> Result<()> {
482    println!(
483        "\n{} Merging Sessions by Workspace Name: {}",
484        "[M]".blue(),
485        workspace_name.cyan()
486    );
487    println!("{}", "=".repeat(70));
488
489    // Find all workspaces matching the pattern
490    let all_workspaces = find_all_workspaces_for_project(workspace_name)?;
491
492    if all_workspaces.is_empty() {
493        println!(
494            "\n{} No workspaces found matching '{}'",
495            "[X]".red(),
496            workspace_name
497        );
498        return Ok(());
499    }
500
501    println!(
502        "\n{} Found {} workspace(s) matching pattern:",
503        "[D]".blue(),
504        all_workspaces.len()
505    );
506    for (ws_id, _, folder_path, _) in &all_workspaces {
507        println!(
508            "   {} {}... -> {}",
509            "[*]".blue(),
510            &ws_id[..16.min(ws_id.len())],
511            folder_path.as_deref().unwrap_or("(unknown)")
512        );
513    }
514
515    // Determine target workspace
516    let target_path = target_path.map(|p| p.to_string()).unwrap_or_else(|| {
517        std::env::current_dir()
518            .map(|p| p.to_string_lossy().to_string())
519            .unwrap_or_else(|_| ".".to_string())
520    });
521
522    let target_ws = find_workspace_by_path(&target_path)?
523        .context("Target workspace not found. Make sure the project is opened in VS Code")?;
524    let (target_ws_id, target_ws_dir, _) = target_ws;
525
526    println!(
527        "\n{} Target workspace: {}...",
528        "[>]".blue(),
529        &target_ws_id[..16.min(target_ws_id.len())]
530    );
531
532    // Collect sessions from all matching workspaces
533    println!("\n{} Collecting sessions...", "[D]".blue());
534
535    let mut all_sessions = Vec::new();
536    for (ws_id, ws_dir, _, _) in &all_workspaces {
537        let sessions = get_chat_sessions_from_workspace(ws_dir)?;
538        if !sessions.is_empty() {
539            println!(
540                "   {} {}... ({} sessions)",
541                "[d]".blue(),
542                &ws_id[..16.min(ws_id.len())],
543                sessions.len()
544            );
545            all_sessions.extend(sessions);
546        }
547    }
548
549    if all_sessions.is_empty() {
550        println!(
551            "\n{} No chat sessions found in matching workspaces",
552            "[X]".red()
553        );
554        return Ok(());
555    }
556
557    // Use the common merge logic
558    merge_sessions_internal(
559        all_sessions,
560        title,
561        &target_ws_id,
562        &target_ws_dir,
563        force,
564        no_backup,
565        &format!("Workspace: {}", workspace_name),
566    )
567}
568
569/// Merge specific chat sessions by their IDs or filenames
570pub fn merge_sessions_by_list(
571    session_ids: &[String],
572    title: Option<&str>,
573    target_path: Option<&str>,
574    force: bool,
575    no_backup: bool,
576) -> Result<()> {
577    println!("\n{} Merging Specific Sessions", "[M]".blue());
578    println!("{}", "=".repeat(70));
579
580    println!(
581        "\n{} Looking for {} session(s):",
582        "[D]".blue(),
583        session_ids.len()
584    );
585    for id in session_ids {
586        println!("   {} {}", "[?]".blue(), id);
587    }
588
589    // Determine target workspace
590    let target_path = target_path.map(|p| p.to_string()).unwrap_or_else(|| {
591        std::env::current_dir()
592            .map(|p| p.to_string_lossy().to_string())
593            .unwrap_or_else(|_| ".".to_string())
594    });
595
596    let target_ws = find_workspace_by_path(&target_path)?
597        .context("Target workspace not found. Make sure the project is opened in VS Code")?;
598    let (target_ws_id, target_ws_dir, _) = target_ws;
599
600    // Find and collect requested sessions from all workspaces
601    println!("\n{} Searching all workspaces...", "[D]".blue());
602
603    let all_workspaces = crate::workspace::discover_workspaces()?;
604    let mut found_sessions = Vec::new();
605    let mut found_ids: Vec<String> = Vec::new();
606
607    // Normalize session IDs for comparison (remove .json extension if present)
608    let normalized_ids: Vec<String> = session_ids
609        .iter()
610        .map(|id| {
611            let id = id.trim();
612            if id.to_lowercase().ends_with(".json") {
613                id[..id.len() - 5].to_string()
614            } else {
615                id.to_string()
616            }
617        })
618        .collect();
619
620    for ws in &all_workspaces {
621        if !ws.has_chat_sessions {
622            continue;
623        }
624
625        let sessions = get_chat_sessions_from_workspace(&ws.workspace_path)?;
626
627        for session_with_path in sessions {
628            let session_id = session_with_path
629                .session
630                .session_id
631                .clone()
632                .unwrap_or_else(|| {
633                    session_with_path
634                        .path
635                        .file_stem()
636                        .map(|s| s.to_string_lossy().to_string())
637                        .unwrap_or_default()
638                });
639
640            // Check if this session matches any requested ID
641            let matches = normalized_ids.iter().any(|req_id| {
642                session_id.starts_with(req_id)
643                    || req_id.starts_with(&session_id)
644                    || session_id == *req_id
645            });
646
647            if matches && !found_ids.contains(&session_id) {
648                println!(
649                    "   {} Found: {} in workspace {}...",
650                    "[OK]".green(),
651                    truncate(&session_with_path.session.title(), 40),
652                    &ws.hash[..16.min(ws.hash.len())]
653                );
654                found_ids.push(session_id);
655                found_sessions.push(session_with_path);
656            }
657        }
658    }
659
660    if found_sessions.is_empty() {
661        println!("\n{} No matching sessions found", "[X]".red());
662        println!(
663            "\n{} Tip: Use 'csm list sessions' or 'csm find session <pattern>' to find session IDs",
664            "[i]".cyan()
665        );
666        return Ok(());
667    }
668
669    // Report any sessions that weren't found
670    let not_found: Vec<_> = normalized_ids
671        .iter()
672        .filter(|id| {
673            !found_ids
674                .iter()
675                .any(|found| found.starts_with(*id) || id.starts_with(found))
676        })
677        .collect();
678
679    if !not_found.is_empty() {
680        println!("\n{} Sessions not found:", "[!]".yellow());
681        for id in not_found {
682            println!("   {} {}", "[X]".red(), id);
683        }
684    }
685
686    println!("\n   Total: {} sessions found", found_sessions.len());
687
688    // Use the common merge logic
689    merge_sessions_internal(
690        found_sessions,
691        title,
692        &target_ws_id,
693        &target_ws_dir,
694        force,
695        no_backup,
696        &format!("{} selected sessions", session_ids.len()),
697    )
698}
699
700/// Internal function to merge sessions and write to target workspace
701fn merge_sessions_internal(
702    sessions: Vec<crate::models::SessionWithPath>,
703    title: Option<&str>,
704    target_ws_id: &str,
705    target_ws_dir: &Path,
706    force: bool,
707    no_backup: bool,
708    source_description: &str,
709) -> Result<()> {
710    // Collect all requests with timestamps
711    println!("\n{} Extracting and sorting messages...", "[*]".blue());
712
713    let mut all_requests: Vec<ChatRequest> = Vec::new();
714    for session_with_path in &sessions {
715        let session = &session_with_path.session;
716        let session_title = session.title();
717
718        for req in &session.requests {
719            let mut req = req.clone();
720            req.source_session = Some(session_title.clone());
721            if req.timestamp.is_some() {
722                all_requests.push(req);
723            }
724        }
725    }
726
727    if all_requests.is_empty() {
728        println!("\n{} No messages found in selected sessions", "[X]".red());
729        return Ok(());
730    }
731
732    // Sort by timestamp
733    all_requests.sort_by_key(|r| r.timestamp.unwrap_or(0));
734
735    // Get timeline info
736    let first_time = all_requests.first().and_then(|r| r.timestamp).unwrap_or(0);
737    let last_time = all_requests.last().and_then(|r| r.timestamp).unwrap_or(0);
738
739    let first_date = timestamp_to_date(first_time);
740    let last_date = timestamp_to_date(last_time);
741    let days_span = if first_time > 0 && last_time > 0 {
742        (last_time - first_time) / (1000 * 60 * 60 * 24)
743    } else {
744        0
745    };
746
747    println!("   Messages: {}", all_requests.len());
748    println!(
749        "   Timeline: {} -> {} ({} days)",
750        first_date, last_date, days_span
751    );
752
753    // Create merged session
754    println!("\n{} Creating merged session...", "[+]".blue());
755
756    let merged_session_id = Uuid::new_v4().to_string();
757    let merged_title = title.map(|t| t.to_string()).unwrap_or_else(|| {
758        format!(
759            "Merged: {} ({} sessions, {} days)",
760            source_description,
761            sessions.len(),
762            days_span
763        )
764    });
765
766    let merged_session = ChatSession {
767        version: 3,
768        session_id: Some(merged_session_id.clone()),
769        creation_date: first_time,
770        last_message_date: last_time,
771        is_imported: false,
772        initial_location: "panel".to_string(),
773        custom_title: Some(merged_title.clone()),
774        requester_username: Some("User".to_string()),
775        requester_avatar_icon_uri: None,
776        responder_username: Some("GitHub Copilot".to_string()),
777        responder_avatar_icon_uri: Some(serde_json::json!({"id": "copilot"})),
778        requests: all_requests.clone(),
779    };
780
781    // Create backup if requested
782    let chat_sessions_dir = target_ws_dir.join("chatSessions");
783
784    if !no_backup {
785        if let Some(backup_dir) = backup_workspace_sessions(target_ws_dir)? {
786            println!(
787                "   {} Backup: {}",
788                "[B]".blue(),
789                backup_dir.file_name().unwrap().to_string_lossy()
790            );
791        }
792    }
793
794    // Write merged session
795    std::fs::create_dir_all(&chat_sessions_dir)?;
796    let merged_file = chat_sessions_dir.join(format!("{}.json", merged_session_id));
797
798    let json = serde_json::to_string_pretty(&merged_session)?;
799    std::fs::write(&merged_file, json)?;
800
801    println!(
802        "   {} File: {}",
803        "[F]".blue(),
804        merged_file.file_name().unwrap().to_string_lossy()
805    );
806
807    // Register in VS Code index
808    println!("\n{} Registering in VS Code index...", "[#]".blue());
809
810    if is_vscode_running() && !force {
811        println!(
812            "{} VS Code is running. Close it and run again, or use --force",
813            "[!]".yellow()
814        );
815    } else {
816        let db_path = get_workspace_storage_db(target_ws_id)?;
817        add_session_to_index(
818            &db_path,
819            &merged_session_id,
820            &merged_title,
821            last_time,
822            false,
823            "panel",
824            false,
825        )?;
826        println!("   {} Registered in index", "[OK]".green());
827    }
828
829    println!("\n{}", "=".repeat(70));
830    println!("{} MERGE COMPLETE!", "[OK]".green().bold());
831    println!("\n{} Summary:", "[=]".blue());
832    println!("   - Sessions merged: {}", sessions.len());
833    println!("   - Total messages: {}", all_requests.len());
834    println!("   - Timeline: {} days", days_span);
835    println!("   - Title: {}", merged_title);
836
837    println!("\n{} Next Steps:", "[i]".cyan());
838    println!("   1. Reload VS Code (Ctrl+R)");
839    println!("   2. Open Chat history dropdown");
840    println!("   3. Select: '{}'", merged_title);
841
842    Ok(())
843}
844
845/// Convert millisecond timestamp to date string
846fn timestamp_to_date(timestamp: i64) -> String {
847    if timestamp == 0 {
848        return "unknown".to_string();
849    }
850
851    // Handle both milliseconds and seconds
852    let secs = if timestamp > 1_000_000_000_000 {
853        timestamp / 1000
854    } else {
855        timestamp
856    };
857
858    DateTime::from_timestamp(secs, 0)
859        .map(|dt| dt.format("%Y-%m-%d").to_string())
860        .unwrap_or_else(|| "unknown".to_string())
861}
862
863/// Truncate string to max length
864fn truncate(s: &str, max_len: usize) -> String {
865    if s.len() <= max_len {
866        s.to_string()
867    } else {
868        format!("{}...", &s[..max_len - 3])
869    }
870}
871
872/// Fetch sessions from workspaces matching a name pattern
873pub fn fetch_by_workspace(
874    workspace_name: &str,
875    target_path: Option<&str>,
876    force: bool,
877    no_register: bool,
878) -> Result<()> {
879    use colored::Colorize;
880    use std::fs;
881
882    println!("\n{}", "=".repeat(70));
883    println!("{} FETCH BY WORKSPACE", "[*]".cyan().bold());
884    println!("{}", "=".repeat(70));
885
886    // Find target workspace
887    let target_dir = match target_path {
888        Some(p) => {
889            let path = std::path::PathBuf::from(p);
890            path.canonicalize().unwrap_or(path)
891        }
892        None => std::env::current_dir().unwrap_or_default(),
893    };
894    let target_normalized = normalize_path(target_dir.to_str().unwrap_or(""));
895
896    println!("\n{} Target: {}", "[>]".blue(), target_normalized);
897    println!("{} Pattern: {}", "[>]".blue(), workspace_name);
898
899    // Find all workspaces
900    let all_workspaces = discover_workspaces()?;
901    let pattern_lower = workspace_name.to_lowercase();
902
903    // Find source workspaces matching pattern
904    let source_workspaces: Vec<_> = all_workspaces
905        .iter()
906        .filter(|ws| {
907            ws.project_path
908                .as_ref()
909                .map(|p| p.to_lowercase().contains(&pattern_lower))
910                .unwrap_or(false)
911        })
912        .filter(|ws| ws.has_chat_sessions)
913        .collect();
914
915    if source_workspaces.is_empty() {
916        println!(
917            "\n{} No workspaces found matching '{}'",
918            "[X]".red(),
919            workspace_name
920        );
921        return Ok(());
922    }
923
924    println!(
925        "\n{} Found {} matching workspace(s)",
926        "[OK]".green(),
927        source_workspaces.len()
928    );
929
930    // Find target workspace
931    let target_ws = all_workspaces.iter().find(|ws| {
932        ws.project_path
933            .as_ref()
934            .map(|p| normalize_path(p) == target_normalized)
935            .unwrap_or(false)
936    });
937
938    let target_ws_dir = match target_ws {
939        Some(ws) => ws.workspace_path.join("workspaceState"),
940        None => {
941            println!(
942                "{} Target workspace not found, creating new...",
943                "[!]".yellow()
944            );
945            // Would need to create workspace - for now return error
946            anyhow::bail!("Target workspace not found. Please open the folder in VS Code first.");
947        }
948    };
949
950    // Collect all sessions from matching workspaces
951    let mut fetched_count = 0;
952
953    for ws in source_workspaces {
954        let sessions = get_chat_sessions_from_workspace(&ws.workspace_path)?;
955
956        for session_with_path in sessions {
957            let src_file = &session_with_path.path;
958            let filename = src_file
959                .file_name()
960                .map(|n| n.to_string_lossy().to_string())
961                .unwrap_or_default();
962
963            let dest_file = target_ws_dir.join(&filename);
964
965            if dest_file.exists() && !force {
966                println!("   {} Skipping (exists): {}", "[!]".yellow(), filename);
967                continue;
968            }
969
970            fs::copy(src_file, &dest_file)?;
971            fetched_count += 1;
972            println!(
973                "   {} Fetched: {}",
974                "[OK]".green(),
975                session_with_path.session.title()
976            );
977        }
978    }
979
980    println!(
981        "\n{} Fetched {} session(s)",
982        "[OK]".green().bold(),
983        fetched_count
984    );
985
986    if !no_register {
987        println!(
988            "{} Sessions will appear in VS Code after reload",
989            "[i]".cyan()
990        );
991    }
992
993    Ok(())
994}
995
996/// Fetch specific sessions by their IDs
997pub fn fetch_sessions(
998    session_ids: &[String],
999    target_path: Option<&str>,
1000    force: bool,
1001    no_register: bool,
1002) -> Result<()> {
1003    use colored::Colorize;
1004    use std::fs;
1005
1006    println!("\n{}", "=".repeat(70));
1007    println!("{} FETCH SESSIONS BY ID", "[*]".cyan().bold());
1008    println!("{}", "=".repeat(70));
1009
1010    if session_ids.is_empty() {
1011        println!("{} No session IDs provided", "[X]".red());
1012        return Ok(());
1013    }
1014
1015    // Find target workspace
1016    let target_dir = match target_path {
1017        Some(p) => {
1018            let path = std::path::PathBuf::from(p);
1019            path.canonicalize().unwrap_or(path)
1020        }
1021        None => std::env::current_dir().unwrap_or_default(),
1022    };
1023    let target_normalized = normalize_path(target_dir.to_str().unwrap_or(""));
1024
1025    println!("\n{} Target: {}", "[>]".blue(), target_normalized);
1026    println!("{} Sessions: {:?}", "[>]".blue(), session_ids);
1027
1028    let all_workspaces = discover_workspaces()?;
1029
1030    // Find target workspace
1031    let target_ws = all_workspaces.iter().find(|ws| {
1032        ws.project_path
1033            .as_ref()
1034            .map(|p| normalize_path(p) == target_normalized)
1035            .unwrap_or(false)
1036    });
1037
1038    let target_ws_dir = match target_ws {
1039        Some(ws) => ws.workspace_path.join("workspaceState"),
1040        None => {
1041            anyhow::bail!("Target workspace not found. Please open the folder in VS Code first.");
1042        }
1043    };
1044
1045    // Normalize session IDs
1046    let normalized_ids: Vec<String> = session_ids
1047        .iter()
1048        .flat_map(|s| s.split(',').map(|p| p.trim().to_lowercase()))
1049        .filter(|s| !s.is_empty())
1050        .collect();
1051
1052    let mut fetched_count = 0;
1053    let mut found_ids = Vec::new();
1054
1055    for ws in &all_workspaces {
1056        if !ws.has_chat_sessions {
1057            continue;
1058        }
1059
1060        let sessions = get_chat_sessions_from_workspace(&ws.workspace_path)?;
1061
1062        for session_with_path in sessions {
1063            let session_id = session_with_path
1064                .session
1065                .session_id
1066                .clone()
1067                .unwrap_or_else(|| {
1068                    session_with_path
1069                        .path
1070                        .file_stem()
1071                        .map(|s| s.to_string_lossy().to_string())
1072                        .unwrap_or_default()
1073                });
1074
1075            let matches = normalized_ids.iter().any(|req_id| {
1076                session_id.to_lowercase().contains(req_id)
1077                    || req_id.contains(&session_id.to_lowercase())
1078            });
1079
1080            if matches && !found_ids.contains(&session_id) {
1081                let src_file = &session_with_path.path;
1082                let filename = src_file
1083                    .file_name()
1084                    .map(|n| n.to_string_lossy().to_string())
1085                    .unwrap_or_default();
1086
1087                let dest_file = target_ws_dir.join(&filename);
1088
1089                if dest_file.exists() && !force {
1090                    println!("   {} Skipping (exists): {}", "[!]".yellow(), filename);
1091                    found_ids.push(session_id);
1092                    continue;
1093                }
1094
1095                fs::copy(src_file, &dest_file)?;
1096                fetched_count += 1;
1097                found_ids.push(session_id);
1098                println!(
1099                    "   {} Fetched: {}",
1100                    "[OK]".green(),
1101                    session_with_path.session.title()
1102                );
1103            }
1104        }
1105    }
1106
1107    // Report not found
1108    let not_found: Vec<_> = normalized_ids
1109        .iter()
1110        .filter(|id| {
1111            !found_ids
1112                .iter()
1113                .any(|found| found.to_lowercase().contains(*id))
1114        })
1115        .collect();
1116
1117    if !not_found.is_empty() {
1118        println!("\n{} Sessions not found:", "[!]".yellow());
1119        for id in not_found {
1120            println!("   {} {}", "[X]".red(), id);
1121        }
1122    }
1123
1124    println!(
1125        "\n{} Fetched {} session(s)",
1126        "[OK]".green().bold(),
1127        fetched_count
1128    );
1129
1130    if !no_register {
1131        println!(
1132            "{} Sessions will appear in VS Code after reload",
1133            "[i]".cyan()
1134        );
1135    }
1136
1137    Ok(())
1138}
1139
1140/// Merge chat sessions from multiple workspace name patterns
1141pub fn merge_by_workspace_names(
1142    workspace_names: &[String],
1143    title: Option<&str>,
1144    target_path: Option<&str>,
1145    force: bool,
1146    no_backup: bool,
1147) -> Result<()> {
1148    println!(
1149        "\n{} Merging Sessions from Multiple Workspaces",
1150        "[M]".blue().bold()
1151    );
1152    println!("{}", "=".repeat(70));
1153
1154    println!("\n{} Workspace patterns:", "[D]".blue());
1155    for name in workspace_names {
1156        println!("   {} {}", "[*]".blue(), name.cyan());
1157    }
1158
1159    // Collect all matching workspaces
1160    let mut all_matching_workspaces = Vec::new();
1161    let mut seen_ws_ids = std::collections::HashSet::new();
1162
1163    for pattern in workspace_names {
1164        let workspaces = find_all_workspaces_for_project(pattern)?;
1165        for ws in workspaces {
1166            if !seen_ws_ids.contains(&ws.0) {
1167                seen_ws_ids.insert(ws.0.clone());
1168                all_matching_workspaces.push(ws);
1169            }
1170        }
1171    }
1172
1173    if all_matching_workspaces.is_empty() {
1174        println!(
1175            "\n{} No workspaces found matching any of the patterns",
1176            "[X]".red()
1177        );
1178        return Ok(());
1179    }
1180
1181    println!(
1182        "\n{} Found {} unique workspace(s):",
1183        "[D]".blue(),
1184        all_matching_workspaces.len()
1185    );
1186    for (ws_id, _, folder_path, _) in &all_matching_workspaces {
1187        println!(
1188            "   {} {}... -> {}",
1189            "[*]".blue(),
1190            &ws_id[..16.min(ws_id.len())],
1191            folder_path.as_deref().unwrap_or("(unknown)")
1192        );
1193    }
1194
1195    // Determine target workspace
1196    let target_path = target_path.map(|p| p.to_string()).unwrap_or_else(|| {
1197        std::env::current_dir()
1198            .map(|p| p.to_string_lossy().to_string())
1199            .unwrap_or_else(|_| ".".to_string())
1200    });
1201
1202    let target_ws = find_workspace_by_path(&target_path)?
1203        .context("Target workspace not found. Make sure the project is opened in VS Code")?;
1204    let (target_ws_id, target_ws_dir, _) = target_ws;
1205
1206    println!(
1207        "\n{} Target workspace: {}...",
1208        "[>]".blue(),
1209        &target_ws_id[..16.min(target_ws_id.len())]
1210    );
1211
1212    // Collect sessions from all matching workspaces
1213    println!("\n{} Collecting sessions...", "[D]".blue());
1214
1215    let mut all_sessions = Vec::new();
1216    for (ws_id, ws_dir, _, _) in &all_matching_workspaces {
1217        let sessions = get_chat_sessions_from_workspace(ws_dir)?;
1218        if !sessions.is_empty() {
1219            println!(
1220                "   {} {}... ({} sessions)",
1221                "[d]".blue(),
1222                &ws_id[..16.min(ws_id.len())],
1223                sessions.len()
1224            );
1225            all_sessions.extend(sessions);
1226        }
1227    }
1228
1229    if all_sessions.is_empty() {
1230        println!(
1231            "\n{} No chat sessions found in matching workspaces",
1232            "[X]".red()
1233        );
1234        return Ok(());
1235    }
1236
1237    // Generate title from workspace names if not provided
1238    let auto_title = format!("Merged: {}", workspace_names.join(" + "));
1239    let merge_title = title.unwrap_or(&auto_title);
1240
1241    // Use the common merge logic
1242    merge_sessions_internal(
1243        all_sessions,
1244        Some(merge_title),
1245        &target_ws_id,
1246        &target_ws_dir,
1247        force,
1248        no_backup,
1249        &format!("{} workspaces", workspace_names.len()),
1250    )
1251}
1252
1253/// Merge chat sessions from an LLM provider
1254pub fn merge_from_provider(
1255    provider_name: &str,
1256    title: Option<&str>,
1257    target_path: Option<&str>,
1258    session_ids: Option<&[String]>,
1259    force: bool,
1260    no_backup: bool,
1261) -> Result<()> {
1262    use crate::providers::{ProviderRegistry, ProviderType};
1263
1264    println!(
1265        "\n{} Merging Sessions from Provider: {}",
1266        "[M]".blue().bold(),
1267        provider_name.cyan()
1268    );
1269    println!("{}", "=".repeat(70));
1270
1271    // Parse provider name
1272    let provider_type = match provider_name.to_lowercase().as_str() {
1273        "copilot" | "github-copilot" | "vscode" => ProviderType::Copilot,
1274        "cursor" => ProviderType::Cursor,
1275        "ollama" => ProviderType::Ollama,
1276        "vllm" => ProviderType::Vllm,
1277        "foundry" | "azure" | "azure-foundry" => ProviderType::Foundry,
1278        "lm-studio" | "lmstudio" => ProviderType::LmStudio,
1279        "localai" | "local-ai" => ProviderType::LocalAI,
1280        "text-gen-webui" | "textgenwebui" | "oobabooga" => ProviderType::TextGenWebUI,
1281        "jan" | "jan-ai" => ProviderType::Jan,
1282        "gpt4all" => ProviderType::Gpt4All,
1283        "llamafile" => ProviderType::Llamafile,
1284        _ => {
1285            println!("{} Unknown provider: {}", "[X]".red(), provider_name);
1286            println!("\n{} Available providers:", "[i]".cyan());
1287            println!("   copilot, cursor, ollama, vllm, foundry, lm-studio,");
1288            println!("   localai, text-gen-webui, jan, gpt4all, llamafile");
1289            return Ok(());
1290        }
1291    };
1292
1293    // Get provider sessions
1294    let registry = ProviderRegistry::new();
1295    let provider = registry
1296        .get_provider(provider_type)
1297        .context(format!("Provider '{}' not available", provider_name))?;
1298
1299    if !provider.is_available() {
1300        println!(
1301            "{} Provider '{}' is not available or not configured",
1302            "[X]".red(),
1303            provider_name
1304        );
1305        return Ok(());
1306    }
1307
1308    println!(
1309        "{} Provider: {} ({})",
1310        "[*]".blue(),
1311        provider.name(),
1312        provider_type.display_name()
1313    );
1314
1315    // Get sessions from provider
1316    let provider_sessions = provider
1317        .list_sessions()
1318        .context("Failed to list sessions from provider")?;
1319
1320    if provider_sessions.is_empty() {
1321        println!("{} No sessions found in provider", "[X]".red());
1322        return Ok(());
1323    }
1324
1325    println!(
1326        "{} Found {} session(s) in provider",
1327        "[D]".blue(),
1328        provider_sessions.len()
1329    );
1330
1331    // Filter sessions if specific IDs provided
1332    let sessions_to_merge: Vec<_> = if let Some(ids) = session_ids {
1333        let ids_lower: Vec<String> = ids.iter().map(|s| s.to_lowercase()).collect();
1334        provider_sessions
1335            .into_iter()
1336            .filter(|s| {
1337                let session_id = s
1338                    .session_id
1339                    .as_ref()
1340                    .unwrap_or(&String::new())
1341                    .to_lowercase();
1342                let title = s.title().to_lowercase();
1343                ids_lower
1344                    .iter()
1345                    .any(|id| session_id.contains(id) || title.contains(id))
1346            })
1347            .collect()
1348    } else {
1349        provider_sessions
1350    };
1351
1352    if sessions_to_merge.is_empty() {
1353        println!("{} No matching sessions found", "[X]".red());
1354        return Ok(());
1355    }
1356
1357    println!(
1358        "{} Merging {} session(s):",
1359        "[D]".blue(),
1360        sessions_to_merge.len()
1361    );
1362    for s in &sessions_to_merge {
1363        println!(
1364            "   {} {} ({} messages)",
1365            "[*]".blue(),
1366            truncate(&s.title(), 50),
1367            s.request_count()
1368        );
1369    }
1370
1371    // Determine target workspace
1372    let target_path = target_path.map(|p| p.to_string()).unwrap_or_else(|| {
1373        std::env::current_dir()
1374            .map(|p| p.to_string_lossy().to_string())
1375            .unwrap_or_else(|_| ".".to_string())
1376    });
1377
1378    let target_ws = find_workspace_by_path(&target_path)?
1379        .context("Target workspace not found. Make sure the project is opened in VS Code")?;
1380    let (target_ws_id, target_ws_dir, _) = target_ws;
1381
1382    println!(
1383        "\n{} Target workspace: {}...",
1384        "[>]".blue(),
1385        &target_ws_id[..16.min(target_ws_id.len())]
1386    );
1387
1388    // Convert to SessionWithPath format (create temporary entries)
1389    let sessions_with_path: Vec<crate::models::SessionWithPath> = sessions_to_merge
1390        .into_iter()
1391        .map(|session| crate::models::SessionWithPath {
1392            session,
1393            path: std::path::PathBuf::new(), // Provider sessions don't have a file path
1394        })
1395        .collect();
1396
1397    // Generate title
1398    let auto_title = format!("Imported from {}", provider.name());
1399    let merge_title = title.unwrap_or(&auto_title);
1400
1401    // Use the common merge logic
1402    merge_sessions_internal(
1403        sessions_with_path,
1404        Some(merge_title),
1405        &target_ws_id,
1406        &target_ws_dir,
1407        force,
1408        no_backup,
1409        &format!("Provider: {}", provider.name()),
1410    )
1411}
1412
1413/// Merge chat sessions from multiple providers (cross-provider merge)
1414pub fn merge_cross_provider(
1415    provider_names: &[String],
1416    title: Option<&str>,
1417    target_path: Option<&str>,
1418    workspace_filter: Option<&str>,
1419    force: bool,
1420    no_backup: bool,
1421) -> Result<()> {
1422    use crate::models::ChatSession;
1423    use crate::providers::{ProviderRegistry, ProviderType};
1424
1425    println!("\n{} Cross-Provider Merge", "[M]".blue().bold());
1426    println!("{}", "=".repeat(70));
1427    println!(
1428        "{} Providers: {}",
1429        "[*]".blue(),
1430        provider_names.join(", ").cyan()
1431    );
1432
1433    if let Some(ws) = workspace_filter {
1434        println!("{} Workspace filter: {}", "[*]".blue(), ws.cyan());
1435    }
1436
1437    let registry = ProviderRegistry::new();
1438    let mut all_sessions: Vec<(String, ChatSession)> = Vec::new(); // (provider_name, session)
1439
1440    // Parse and collect sessions from each provider
1441    for provider_name in provider_names {
1442        let provider_type = match provider_name.to_lowercase().as_str() {
1443            "copilot" | "github-copilot" | "vscode" => Some(ProviderType::Copilot),
1444            "cursor" => Some(ProviderType::Cursor),
1445            "ollama" => Some(ProviderType::Ollama),
1446            "vllm" => Some(ProviderType::Vllm),
1447            "foundry" | "azure" | "azure-foundry" => Some(ProviderType::Foundry),
1448            "lm-studio" | "lmstudio" => Some(ProviderType::LmStudio),
1449            "localai" | "local-ai" => Some(ProviderType::LocalAI),
1450            "text-gen-webui" | "textgenwebui" | "oobabooga" => Some(ProviderType::TextGenWebUI),
1451            "jan" | "jan-ai" => Some(ProviderType::Jan),
1452            "gpt4all" => Some(ProviderType::Gpt4All),
1453            "llamafile" => Some(ProviderType::Llamafile),
1454            _ => {
1455                println!(
1456                    "{} Unknown provider: {} (skipping)",
1457                    "[!]".yellow(),
1458                    provider_name
1459                );
1460                None
1461            }
1462        };
1463
1464        if let Some(pt) = provider_type {
1465            if let Some(provider) = registry.get_provider(pt) {
1466                if provider.is_available() {
1467                    match provider.list_sessions() {
1468                        Ok(sessions) => {
1469                            let filtered: Vec<_> = if let Some(ws_filter) = workspace_filter {
1470                                let pattern = ws_filter.to_lowercase();
1471                                sessions
1472                                    .into_iter()
1473                                    .filter(|s| s.title().to_lowercase().contains(&pattern))
1474                                    .collect()
1475                            } else {
1476                                sessions
1477                            };
1478
1479                            println!(
1480                                "{} {} ({}): {} session(s)",
1481                                "[D]".blue(),
1482                                provider.name(),
1483                                provider_type
1484                                    .as_ref()
1485                                    .map(|p| p.display_name())
1486                                    .unwrap_or("?"),
1487                                filtered.len()
1488                            );
1489
1490                            for session in filtered {
1491                                all_sessions.push((provider.name().to_string(), session));
1492                            }
1493                        }
1494                        Err(e) => {
1495                            println!(
1496                                "{} Failed to get sessions from {}: {}",
1497                                "[!]".yellow(),
1498                                provider.name(),
1499                                e
1500                            );
1501                        }
1502                    }
1503                } else {
1504                    println!(
1505                        "{} Provider {} not available",
1506                        "[!]".yellow(),
1507                        provider.name()
1508                    );
1509                }
1510            }
1511        }
1512    }
1513
1514    if all_sessions.is_empty() {
1515        println!("{} No sessions found across providers", "[X]".red());
1516        return Ok(());
1517    }
1518
1519    println!(
1520        "\n{} Total: {} sessions from {} provider(s)",
1521        "[*]".green().bold(),
1522        all_sessions.len(),
1523        provider_names.len()
1524    );
1525
1526    // Sort all sessions by timestamp
1527    all_sessions.sort_by(|(_, a), (_, b)| {
1528        let a_time = a
1529            .requests
1530            .first()
1531            .map(|r| r.timestamp.unwrap_or(0))
1532            .unwrap_or(0);
1533        let b_time = b
1534            .requests
1535            .first()
1536            .map(|r| r.timestamp.unwrap_or(0))
1537            .unwrap_or(0);
1538        a_time.cmp(&b_time)
1539    });
1540
1541    // Print sessions being merged
1542    println!("\n{} Sessions to merge:", "[D]".blue());
1543    for (provider_name, session) in &all_sessions {
1544        println!(
1545            "   {} [{}] {} ({} messages)",
1546            "[*]".blue(),
1547            provider_name.cyan(),
1548            truncate(&session.title(), 40),
1549            session.request_count()
1550        );
1551    }
1552
1553    // Determine target workspace
1554    let target_path = target_path.map(|p| p.to_string()).unwrap_or_else(|| {
1555        std::env::current_dir()
1556            .map(|p| p.to_string_lossy().to_string())
1557            .unwrap_or_else(|_| ".".to_string())
1558    });
1559
1560    let target_ws = find_workspace_by_path(&target_path)?
1561        .context("Target workspace not found. Make sure the project is opened in VS Code")?;
1562    let (target_ws_id, target_ws_dir, _) = target_ws;
1563
1564    println!(
1565        "\n{} Target workspace: {}...",
1566        "[>]".blue(),
1567        &target_ws_id[..16.min(target_ws_id.len())]
1568    );
1569
1570    // Convert to SessionWithPath format
1571    let sessions_with_path: Vec<crate::models::SessionWithPath> = all_sessions
1572        .into_iter()
1573        .map(|(_, session)| crate::models::SessionWithPath {
1574            session,
1575            path: std::path::PathBuf::new(),
1576        })
1577        .collect();
1578
1579    // Generate title
1580    let auto_title = format!("Cross-provider merge: {}", provider_names.join(", "));
1581    let merge_title = title.unwrap_or(&auto_title);
1582
1583    merge_sessions_internal(
1584        sessions_with_path,
1585        Some(merge_title),
1586        &target_ws_id,
1587        &target_ws_dir,
1588        force,
1589        no_backup,
1590        &format!("{} providers", provider_names.len()),
1591    )
1592}
1593
1594/// Merge all sessions from all available providers
1595pub fn merge_all_providers(
1596    title: Option<&str>,
1597    target_path: Option<&str>,
1598    workspace_filter: Option<&str>,
1599    force: bool,
1600    no_backup: bool,
1601) -> Result<()> {
1602    use crate::models::ChatSession;
1603    use crate::providers::{ProviderRegistry, ProviderType};
1604
1605    println!("\n{} Merge All Providers", "[M]".blue().bold());
1606    println!("{}", "=".repeat(70));
1607
1608    if let Some(ws) = workspace_filter {
1609        println!("{} Workspace filter: {}", "[*]".blue(), ws.cyan());
1610    }
1611
1612    let registry = ProviderRegistry::new();
1613    let mut all_sessions: Vec<(String, ChatSession)> = Vec::new();
1614    let mut providers_found = 0;
1615
1616    // List of all provider types to check
1617    let all_provider_types = vec![
1618        ProviderType::Copilot,
1619        ProviderType::Cursor,
1620        ProviderType::Ollama,
1621        ProviderType::Vllm,
1622        ProviderType::Foundry,
1623        ProviderType::LmStudio,
1624        ProviderType::LocalAI,
1625        ProviderType::TextGenWebUI,
1626        ProviderType::Jan,
1627        ProviderType::Gpt4All,
1628        ProviderType::Llamafile,
1629    ];
1630
1631    println!("{} Scanning providers...", "[*]".blue());
1632
1633    for provider_type in all_provider_types {
1634        if let Some(provider) = registry.get_provider(provider_type) {
1635            if provider.is_available() {
1636                match provider.list_sessions() {
1637                    Ok(sessions) if !sessions.is_empty() => {
1638                        let filtered: Vec<_> = if let Some(ws_filter) = workspace_filter {
1639                            let pattern = ws_filter.to_lowercase();
1640                            sessions
1641                                .into_iter()
1642                                .filter(|s| s.title().to_lowercase().contains(&pattern))
1643                                .collect()
1644                        } else {
1645                            sessions
1646                        };
1647
1648                        if !filtered.is_empty() {
1649                            println!(
1650                                "   {} {}: {} session(s)",
1651                                "[+]".green(),
1652                                provider.name(),
1653                                filtered.len()
1654                            );
1655                            providers_found += 1;
1656
1657                            for session in filtered {
1658                                all_sessions.push((provider.name().to_string(), session));
1659                            }
1660                        }
1661                    }
1662                    Ok(_) => {
1663                        // Empty sessions, skip silently
1664                    }
1665                    Err(_) => {
1666                        // Failed to list, skip silently
1667                    }
1668                }
1669            }
1670        }
1671    }
1672
1673    if all_sessions.is_empty() {
1674        println!("{} No sessions found across any providers", "[X]".red());
1675        return Ok(());
1676    }
1677
1678    println!(
1679        "\n{} Found {} sessions across {} provider(s)",
1680        "[*]".green().bold(),
1681        all_sessions.len(),
1682        providers_found
1683    );
1684
1685    // Sort all sessions by timestamp
1686    all_sessions.sort_by(|(_, a), (_, b)| {
1687        let a_time = a
1688            .requests
1689            .first()
1690            .map(|r| r.timestamp.unwrap_or(0))
1691            .unwrap_or(0);
1692        let b_time = b
1693            .requests
1694            .first()
1695            .map(|r| r.timestamp.unwrap_or(0))
1696            .unwrap_or(0);
1697        a_time.cmp(&b_time)
1698    });
1699
1700    // Print sessions being merged (limit to first 20)
1701    println!("\n{} Sessions to merge:", "[D]".blue());
1702    for (i, (provider_name, session)) in all_sessions.iter().enumerate() {
1703        if i >= 20 {
1704            println!(
1705                "   {} ... and {} more",
1706                "[*]".blue(),
1707                all_sessions.len() - 20
1708            );
1709            break;
1710        }
1711        println!(
1712            "   {} [{}] {} ({} messages)",
1713            "[*]".blue(),
1714            provider_name.cyan(),
1715            truncate(&session.title(), 40),
1716            session.request_count()
1717        );
1718    }
1719
1720    // Determine target workspace
1721    let target_path = target_path.map(|p| p.to_string()).unwrap_or_else(|| {
1722        std::env::current_dir()
1723            .map(|p| p.to_string_lossy().to_string())
1724            .unwrap_or_else(|_| ".".to_string())
1725    });
1726
1727    let target_ws = find_workspace_by_path(&target_path)?
1728        .context("Target workspace not found. Make sure the project is opened in VS Code")?;
1729    let (target_ws_id, target_ws_dir, _) = target_ws;
1730
1731    println!(
1732        "\n{} Target workspace: {}...",
1733        "[>]".blue(),
1734        &target_ws_id[..16.min(target_ws_id.len())]
1735    );
1736
1737    // Convert to SessionWithPath format
1738    let sessions_with_path: Vec<crate::models::SessionWithPath> = all_sessions
1739        .into_iter()
1740        .map(|(_, session)| crate::models::SessionWithPath {
1741            session,
1742            path: std::path::PathBuf::new(),
1743        })
1744        .collect();
1745
1746    // Generate title
1747    let auto_title = format!("All providers merge ({})", providers_found);
1748    let merge_title = title.unwrap_or(&auto_title);
1749
1750    merge_sessions_internal(
1751        sessions_with_path,
1752        Some(merge_title),
1753        &target_ws_id,
1754        &target_ws_dir,
1755        force,
1756        no_backup,
1757        &format!("{} providers (all)", providers_found),
1758    )
1759}