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