diff --git a/frontend/e2e/activity-minimap.spec.ts b/frontend/e2e/activity-minimap.spec.ts index 503d38bb..a083b947 100644 --- a/frontend/e2e/activity-minimap.spec.ts +++ b/frontend/e2e/activity-minimap.spec.ts @@ -66,12 +66,15 @@ test.describe("Activity Minimap", () => { const lastBar = clickableBars.nth(barCount - 1); await lastBar.click(); - if (barCount > 1) { + const isScrollable = await sp.scroller.evaluate( + (el) => el.scrollHeight > el.clientHeight, + ); + if (barCount > 1 && isScrollable) { await expect .poll( () => sp.scroller.evaluate((el) => el.scrollTop), - { timeout: 3000 }, + { timeout: 5000 }, ) .not.toBe(scrollBefore); } diff --git a/frontend/src/lib/components/layout/SessionBreadcrumb.svelte b/frontend/src/lib/components/layout/SessionBreadcrumb.svelte index cd057b2b..4b3256b0 100644 --- a/frontend/src/lib/components/layout/SessionBreadcrumb.svelte +++ b/frontend/src/lib/components/layout/SessionBreadcrumb.svelte @@ -16,6 +16,7 @@ import { supportsResume, buildResumeCommand, + formatResumeResponseCommand, } from "../../utils/resume.js"; import { inSessionSearch } from "../../stores/inSessionSearch.svelte.js"; @@ -195,7 +196,8 @@ } // Launch failed — fall back to clipboard copy. if (resp.command) { - const ok = await copyToClipboard(resp.command); + const cmd = formatResumeResponseCommand(session.agent, resp); + const ok = cmd ? await copyToClipboard(cmd) : false; showFeedback(ok ? "Command copied!" : "Failed"); return; } @@ -217,7 +219,8 @@ try { const resp = await resumeSession(session.id, { command_only: true }); if (resp.command) { - const ok = await copyToClipboard(resp.command); + const cmd = formatResumeResponseCommand(session.agent, resp); + const ok = cmd ? await copyToClipboard(cmd) : false; showFeedback(ok ? "Command copied!" : "Failed"); return; } @@ -266,7 +269,8 @@ return; } if (resp.command) { - const ok = await copyToClipboard(resp.command); + const cmd = formatResumeResponseCommand(session.agent, resp); + const ok = cmd ? await copyToClipboard(cmd) : false; showFeedback(ok ? "Command copied!" : "Failed"); return; } diff --git a/frontend/src/lib/utils/keyboard.ts b/frontend/src/lib/utils/keyboard.ts index bafc6ec9..67866db7 100644 --- a/frontend/src/lib/utils/keyboard.ts +++ b/frontend/src/lib/utils/keyboard.ts @@ -8,7 +8,11 @@ import { getExportUrl, resumeSession, } from "../api/client.js"; -import { supportsResume, buildResumeCommand } from "./resume.js"; +import { + supportsResume, + buildResumeCommand, + formatResumeResponseCommand, +} from "./resume.js"; import { copyToClipboard } from "./clipboard.js"; function isInputFocused(): boolean { @@ -179,10 +183,12 @@ export function registerShortcuts( c: () => { const session = sessions.activeSession; if (session && supportsResume(session.agent)) { - // Copy resume command to clipboard. Use backend-built command - // (includes cd to project dir) with local fallback. + // Copy a runnable resume command. Cursor needs the backend cwd + // applied client-side so the copied command is self-contained. resumeSession(session.id, { command_only: true }).then((resp) => { - const cmd = resp.command || buildResumeCommand( + const cmd = formatResumeResponseCommand( + session.agent, resp, + ) || buildResumeCommand( session.agent, session.id, ); diff --git a/frontend/src/lib/utils/resume.test.ts b/frontend/src/lib/utils/resume.test.ts index 155811c7..8d228581 100644 --- a/frontend/src/lib/utils/resume.test.ts +++ b/frontend/src/lib/utils/resume.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { buildResumeCommand, + formatResumeResponseCommand, supportsResume, } from "./resume.js"; @@ -9,13 +10,13 @@ describe("supportsResume", () => { expect(supportsResume("claude")).toBe(true); expect(supportsResume("codex")).toBe(true); expect(supportsResume("copilot")).toBe(true); + expect(supportsResume("cursor")).toBe(true); expect(supportsResume("gemini")).toBe(true); expect(supportsResume("opencode")).toBe(true); expect(supportsResume("amp")).toBe(true); }); it("returns false for unsupported agents", () => { - expect(supportsResume("cursor")).toBe(false); expect(supportsResume("vscode-copilot")).toBe(false); expect(supportsResume("unknown")).toBe(false); }); @@ -46,6 +47,12 @@ describe("buildResumeCommand", () => { ).toBe("gemini --resume sess-2"); }); + it("returns null for cursor (server-only resume)", () => { + expect( + buildResumeCommand("cursor", "cursor:chat-7"), + ).toBeNull(); + }); + it("generates opencode resume command", () => { expect( buildResumeCommand("opencode", "opencode:s3"), @@ -71,7 +78,6 @@ describe("buildResumeCommand", () => { }); it("returns null for unsupported agents", () => { - expect(buildResumeCommand("cursor", "id")).toBeNull(); expect(buildResumeCommand("unknown", "id")).toBeNull(); }); @@ -171,3 +177,52 @@ describe("buildResumeCommand", () => { expect(cmd).toBe("amp --resume '$HOME/evil'"); }); }); + +describe("formatResumeResponseCommand", () => { + it("keeps non-cursor backend commands unchanged", () => { + expect( + formatResumeResponseCommand("claude", { + command: "claude --resume sess-1", + cwd: "/tmp/project", + }), + ).toBe("claude --resume sess-1"); + }); + + it("prepends cwd for cursor clipboard copy", () => { + expect( + formatResumeResponseCommand("cursor", { + command: "cursor agent --resume chat-7 --workspace '/tmp/project'", + cwd: "/tmp/project/frontend", + }), + ).toBe( + "cd '/tmp/project/frontend' && " + + "cursor agent --resume chat-7 --workspace '/tmp/project'", + ); + }); + + it("quotes cursor cwd when needed", () => { + expect( + formatResumeResponseCommand("cursor", { + command: "cursor agent --resume chat-7 --workspace '/tmp/project dir'", + cwd: "/tmp/project dir/frontend", + }), + ).toBe( + "cd '/tmp/project dir/frontend' && " + + "cursor agent --resume chat-7 --workspace '/tmp/project dir'", + ); + }); + + it("returns bare cursor command when cwd is unavailable", () => { + expect( + formatResumeResponseCommand("cursor", { + command: "cursor agent --resume chat-7", + }), + ).toBe("cursor agent --resume chat-7"); + }); + + it("returns null for missing backend command", () => { + expect( + formatResumeResponseCommand("cursor", null), + ).toBeNull(); + }); +}); diff --git a/frontend/src/lib/utils/resume.ts b/frontend/src/lib/utils/resume.ts index 7acc151a..d275a763 100644 --- a/frontend/src/lib/utils/resume.ts +++ b/frontend/src/lib/utils/resume.ts @@ -9,6 +9,8 @@ RESUME_AGENTS["codex"] = (id) => `codex resume ${shellQuote(id)}`; RESUME_AGENTS["copilot"] = (id) => `copilot --resume=${shellQuote(id)}`; +RESUME_AGENTS["cursor"] = (id) => + `cursor agent --resume ${shellQuote(id)}`; RESUME_AGENTS["gemini"] = (id) => `gemini --resume ${shellQuote(id)}`; RESUME_AGENTS["opencode"] = (id) => @@ -16,6 +18,14 @@ RESUME_AGENTS["opencode"] = (id) => RESUME_AGENTS["amp"] = (id) => `amp --resume ${shellQuote(id)}`; +/** + * Agents whose resume commands require server-resolved parameters + * (e.g. --workspace, cwd) that the client cannot compute locally. + * buildResumeCommand returns null for these agents so callers + * don't produce incomplete fallback commands. + */ +const SERVER_ONLY_RESUME = new Set(["cursor"]); + /** Flags available for Claude Code resume. */ export interface ClaudeResumeFlags { skipPermissions?: boolean; @@ -23,6 +33,12 @@ export interface ClaudeResumeFlags { print?: boolean; } +/** Minimal shape of a backend resume response used for clipboard copy. */ +export interface ResumeCommandResponse { + command: string; + cwd?: string; +} + /** * POSIX-safe shell quoting using single quotes. * Any embedded single quotes are escaped as '"'"'. @@ -33,6 +49,11 @@ function shellQuote(s: string): string { return "'" + s.replace(/'/g, "'\"'\"'") + "'"; } +function commandWithCwd(cmd: string, cwd?: string): string { + if (!cwd) return cmd; + return `cd ${shellQuote(cwd)} && ${cmd}`; +} + /** * Strip the agent-type prefix from a compound session ID, but only * when the prefix matches a known agent. Raw IDs that happen to @@ -56,7 +77,7 @@ export function supportsResume(agent: string): boolean { /** * Build a CLI command to resume the given session in a terminal. * - * @param agent - The agent type (e.g. "claude", "codex", "gemini") + * @param agent - The agent type (e.g. "claude", "codex", "cursor") * @param sessionId - The session ID (may include agent prefix) * @param flags - Optional Claude-specific resume flags * @returns The shell command string, or null if the agent is not supported @@ -66,6 +87,7 @@ export function buildResumeCommand( sessionId: string, flags?: ClaudeResumeFlags, ): string | null { + if (SERVER_ONLY_RESUME.has(agent)) return null; const builder = RESUME_AGENTS[agent]; if (!builder) return null; @@ -81,3 +103,19 @@ export function buildResumeCommand( return cmd; } + +/** + * Format a backend-built resume response for clipboard copy. + * + * Cursor keeps `command` and `cwd` separate in the API so callers can + * choose whether to apply the cwd directly. Clipboard copy needs a + * runnable one-liner, so rebuild it here only for Cursor. + */ +export function formatResumeResponseCommand( + agent: string, + response: ResumeCommandResponse | null | undefined, +): string | null { + if (!response?.command) return null; + if (agent !== "cursor") return response.command; + return commandWithCwd(response.command, response.cwd); +} diff --git a/internal/parser/cursor_paths.go b/internal/parser/cursor_paths.go new file mode 100644 index 00000000..11bf9f56 --- /dev/null +++ b/internal/parser/cursor_paths.go @@ -0,0 +1,36 @@ +package parser + +import ( + "path/filepath" + "strings" +) + +// ParseCursorTranscriptRelPath validates a path relative to a +// Cursor projects dir and returns the encoded project directory +// name for recognized transcript layouts. +func ParseCursorTranscriptRelPath(rel string) (string, bool) { + rel = filepath.Clean(rel) + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) < 3 || parts[1] != "agent-transcripts" { + return "", false + } + + switch len(parts) { + case 3: + if !IsCursorTranscriptExt(parts[2]) { + return "", false + } + return parts[0], true + case 4: + if !IsCursorTranscriptExt(parts[3]) { + return "", false + } + stem := strings.TrimSuffix(parts[3], filepath.Ext(parts[3])) + if stem != parts[2] { + return "", false + } + return parts[0], true + default: + return "", false + } +} diff --git a/internal/parser/discovery_test.go b/internal/parser/discovery_test.go index 0de2984e..4ada6eba 100644 --- a/internal/parser/discovery_test.go +++ b/internal/parser/discovery_test.go @@ -1295,6 +1295,75 @@ func TestDiscoverCursorSessions_DedupPrefersJsonl(t *testing.T) { } } +func TestParseCursorTranscriptRelPath(t *testing.T) { + tests := []struct { + name string + rel string + wantProject string + wantOK bool + }{ + { + name: "flat txt", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess.txt"), + wantProject: "proj-dir", + wantOK: true, + }, + { + name: "flat jsonl", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess.jsonl"), + wantProject: "proj-dir", + wantOK: true, + }, + { + name: "nested jsonl", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "sess.jsonl"), + wantProject: "proj-dir", + wantOK: true, + }, + { + name: "nested txt", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "sess.txt"), + wantProject: "proj-dir", + wantOK: true, + }, + { + name: "nested mismatched filename", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "other.jsonl"), + wantOK: false, + }, + { + name: "nested auxiliary file", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "notes.txt"), + wantOK: false, + }, + { + name: "subagent file ignored", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "subagents", "child.jsonl"), + wantOK: false, + }, + { + name: "wrong extension", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess.json"), + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotProject, gotOK := ParseCursorTranscriptRelPath(tt.rel) + if gotOK != tt.wantOK { + t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK) + } + if gotProject != tt.wantProject { + t.Errorf( + "project = %q, want %q", + gotProject, tt.wantProject, + ) + } + }) + } +} + func TestFindCursorSourceFile(t *testing.T) { cursorTranscripts := filepath.Join( "proj-dir", "agent-transcripts", diff --git a/internal/server/cursor_dir.go b/internal/server/cursor_dir.go new file mode 100644 index 00000000..31ad1742 --- /dev/null +++ b/internal/server/cursor_dir.go @@ -0,0 +1,290 @@ +package server + +import ( + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +// resolveCursorProjectDirFromSessionFile derives the real workspace +// directory for a Cursor session from the stored transcript path. +// The bool reports whether multiple matching paths exist on disk. +func resolveCursorProjectDirFromSessionFile( + filePath string, +) (string, bool) { + projectDir := cursorProjectDirNameFromTranscriptPath(filePath) + if projectDir == "" { + return "", false + } + return resolveCursorProjectDirName(projectDir) +} + +// resolveCursorProjectDirFromSessionFileHint derives the real workspace +// directory for a Cursor session from the stored transcript path, +// preferring candidates that contain the provided hint. +func resolveCursorProjectDirFromSessionFileHint( + filePath, hint string, +) string { + projectDir := cursorProjectDirNameFromTranscriptPath(filePath) + if projectDir == "" { + return "" + } + return resolveCursorProjectDirNameHint(projectDir, hint) +} + +// cursorProjectDirNameFromTranscriptPath extracts the encoded Cursor +// project directory name from either flat or nested transcript paths. +func cursorProjectDirNameFromTranscriptPath(path string) string { + path = filepath.Clean(path) + dir := filepath.Dir(path) + for { + base := filepath.Base(dir) + if base == "." || base == string(filepath.Separator) { + return "" + } + if base == "agent-transcripts" { + parent := filepath.Dir(dir) + name := filepath.Base(parent) + if name == "." || name == string(filepath.Separator) { + return "" + } + return name + } + next := filepath.Dir(dir) + if next == dir { + return "" + } + dir = next + } +} + +// resolveCursorProjectDirName derives a real workspace path from a +// Cursor-encoded directory name. The bool reports whether more than +// one matching path exists on disk. +func resolveCursorProjectDirName(dirName string) (string, bool) { + matches := resolveCursorProjectDirNameMatches(dirName, "", 2) + switch len(matches) { + case 0: + return "", false + case 1: + return matches[0], false + default: + return matches[0], true + } +} + +// resolveCursorProjectDirNameHint derives a real workspace path from a +// Cursor-encoded directory name, preferring candidates that contain the +// provided hint. +func resolveCursorProjectDirNameHint(dirName, hint string) string { + matches := resolveCursorProjectDirNameMatches(dirName, hint, 1) + if len(matches) == 0 { + return "" + } + nh := normalizeCursorDir(hint) + if nh != "" && !cursorPathContainsHint( + normalizeCursorDir(matches[0]), nh, + ) { + return "" + } + return matches[0] +} + +func resolveCursorProjectDirNameMatches( + dirName, hint string, limit int, +) []string { + dirName = strings.TrimSpace(dirName) + if dirName == "" { + return nil + } + hint = normalizeCursorDir(hint) + + if runtime.GOOS == "windows" { + return resolveCursorProjectDirNameFromRootMatches( + "", dirName, hint, limit, + ) + } + return resolveCursorProjectDirNameFromRootMatches( + string(filepath.Separator), dirName, hint, limit, + ) +} + +// resolveCursorProjectDirNameFromRoot reconstructs a real path from a +// Cursor-encoded project directory name by walking an existing +// filesystem tree and matching each component against the encoded token +// stream. The root parameter is mainly for tests; empty means use the +// OS default root. +func resolveCursorProjectDirNameFromRoot( + root, dirName string, +) string { + return resolveCursorProjectDirNameFromRootHint(root, dirName, "") +} + +// resolveCursorProjectDirNameFromRootHint reconstructs a real path from +// a Cursor-encoded project directory name. It backtracks across matching +// path components instead of committing to the first greedy match, and +// prefers candidates that contain the latest transcript cwd when one is +// available. +func resolveCursorProjectDirNameFromRootHint( + root, dirName, hint string, +) string { + matches := resolveCursorProjectDirNameFromRootMatches( + root, dirName, hint, 1, + ) + if len(matches) == 0 { + return "" + } + nh := normalizeCursorDir(hint) + if nh != "" && !cursorPathContainsHint( + normalizeCursorDir(matches[0]), nh, + ) { + return "" + } + return matches[0] +} + +func resolveCursorProjectDirNameFromRootMatches( + root, dirName, hint string, limit int, +) []string { + tokens := cursorEncodedTokens(dirName) + if len(tokens) == 0 { + return nil + } + + current := root + if runtime.GOOS == "windows" { + if len(tokens[0]) == 1 { + drive := tokens[0][0] + if (drive >= 'A' && drive <= 'Z') || + (drive >= 'a' && drive <= 'z') { + current = strings.ToUpper(tokens[0]) + ":" + + string(filepath.Separator) + tokens = tokens[1:] + } + } + } + if current == "" { + current = string(filepath.Separator) + } + if !isDir(current) { + return nil + } + if len(tokens) == 0 { + return []string{current} + } + + var matches []string + collectCursorPathMatches( + current, tokens, hint, limit, &matches, + ) + return matches +} + +type cursorPathMatch struct { + name string + path string + consumed int + hinted bool +} + +func collectCursorPathMatches( + dir string, tokens []string, hint string, limit int, + matches *[]string, +) { + if limit > 0 && len(*matches) >= limit { + return + } + if len(tokens) == 0 { + if isDir(dir) { + *matches = append(*matches, dir) + } + return + } + + for _, match := range matchCursorPathComponents(dir, tokens, hint) { + collectCursorPathMatches( + match.path, tokens[match.consumed:], hint, + limit, matches, + ) + if limit > 0 && len(*matches) >= limit { + return + } + } +} + +func matchCursorPathComponents( + dir string, tokens []string, hint string, +) []cursorPathMatch { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + matches := make([]cursorPathMatch, 0, len(entries)) + for _, entry := range entries { + fullPath := filepath.Join(dir, entry.Name()) + if !isDir(fullPath) { + continue + } + candidate := cursorComponentTokens(entry.Name()) + if len(candidate) == 0 || len(candidate) > len(tokens) { + continue + } + if !cursorTokenPrefixMatch(tokens, candidate) { + continue + } + matches = append(matches, cursorPathMatch{ + name: entry.Name(), + path: fullPath, + consumed: len(candidate), + hinted: cursorPathContainsHint(fullPath, hint), + }) + } + + sort.Slice(matches, func(i, j int) bool { + if matches[i].hinted != matches[j].hinted { + return matches[i].hinted + } + if matches[i].consumed != matches[j].consumed { + return matches[i].consumed > matches[j].consumed + } + return matches[i].name < matches[j].name + }) + return matches +} + +func cursorPathContainsHint(path, hint string) bool { + if path == "" || hint == "" { + return false + } + rel, err := filepath.Rel(path, hint) + if err != nil { + return false + } + return rel == "." || + (rel != ".." && + !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +func cursorTokenPrefixMatch(tokens, candidate []string) bool { + for i := range candidate { + if tokens[i] != candidate[i] { + return false + } + } + return true +} + +func cursorEncodedTokens(s string) []string { + return strings.FieldsFunc(s, func(r rune) bool { + return r == '-' + }) +} + +func cursorComponentTokens(s string) []string { + return strings.FieldsFunc(s, func(r rune) bool { + return r == '-' || r == '.' || r == '_' + }) +} diff --git a/internal/server/ghostty_test.go b/internal/server/ghostty_test.go new file mode 100644 index 00000000..c49ea86b --- /dev/null +++ b/internal/server/ghostty_test.go @@ -0,0 +1,115 @@ +package server + +import ( + "runtime" + "slices" + "strings" + "testing" +) + +func TestLaunchResumeDarwinGhosttyDirectCli(t *testing.T) { + cwd := t.TempDir() + proc := launchResumeDarwin( + Opener{ + ID: "ghostty", + Name: "Ghostty", + Kind: "terminal", + Bin: "/usr/local/bin/ghostty", + }, + "cursor agent --resume chat-1", + cwd, + ) + if proc == nil { + t.Fatal("launchResumeDarwin returned nil") + } + if strings.HasSuffix(proc.Args[0], "osascript") { + t.Fatalf("expected direct CLI, got osascript: %v", + proc.Args) + } + wantWD := "--working-directory=" + cwd + if !sliceContains(proc.Args, wantWD) { + t.Fatalf("missing %q in args: %v", wantWD, proc.Args) + } +} + +func TestLaunchResumeDarwinGhosttyAppBundle(t *testing.T) { + cwd := t.TempDir() + proc := launchResumeDarwin( + Opener{ + ID: "ghostty", + Name: "Ghostty", + Kind: "terminal", + Bin: "/Applications/Ghostty.app", + }, + "cursor agent --resume chat-1", + cwd, + ) + if proc == nil { + t.Fatal("launchResumeDarwin returned nil") + } + // App bundle wraps with `open -na`. + if !strings.HasSuffix(proc.Args[0], "open") { + t.Fatalf("expected open for app bundle, got %q", + proc.Args[0]) + } + if !sliceContains(proc.Args, "-na") { + t.Fatalf("missing -na flag: %v", proc.Args) + } + wantWD := "--working-directory=" + cwd + if !sliceContains(proc.Args, wantWD) { + t.Fatalf("missing %q in args: %v", wantWD, proc.Args) + } +} + +func TestLaunchResumeDarwinGhosttyNoCwd(t *testing.T) { + proc := launchResumeDarwin( + Opener{ + ID: "ghostty", + Name: "Ghostty", + Kind: "terminal", + Bin: "/usr/local/bin/ghostty", + }, + "cursor agent --resume chat-1", + "", + ) + if proc == nil { + t.Fatal("launchResumeDarwin returned nil") + } + for _, arg := range proc.Args { + if strings.HasPrefix(arg, "--working-directory") { + t.Fatalf("unexpected --working-directory with empty cwd: %v", + proc.Args) + } + } +} + +func TestLaunchTerminalInDirGhosttyDirectCliOnDarwin(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("macOS-specific Ghostty launch path") + } + dir := t.TempDir() + proc := launchTerminalInDir( + Opener{ + ID: "ghostty", + Name: "Ghostty", + Kind: "terminal", + Bin: "/Applications/Ghostty.app", + }, + dir, + ) + if proc == nil { + t.Fatal("launchTerminalInDir returned nil") + } + if strings.HasSuffix(proc.Args[0], "osascript") { + t.Fatalf("expected direct launch, got osascript: %v", + proc.Args) + } + wantWD := "--working-directory=" + dir + if !sliceContains(proc.Args, wantWD) { + t.Fatalf("missing %q in args: %v", wantWD, proc.Args) + } +} + +func sliceContains(ss []string, s string) bool { + return slices.Contains(ss, s) +} diff --git a/internal/server/openers.go b/internal/server/openers.go index 57964f1c..860744f3 100644 --- a/internal/server/openers.go +++ b/internal/server/openers.go @@ -80,7 +80,10 @@ var darwinOpenerCandidates = []openerCandidate{ // Terminals — Ghostty, iTerm2, kitty, and Terminal.app are macOS // GUI apps; detect via app bundle first, fall back to PATH binary // for non-default installs (e.g. Homebrew formula). - {"ghostty", "Ghostty", "terminal", []string{"ghostty"}, "/Applications/Ghostty.app"}, + {"ghostty", "Ghostty", "terminal", []string{ + "ghostty", + "/Applications/Ghostty.app/Contents/MacOS/ghostty", + }, ""}, {"iterm2", "iTerm2", "terminal", nil, "/Applications/iTerm.app"}, {"kitty", "kitty", "terminal", []string{"kitty"}, "/Applications/kitty.app"}, {"alacritty", "Alacritty", "terminal", []string{"alacritty"}, ""}, diff --git a/internal/server/resume.go b/internal/server/resume.go index 934083db..c5e20e36 100644 --- a/internal/server/resume.go +++ b/internal/server/resume.go @@ -45,6 +45,7 @@ var resumeAgents = map[string]string{ "claude": "claude --resume %s", "codex": "codex resume %s", "copilot": "copilot --resume=%s", + "cursor": "cursor agent --resume %s", "gemini": "gemini --resume %s", "opencode": "opencode --session %s", "amp": "amp --resume %s", @@ -130,16 +131,18 @@ func (s *Server) handleResumeSession( } } - // Resolve the project directory from the session file or - // project field for use in cd prefix and response metadata. - sessionDir := resolveSessionDir(session) + // Resolve the terminal launch directory. Cursor resume needs the + // shell to start in the latest session cwd so the resumed chat + // inherits the same working directory it last used. + launchDir, workspaceDir := resolveResumePaths(session) + if string(session.Agent) == "cursor" && workspaceDir != "" { + cmd += " --workspace " + shellQuote(workspaceDir) + } - // Claude Code and Kiro CLI scope sessions by the working - // directory the session was started from. Prepend cd so - // the resume works from any terminal location. - agent := string(session.Agent) - if (agent == "claude" || agent == "kiro") && sessionDir != "" { - cmd = fmt.Sprintf("cd %s && %s", shellQuote(sessionDir), cmd) + responseCmd := cmd + switch string(session.Agent) { + case "claude", "kiro": + responseCmd = commandWithCwd(cmd, launchDir) } // If the caller only wants the command string (e.g. for @@ -147,8 +150,8 @@ func (s *Server) handleResumeSession( if req.CommandOnly { writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, }) return } @@ -184,13 +187,13 @@ func (s *Server) handleResumeSession( "Claude Desktop resume only supports Claude sessions") return } - proc := launchClaudeDesktop(rawID, sessionDir) + proc := launchClaudeDesktop(rawID, launchDir) if err := proc.Start(); err != nil { log.Printf("resume: Claude Desktop launch failed: %v", err) writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, Error: "desktop_launch_failed", }) return @@ -199,18 +202,21 @@ func (s *Server) handleResumeSession( writeJSON(w, http.StatusOK, resumeResponse{ Launched: true, Terminal: opener.Name, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, }) return } - proc := launchResumeInOpener(*opener, cmd, sessionDir) + openerCwd := resumeLaunchCwd( + string(session.Agent), opener.ID, runtime.GOOS, launchDir, + ) + proc := launchResumeInOpener(*opener, cmd, openerCwd) if proc == nil { writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, Error: "unsupported_opener", }) return @@ -219,8 +225,8 @@ func (s *Server) handleResumeSession( log.Printf("resume: opener start failed: %v", err) writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, Error: "terminal_launch_failed", }) return @@ -229,8 +235,8 @@ func (s *Server) handleResumeSession( writeJSON(w, http.StatusOK, resumeResponse{ Launched: true, Terminal: opener.Name, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, }) return } @@ -244,20 +250,27 @@ func (s *Server) handleResumeSession( // User explicitly chose clipboard-only mode. writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, + Command: responseCmd, + Cwd: launchDir, }) return } // Detect and launch a terminal. - termBin, termArgs, termName, termErr := detectTerminal(cmd, sessionDir, termCfg) + detectCwd := launchDir + if termCfg.Mode == "auto" { + detectCwd = resumeLaunchCwd( + string(session.Agent), "auto", runtime.GOOS, launchDir, + ) + } + termBin, termArgs, termName, termErr := detectTerminal(cmd, detectCwd, termCfg) if termErr != nil { // Can't launch — return the command for clipboard fallback. log.Printf("resume: terminal detection failed: %v", termErr) writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, Error: "no_terminal_found", }) return @@ -269,16 +282,16 @@ func (s *Server) handleResumeSession( proc.Stdout = nil proc.Stderr = nil proc.Stdin = nil - if sessionDir != "" { - proc.Dir = sessionDir + if detectCwd != "" { + proc.Dir = detectCwd } if err := proc.Start(); err != nil { log.Printf("resume: terminal start failed: %v", err) writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, Error: "terminal_launch_failed", }) return @@ -290,8 +303,8 @@ func (s *Server) handleResumeSession( writeJSON(w, http.StatusOK, resumeResponse{ Launched: true, Terminal: termName, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, }) } @@ -316,6 +329,21 @@ func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" } +func commandWithCwd(cmd, cwd string) string { + if !isDir(cwd) { + return cmd + } + return fmt.Sprintf("cd %s && %s", shellQuote(cwd), cmd) +} + +// resumeLaunchCwd returns the cwd a terminal launcher should apply for +// a resume command. Cursor resumes still need the terminal shell to +// start in the last working directory even when --workspace points the +// CLI at the session's workspace root. +func resumeLaunchCwd(agent, openerID, goos, cwd string) string { + return cwd +} + // detectTerminal finds a suitable terminal emulator and builds the // full argument list to launch the given command. Returns the // executable path, args, a user-facing display name, and any error. @@ -369,23 +397,11 @@ func detectTerminalDarwin( // Check for iTerm2 first, then fall back to Terminal.app. // Use osascript to tell the app to open a new window and run // the command. - script := cmd - if cwd != "" { - if info, err := os.Stat(cwd); err == nil && info.IsDir() { - script = fmt.Sprintf("cd %s && %s", shellQuote(cwd), cmd) - } - } + script := commandWithCwd(cmd, cwd) // Try iTerm2 first. if _, err := exec.LookPath("osascript"); err == nil { - // Sanitize for AppleScript: escape backslashes, then quotes, - // and reject newlines to prevent multi-line injection. - safe := strings.NewReplacer( - "\n", " ", - "\r", " ", - `\`, `\\`, - `"`, `\"`, - ).Replace(script) + safe := escapeForAppleScript(script) // Check if iTerm is installed. iterm := "/Applications/iTerm.app" @@ -493,7 +509,7 @@ func (s *Server) handleSetTerminalConfig( } // readSessionCwd reads the first few lines of a session JSONL file -// and extracts the "cwd" field. Claude Code stores the working +// and extracts the initial "cwd" field. Claude Code stores the working // directory in early conversation entries; some agents (e.g. Codex) // store it under payload.cwd. Returns "" if not found. func readSessionCwd(path string) string { @@ -508,39 +524,176 @@ func readSessionCwd(path string) string { } } + var cwd string + scanJSONLLines(path, 20, func(line []byte) bool { + for _, jsonPath := range []string{ + "cwd", + "payload.cwd", + // Copilot stores cwd under data.context.cwd on the + // session.start event. + "data.context.cwd", + } { + if value := gjson.GetBytes(line, jsonPath).Str; value != "" { + cwd = value + return false + } + } + return true + }) + return cwd +} + +// readCursorLastWorkingDir scans a Cursor transcript for the most +// recent tool invocation that recorded a working_directory. Returns +// the latest existing absolute directory, or "" if not found. +func readCursorLastWorkingDir(path string) string { + last := "" + scanJSONLLines(path, 0, func(line []byte) bool { + content := gjson.GetBytes(line, "message.content") + if content.IsArray() { + content.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").Str != "tool_use" { + return true + } + for _, jsonPath := range []string{ + "input.working_directory", + "parameters.working_directory", + } { + wd := normalizeCursorDir(item.Get(jsonPath).Str) + if wd != "" { + last = wd + } + } + return true + }) + } + return true + }) + return last +} + +func scanJSONLLines( + path string, maxLines int, visit func([]byte) bool, +) { f, err := os.Open(path) if err != nil { - return "" + return } defer f.Close() reader := bufio.NewReader(f) - for range 20 { + for lineNum := 0; maxLines <= 0 || lineNum < maxLines; lineNum++ { line, err := reader.ReadBytes('\n') - if len(line) > 0 { - s := string(line) - if cwd := gjson.Get(s, "cwd").Str; cwd != "" { - return cwd - } - if cwd := gjson.Get(s, "payload.cwd").Str; cwd != "" { - return cwd - } - // Copilot stores cwd under data.context.cwd on the - // session.start event. - if cwd := gjson.Get(s, "data.context.cwd").Str; cwd != "" { - return cwd - } + if len(line) > 0 && !visit(line) { + return } if err != nil { - break + return } } - return "" +} + +func cursorLastWorkingDir(session *db.Session) string { + if session.Agent != "cursor" || session.FilePath == nil { + return "" + } + return readCursorLastWorkingDir(*session.FilePath) +} + +func resolveCursorResumePaths( + session *db.Session, lastCwd string, +) (launchDir, workspaceDir string) { + workspaceDir = resolveCursorWorkspaceDirWithHint( + session, + func() string { return lastCwd }, + ) + if workspaceDir == "" { + workspaceDir = lastCwd + } + if lastCwd != "" { + return lastCwd, workspaceDir + } + return workspaceDir, workspaceDir +} + +func resolveResumePaths(session *db.Session) (launchDir, workspaceDir string) { + if session.Agent != "cursor" { + return resolveSessionDir(session), "" + } + return resolveCursorResumePaths( + session, cursorLastWorkingDir(session), + ) +} + +func resolveCursorWorkspaceDirFromTranscriptPath( + session *db.Session, +) (string, bool) { + if session.FilePath == nil { + return "", false + } + dir, ambiguous := resolveCursorProjectDirFromSessionFile( + *session.FilePath, + ) + if canonical := normalizeCursorDir(dir); canonical != "" { + return canonical, ambiguous + } + return "", false +} + +func resolveCursorWorkspaceDirFromTranscriptPathHint( + session *db.Session, hint string, +) string { + if session.FilePath == nil { + return "" + } + dir := resolveCursorProjectDirFromSessionFileHint( + *session.FilePath, hint, + ) + return normalizeCursorDir(dir) +} + +func resolveCursorWorkspaceDirWithHint( + session *db.Session, hintFn func() string, +) string { + projectDir := normalizeCursorDir(session.Project) + if dir, ambiguous := resolveCursorWorkspaceDirFromTranscriptPath( + session, + ); dir != "" { + if ambiguous { + hint := projectDir + if hintFn != nil { + if value := hintFn(); value != "" { + hint = value + } + } + if hint != "" { + if hinted := resolveCursorWorkspaceDirFromTranscriptPathHint( + session, hint, + ); hinted != "" { + return hinted + } + } + // Ambiguous with no useful hint — don't guess. + return projectDir + } + return dir + } + return projectDir +} + +// resolveResumeDir determines the terminal launch directory for a +// session resume. Cursor sessions prefer the latest recorded +// working_directory so resumed chats reopen in the same shell cwd +// they last used instead of a generic workspace root. +func resolveResumeDir(session *db.Session) string { + launchDir, _ := resolveResumePaths(session) + return launchDir } // resolveSessionDir determines the project directory for a session. -// It tries the session file's embedded cwd first, then falls back to -// the session's project field. Both candidates must be absolute paths +// It tries the session file's embedded cwd first, then Cursor's +// transcript-derived workspace path, then falls back to the session's +// project field. All returned candidates must be absolute paths // pointing to existing directories. func resolveSessionDir(session *db.Session) string { if session.FilePath != nil { @@ -548,12 +701,51 @@ func resolveSessionDir(session *db.Session) string { return cwd } } + if session.Agent == "cursor" { + if dir := resolveCursorWorkspaceDir(session); dir != "" { + return dir + } + } if isDir(session.Project) { return session.Project } return "" } +// resolveCursorWorkspaceDir returns the real workspace root for a +// Cursor session, preferring the transcript path and falling back to +// an absolute project field when available. It only scans transcript +// contents when the transcript path maps to multiple plausible +// workspace roots. +func resolveCursorWorkspaceDir(session *db.Session) string { + return resolveCursorWorkspaceDirWithHint( + session, + func() string { return cursorLastWorkingDir(session) }, + ) +} + +func normalizeCursorDir(path string) string { + if !isDir(path) { + return "" + } + clean := filepath.Clean(path) + resolved, err := filepath.EvalSymlinks(clean) + if err != nil || !isDir(resolved) { + return clean + } + resolved = filepath.Clean(resolved) + if runtime.GOOS == "darwin" && + strings.HasPrefix(resolved, "/private/") { + publicPath := filepath.Clean( + strings.TrimPrefix(resolved, "/private"), + ) + if isDir(publicPath) { + return publicPath + } + } + return resolved +} + func isDir(path string) bool { if !filepath.IsAbs(path) { return false @@ -662,13 +854,10 @@ func launchResumeDarwin( o Opener, cmd string, cwd string, ) *exec.Cmd { // For AppleScript-based terminals, build a single shell command - // that cd's and then runs the resume command. - shellCmd := cmd - if cwd != "" { - shellCmd = fmt.Sprintf( - "cd %s && %s", shellQuote(cwd), cmd, - ) - } + // that enters the requested directory and then runs the resume + // command. The caller passes the raw resume command without a + // leading `cd` so terminal-specific launchers only add it once. + shellCmd := commandWithCwd(cmd, cwd) safe := escapeForAppleScript(shellCmd) switch o.ID { diff --git a/internal/server/resume_handler_test.go b/internal/server/resume_handler_test.go index a441a12b..bfccbfa2 100644 --- a/internal/server/resume_handler_test.go +++ b/internal/server/resume_handler_test.go @@ -5,11 +5,39 @@ import ( "net/http" "os" "path/filepath" + "runtime" + "strings" "testing" "github.com/wesm/agentsview/internal/db" ) +func canonicalTestPath(path string) string { + if path == "" { + return "" + } + clean := filepath.Clean(path) + if resolved, err := filepath.EvalSymlinks(clean); err == nil { + clean = filepath.Clean(resolved) + } + if runtime.GOOS == "darwin" && strings.HasPrefix(clean, "/private/") { + publicPath := filepath.Clean(strings.TrimPrefix(clean, "/private")) + if info, err := os.Stat(publicPath); err == nil && info.IsDir() { + return publicPath + } + } + return clean +} + +func assertSamePath(t *testing.T, label, got, want string) { + t.Helper() + got = canonicalTestPath(got) + want = canonicalTestPath(want) + if got != want { + t.Errorf("%s = %q, want %q", label, got, want) + } +} + func TestResumeSession(t *testing.T) { te := setup(t) @@ -39,9 +67,7 @@ func TestResumeSession(t *testing.T) { if resp.Command == "" { t.Error("expected non-empty command") } - if resp.Cwd != projectDir { - t.Errorf("cwd = %q, want %q", resp.Cwd, projectDir) - } + assertSamePath(t, "cwd", resp.Cwd, projectDir) }) t.Run("not found", func(t *testing.T) { @@ -91,12 +117,93 @@ func TestResumeSession(t *testing.T) { assertStatus(t, w, http.StatusBadRequest) }) - t.Run("unsupported agent", func(t *testing.T) { - te.seedSession(t, "cursor-1", "/tmp", 3, func(s *db.Session) { + t.Run("cursor command only", func(t *testing.T) { + projectDir := t.TempDir() + runDir := filepath.Join(projectDir, "frontend") + if err := os.MkdirAll(runDir, 0o755); err != nil { + t.Fatal(err) + } + runDirJSON, _ := json.Marshal(runDir) + sessionFile := filepath.Join(t.TempDir(), "cursor.jsonl") + content := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(runDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile(sessionFile, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + te.seedSession(t, "cursor:chat-1", projectDir, 3, func(s *db.Session) { s.Agent = "cursor" + s.FilePath = &sessionFile }) w := te.post(t, - "/api/v1/sessions/cursor-1/resume", + "/api/v1/sessions/cursor:chat-1/resume", + `{"command_only":true}`, + ) + assertStatus(t, w, http.StatusOK) + var resp struct { + Launched bool `json:"launched"` + Command string `json:"command"` + Cwd string `json:"cwd"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Launched { + t.Error("expected launched=false for command_only") + } + wantProjectDir := canonicalTestPath(projectDir) + wantCmd := "cursor agent --resume chat-1 --workspace '" + wantProjectDir + "'" + if resp.Command != wantCmd { + t.Errorf("command = %q, want %q", resp.Command, wantCmd) + } + assertSamePath(t, "cwd", resp.Cwd, runDir) + }) + + t.Run("cursor command only falls back workspace to cwd", func(t *testing.T) { + runDir := filepath.Join(t.TempDir(), "frontend") + if err := os.MkdirAll(runDir, 0o755); err != nil { + t.Fatal(err) + } + runDirJSON, _ := json.Marshal(runDir) + sessionFile := filepath.Join(t.TempDir(), "cursor.jsonl") + content := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(runDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile(sessionFile, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + te.seedSession(t, "cursor:chat-2", "li_tools", 3, func(s *db.Session) { + s.Agent = "cursor" + s.FilePath = &sessionFile + }) + w := te.post(t, + "/api/v1/sessions/cursor:chat-2/resume", + `{"command_only":true}`, + ) + assertStatus(t, w, http.StatusOK) + var resp struct { + Launched bool `json:"launched"` + Command string `json:"command"` + Cwd string `json:"cwd"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Launched { + t.Error("expected launched=false for command_only") + } + wantRunDir := canonicalTestPath(runDir) + wantCmd := "cursor agent --resume chat-2 --workspace '" + wantRunDir + "'" + if resp.Command != wantCmd { + t.Errorf("command = %q, want %q", resp.Command, wantCmd) + } + assertSamePath(t, "cwd", resp.Cwd, runDir) + }) + + t.Run("unsupported agent", func(t *testing.T) { + te.seedSession(t, "vscode-1", "/tmp", 3, func(s *db.Session) { + s.Agent = "vscode-copilot" + }) + w := te.post(t, + "/api/v1/sessions/vscode-1/resume", `{"command_only":true}`, ) assertStatus(t, w, http.StatusBadRequest) @@ -132,9 +239,7 @@ func TestGetSessionDirectory(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } - if resp.Path != projectDir { - t.Errorf("path = %q, want %q", resp.Path, projectDir) - } + assertSamePath(t, "path", resp.Path, projectDir) }) t.Run("empty path for relative project", func(t *testing.T) { @@ -179,9 +284,36 @@ func TestGetSessionDirectory(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } - if resp.Path != cwdDir { - t.Errorf("path = %q, want %q", resp.Path, cwdDir) + assertSamePath(t, "path", resp.Path, cwdDir) + }) + + t.Run("cursor directory returns workspace root", func(t *testing.T) { + projectDir := t.TempDir() + runDir := filepath.Join(projectDir, "frontend") + if err := os.MkdirAll(runDir, 0o755); err != nil { + t.Fatal(err) + } + runDirJSON, _ := json.Marshal(runDir) + sessionFile := filepath.Join(t.TempDir(), "cursor.jsonl") + content := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(runDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile(sessionFile, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + te.seedSession(t, "dir-cursor", projectDir, 3, func(s *db.Session) { + s.Agent = "cursor" + s.FilePath = &sessionFile + }) + + w := te.get(t, "/api/v1/sessions/dir-cursor/directory") + assertStatus(t, w, http.StatusOK) + var resp struct { + Path string `json:"path"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) } + assertSamePath(t, "path", resp.Path, projectDir) }) } diff --git a/internal/server/resume_test.go b/internal/server/resume_test.go index 742b3628..4fdb00e3 100644 --- a/internal/server/resume_test.go +++ b/internal/server/resume_test.go @@ -11,6 +11,25 @@ import ( "github.com/wesm/agentsview/internal/db" ) +func canonicalTestDir(path string) string { + if path == "" { + return "" + } + if normalized := normalizeCursorDir(path); normalized != "" { + return normalized + } + return filepath.Clean(path) +} + +func assertSameDir(t *testing.T, label, got, want string) { + t.Helper() + got = canonicalTestDir(got) + want = canonicalTestDir(want) + if got != want { + t.Errorf("%s = %q, want %q", label, got, want) + } +} + func TestShellQuote(t *testing.T) { tests := []struct { name string @@ -204,6 +223,334 @@ func TestReadSessionCwd_CopilotFormat(t *testing.T) { } } +func TestReadCursorLastWorkingDir(t *testing.T) { + dir := t.TempDir() + workspaceDir := filepath.Join(dir, "project") + lastDir := filepath.Join(workspaceDir, "frontend") + if err := os.MkdirAll(lastDir, 0o755); err != nil { + t.Fatal(err) + } + + firstJSON, _ := json.Marshal(workspaceDir) + lastJSON, _ := json.Marshal(lastDir) + sessionFile := filepath.Join(dir, "cursor.jsonl") + content := "" + + `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"ReadFile","input":{"path":"/tmp/file.txt"}}]}}` + "\n" + + `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + string(firstJSON) + `}}]}}` + "\n" + + `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":"relative/path"}}]}}` + "\n" + + `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + string(lastJSON) + `}}]}}` + "\n" + if err := os.WriteFile(sessionFile, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + got := readCursorLastWorkingDir(sessionFile) + assertSameDir(t, "readCursorLastWorkingDir()", got, lastDir) +} + +func TestCursorProjectDirNameFromTranscriptPath(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "flat transcript", + path: filepath.Join( + "/tmp", ".cursor", "projects", + "Users-alice-code-my-app", + "agent-transcripts", "sess.jsonl", + ), + want: "Users-alice-code-my-app", + }, + { + name: "nested transcript", + path: filepath.Join( + "/tmp", ".cursor", "projects", + "Users-alice-code-my-app", + "agent-transcripts", "sess", "sess.jsonl", + ), + want: "Users-alice-code-my-app", + }, + { + name: "missing agent transcripts ancestor", + path: filepath.Join( + "/tmp", ".cursor", "projects", + "Users-alice-code-my-app", "other", "sess.jsonl", + ), + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cursorProjectDirNameFromTranscriptPath(tt.path) + if got != tt.want { + t.Errorf( + "cursorProjectDirNameFromTranscriptPath(%q) = %q, want %q", + tt.path, got, tt.want, + ) + } + }) + } +} + +func TestResolveCursorProjectDirNameFromRoot(t *testing.T) { + root := t.TempDir() + want := filepath.Join( + root, "Users", "alice", "code", "li", + "project-cache-hdfs", + ) + if err := os.MkdirAll(want, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li"), + 0o755, + ); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li", "project"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + got := resolveCursorProjectDirNameFromRoot( + root, "Users-alice-code-li-project-cache-hdfs", + ) + if got != want { + t.Errorf( + "resolveCursorProjectDirNameFromRoot() = %q, want %q", + got, want, + ) + } +} + +func TestResolveCursorProjectDirNameFromRootMatchesUnderscoreComponents( + t *testing.T, +) { + root := t.TempDir() + want := filepath.Join( + root, "Users", "alice", "code", "li", + "project_cache_hdfs", + ) + if err := os.MkdirAll(want, 0o755); err != nil { + t.Fatal(err) + } + + got := resolveCursorProjectDirNameFromRoot( + root, "Users-alice-code-li-project-cache-hdfs", + ) + if got != want { + t.Errorf( + "resolveCursorProjectDirNameFromRoot() = %q, want %q", + got, want, + ) + } +} + +func TestResolveCursorProjectDirFromSessionFileDetectsAmbiguity( + t *testing.T, +) { + root := t.TempDir() + want := filepath.Join( + root, "Users", "alice", "code", "li-tools", + ) + if err := os.MkdirAll(want, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li", "tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + filePath := filepath.Join( + root, ".cursor", "projects", + "Users-alice-code-li-tools", + "agent-transcripts", "sess", "sess.jsonl", + ) + dirName := cursorProjectDirNameFromTranscriptPath(filePath) + matches := resolveCursorProjectDirNameFromRootMatches( + root, dirName, "", 2, + ) + got := "" + if len(matches) > 0 { + got = matches[0] + } + ambiguous := len(matches) > 1 + if got != want { + t.Errorf( + "resolveCursorProjectDirFromSessionFile() = %q, want %q", + got, want, + ) + } + if !ambiguous { + t.Error("expected ambiguous transcript path") + } +} + +func TestResolveCursorProjectDirFromSessionFileUnambiguous( + t *testing.T, +) { + root := t.TempDir() + want := filepath.Join( + root, "Users", "alice", "code", "li-openhouse", + ) + if err := os.MkdirAll(want, 0o755); err != nil { + t.Fatal(err) + } + + filePath := filepath.Join( + root, ".cursor", "projects", + "Users-alice-code-li-openhouse", + "agent-transcripts", "sess", "sess.jsonl", + ) + dirName := cursorProjectDirNameFromTranscriptPath(filePath) + matches := resolveCursorProjectDirNameFromRootMatches( + root, dirName, "", 2, + ) + got := "" + if len(matches) > 0 { + got = matches[0] + } + ambiguous := len(matches) > 1 + if got != want { + t.Errorf( + "resolveCursorProjectDirFromSessionFile() = %q, want %q", + got, want, + ) + } + if ambiguous { + t.Error("expected unambiguous transcript path") + } +} + +func TestResolveCursorProjectDirNameFromRootBacktracksOnDeadEnd( + t *testing.T, +) { + root := t.TempDir() + want := filepath.Join( + root, "Users", "alice", "code", "li", "tools-app", + ) + if err := os.MkdirAll(want, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li-tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + got := resolveCursorProjectDirNameFromRoot( + root, "Users-alice-code-li-tools-app", + ) + if got != want { + t.Errorf( + "resolveCursorProjectDirNameFromRoot() = %q, want %q", + got, want, + ) + } +} + +func TestResolveCursorProjectDirNameFromRootHintPrefersContainingPath( + t *testing.T, +) { + root := t.TempDir() + want := filepath.Join( + root, "Users", "alice", "code", "li", "tools", + ) + hint := filepath.Join(want, "frontend") + if err := os.MkdirAll(hint, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li-tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + got := resolveCursorProjectDirNameFromRootHint( + root, "Users-alice-code-li-tools", hint, + ) + if got != want { + t.Errorf( + "resolveCursorProjectDirNameFromRootHint() = %q, want %q", + got, want, + ) + } +} + +func TestResolveCursorProjectDirNameFromRootHintStaleReturnsEmpty( + t *testing.T, +) { + root := t.TempDir() + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li-tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li", "tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + staleHint := filepath.Join(root, "unrelated") + if err := os.MkdirAll(staleHint, 0o755); err != nil { + t.Fatal(err) + } + + got := resolveCursorProjectDirNameFromRootHint( + root, "Users-alice-code-li-tools", staleHint, + ) + if got != "" { + t.Errorf( + "with stale hint = %q, want empty", got, + ) + } +} + +func TestResolveCursorProjectDirNameFromRootHintSymlinkMatch( + t *testing.T, +) { + root := t.TempDir() + + // Real project with a hint subdir. + realProject := filepath.Join(root, "repos", "li-tools") + hintDir := filepath.Join(realProject, "src") + if err := os.MkdirAll(hintDir, 0o755); err != nil { + t.Fatal(err) + } + // Second ambiguous path. + if err := os.MkdirAll( + filepath.Join(root, "repos", "li", "tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + // Symlink: root/code -> root/repos. The DFS walks through + // the symlink but the hint uses the resolved real path. + if err := os.Symlink( + filepath.Join(root, "repos"), + filepath.Join(root, "code"), + ); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + got := resolveCursorProjectDirNameFromRootHint( + root, "code-li-tools", hintDir, + ) + assertSameDir(t, "result", got, realProject) +} + func TestResolveSessionDir(t *testing.T) { // Create a real temp directory for the "absolute path" cases. tmpDir := t.TempDir() @@ -220,6 +567,50 @@ func TestResolveSessionDir(t *testing.T) { t.Fatal(err) } + cursorProject := filepath.Join( + tmpDir, "workspace-root", "li-openhouse", + ) + if err := os.MkdirAll(cursorProject, 0o755); err != nil { + t.Fatal(err) + } + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encodeCursorProjectPathForTest(cursorProject), + "agent-transcripts", "cursor-sess", + "cursor-sess.jsonl", + ) + if err := os.MkdirAll( + filepath.Dir(cursorTranscript), 0o755, + ); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cursorTranscript, []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + cursorLastDir := filepath.Join(cursorProject, "frontend") + if err := os.MkdirAll(cursorLastDir, 0o755); err != nil { + t.Fatal(err) + } + cursorLastDirJSON, _ := json.Marshal(cursorLastDir) + cursorTranscriptWithLastDir := filepath.Join( + tmpDir, ".cursor", "projects", + encodeCursorProjectPathForTest(cursorProject), + "agent-transcripts", "cursor-sess-last", + "cursor-sess-last.jsonl", + ) + if err := os.MkdirAll( + filepath.Dir(cursorTranscriptWithLastDir), 0o755, + ); err != nil { + t.Fatal(err) + } + lastDirContent := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(cursorLastDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile( + cursorTranscriptWithLastDir, []byte(lastDirContent), 0o644, + ); err != nil { + t.Fatal(err) + } + tests := []struct { name string session *db.Session @@ -274,17 +665,455 @@ func TestResolveSessionDir(t *testing.T) { }(), want: tmpDir, }, + { + name: "cursor transcript path resolves workspace dir", + session: &db.Session{ + Agent: "cursor", + Project: cursorProject, + FilePath: &cursorTranscript, + }, + want: cursorProject, + }, + { + name: "cursor transcript with last shell dir still resolves workspace", + session: &db.Session{ + Agent: "cursor", + Project: cursorProject, + FilePath: &cursorTranscriptWithLastDir, + }, + want: cursorProject, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := resolveSessionDir(tt.session) - if got != tt.want { + if tt.want == "" { + if got != "" { + t.Errorf( + "resolveSessionDir() = %q, want %q", + got, tt.want, + ) + } + return + } + if canonicalTestDir(got) != canonicalTestDir(tt.want) { t.Errorf( "resolveSessionDir() = %q, want %q", + canonicalTestDir(got), canonicalTestDir(tt.want), + ) + } + }) + } +} + +func TestResolveResumeDir(t *testing.T) { + tmpDir := t.TempDir() + + cursorProject := filepath.Join( + tmpDir, "workspace-root", "li-openhouse", + ) + cursorLastDir := filepath.Join(cursorProject, "frontend") + if err := os.MkdirAll(cursorLastDir, 0o755); err != nil { + t.Fatal(err) + } + + cursorLastDirJSON, _ := json.Marshal(cursorLastDir) + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encodeCursorProjectPathForTest(cursorProject), + "agent-transcripts", "cursor-sess-last", + "cursor-sess-last.jsonl", + ) + if err := os.MkdirAll( + filepath.Dir(cursorTranscript), 0o755, + ); err != nil { + t.Fatal(err) + } + lastDirContent := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(cursorLastDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile( + cursorTranscript, []byte(lastDirContent), 0o644, + ); err != nil { + t.Fatal(err) + } + + got := resolveResumeDir(&db.Session{ + Agent: "cursor", + Project: "li_openhouse", + FilePath: &cursorTranscript, + }) + assertSameDir(t, "resolveResumeDir()", got, cursorLastDir) +} + +func TestResolveCursorWorkspaceDirUsesLastWorkingDirHint(t *testing.T) { + tmpDir := t.TempDir() + + cursorProject := filepath.Join( + tmpDir, "workspace-root", "li", "tools", + ) + cursorLastDir := filepath.Join(cursorProject, "frontend") + if err := os.MkdirAll(cursorLastDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(tmpDir, "workspace-root", "li-tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + cursorLastDirJSON, _ := json.Marshal(cursorLastDir) + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encodeCursorProjectPathForTest(cursorProject), + "agent-transcripts", "cursor-sess-last", + "cursor-sess-last.jsonl", + ) + if err := os.MkdirAll( + filepath.Dir(cursorTranscript), 0o755, + ); err != nil { + t.Fatal(err) + } + lastDirContent := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(cursorLastDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile( + cursorTranscript, []byte(lastDirContent), 0o644, + ); err != nil { + t.Fatal(err) + } + + got := resolveCursorWorkspaceDir(&db.Session{ + Agent: "cursor", + Project: cursorProject, + FilePath: &cursorTranscript, + }) + assertSameDir(t, "resolveCursorWorkspaceDir()", got, cursorProject) +} + +func TestResolveCursorWorkspaceDirAmbiguousWithoutHintReturnsEmpty( + t *testing.T, +) { + tmpDir := t.TempDir() + + // Create two paths that decode from the same encoded name. + pathA := filepath.Join(tmpDir, "li-tools") + pathB := filepath.Join(tmpDir, "li", "tools") + if err := os.MkdirAll(pathA, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(pathB, 0o755); err != nil { + t.Fatal(err) + } + + encoded := encodeCursorProjectPathForTest(pathA) + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encoded, + "agent-transcripts", "cursor-sess", + "cursor-sess.jsonl", + ) + + got := resolveCursorWorkspaceDir(&db.Session{ + Agent: "cursor", + Project: "li_tools", // Not absolute — no hint. + FilePath: &cursorTranscript, + }) + if got != "" { + t.Errorf( + "resolveCursorWorkspaceDir() = %q, want empty "+ + "(ambiguous without hint)", + got, + ) + } +} + +func TestResolveCursorWorkspaceDirStaleHintReturnsEmpty( + t *testing.T, +) { + tmpDir := t.TempDir() + + pathA := filepath.Join(tmpDir, "li-tools") + pathB := filepath.Join(tmpDir, "li", "tools") + if err := os.MkdirAll(pathA, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(pathB, 0o755); err != nil { + t.Fatal(err) + } + + // Stale hint: exists on disk but not under either candidate. + staleDir := filepath.Join(tmpDir, "unrelated-project") + if err := os.MkdirAll(staleDir, 0o755); err != nil { + t.Fatal(err) + } + + encoded := encodeCursorProjectPathForTest(pathA) + staleDirJSON, _ := json.Marshal(staleDir) + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encoded, + "agent-transcripts", "cursor-sess", + "cursor-sess.jsonl", + ) + if err := os.MkdirAll( + filepath.Dir(cursorTranscript), 0o755, + ); err != nil { + t.Fatal(err) + } + content := `{"role":"assistant","message":{"content":[` + + `{"type":"tool_use","name":"Shell","input":{` + + `"command":"pwd","working_directory":` + + string(staleDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile( + cursorTranscript, []byte(content), 0o644, + ); err != nil { + t.Fatal(err) + } + + got := resolveCursorWorkspaceDir(&db.Session{ + Agent: "cursor", + Project: "li_tools", + FilePath: &cursorTranscript, + }) + if got != "" { + t.Errorf( + "resolveCursorWorkspaceDir() with stale hint = %q, "+ + "want empty", + got, + ) + } +} + +func TestResolveCursorWorkspaceDirWithoutTranscriptContents( + t *testing.T, +) { + tmpDir := t.TempDir() + + cursorProject := filepath.Join( + tmpDir, "workspace-root", "li-openhouse", + ) + if err := os.MkdirAll(cursorProject, 0o755); err != nil { + t.Fatal(err) + } + + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encodeCursorProjectPathForTest(cursorProject), + "agent-transcripts", "cursor-sess", + "cursor-sess.jsonl", + ) + + got := resolveCursorWorkspaceDir(&db.Session{ + Agent: "cursor", + Project: cursorProject, + FilePath: &cursorTranscript, + }) + assertSameDir(t, "resolveCursorWorkspaceDir()", got, cursorProject) +} + +func TestResolveCursorResumePathsUsesProvidedLastWorkingDir( + t *testing.T, +) { + tmpDir := t.TempDir() + + cursorProject := filepath.Join( + tmpDir, "workspace-root", "li", "tools", + ) + cursorLastDir := filepath.Join(cursorProject, "frontend") + if err := os.MkdirAll(cursorLastDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(tmpDir, "workspace-root", "li-tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encodeCursorProjectPathForTest(cursorProject), + "agent-transcripts", "cursor-sess-last", + "cursor-sess-last.jsonl", + ) + + launchDir, workspaceDir := resolveCursorResumePaths( + &db.Session{ + Agent: "cursor", + Project: cursorProject, + FilePath: &cursorTranscript, + }, + cursorLastDir, + ) + assertSameDir(t, "launchDir", launchDir, cursorLastDir) + assertSameDir(t, "workspaceDir", workspaceDir, cursorProject) +} + +func TestResolveCursorResumePathsFallbackWorkspaceToLastWorkingDir( + t *testing.T, +) { + lastCwd := filepath.Join(t.TempDir(), "frontend") + if err := os.MkdirAll(lastCwd, 0o755); err != nil { + t.Fatal(err) + } + + launchDir, workspaceDir := resolveCursorResumePaths( + &db.Session{ + Agent: "cursor", + Project: "li_tools", + FilePath: nil, + }, + lastCwd, + ) + assertSameDir(t, "launchDir", launchDir, lastCwd) + assertSameDir(t, "workspaceDir", workspaceDir, lastCwd) +} + +func TestResolveResumeDirCanonicalizesSymlink(t *testing.T) { + tmpDir := t.TempDir() + + realProject := filepath.Join(tmpDir, "repos", "openhouse") + if err := os.MkdirAll(realProject, 0o755); err != nil { + t.Fatal(err) + } + cacheDir := filepath.Join(tmpDir, "project_cache_hdfs") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + t.Fatal(err) + } + linkProject := filepath.Join(cacheDir, "openhouse") + if err := os.Symlink(realProject, linkProject); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + linkJSON, _ := json.Marshal(linkProject) + sessionFile := filepath.Join(tmpDir, "cursor-symlink.jsonl") + content := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(linkJSON) + `}}]}}` + "\n" + if err := os.WriteFile(sessionFile, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + got := resolveResumeDir(&db.Session{ + Agent: "cursor", + Project: "li_openhouse", + FilePath: &sessionFile, + }) + assertSameDir(t, "resolveResumeDir()", got, realProject) +} + +func TestResolveSessionDirCursorProjectFallbackCanonicalizesSymlink(t *testing.T) { + tmpDir := t.TempDir() + + realProject := filepath.Join(tmpDir, "repos", "openhouse") + if err := os.MkdirAll(realProject, 0o755); err != nil { + t.Fatal(err) + } + cacheDir := filepath.Join(tmpDir, "project_cache_hdfs") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + t.Fatal(err) + } + linkProject := filepath.Join(cacheDir, "openhouse") + if err := os.Symlink(realProject, linkProject); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + got := resolveSessionDir(&db.Session{ + Agent: "cursor", + Project: linkProject, + }) + assertSameDir(t, "resolveSessionDir()", got, realProject) +} + +func TestResumeLaunchCwd(t *testing.T) { + cwd := t.TempDir() + + tests := []struct { + name string + agent string + openerID string + goos string + want string + }{ + { + name: "claude keeps cwd for auto darwin launch", + agent: "claude", + openerID: "auto", + goos: "darwin", + want: cwd, + }, + { + name: "cursor auto darwin launch keeps cwd", + agent: "cursor", + openerID: "auto", + goos: "darwin", + want: cwd, + }, + { + name: "cursor iterm2 darwin launch keeps cwd", + agent: "cursor", + openerID: "iterm2", + goos: "darwin", + want: cwd, + }, + { + name: "cursor terminal darwin launch keeps cwd", + agent: "cursor", + openerID: "terminal", + goos: "darwin", + want: cwd, + }, + { + name: "cursor ghostty darwin launch keeps cwd", + agent: "cursor", + openerID: "ghostty", + goos: "darwin", + want: cwd, + }, + { + name: "cursor kitty darwin launch keeps cwd flag", + agent: "cursor", + openerID: "kitty", + goos: "darwin", + want: cwd, + }, + { + name: "cursor linux launch keeps cwd", + agent: "cursor", + openerID: "ghostty", + goos: "linux", + want: cwd, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resumeLaunchCwd( + tt.agent, tt.openerID, tt.goos, cwd, + ) + if got != tt.want { + t.Errorf( + "resumeLaunchCwd() = %q, want %q", got, tt.want, ) } }) } } + +func encodeCursorProjectPathForTest(path string) string { + clean := filepath.Clean(path) + var tokens []string + if volume := filepath.VolumeName(clean); volume != "" { + tokens = append(tokens, strings.TrimSuffix(volume, ":")) + clean = strings.TrimPrefix(clean, volume) + } + parts := strings.SplitSeq(clean, string(filepath.Separator)) + for part := range parts { + if part == "" { + continue + } + tokens = append(tokens, cursorComponentTokens(part)...) + } + return strings.Join(tokens, "-") +} diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 703f03a6..c83ec9bc 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -347,23 +347,19 @@ func (e *Engine) classifyOnePath( } } - // Cursor: //agent-transcripts/.{txt,jsonl} + // Cursor: + // //agent-transcripts/.{txt,jsonl} + // //agent-transcripts//.{txt,jsonl} for _, cursorDir := range e.agentDirs[parser.AgentCursor] { if cursorDir == "" { continue } if rel, ok := isUnder(cursorDir, path); ok { - parts := strings.Split(rel, sep) - if len(parts) != 3 { - continue - } - if parts[1] != "agent-transcripts" { + projectDir, ok := parser.ParseCursorTranscriptRelPath(rel) + if !ok { continue } - if !parser.IsCursorTranscriptExt(parts[2]) { - continue - } - project := parser.DecodeCursorProjectDir(parts[0]) + project := parser.DecodeCursorProjectDir(projectDir) if project == "" { project = "unknown" } @@ -2385,12 +2381,22 @@ func (e *Engine) SyncSingleSession(sessionID string) error { file.Project = filepath.Base(filepath.Dir(path)) } case parser.AgentCursor: - // path is //agent-transcripts/.txt - // Extract project dir name from two levels up - projDir := filepath.Base( - filepath.Dir(filepath.Dir(path)), - ) - file.Project = parser.DecodeCursorProjectDir(projDir) + // Support both flat and nested transcript layouts. + for _, cursorDir := range e.agentDirs[parser.AgentCursor] { + rel, ok := isUnder(cursorDir, path) + if !ok { + continue + } + projDir, ok := parser.ParseCursorTranscriptRelPath(rel) + if !ok { + continue + } + file.Project = parser.DecodeCursorProjectDir(projDir) + break + } + if file.Project == "" { + file.Project = "unknown" + } case parser.AgentIflow: // path is //session-.jsonl // Extract project dir name from parent directory diff --git a/internal/sync/engine_integration_test.go b/internal/sync/engine_integration_test.go index fd7cb486..d552b1f4 100644 --- a/internal/sync/engine_integration_test.go +++ b/internal/sync/engine_integration_test.go @@ -199,6 +199,23 @@ func (e *testEnv) writeCursorSession( ) } +// writeNestedCursorSession creates a Cursor transcript file under +// the nested layout /agent-transcripts//. +func (e *testEnv) writeNestedCursorSession( + t *testing.T, cursorDir, project, sessionID, ext, + content string, +) string { + t.Helper() + return e.writeSession( + t, cursorDir, + filepath.Join( + project, "agent-transcripts", sessionID, + sessionID+ext, + ), + content, + ) +} + func TestSyncEngineIntegration(t *testing.T) { env := setupTestEnv(t) @@ -1752,6 +1769,67 @@ func TestSyncEngineMultiCursorDir(t *testing.T) { } } +func TestSyncPathsCursorNestedLayout(t *testing.T) { + env := setupTestEnv(t) + + path := env.writeNestedCursorSession( + t, env.cursorDir, + "Users-alice-code-nested-proj", + "nested-sync", ".jsonl", + "user:\nHello nested cursor\nassistant:\nHi there!\n", + ) + + env.engine.SyncPaths([]string{path}) + + assertSessionProject( + t, env.db, "cursor:nested-sync", "nested_proj", + ) + assertSessionMessageCount( + t, env.db, "cursor:nested-sync", 2, + ) +} + +func TestSyncSingleSessionCursorNestedLayoutPreservesProject( + t *testing.T, +) { + env := setupTestEnv(t) + + path := env.writeNestedCursorSession( + t, env.cursorDir, + "Users-alice-code-nested-proj", + "nested-resync", ".jsonl", + "user:\nHello nested cursor\nassistant:\nHi there!\n", + ) + + runSyncAndAssert(t, env.engine, sync.SyncStats{ + TotalSessions: 1, Synced: 1, Skipped: 0, + }) + assertSessionProject( + t, env.db, "cursor:nested-resync", "nested_proj", + ) + + updated := "user:\nHello nested cursor\n" + + "assistant:\nHi there!\n" + + "user:\nFollow-up\n" + + "assistant:\nGot it.\n" + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := env.engine.SyncSingleSession( + "cursor:nested-resync", + ); err != nil { + t.Fatalf("SyncSingleSession: %v", err) + } + + assertSessionProject( + t, env.db, "cursor:nested-resync", "nested_proj", + ) + assertSessionMessageCount( + t, env.db, "cursor:nested-resync", 4, + ) +} + func TestSyncForkDetection(t *testing.T) { env := setupTestEnv(t)