diff --git a/CLAUDE.md b/CLAUDE.md index 5c9809b..2588ebd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Purpose -Search and resume past conversations from Claude Code, Codex CLI, and Factory (Droid). +Search and resume past conversations from Claude Code, Codex CLI, Factory (Droid), OpenCode, Copilot CLI, and Cursor. ## Principles @@ -41,7 +41,7 @@ cargo install --path . ## Architecture -Rust TUI for searching Claude Code, Codex CLI, and Factory conversation history. +Rust TUI for searching Claude Code, Codex CLI, Factory, OpenCode, Copilot CLI, and Cursor conversation history. - `src/main.rs` - Entry point, event loop, exec into CLI on resume - `src/app.rs` - Application state, search logic, background indexing thread @@ -49,7 +49,7 @@ Rust TUI for searching Claude Code, Codex CLI, and Factory conversation history. - `src/tui.rs` - Terminal setup/teardown - `src/theme.rs` - Light/dark theme with auto-detection - `src/session.rs` - Core types: Session, Message, SearchResult -- `src/parser/` - JSONL parsers for Claude (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), and Factory (`~/.factory/sessions/`) +- `src/parser/` - JSONL parsers for Claude (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Factory (`~/.factory/sessions/`), OpenCode (`~/.local/share/opencode/`), Copilot CLI (`~/.copilot/session-state/`), and Cursor (`~/.cursor/chats/`) - `src/index/` - Tantivy full-text search index, stored in `~/Library/Caches/recall/` (macOS) or `~/.cache/recall/` (Linux) ## Key Patterns diff --git a/README.md b/README.md index d460eaf..f8af5a6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # recall   [![Mentioned in Awesome Claude Code](https://awesome.re/mentioned-badge.svg)](https://github.com/hesreallyhim/awesome-claude-code) -Search and resume your Claude Code conversations. Also supports Codex, OpenCode and Factory (Droid). +Search and resume your Claude Code conversations. Also supports Codex, OpenCode, Factory (Droid), Copilot CLI and Cursor CLI. **Tip**: Don't like reading? Tell your agent to use `recall search --help` and it'll search for you. @@ -66,6 +66,8 @@ For example, to resume conversations in YOLO mode, add this to your `.bashrc` or ```bash export RECALL_CLAUDE_CMD="claude --dangerously-skip-permissions --resume {id}" export RECALL_CODEX_CMD="codex --dangerously-bypass-approvals-and-sandbox resume {id}" +export RECALL_COPILOT_CMD="copilot --resume={id}" +export RECALL_CURSOR_CMD="cursor-agent --resume={id}" ``` --- diff --git a/src/app.rs b/src/app.rs index 737ca26..9d83d29 100644 --- a/src/app.rs +++ b/src/app.rs @@ -231,16 +231,19 @@ impl App { // Remember currently selected session to preserve selection let selected_session_id = self.results.get(self.selected).map(|r| r.session.id.clone()); - let mut results = if self.query.is_empty() { - self.index.recent(50)? + let results = if self.query.is_empty() { + match &self.search_scope { + SearchScope::Everything => self.index.recent(50)?, + SearchScope::Folder(cwd) => self.index.recent_filtered(50, None, Some(cwd))?, + } } else { - self.index.search(&self.query, 50)? - }; - - // Filter by scope if searching within a folder - if let SearchScope::Folder(ref cwd) = self.search_scope { - results.retain(|r| r.session.cwd == *cwd); + let mut results = self.index.search(&self.query, 50)?; + if let SearchScope::Folder(ref cwd) = self.search_scope { + results.retain(|r| r.session.cwd == *cwd); + } + results } + ; self.results = results; diff --git a/src/cli.rs b/src/cli.rs index e247f21..fdccb57 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -233,18 +233,14 @@ pub fn run_list( let since_dt = since.as_ref().map(|s| parse_time(s)).transpose()?; let until_dt = until.as_ref().map(|s| parse_time(s)).transpose()?; - let results = index.recent(limit * 2)?; // Get more to filter + let results = index.recent_filtered(limit, source, cwd.as_deref())?; let output = ListOutput { sessions: results .iter() - // Filter by source - .filter(|r| source.is_none_or(|s| r.session.source == s)) // Filter by time .filter(|r| since_dt.is_none_or(|t| r.session.timestamp >= t)) .filter(|r| until_dt.is_none_or(|t| r.session.timestamp <= t)) - // Filter by working directory - .filter(|r| cwd.as_ref().is_none_or(|c| r.session.cwd == *c)) .take(limit) .map(|r| r.session.to_summary()) .collect(), diff --git a/src/index/schema.rs b/src/index/schema.rs index cf36560..def7cf6 100644 --- a/src/index/schema.rs +++ b/src/index/schema.rs @@ -309,15 +309,50 @@ impl SessionIndex { /// Get recent sessions sorted by timestamp (most recent first) pub fn recent(&self, limit: usize) -> Result> { + self.recent_filtered(limit, None, None) + } + + /// Get recent sessions sorted by timestamp (most recent first), with optional filters. + pub fn recent_filtered( + &self, + limit: usize, + source: Option, + cwd: Option<&str>, + ) -> Result> { use tantivy::collector::TopDocs; use tantivy::query::AllQuery; let searcher = self.reader.searcher(); + let query: Box = if source.is_none() && cwd.is_none() { + Box::new(AllQuery) + } else { + let mut clauses: Vec<(Occur, Box)> = Vec::new(); + clauses.push((Occur::Must, Box::new(AllQuery))); + + if let Some(source) = source { + let term = tantivy::Term::from_field_text(self.source, source.as_str()); + clauses.push(( + Occur::Must, + Box::new(TermQuery::new(term, IndexRecordOption::Basic)), + )); + } + + if let Some(cwd) = cwd { + let term = tantivy::Term::from_field_text(self.cwd, cwd); + clauses.push(( + Occur::Must, + Box::new(TermQuery::new(term, IndexRecordOption::Basic)), + )); + } + + Box::new(BooleanQuery::new(clauses)) + }; + // Get all docs sorted by timestamp descending // Fetch many more docs since each session has multiple messages indexed let top_docs = searcher.search( - &AllQuery, + &*query, &TopDocs::with_limit(limit * 100).order_by_fast_field::("timestamp", tantivy::Order::Desc), )?; @@ -435,4 +470,3 @@ impl SessionIndex { } } } - diff --git a/src/main.rs b/src/main.rs index 251a4ed..4590015 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ mod cli; #[derive(Parser)] #[command(name = "recall")] -#[command(version, about = "Search and resume Claude Code, Codex CLI, and Factory conversations")] +#[command(version, about = "Search and resume Claude Code, Codex CLI, Factory, OpenCode, Copilot, and Cursor conversations")] struct Cli { #[command(subcommand)] command: Option, @@ -30,7 +30,7 @@ enum Command { #[arg(required = true)] query: Vec, - /// Filter by source (claude, codex, factory, opencode) + /// Filter by source (claude, codex, factory, opencode, copilot, cursor) #[arg(long, short)] source: Option, @@ -65,7 +65,7 @@ enum Command { #[arg(long, short, default_value = "20")] limit: usize, - /// Filter by source (claude, codex, factory, opencode) + /// Filter by source (claude, codex, factory, opencode, copilot, cursor) #[arg(long, short)] source: Option, @@ -143,7 +143,7 @@ fn main() -> Result<()> { fn parse_source(source: &Option) -> Result> { match source { Some(s) => SessionSource::parse(s) - .ok_or_else(|| anyhow::anyhow!("Invalid source '{}'. Valid: claude, codex, factory, opencode", s)) + .ok_or_else(|| anyhow::anyhow!("Invalid source '{}'. Valid: claude, codex, factory, opencode, copilot, cursor", s)) .map(Some), None => Ok(None), } diff --git a/src/parser/claude.rs b/src/parser/claude.rs index ce429a4..88a17d2 100644 --- a/src/parser/claude.rs +++ b/src/parser/claude.rs @@ -6,7 +6,7 @@ use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::Path; -use super::{join_consecutive_messages, SessionParser}; +use super::{extract_text_content, join_consecutive_messages, SessionParser}; #[derive(Debug, Deserialize)] struct ClaudeLine { @@ -42,7 +42,7 @@ impl SessionParser for ClaudeParser { fn can_parse(path: &Path) -> bool { // Claude Code sessions are in ~/.claude/projects/ path.to_str() - .map(|s| s.contains(".claude/projects")) + .map(|s| s.contains(".claude/projects") || s.contains(".claude\\projects")) .unwrap_or(false) } @@ -114,7 +114,7 @@ impl SessionParser for ClaudeParser { _ => continue, }; - let content = extract_content(&msg.content); + let content = extract_text_content(&msg.content); if content.is_empty() { continue; } @@ -155,42 +155,16 @@ impl SessionParser for ClaudeParser { } } -/// Extract text content from Claude's message content field. -/// - User messages: content is a plain string -/// - Assistant messages: content is an array of {type, text} objects -fn extract_content(content: &serde_json::Value) -> String { - match content { - // Direct string (user messages) - serde_json::Value::String(s) => s.clone(), - - // Array of content blocks (assistant messages) - serde_json::Value::Array(arr) => { - let mut texts = Vec::new(); - for item in arr { - if let Some(obj) = item.as_object() { - // Only extract "text" type blocks, skip tool_use, thinking, etc. - if obj.get("type").and_then(|v| v.as_str()) == Some("text") { - if let Some(text) = obj.get("text").and_then(|v| v.as_str()) { - texts.push(text.to_string()); - } - } - } - } - texts.join("\n") - } - - _ => String::new(), - } -} #[cfg(test)] mod tests { use super::*; + use crate::parser::extract_text_content; #[test] fn test_extract_content_string() { let content = serde_json::json!("Hello, world!"); - assert_eq!(extract_content(&content), "Hello, world!"); + assert_eq!(extract_text_content(&content), "Hello, world!"); } #[test] @@ -200,7 +174,6 @@ mod tests { {"type": "tool_use", "name": "Read"}, {"type": "text", "text": "World"} ]); - assert_eq!(extract_content(&content), "Hello\nWorld"); + assert_eq!(extract_text_content(&content), "Hello\nWorld"); } - } diff --git a/src/parser/codex.rs b/src/parser/codex.rs index bfe3051..a6e1028 100644 --- a/src/parser/codex.rs +++ b/src/parser/codex.rs @@ -47,7 +47,7 @@ impl SessionParser for CodexParser { fn can_parse(path: &Path) -> bool { // Codex sessions are in ~/.codex/sessions/ and start with "rollout-" path.to_str() - .map(|s| s.contains(".codex/sessions")) + .map(|s| s.contains(".codex/sessions") || s.contains(".codex\\sessions")) .unwrap_or(false) } @@ -83,6 +83,10 @@ impl SessionParser for CodexParser { match entry.entry_type.as_str() { "session_meta" => { if let Some(payload) = &entry.payload { + if is_subagent_session_meta(payload) { + anyhow::bail!("Codex subagent sidechain session: {:?}", path); + } + if let Ok(meta) = serde_json::from_value::(payload.clone()) { // Only set if not already set (first session_meta wins) if session_id.is_none() { @@ -164,6 +168,14 @@ impl SessionParser for CodexParser { } } +fn is_subagent_session_meta(payload: &serde_json::Value) -> bool { + payload + .get("source") + .and_then(|source| source.get("subagent")) + .and_then(|subagent| subagent.get("thread_spawn")) + .is_some() +} + /// Extract text content from a Codex response item. /// Filters out CLI-injected blocks (AGENTS.md instructions, environment_context). fn extract_codex_content(item: &ResponseItem) -> String { @@ -256,4 +268,36 @@ mod tests { " what is this?" ); } + + #[test] + fn test_is_subagent_session_meta() { + let payload = serde_json::json!({ + "id": "child-session", + "source": { + "subagent": { + "thread_spawn": { + "parent_thread_id": "parent-session", + "depth": 1 + } + } + } + }); + + assert!(is_subagent_session_meta(&payload)); + } + + #[test] + fn test_parse_subagent_session_returns_error() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("rollout-child.jsonl"); + + let content = r#"{"timestamp":"2026-03-25T07:35:38.039Z","type":"session_meta","payload":{"id":"child-session","forked_from_id":"parent-session","cwd":"/tmp/project","source":{"subagent":{"thread_spawn":{"parent_thread_id":"parent-session","depth":1}}}}} +{"timestamp":"2026-03-25T07:35:38.040Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Child agent response"}]}} +{"timestamp":"2026-03-25T07:35:38.041Z","type":"session_meta","payload":{"id":"parent-session","cwd":"/tmp/project","source":"cli"}}"#; + + std::fs::write(&path, content).unwrap(); + + let err = CodexParser::parse_file(&path).unwrap_err(); + assert!(err.to_string().contains("subagent sidechain")); + } } diff --git a/src/parser/copilot.rs b/src/parser/copilot.rs new file mode 100644 index 0000000..7b1ec14 --- /dev/null +++ b/src/parser/copilot.rs @@ -0,0 +1,212 @@ +use crate::session::{Message, Role, Session, SessionSource}; +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +use super::{join_consecutive_messages, SessionParser}; + +#[derive(Debug, Deserialize)] +struct CopilotLine { + #[serde(rename = "type")] + entry_type: String, + data: serde_json::Value, + timestamp: Option, +} + +pub struct CopilotParser; + +impl SessionParser for CopilotParser { + fn can_parse(path: &Path) -> bool { + let s = path.to_str().unwrap_or(""); + (s.contains(".copilot/session-state") || s.contains(".copilot\\session-state")) + && path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n == "events.jsonl") + .unwrap_or(false) + } + + fn parse_file(path: &Path) -> Result { + let file = File::open(path).context("Failed to open file")?; + let reader = BufReader::with_capacity(64 * 1024, file); + + let mut session_id: Option = None; + let mut cwd: Option = None; + let mut git_branch: Option = None; + let mut latest_timestamp: Option> = None; + let mut messages: Vec = Vec::new(); + + for line in reader.lines() { + let line = line.context("Failed to read line")?; + if line.trim().is_empty() { + continue; + } + + let entry: CopilotLine = match serde_json::from_str(&line) { + Ok(e) => e, + Err(_) => continue, + }; + + // Parse timestamp + let timestamp = entry + .timestamp + .as_ref() + .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok()) + .map(|dt| dt.with_timezone(&Utc)); + + match entry.entry_type.as_str() { + "session.start" => { + if session_id.is_none() { + session_id = entry.data["sessionId"].as_str().map(|s| s.to_string()); + } + if cwd.is_none() { + cwd = entry.data["context"]["cwd"] + .as_str() + .map(|s| s.to_string()); + } + if git_branch.is_none() { + git_branch = entry.data["context"]["branch"] + .as_str() + .map(|s| s.to_string()); + } + } + "user.message" | "assistant.message" => { + let role = if entry.entry_type == "user.message" { + Role::User + } else { + Role::Assistant + }; + if let Some(content) = entry.data["content"].as_str() { + if !content.is_empty() { + let ts = timestamp.unwrap_or_else(Utc::now); + if latest_timestamp.is_none_or(|prev| ts > prev) { + latest_timestamp = Some(ts); + } + messages.push(Message { + role, + content: content.to_string(), + timestamp: ts, + }); + } + } + } + _ => {} + } + } + + // Fall back to directory name for session ID + let session_id = session_id.unwrap_or_else(|| { + path.parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string() + }); + + Ok(Session { + id: session_id, + source: SessionSource::CopilotCli, + file_path: path.to_path_buf(), + cwd: cwd.unwrap_or_else(|| ".".to_string()), + git_branch, + timestamp: latest_timestamp.unwrap_or_else(Utc::now), + messages: join_consecutive_messages(messages), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_can_parse_copilot_path() { + assert!(CopilotParser::can_parse(Path::new( + "/home/user/.copilot/session-state/abc-123/events.jsonl" + ))); + } + + #[test] + fn test_can_parse_rejects_other_paths() { + assert!(!CopilotParser::can_parse(Path::new( + "/home/user/.claude/projects/test/session.jsonl" + ))); + assert!(!CopilotParser::can_parse(Path::new( + "/home/user/.copilot/session-state/abc-123/workspace.yaml" + ))); + } + + #[test] + fn test_parse_events_basic() { + let dir = tempfile::TempDir::new().unwrap(); + let session_dir = dir.path().join(".copilot/session-state/test-id-001"); + std::fs::create_dir_all(&session_dir).unwrap(); + let events_path = session_dir.join("events.jsonl"); + + let content = r#"{"type":"session.start","data":{"sessionId":"test-id-001","context":{"cwd":"/test/project","branch":"main"}},"timestamp":"2026-01-15T10:00:00.000Z"} +{"type":"user.message","data":{"content":"hello"},"timestamp":"2026-01-15T10:00:05.000Z"} +{"type":"assistant.turn_start","data":{"turnId":"0"},"timestamp":"2026-01-15T10:00:05.100Z"} +{"type":"assistant.message","data":{"content":"Hi there!"},"timestamp":"2026-01-15T10:00:10.000Z"} +{"type":"assistant.turn_end","data":{},"timestamp":"2026-01-15T10:00:10.100Z"}"#; + + std::fs::write(&events_path, content).unwrap(); + + let session = CopilotParser::parse_file(&events_path).unwrap(); + assert_eq!(session.id, "test-id-001"); + assert_eq!(session.source, SessionSource::CopilotCli); + assert_eq!(session.cwd, "/test/project"); + assert_eq!(session.git_branch, Some("main".to_string())); + assert_eq!(session.messages.len(), 2); + assert_eq!(session.messages[0].role, Role::User); + assert_eq!(session.messages[0].content, "hello"); + assert_eq!(session.messages[1].role, Role::Assistant); + assert_eq!(session.messages[1].content, "Hi there!"); + } + + #[test] + fn test_parse_events_skips_non_messages() { + let dir = tempfile::TempDir::new().unwrap(); + let session_dir = dir.path().join(".copilot/session-state/test-skip"); + std::fs::create_dir_all(&session_dir).unwrap(); + let events_path = session_dir.join("events.jsonl"); + + let content = r#"{"type":"session.start","data":{"sessionId":"test-skip","context":{"cwd":"/"}},"timestamp":"2026-01-15T10:00:00.000Z"} +{"type":"session.model_change","data":{"newModel":"gpt-4.1"},"timestamp":"2026-01-15T10:00:01.000Z"} +{"type":"session.info","data":{"infoType":"model","message":"Model changed"},"timestamp":"2026-01-15T10:00:01.000Z"} +{"type":"session.error","data":{"message":"something failed"},"timestamp":"2026-01-15T10:00:02.000Z"} +{"type":"user.message","data":{"content":"test"},"timestamp":"2026-01-15T10:00:05.000Z"}"#; + + std::fs::write(&events_path, content).unwrap(); + + let session = CopilotParser::parse_file(&events_path).unwrap(); + assert_eq!(session.messages.len(), 1); + assert_eq!(session.messages[0].content, "test"); + } + + #[test] + fn test_session_timestamp_uses_message_time_not_event_time() { + let dir = tempfile::TempDir::new().unwrap(); + let session_dir = dir.path().join(".copilot/session-state/test-ts"); + std::fs::create_dir_all(&session_dir).unwrap(); + let events_path = session_dir.join("events.jsonl"); + + // session.info at :02 and session.error at :03 are AFTER the user.message at :01 + // Session timestamp should be :01 (last message), not :03 (last event) + let content = r#"{"type":"session.start","data":{"sessionId":"test-ts","context":{"cwd":"/"}},"timestamp":"2026-01-15T10:00:00.000Z"} +{"type":"user.message","data":{"content":"hello"},"timestamp":"2026-01-15T10:00:01.000Z"} +{"type":"session.info","data":{"infoType":"model","message":"changed"},"timestamp":"2026-01-15T10:00:02.000Z"} +{"type":"session.error","data":{"message":"err"},"timestamp":"2026-01-15T10:00:03.000Z"}"#; + + std::fs::write(&events_path, content).unwrap(); + + let session = CopilotParser::parse_file(&events_path).unwrap(); + // Timestamp should match the user message, not the later non-message events + let expected = DateTime::parse_from_rfc3339("2026-01-15T10:00:01.000Z") + .unwrap() + .with_timezone(&Utc); + assert_eq!(session.timestamp, expected); + } +} diff --git a/src/parser/cursor.rs b/src/parser/cursor.rs new file mode 100644 index 0000000..05ad093 --- /dev/null +++ b/src/parser/cursor.rs @@ -0,0 +1,294 @@ +use crate::session::{Message, Role, Session, SessionSource}; +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use super::{extract_text_content, join_consecutive_messages, SessionParser}; + +/// Cursor Agent CLI transcript line format +/// Each line: {"role": "user"|"assistant", "message": {"content": [{"type": "text", "text": "..."}]}} +#[derive(Debug, Deserialize)] +struct CursorLine { + role: String, + message: Option, +} + +#[derive(Debug, Deserialize)] +struct CursorMessage { + content: serde_json::Value, +} + +pub struct CursorParser; + +impl SessionParser for CursorParser { + fn can_parse(path: &Path) -> bool { + let s = path.to_str().unwrap_or(""); + (s.contains(".cursor/projects") || s.contains(".cursor\\projects")) + && s.contains("agent-transcripts") + && path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e == "jsonl") + .unwrap_or(false) + } + + fn parse_file(path: &Path) -> Result { + let file = File::open(path).context("Failed to open file")?; + let reader = BufReader::with_capacity(64 * 1024, file); + + // Session ID = filename without extension (UUID) + let session_id = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + // Resolve cwd from the project directory name + let cwd = path + .ancestors() + .find(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n == "agent-transcripts") + .unwrap_or(false) + }) + .and_then(|transcripts_dir| transcripts_dir.parent()) + .and_then(|project_dir| project_dir.file_name()) + .and_then(|name| name.to_str()) + .and_then(decode_workspace_path) + .unwrap_or_else(|| ".".to_string()); + + let mut messages: Vec = Vec::new(); + + // Use file mtime as timestamp (transcripts don't have per-message timestamps) + let file_ts = std::fs::metadata(path) + .ok() + .and_then(|m| m.modified().ok()) + .map(DateTime::::from) + .unwrap_or_else(Utc::now); + + for line in reader.lines() { + let line = line.context("Failed to read line")?; + if line.trim().is_empty() { + continue; + } + + let entry: CursorLine = match serde_json::from_str(&line) { + Ok(e) => e, + Err(_) => continue, + }; + + let role = match entry.role.as_str() { + "user" => Role::User, + "assistant" => Role::Assistant, + _ => continue, + }; + + let content = entry + .message + .as_ref() + .map(|m| extract_text_content(&m.content)) + .unwrap_or_default(); + + if content.is_empty() { + continue; + } + + messages.push(Message { + role, + content, + timestamp: file_ts, + }); + } + + Ok(Session { + id: session_id, + source: SessionSource::CursorCli, + file_path: path.to_path_buf(), + cwd, + git_branch: None, + timestamp: file_ts, + messages: join_consecutive_messages(messages), + }) + } +} + + +/// Encode a filesystem path the same way Cursor does: +/// replace all non-alphanumeric chars with `-`, then collapse consecutive `-`. +fn cursor_encode_path(path: &str) -> String { + let replaced = path + .trim_start_matches('/') + .replace(|c: char| !c.is_alphanumeric(), "-"); + let mut prev_dash = false; + replaced + .chars() + .filter(|&c| { + if c == '-' { + if prev_dash { + return false; + } + prev_dash = true; + } else { + prev_dash = false; + } + true + }) + .collect() +} + +/// Decode a Cursor workspace directory name to a filesystem path. +/// Cursor encodes all non-alphanumeric chars as `-`, collapses runs of `-`. +/// Strategy: walk the filesystem, encoding each candidate path and comparing +/// against the target name. This avoids exponential DFS over separator guesses. +pub fn decode_workspace_path(name: &str) -> Option { + let parts: Vec<&str> = name.split('-').collect(); + if parts.is_empty() { + return None; + } + + // Start from the first component (e.g., "Users", "var", "home") + let start = PathBuf::from("/").join(parts[0]); + if !start.exists() { + return None; + } + + let mut best: Option = None; + walk_decode(name, &start, &mut best); + best.map(|p| p.to_string_lossy().into_owned()) +} + +/// Recursively walk directories, checking if encoding each path matches the target name. +fn walk_decode(target: &str, current: &Path, best: &mut Option) { + let encoded = cursor_encode_path(¤t.to_string_lossy()); + + // If current path's encoding matches the target exactly, we found it + if encoded == target { + *best = Some(current.to_path_buf()); + return; + } + + // If the target doesn't start with our encoding, prune this branch + if !target.starts_with(&encoded) || !target[encoded.len()..].starts_with('-') { + return; + } + + // List children and recurse + let entries = match std::fs::read_dir(current) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk_decode(target, &path, best); + if best.is_some() { + return; // Exact match found, stop early + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_can_parse_cursor_path() { + assert!(CursorParser::can_parse(Path::new( + "/home/user/.cursor/projects/myproject/agent-transcripts/abc-123.jsonl" + ))); + } + + #[test] + fn test_can_parse_rejects_other() { + assert!(!CursorParser::can_parse(Path::new( + "/home/user/.claude/projects/test/session.jsonl" + ))); + assert!(!CursorParser::can_parse(Path::new( + "/home/user/.cursor/chats/abc-123/store.db" + ))); + } + + #[test] + fn test_parse_cursor_transcript() { + let dir = tempfile::TempDir::new().unwrap(); + let transcripts_dir = dir + .path() + .join(".cursor/projects/test-project/agent-transcripts"); + std::fs::create_dir_all(&transcripts_dir).unwrap(); + let jsonl_path = transcripts_dir.join("abc-123-def.jsonl"); + + let content = r#"{"role":"user","message":{"content":[{"type":"text","text":"Hello cursor"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Hi there!"}]}} +{"role":"system","message":{"content":"system prompt"}} +"#; + std::fs::write(&jsonl_path, content).unwrap(); + + let session = CursorParser::parse_file(&jsonl_path).unwrap(); + assert_eq!(session.id, "abc-123-def"); + assert_eq!(session.source, SessionSource::CursorCli); + assert_eq!(session.messages.len(), 2); // system skipped + assert_eq!(session.messages[0].role, Role::User); + assert_eq!(session.messages[0].content, "Hello cursor"); + assert_eq!(session.messages[1].role, Role::Assistant); + assert_eq!(session.messages[1].content, "Hi there!"); + } + + #[test] + fn test_parse_cursor_consecutive_messages() { + let dir = tempfile::TempDir::new().unwrap(); + let transcripts_dir = dir + .path() + .join(".cursor/projects/test-project/agent-transcripts"); + std::fs::create_dir_all(&transcripts_dir).unwrap(); + let jsonl_path = transcripts_dir.join("test-join.jsonl"); + + let content = r#"{"role":"user","message":{"content":[{"type":"text","text":"Part 1"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Response A"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Response B"}]}} +"#; + std::fs::write(&jsonl_path, content).unwrap(); + + let session = CursorParser::parse_file(&jsonl_path).unwrap(); + assert_eq!(session.messages.len(), 2); + assert_eq!(session.messages[1].content, "Response A\n\nResponse B"); + } + + #[test] + fn test_decode_workspace_path() { + let dir = tempfile::TempDir::new().unwrap(); + let nested = dir.path().join("a/b.c/d"); + std::fs::create_dir_all(&nested).unwrap(); + + let encoded = cursor_encode_path(&nested.to_string_lossy()); + let result = decode_workspace_path(&encoded); + assert_eq!(result, Some(nested.to_string_lossy().into_owned())); + } + + #[test] + fn test_decode_workspace_path_simple() { + let dir = tempfile::TempDir::new().unwrap(); + let project = dir.path().join("myproject"); + std::fs::create_dir_all(&project).unwrap(); + + let encoded = cursor_encode_path(&project.to_string_lossy()); + let result = decode_workspace_path(&encoded); + assert_eq!(result, Some(project.to_string_lossy().into_owned())); + } + + #[test] + fn test_decode_workspace_path_with_hyphen() { + let dir = tempfile::TempDir::new().unwrap(); + let project = dir.path().join("my-project/sub-dir"); + std::fs::create_dir_all(&project).unwrap(); + + let encoded = cursor_encode_path(&project.to_string_lossy()); + let result = decode_workspace_path(&encoded); + assert_eq!(result, Some(project.to_string_lossy().into_owned())); + } +} diff --git a/src/parser/mod.rs b/src/parser/mod.rs index d128ec9..340081b 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1,10 +1,14 @@ mod claude; mod codex; +mod copilot; +mod cursor; mod factory; mod opencode; pub use claude::ClaudeParser; pub use codex::CodexParser; +pub use copilot::CopilotParser; +pub use cursor::CursorParser; pub use factory::FactoryParser; pub use opencode::OpenCodeParser; @@ -29,6 +33,26 @@ pub fn join_consecutive_messages(messages: Vec) -> Vec { }) } +/// Extract text content from a message content field. +/// Handles both plain strings and arrays of {type, text} content blocks. +pub fn extract_text_content(content: &serde_json::Value) -> String { + match content { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|part| { + if part.get("type")?.as_str()? == "text" { + part.get("text")?.as_str().map(|s| s.to_string()) + } else { + None + } + }) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + /// Trait for parsing session files pub trait SessionParser { /// Parse a session file into a Session @@ -118,6 +142,38 @@ pub fn discover_session_files() -> Vec { } } } + + // Copilot CLI: ~/.copilot/session-state/*/events.jsonl + let copilot_dir = home.join(".copilot/session-state"); + if copilot_dir.exists() { + if let Ok(sessions) = std::fs::read_dir(&copilot_dir) { + for session in sessions.flatten() { + let events = session.path().join("events.jsonl"); + if events.exists() { + files.push(events); + } + } + } + } + + // Cursor Agent CLI: ~/.cursor/projects/*/agent-transcripts/**/*.jsonl + let cursor_projects = home.join(".cursor/projects"); + if let Ok(projects) = std::fs::read_dir(&cursor_projects) { + for project in projects.flatten() { + let transcripts = project.path().join("agent-transcripts"); + if transcripts.exists() { + for entry in walkdir::WalkDir::new(&transcripts).into_iter().flatten() { + let path = entry.path(); + if path.components().any(|c| c.as_os_str() == "subagents") { + continue; + } + if path.extension().map(|e| e == "jsonl").unwrap_or(false) { + files.push(path.to_path_buf()); + } + } + } + } + } } files @@ -133,6 +189,10 @@ pub fn parse_session_file(path: &Path) -> Result { FactoryParser::parse_file(path) } else if OpenCodeParser::can_parse(path) { OpenCodeParser::parse_file(path) + } else if CopilotParser::can_parse(path) { + CopilotParser::parse_file(path) + } else if CursorParser::can_parse(path) { + CursorParser::parse_file(path) } else { anyhow::bail!("Unknown session file format: {:?}", path) } diff --git a/src/parser/opencode.rs b/src/parser/opencode.rs index bbdf0cf..409bb87 100644 --- a/src/parser/opencode.rs +++ b/src/parser/opencode.rs @@ -68,7 +68,10 @@ impl SessionParser for OpenCodeParser { fn can_parse(path: &Path) -> bool { // OpenCode sessions are in ~/.local/share/opencode/storage/session/ path.to_str() - .map(|s| s.contains(".local/share/opencode/storage/session")) + .map(|s| { + s.contains(".local/share/opencode/storage/session") + || s.contains(".local\\share\\opencode\\storage\\session") + }) .unwrap_or(false) } diff --git a/src/session.rs b/src/session.rs index 5ed1d4e..c300f12 100644 --- a/src/session.rs +++ b/src/session.rs @@ -12,6 +12,10 @@ pub enum SessionSource { Factory, #[serde(rename = "opencode")] OpenCode, + #[serde(rename = "copilot")] + CopilotCli, + #[serde(rename = "cursor")] + CursorCli, } impl SessionSource { @@ -21,6 +25,8 @@ impl SessionSource { SessionSource::CodexCli => "codex", SessionSource::Factory => "factory", SessionSource::OpenCode => "opencode", + SessionSource::CopilotCli => "copilot", + SessionSource::CursorCli => "cursor", } } @@ -30,6 +36,8 @@ impl SessionSource { "codex" => Some(SessionSource::CodexCli), "factory" => Some(SessionSource::Factory), "opencode" => Some(SessionSource::OpenCode), + "copilot" => Some(SessionSource::CopilotCli), + "cursor" => Some(SessionSource::CursorCli), _ => None, } } @@ -40,6 +48,8 @@ impl SessionSource { SessionSource::CodexCli => "Codex", SessionSource::Factory => "Factory", SessionSource::OpenCode => "OpenCode", + SessionSource::CopilotCli => "Copilot", + SessionSource::CursorCli => "Cursor", } } @@ -49,6 +59,8 @@ impl SessionSource { SessionSource::CodexCli => "■", SessionSource::Factory => "◆", SessionSource::OpenCode => "○", + SessionSource::CopilotCli => "▲", + SessionSource::CursorCli => "◇", } } } @@ -105,6 +117,8 @@ impl Session { SessionSource::CodexCli => "RECALL_CODEX_CMD", SessionSource::Factory => "RECALL_FACTORY_CMD", SessionSource::OpenCode => "RECALL_OPENCODE_CMD", + SessionSource::CopilotCli => "RECALL_COPILOT_CMD", + SessionSource::CursorCli => "RECALL_CURSOR_CMD", }; if let Ok(cmd) = std::env::var(env_var) { @@ -136,6 +150,14 @@ impl Session { "opencode".to_string(), vec!["--session".to_string(), self.id.clone()], ), + SessionSource::CopilotCli => ( + "copilot".to_string(), + vec![format!("--resume={}", self.id)], + ), + SessionSource::CursorCli => ( + "cursor-agent".to_string(), + vec![format!("--resume={}", self.id)], + ), } } } @@ -239,3 +261,53 @@ impl Session { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// Mutex to serialize tests that modify environment variables + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn make_session(source: SessionSource, id: &str) -> Session { + Session { + id: id.to_string(), + source, + file_path: std::path::PathBuf::from("/tmp/test"), + cwd: ".".to_string(), + git_branch: None, + timestamp: chrono::Utc::now(), + messages: vec![], + } + } + + #[test] + fn test_resume_command_copilot() { + let _lock = ENV_LOCK.lock().unwrap(); + let session = make_session(SessionSource::CopilotCli, "abc-123"); + let (cmd, args) = session.resume_command(); + assert_eq!(cmd, "copilot"); + assert_eq!(args, vec!["--resume=abc-123"]); + } + + #[test] + fn test_resume_command_cursor() { + let _lock = ENV_LOCK.lock().unwrap(); + let session = make_session(SessionSource::CursorCli, "def-456"); + let (cmd, args) = session.resume_command(); + assert_eq!(cmd, "cursor-agent"); + assert_eq!(args, vec!["--resume=def-456"]); + } + + #[test] + fn test_resume_command_env_override() { + let _lock = ENV_LOCK.lock().unwrap(); + let session = make_session(SessionSource::CopilotCli, "abc-123"); + std::env::set_var("RECALL_COPILOT_CMD", "my-copilot --yolo {id}"); + let (cmd, args) = session.resume_command(); + std::env::remove_var("RECALL_COPILOT_CMD"); + assert_eq!(cmd, "my-copilot"); + assert_eq!(args, vec!["--yolo", "abc-123"]); + } +} diff --git a/src/theme.rs b/src/theme.rs index 5f44492..bd62d61 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -44,6 +44,14 @@ pub struct Theme { pub opencode_bubble_bg: Color, /// OpenCode source indicator color pub opencode_source: Color, + /// Copilot message bubble background + pub copilot_bubble_bg: Color, + /// Copilot source indicator color + pub copilot_source: Color, + /// Cursor message bubble background + pub cursor_bubble_bg: Color, + /// Cursor source indicator color + pub cursor_source: Color, /// Scope indicator background (slightly different from search_bg) pub scope_bg: Color, /// Scope keycap background (for "/" key) @@ -89,6 +97,10 @@ impl Theme { factory_source: Color::Rgb(150, 120, 200), // Google purple opencode_bubble_bg: Color::Rgb(30, 40, 55), // subtle blue tint opencode_source: Color::Rgb(100, 150, 255), // sky blue + copilot_bubble_bg: Color::Rgb(30, 45, 45), // subtle teal tint + copilot_source: Color::Rgb(100, 200, 180), // GitHub Copilot teal + cursor_bubble_bg: Color::Rgb(35, 35, 50), // subtle indigo tint + cursor_source: Color::Rgb(130, 130, 220), // Cursor indigo scope_bg: Color::Rgb(45, 45, 50), // slightly lighter than search_bg scope_key_bg: Color::Rgb(60, 60, 65), // keycap style separator_fg: Color::Rgb(60, 60, 65), // subtle separator @@ -120,6 +132,10 @@ impl Theme { factory_source: Color::Rgb(100, 80, 160), // Google purple (darker for light bg) opencode_bubble_bg: Color::Rgb(225, 235, 250), // subtle blue tint opencode_source: Color::Rgb(50, 100, 200), // sky blue (darker for light bg) + copilot_bubble_bg: Color::Rgb(220, 240, 235), // subtle teal tint + copilot_source: Color::Rgb(40, 140, 120), // GitHub Copilot teal (darker for light bg) + cursor_bubble_bg: Color::Rgb(230, 230, 245), // subtle indigo tint + cursor_source: Color::Rgb(80, 80, 170), // Cursor indigo (darker for light bg) scope_bg: Color::Rgb(215, 215, 220), // slightly darker than search_bg scope_key_bg: Color::Rgb(200, 200, 205), // keycap style separator_fg: Color::Rgb(195, 195, 200), // visible on light bg diff --git a/src/ui.rs b/src/ui.rs index 6318fb8..1a16bc6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -210,6 +210,8 @@ fn render_results_list(frame: &mut Frame, app: &mut App, area: Rect) { SessionSource::CodexCli => t.codex_source, SessionSource::Factory => t.factory_source, SessionSource::OpenCode => t.opencode_source, + SessionSource::CopilotCli => t.copilot_source, + SessionSource::CursorCli => t.cursor_source, }; // Build header with colored source indicator @@ -354,6 +356,8 @@ fn render_preview(frame: &mut Frame, app: &mut App, area: Rect) { crate::session::SessionSource::CodexCli => (t.codex_source, t.codex_bubble_bg), crate::session::SessionSource::Factory => (t.factory_source, t.factory_bubble_bg), crate::session::SessionSource::OpenCode => (t.opencode_source, t.opencode_bubble_bg), + crate::session::SessionSource::CopilotCli => (t.copilot_source, t.copilot_bubble_bg), + crate::session::SessionSource::CursorCli => (t.cursor_source, t.cursor_bubble_bg), }, }; @@ -374,6 +378,8 @@ fn render_preview(frame: &mut Frame, app: &mut App, area: Rect) { crate::session::SessionSource::CodexCli => "Codex", crate::session::SessionSource::Factory => "Droid", crate::session::SessionSource::OpenCode => "OpenCode", + crate::session::SessionSource::CopilotCli => "Copilot", + crate::session::SessionSource::CursorCli => "Cursor", }, }; diff --git a/tests/fixtures/.codex/sessions/test-codex-subagent.jsonl b/tests/fixtures/.codex/sessions/test-codex-subagent.jsonl new file mode 100644 index 0000000..7e2b27a --- /dev/null +++ b/tests/fixtures/.codex/sessions/test-codex-subagent.jsonl @@ -0,0 +1,3 @@ +{"timestamp":"2026-03-25T07:35:38.039Z","type":"session_meta","payload":{"id":"test-codex-subagent","forked_from_id":"test-codex-456","cwd":"/projects/webapp","originator":"codex_cli_rs","cli_version":"0.116.0","source":{"subagent":{"thread_spawn":{"parent_thread_id":"test-codex-456","depth":1,"agent_nickname":"Euclid","agent_role":"default"}}},"agent_nickname":"Euclid","agent_role":"default","model_provider":"openai","git":{"branch":"main"}}} +{"timestamp":"2026-03-25T07:35:39.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Child agent sidechain response"}]}} +{"timestamp":"2026-03-25T07:35:40.000Z","type":"session_meta","payload":{"id":"test-codex-456","cwd":"/projects/webapp","originator":"codex_cli_rs","cli_version":"0.116.0","source":"cli","model_provider":"openai","git":{"branch":"main"}}} diff --git a/tests/fixtures/.copilot/session-state/test-copilot-001/events.jsonl b/tests/fixtures/.copilot/session-state/test-copilot-001/events.jsonl new file mode 100644 index 0000000..bd157da --- /dev/null +++ b/tests/fixtures/.copilot/session-state/test-copilot-001/events.jsonl @@ -0,0 +1,7 @@ +{"type":"session.start","data":{"sessionId":"test-copilot-001","version":1,"producer":"copilot-agent","copilotVersion":"0.0.420","startTime":"2026-01-15T10:00:00.000Z","context":{"cwd":"/test/project","gitRoot":"/test/project","branch":"main","repository":"test/project"}},"id":"ev1","timestamp":"2026-01-15T10:00:00.000Z","parentId":null} +{"type":"user.message","data":{"content":"hello copilot","attachments":[],"interactionId":"int-001"},"id":"ev2","timestamp":"2026-01-15T10:00:05.000Z","parentId":"ev1"} +{"type":"assistant.turn_start","data":{"turnId":"0","interactionId":"int-001"},"id":"ev3","timestamp":"2026-01-15T10:00:05.100Z","parentId":"ev2"} +{"type":"session.model_change","data":{"newModel":"gpt-4.1"},"id":"ev4","timestamp":"2026-01-15T10:00:06.000Z","parentId":"ev3"} +{"type":"session.info","data":{"infoType":"model","message":"Model changed to: gpt-4.1"},"id":"ev5","timestamp":"2026-01-15T10:00:06.000Z","parentId":"ev4"} +{"type":"assistant.message","data":{"content":"Hello! How can I help you today?","messageId":"msg1","toolRequests":[],"interactionId":"int-001"},"id":"ev6","timestamp":"2026-01-15T10:00:10.000Z","parentId":"ev5"} +{"type":"assistant.turn_end","data":{},"id":"ev7","timestamp":"2026-01-15T10:00:10.100Z","parentId":"ev6"} diff --git a/tests/integration.rs b/tests/integration.rs index fd7f386..8f47abd 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -35,9 +35,54 @@ fn setup_test_env() -> TempDir { let codex_dst = temp_path.join(".codex"); copy_dir_recursive(&codex_src, &codex_dst); + // Copy .copilot directory + let copilot_src = fixtures.join(".copilot"); + let copilot_dst = temp_path.join(".copilot"); + copy_dir_recursive(&copilot_src, &copilot_dst); + + // Create Cursor Agent CLI JSONL fixtures + create_cursor_fixture(temp_path); + temp_dir } +/// Create Cursor Agent CLI transcript fixtures in the temp directory +fn create_cursor_fixture(temp_path: &std::path::Path) { + let transcripts_dir = temp_path.join(".cursor/projects/test-project/agent-transcripts"); + std::fs::create_dir_all(&transcripts_dir).unwrap(); + + // First session + let session1 = transcripts_dir.join("test-cursor-001.jsonl"); + let content1 = r#"{"role":"user","message":{"content":[{"type":"text","text":"hello cursor"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Hi! How can I help with cursor?"}]}} +"#; + std::fs::write(&session1, content1).unwrap(); + + // Second session + let session2 = transcripts_dir.join("test-cursor-openai.jsonl"); + let content2 = r#"{"role":"user","message":{"content":[{"type":"text","text":"hello openai cursor"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"OpenAI format response"}]}} +"#; + std::fs::write(&session2, content2).unwrap(); + + // Nested main-session layout used by recent Cursor versions + let nested_dir = transcripts_dir.join("test-cursor-nested"); + std::fs::create_dir_all(&nested_dir).unwrap(); + let nested_session = nested_dir.join("test-cursor-nested.jsonl"); + let nested_content = r#"{"role":"user","message":{"content":[{"type":"text","text":"hello nested cursor"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Nested cursor response"}]}} +"#; + std::fs::write(&nested_session, nested_content).unwrap(); + + // Subagent transcripts should not be indexed as top-level sessions + let subagent_dir = nested_dir.join("subagents"); + std::fs::create_dir_all(&subagent_dir).unwrap(); + let subagent_session = subagent_dir.join("test-cursor-subagent.jsonl"); + let subagent_content = r#"{"role":"user","message":{"content":[{"type":"text","text":"subagent only"}]}} +"#; + std::fs::write(&subagent_session, subagent_content).unwrap(); +} + /// Recursively copy a directory fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) { if !src.exists() { @@ -145,6 +190,91 @@ fn test_discovers_codex_sessions() { ); } +#[test] +fn test_recent_sessions_skip_codex_subagent_sidechains() { + let _lock = lock_test(); + let temp_dir = setup_test_env(); + std::env::set_var("RECALL_HOME_OVERRIDE", temp_dir.path()); + + let mut app = recall::App::new(String::new()).unwrap(); + wait_for_indexing(&mut app, 100); + app.toggle_scope(); + + std::env::remove_var("RECALL_HOME_OVERRIDE"); + + let codex_results: Vec<_> = app + .results + .iter() + .filter(|r| matches!(r.session.source, recall::SessionSource::CodexCli)) + .collect(); + + assert!( + codex_results.iter().any(|r| r.session.id == "test-codex-456"), + "Should include the main Codex session" + ); + assert!( + codex_results + .iter() + .all(|r| r.session.id != "test-codex-subagent"), + "Should skip Codex subagent sidechains" + ); +} + +#[test] +fn test_discovers_copilot_sessions() { + let _lock = lock_test(); + let temp_dir = setup_test_env(); + std::env::set_var("RECALL_HOME_OVERRIDE", temp_dir.path()); + + let files = recall::parser::discover_session_files(); + + std::env::remove_var("RECALL_HOME_OVERRIDE"); + + assert!( + files.iter().any(|f| f.to_string_lossy().contains(".copilot/session-state")), + "Should find files in .copilot/session-state" + ); +} + +#[test] +fn test_discovers_cursor_sessions() { + let _lock = lock_test(); + let temp_dir = setup_test_env(); + std::env::set_var("RECALL_HOME_OVERRIDE", temp_dir.path()); + + let files = recall::parser::discover_session_files(); + + std::env::remove_var("RECALL_HOME_OVERRIDE"); + + let cursor_files: Vec<_> = files + .iter() + .filter(|f| f.to_string_lossy().contains(".cursor/projects")) + .collect(); + assert!( + cursor_files.len() >= 2, + "Should find at least 2 cursor sessions, found {}", + cursor_files.len() + ); + assert!( + cursor_files.iter().any(|f| f.to_string_lossy().contains("test-cursor-001")), + "Should find cursor session 001" + ); + assert!( + cursor_files.iter().any(|f| f.to_string_lossy().contains("test-cursor-openai")), + "Should find cursor session openai" + ); + assert!( + cursor_files.iter().any(|f| f.to_string_lossy().contains("test-cursor-nested")), + "Should find nested cursor session" + ); + assert!( + cursor_files + .iter() + .all(|f| !f.to_string_lossy().contains("test-cursor-subagent")), + "Should skip cursor subagent sessions" + ); +} + #[test] fn test_search_finds_matching_content() { let _lock = lock_test(); @@ -540,6 +670,7 @@ fn test_cli_search_with_source_filter() { let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let results = json["results"].as_array().unwrap(); + assert!(!results.is_empty(), "Should find at least one Claude result"); // All results should be Claude for result in results { @@ -547,6 +678,158 @@ fn test_cli_search_with_source_filter() { } } +#[test] +fn test_cli_search_with_copilot_source_filter() { + let _lock = lock_test(); + let temp_dir = setup_test_env(); + + let (stdout, _stderr, success) = run_cli( + &["search", "hello", "--source", "copilot", "--limit", "10"], + temp_dir.path(), + ); + + assert!(success); + + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let results = json["results"].as_array().unwrap(); + assert!(!results.is_empty(), "Should find at least one Copilot result"); + + // All results should be Copilot + for result in results { + assert_eq!(result["source"], "copilot"); + } +} + +#[test] +fn test_cli_search_with_cursor_source_filter() { + let _lock = lock_test(); + let temp_dir = setup_test_env(); + + let (stdout, _stderr, success) = run_cli( + &["search", "hello cursor", "--source", "cursor", "--limit", "10"], + temp_dir.path(), + ); + + assert!(success); + + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let results = json["results"].as_array().unwrap(); + assert!(!results.is_empty(), "Should find at least one Cursor result"); + + // All results should be Cursor + for result in results { + assert_eq!(result["source"], "cursor"); + } +} + +#[test] +fn test_cli_search_finds_cursor_content() { + let _lock = lock_test(); + let temp_dir = setup_test_env(); + + let (stdout, _stderr, success) = run_cli( + &["search", "hello cursor", "--limit", "10"], + temp_dir.path(), + ); + + assert!(success); + + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let results = json["results"].as_array().unwrap(); + + assert!( + results.iter().any(|r| r["session_id"] == "test-cursor-001"), + "Should find Cursor fixture session" + ); +} + +#[test] +fn test_cli_list_with_cursor_source_filter() { + let _lock = lock_test(); + let temp_dir = setup_test_env(); + + let (stdout, _stderr, success) = run_cli( + &["list", "--source", "cursor", "--limit", "10"], + temp_dir.path(), + ); + + assert!(success); + + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let sessions = json["sessions"].as_array().unwrap(); + + // All sessions should be Cursor + for session in sessions { + assert_eq!(session["source"], "cursor"); + } +} + +#[test] +fn test_cli_read_cursor_session() { + let _lock = lock_test(); + let temp_dir = setup_test_env(); + + let (stdout, _stderr, success) = run_cli( + &["read", "test-cursor-001"], + temp_dir.path(), + ); + + assert!(success, "CLI read should succeed for cursor session"); + + let json: serde_json::Value = serde_json::from_str(&stdout) + .expect("Output should be valid JSON"); + + assert_eq!(json["session_id"], "test-cursor-001"); + assert_eq!(json["source"], "cursor"); + assert!(json["messages"].is_array()); + assert!(!json["messages"].as_array().unwrap().is_empty()); +} + +#[test] +fn test_cli_search_finds_second_cursor_content() { + let _lock = lock_test(); + let temp_dir = setup_test_env(); + + let (stdout, _stderr, success) = run_cli( + &["search", "openai cursor", "--source", "cursor", "--limit", "10"], + temp_dir.path(), + ); + + assert!(success); + + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let results = json["results"].as_array().unwrap(); + + assert!( + results.iter().any(|r| r["session_id"] == "test-cursor-openai"), + "Should find second Cursor session" + ); +} + +#[test] +fn test_cli_read_second_cursor_session() { + let _lock = lock_test(); + let temp_dir = setup_test_env(); + + let (stdout, _stderr, success) = run_cli( + &["read", "test-cursor-openai"], + temp_dir.path(), + ); + + assert!(success, "CLI read should succeed for second cursor session"); + + let json: serde_json::Value = serde_json::from_str(&stdout) + .expect("Output should be valid JSON"); + + assert_eq!(json["session_id"], "test-cursor-openai"); + assert_eq!(json["source"], "cursor"); + let messages = json["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["role"], "user"); + assert!(messages[0]["content"].as_str().unwrap().contains("hello openai cursor")); + assert_eq!(messages[1]["role"], "assistant"); +} + #[test] fn test_cli_search_no_results() { let _lock = lock_test(); diff --git a/tests/snapshots/integration__ui_no_query_folder_scope.snap b/tests/snapshots/integration__ui_no_query_folder_scope.snap index a362972..98f8a98 100644 --- a/tests/snapshots/integration__ui_no_query_folder_scope.snap +++ b/tests/snapshots/integration__ui_no_query_folder_scope.snap @@ -1,5 +1,6 @@ --- source: tests/integration.rs +assertion_line: 541 expression: buffer_to_string(&terminal) --- │ @@ -25,4 +26,4 @@ expression: buffer_to_string(&terminal) - ↑↓ navigate │ Esc quit 2 sessions + ↑↓ navigate │ Esc quit 5 sessions diff --git a/tests/snapshots/integration__ui_with_query_everywhere_scope_no_results.snap b/tests/snapshots/integration__ui_with_query_everywhere_scope_no_results.snap index 30213d8..18dfd66 100644 --- a/tests/snapshots/integration__ui_with_query_everywhere_scope_no_results.snap +++ b/tests/snapshots/integration__ui_with_query_everywhere_scope_no_results.snap @@ -1,5 +1,6 @@ --- source: tests/integration.rs +assertion_line: 608 expression: buffer_to_string(&terminal) --- │ @@ -25,4 +26,4 @@ expression: buffer_to_string(&terminal) - ↑↓ navigate │ Esc quit 2 sessions + ↑↓ navigate │ Esc quit 5 sessions diff --git a/tests/snapshots/integration__ui_with_query_folder_scope_no_results.snap b/tests/snapshots/integration__ui_with_query_folder_scope_no_results.snap index 064caac..15e30a9 100644 --- a/tests/snapshots/integration__ui_with_query_folder_scope_no_results.snap +++ b/tests/snapshots/integration__ui_with_query_folder_scope_no_results.snap @@ -1,5 +1,6 @@ --- source: tests/integration.rs +assertion_line: 586 expression: buffer_to_string(&terminal) --- │ @@ -25,4 +26,4 @@ expression: buffer_to_string(&terminal) - ↑↓ navigate │ Esc quit 2 sessions + ↑↓ navigate │ Esc quit 5 sessions