Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,10 @@ fn run(terminal: &mut tui::Tui, app: &mut App) -> Result<()> {
fn resume_session(session: &session::Session) -> Result<()> {
use std::os::unix::process::CommandExt;

// Change to conversation's working directory
if !session.cwd.is_empty() {
let _ = std::env::set_current_dir(&session.cwd);
// Change to the appropriate directory for resuming
let resume_cwd = session.resume_cwd();
if !resume_cwd.is_empty() {
let _ = std::env::set_current_dir(&resume_cwd);
}

let (program, args) = session.resume_command();
Expand All @@ -284,9 +285,10 @@ fn resume_session(session: &session::Session) -> Result<()> {

#[cfg(not(unix))]
fn resume_session(session: &session::Session) -> Result<()> {
// Change to conversation's working directory
if !session.cwd.is_empty() {
let _ = std::env::set_current_dir(&session.cwd);
// Change to the appropriate directory for resuming
let resume_cwd = session.resume_cwd();
if !resume_cwd.is_empty() {
let _ = std::env::set_current_dir(&resume_cwd);
}

let (program, args) = session.resume_command();
Expand Down
21 changes: 7 additions & 14 deletions src/parser/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ use super::{join_consecutive_messages, SessionParser};
struct ClaudeLine {
#[serde(rename = "type")]
entry_type: String,
#[serde(rename = "sessionId")]
session_id: Option<String>,
cwd: Option<String>,
#[serde(rename = "gitBranch")]
git_branch: Option<String>,
Expand Down Expand Up @@ -50,7 +48,13 @@ impl SessionParser for ClaudeParser {
let file = File::open(path).context("Failed to open file")?;
let reader = BufReader::with_capacity(64 * 1024, file);

let mut session_id: Option<String> = None;
// Use filename as session ID (what Claude Code expects for --resume)
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();

let mut cwd: Option<String> = None;
let mut git_branch: Option<String> = None;
let mut latest_timestamp: Option<DateTime<Utc>> = None;
Expand Down Expand Up @@ -83,9 +87,6 @@ impl SessionParser for ClaudeParser {
}

// Extract session metadata from the first valid message
if session_id.is_none() {
session_id = entry.session_id.clone();
}
if cwd.is_none() {
cwd = entry.cwd.clone();
}
Expand Down Expand Up @@ -135,14 +136,6 @@ impl SessionParser for ClaudeParser {
}
}

// Fall back to filename for session ID if not found
let session_id = session_id.unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string()
});

Ok(Session {
id: session_id,
source: SessionSource::ClaudeCode,
Expand Down
29 changes: 29 additions & 0 deletions src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,35 @@ impl Session {
.unwrap_or(&self.cwd)
}

/// Get the directory to cd into before resuming the session.
/// For Claude Code: decodes the project folder from file_path since sessions
/// are stored in project-specific folders, not the cwd recorded in messages.
/// For other sources: uses the cwd field.
pub fn resume_cwd(&self) -> String {
match self.source {
SessionSource::ClaudeCode => {
// Extract project folder from file_path: ~/.claude/projects/<project>/session.jsonl
// The project folder encodes the original cwd:
// "-Users-bob--config-nvim" -> "/Users/bob/.config/nvim"
if let Some(project_dir) = self.file_path.parent() {
if let Some(project_name) = project_dir.file_name().and_then(|s| s.to_str()) {
// Decode: "--" -> "/." (hidden dir), "-" -> "/"
let decoded = project_name
.replace("--", "\x00") // Temporarily mark hidden dirs
.replace('-', "/")
.replace('\x00', "/.");
if std::path::Path::new(&decoded).exists() {
return decoded;
}
}
}
// Fall back to cwd if decoding fails
self.cwd.clone()
}
_ => self.cwd.clone(),
}
}

/// Get the resume command for this session
/// Checks RECALL_CLAUDE_CMD / RECALL_CODEX_CMD / RECALL_FACTORY_CMD env vars first, falls back to defaults
/// Env var format: "program arg1 arg2 {id}" where {id} is replaced with session ID
Expand Down
8 changes: 4 additions & 4 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ fn test_search_finds_matching_content() {

assert!(!app.results.is_empty(), "Should find results for 'hello'");
assert!(
app.results.iter().any(|r| r.session.id == "test-claude-123"),
app.results.iter().any(|r| r.session.id == "session"),
"Should find Claude session"
);
}
Expand Down Expand Up @@ -521,7 +521,7 @@ fn test_cli_search_finds_fixture_content() {

// Should find the Claude fixture session
assert!(
results.iter().any(|r| r["session_id"] == "test-claude-123"),
results.iter().any(|r| r["session_id"] == "session"),
"Should find Claude fixture session"
);
}
Expand Down Expand Up @@ -610,7 +610,7 @@ fn test_cli_read_returns_session() {
let temp_dir = setup_test_env();

let (stdout, _stderr, success) = run_cli(
&["read", "test-claude-123"],
&["read", "session"],
temp_dir.path(),
);

Expand All @@ -619,7 +619,7 @@ fn test_cli_read_returns_session() {
let json: serde_json::Value = serde_json::from_str(&stdout)
.expect("Output should be valid JSON");

assert_eq!(json["session_id"], "test-claude-123");
assert_eq!(json["session_id"], "session");
assert_eq!(json["source"], "claude");
assert!(json["messages"].is_array());
assert!(!json["messages"].as_array().unwrap().is_empty());
Expand Down