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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## 1.0.75

- Feat: Launch new Claude session as a git worktree from the Projects tab
- Press `⌘+Shift+Enter` on a project → dialog asks for branch name (optional)
- With a name: runs `claude -w "<name>" -n "<name>"` in the configured terminal
- Without a name: behaves like the existing `⌘+Enter` (normal session)
- The `-n` flag also sets a custom title so Ghostty's title-match can locate
the right tab when switching back (Ghostty has no per-tab TTY exposure).
- Feat: worktree-aware session display
- Worktree session paths (`<repo>/.claude/worktrees/<name>`) now show the
parent repo name (e.g., `codev`) with a small `WT` badge, instead of the
worktree folder name (e.g., `test-worktree-4`).
- New `parseWorktreePath()` helper + `isWorktree` / `parentRepo` fields on
`ClaudeSession`.
- Fix: Ghostty session-switching no longer "jumps to the wrong window" on
match miss
- The `activate` AppleScript call is now inside the success branch, so we
don't bring Ghostty forward when no matching tab is found.
- Benefits all session switches, not just worktrees.

## 1.0.74

- Fix: session status dot stuck on purple for sessions with large responses (#116)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Press `⌃+⌘+R` or click the menu bar icon to launch the Quick Switcher. Searc
- Supports VS Code and Cursor — switch between them in Settings → General → IDE
- Remove items from the recent list by hovering and clicking "x"
- **Quick-launch Claude session**: `⌘+Enter` to launch a new Claude Code session in the configured terminal, `⇧+Enter` to launch in CodeV's embedded terminal, `⌘+Click` as mouse alternative
- **Launch as git worktree**: `⌘+Shift+Enter` opens a small dialog where you enter a branch name. CodeV launches `claude -w "<name>" -n "<name>"` so Claude creates a fresh worktree at `<repo>/.claude/worktrees/<name>`. Leaving the name empty falls back to a normal session. Worktree sessions show the parent project name with a `WT` badge in the Sessions list.

### Claude Code Session Switching

Expand Down
77 changes: 77 additions & 0 deletions docs/claude-session-integration-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,83 @@ Ghostty has full AppleScript support via `Ghostty.sdef`:
- Custom terminal command template
- Per-terminal PID/TTY matching (pending upstream: Ghostty #11592, cmux #1826)

## Git Worktree Sessions

Claude Code's `claude -w <name>` creates a git worktree at `<repo>/.claude/worktrees/<name>` and starts a session inside it. CodeV exposes this from the Projects tab via `⌘+Shift+Enter`.

### Why we leverage `claude -w` instead of `git worktree add` ourselves

Two approaches were considered:

| Approach | Description | Trade-off |
|---|---|---|
| **A. `git worktree add`** (codev manages) | Create worktree at sibling path (`<parent>/<repo>-<branch>`), launch terminal there. claude-control uses this. | Full control, sibling visibility, **no IDE/git-GUI clutter inside the parent repo**. But we own all lifecycle (cleanup UI, branch conflicts, error handling). |
| **B. `claude -w <name> -n <name>`** (chosen) | Let Claude CLI create+manage the worktree. Pass `-n` to set a custom title. | Minimal code, leverages Claude CLI features (auto-cleanup, tmux). **Downside: nested worktree folder visible to VS Code / git GUI** — users may see worktree files in their workspace, accidentally commit them, or include them in cross-repo searches. Mitigation: `.gitignore` `.claude/worktrees/`. |

We chose **B** because codev is Claude-Code-focused (claude-control is multi-tool, so they need their own implementation). Claude CLI's worktree lifecycle (auto-cleanup if clean) is the right default for codev users.

**A and B are not mutually exclusive.** A future iteration could add a setting to choose between them, or fall back to A for users who hit B's limitations (e.g., the IDE-clutter concern).

### The `-n` flag and Ghostty switch

`claude -w <name>` alone breaks Ghostty session switching:

- **Worktree terminal cwd = parent repo** (because we `cd <parent> && claude -w <name>`; the shell stays in parent, only the claude process internally cd's into the worktree).
- **Main session terminal cwd = parent repo** too.
- Ghostty's AppleScript `working directory of term` reports the shell's cwd → both terminals report the same cwd → AppleScript "first match wins" focuses the wrong tab.

Fix: pass `-n <name>` so Claude CLI sets a custom title. Codev's existing AppleScript title-match (Layer 1) finds the correct tab regardless of cwd. Other terminals (iTerm2 / Terminal.app / cmux) use TTY match and were already unaffected.

**Future:** once Ghostty exposes per-tab TTY ([ghostty-org/ghostty#11354](https://github.com/ghostty-org/ghostty/pull/11354), merged but not released yet), Ghostty switch can use TTY match like the other terminals. The `-n` flag and the title-match layer become optional at that point — we can drop them or keep them as defense-in-depth.

### AppleScript switch latency

The Ghostty / Terminal.app / iTerm2 switch scripts iterate `windows × tabs × terminals` to find a match. Latency therefore scales with the user's open terminal count. With many windows/tabs (10+ Ghostty tabs, 50+ Terminal.app tabs) `osascript` invocation can be perceptibly slow (hundreds of ms). This is independent of worktree code — it predates this PR. Mitigations to investigate later: cache window IDs by sessionId, short-circuit on first match, or use TTY/PID directly once Ghostty exposes them.

### AppleScript `activate` timing

Old behavior:
```applescript
tell application "Ghostty"
activate -- always brings Ghostty front
... match logic ...
return "not found"
end tell
```

When match failed, `activate` had already run, so Ghostty would surface its last-active window — looking like "switched to the wrong tab". Fixed by moving `activate` inside each match success branch.

### Worktree session display

`parseWorktreePath()` recognizes `<repo>/.claude/worktrees/<name>` paths and exposes:
- `projectName` = parent repo name (e.g., `codev` instead of `test-worktree-4`)
- `isWorktree` = true → renders a `WT` badge in the Sessions list
- `parentRepo` = parent repo path (kept for grouping/display logic)

The `project` field still holds the original worktree path so resume / cwd matching continue to work.

### Resume after Claude CLI auto-cleanup

When a `-w` session exits without uncommitted changes, Claude CLI may remove the worktree directory. The session JSONL is stored at `~/.claude/projects/<encoded>/<uuid>.jsonl` (independent of the worktree directory) so `claude --resume <uuid>` still works — the cwd shown in the session header just reflects wherever resume was invoked from. No special handling needed in codev.

### Detection is path-based, not launch-based

`parseWorktreePath()` recognizes any path matching `<repo>/.claude/worktrees/<name>` — regardless of who created the worktree. This means codev shows the `WT` badge for:

- Sessions launched via codev's `⌘+Shift+Enter` (this PR's feature)
- Sessions where the user manually ran `claude -w <name>` from a terminal
- Sessions created by other tools (c9watch, older claude-control versions, etc.) that put worktrees in `.claude/worktrees/`

If we ever switch to **Approach A** (codev-managed sibling worktrees at `<parent>/<repo>-<branch>`), the detection path can be expanded to recognize **both** patterns simultaneously — the nested pattern is independent of how new worktrees are created. So switching launch strategy doesn't break detection of pre-existing nested worktrees.

### Future-proofing: when Ghostty exposes per-tab TTY

The `-n` flag (and the title-match layer) is currently the only way to disambiguate Ghostty tabs that share a cwd. Once Ghostty's AppleScript surface exposes per-tab TTY ([ghostty-org/ghostty#11354](https://github.com/ghostty-org/ghostty/pull/11354), merged but unreleased as of v1.0.75), Ghostty switching can use TTY match the same way iTerm2 / Terminal.app / cmux do. At that point:

- The `-n` flag becomes optional — tab disambiguation no longer relies on title
- Codev should add a Ghostty TTY-match layer (parallel to existing iTerm2 / Terminal.app code)
- Existing `-n`-set sessions keep working (title match still functions as a Layer 1 fast path)

## Technical Decisions

### TypeScript vs Rust
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "CodeV",
"productName": "CodeV",
"version": "1.0.74",
"version": "1.0.75",
"description": "Quick switcher for VS Code, Cursor, and Claude Code sessions",
"repository": {
"type": "git",
Expand Down
123 changes: 104 additions & 19 deletions src/claude-session-utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import { getCurrentIDEBundleId } from './vscode-based-ide-utility';

export interface ClaudeSession {
sessionId: string;
project: string; // full path, e.g. /Users/grimmer/git/fred-ff
projectName: string; // folder name, e.g. fred-ff
project: string; // full path, e.g. /Users/grimmer/git/fred-ff or <repo>/.claude/worktrees/<name>
projectName: string; // display name: parent repo name if worktree, else folder name
firstUserMessage: string;
lastUserMessage: string;
lastAssistantMessage?: string; // only loaded for active sessions
Expand All @@ -22,8 +22,56 @@ export interface ClaudeSession {
activePid?: number;
terminalApp?: string; // detected terminal: 'iterm2', 'cmux', 'ghostty', 'vscode', etc.
entrypoint?: string; // 'cli', 'claude-vscode', etc.
isWorktree?: boolean; // true if project path is <repo>/.claude/worktrees/<name>
parentRepo?: string; // for worktree sessions: the parent repo path
}

/**
* Parse a Claude Code worktree path into parent repo + worktree name.
* Returns null if the path is not a worktree path.
* E.g. /Users/me/git/codev/.claude/worktrees/fix-bug
* → { parentRepo: '/Users/me/git/codev', worktreeName: 'fix-bug' }
*/
export const parseWorktreePath = (
p: string,
): { parentRepo: string; worktreeName: string } | null => {
if (!p) return null;
const match = p.match(/^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/);
return match ? { parentRepo: match[1], worktreeName: match[2] } : null;
Comment on lines +35 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

parseWorktreePath() breaks on slash-delimited worktree names.

isValidWorktreeName() explicitly allows names like fix/login-bug, but this regex only captures a single segment after /.claude/worktrees/. For a path like /repo/.claude/worktrees/fix/login-bug, it misidentifies the parent repo as /repo/.claude/worktrees/fix, so the new projectName/isWorktree metadata is wrong across history reads and active-session detection.

Suggested fix
 export const parseWorktreePath = (
   p: string,
 ): { parentRepo: string; worktreeName: string } | null => {
   if (!p) return null;
-  const match = p.match(/^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/);
-  return match ? { parentRepo: match[1], worktreeName: match[2] } : null;
+  const marker = `${path.sep}.claude${path.sep}worktrees${path.sep}`;
+  const idx = p.indexOf(marker);
+  if (idx === -1) return null;
+  const parentRepo = p.slice(0, idx);
+  const worktreeName = p.slice(idx + marker.length).replace(/\/$/, '');
+  if (!parentRepo || !worktreeName) return null;
+  return { parentRepo, worktreeName };
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/claude-session-utility.ts` around lines 35 - 40, parseWorktreePath
currently uses a regex that only captures a single path segment for the worktree
name, breaking names containing slashes (e.g., fix/login-bug); update
parseWorktreePath to use a regex that captures the entire remainder of the path
as the worktreeName while stopping at the `/.claude/worktrees/` boundary for
parentRepo (for example change the pattern to something like
/^(.+?)\/\.claude\/worktrees\/(.+?)\/?$/), then return parentRepo and
worktreeName accordingly (no code formatting changes beyond replacing the
regex), and keep in mind isValidWorktreeName allows slash-delimited names.

};

/**
* Validate a worktree name before passing it into the launch shell command.
* Whitelist: alphanumerics + a few branch-name-safe punctuation chars.
* Rejects shell metacharacters ($ ` " ' \ ; | & space etc.) so the name
* cannot break out of the quoted argument in `claude -w "<name>" -n "<name>"`.
*
* Conservative subset of git's branch name rules:
* - allows: a-z A-Z 0-9 / - _ . +
* - disallows: leading/trailing dot, leading dash, double slash,
* leading/trailing slash, anything else.
*/
export const isValidWorktreeName = (name: string): boolean => {
if (!name) return false;
if (name.length > 100) return false;
if (!/^[A-Za-z0-9_./+-]+$/.test(name)) return false;
if (name.startsWith('-') || name.startsWith('.')) return false;
if (name.startsWith('/') || name.endsWith('/')) return false;
if (name.endsWith('.')) return false;
if (name.includes('//')) return false;
if (name.includes('..')) return false;
return true;
};

/**
* Compute the display project name. For worktree paths, returns parent repo name.
*/
export const getProjectDisplayName = (projectPath: string): string => {
const worktree = parseWorktreePath(projectPath);
const displayPath = worktree ? worktree.parentRepo : projectPath;
return path.basename(displayPath) || displayPath;
};

export interface ActiveSessionResult {
activeMap: Map<string, number>; // sessionId -> pid (all active sessions)
vscodeSessions: ClaudeSession[]; // VS Code sessions not in history.jsonl
Expand Down Expand Up @@ -133,16 +181,22 @@ export const readClaudeSessions = (limit = 100): ClaudeSession[] => {

const allSessions = Array.from(bySession.values())
.sort((a, b) => b.lastTimestamp - a.lastTimestamp)
.map((s) => ({
sessionId: s.sessionId,
project: s.project,
projectName: path.basename(s.project) || s.project,
firstUserMessage: s.firstDisplay,
lastUserMessage: s.lastDisplay,
lastTimestamp: s.lastTimestamp,
messageCount: s.promptCount,
isActive: false,
}));
.map((s) => {
const worktree = parseWorktreePath(s.project);
return {
sessionId: s.sessionId,
project: s.project,
projectName: worktree
? path.basename(worktree.parentRepo) || worktree.parentRepo
: (path.basename(s.project) || s.project),
firstUserMessage: s.firstDisplay,
lastUserMessage: s.lastDisplay,
lastTimestamp: s.lastTimestamp,
messageCount: s.promptCount,
isActive: false,
...(worktree && { isWorktree: true, parentRepo: worktree.parentRepo }),
};
});

cachedSessions = allSessions;
cacheTimestamp = now;
Expand Down Expand Up @@ -825,17 +879,21 @@ export const scanClosedVSCodeSessions = async (
);
// Use actual cwd from JSONL content, fall back to hooks index cwd
const actualCwd = info.cwd || cwd;
const worktree = parseWorktreePath(actualCwd);
sessions.push({
sessionId,
project: actualCwd,
projectName: path.basename(actualCwd) || actualCwd,
projectName: worktree
? path.basename(worktree.parentRepo) || worktree.parentRepo
: (path.basename(actualCwd) || actualCwd),
firstUserMessage: info.firstUserMessage,
lastUserMessage: info.lastUserMessage,
lastAssistantMessage: info.lastAssistantMessage,
lastTimestamp: info.lastTimestamp,
messageCount: info.messageCount,
isActive: false,
entrypoint: 'claude-vscode',
...(worktree && { isWorktree: true, parentRepo: worktree.parentRepo }),
});
});

Expand Down Expand Up @@ -903,12 +961,16 @@ export const detectActiveSessions = async (): Promise<ActiveSessionResult> => {
activeMap.set(sessionId, pid);
// Queue async JSONL read (head/tail in parallel)
const startedAt = data.startedAt;
const activeWorktree = parseWorktreePath(cwd);
vscodeReadPromises.push(
readVSCodeSessionFromJSONL(sessionId, cwd, execPromise).then((info) => {
vscodeSessions.push({
sessionId,
project: cwd,
projectName: path.basename(cwd) || cwd,
projectName: activeWorktree
? path.basename(activeWorktree.parentRepo) || activeWorktree.parentRepo
: (path.basename(cwd) || cwd),
...(activeWorktree && { isWorktree: true, parentRepo: activeWorktree.parentRepo }),
firstUserMessage: info.firstUserMessage,
lastUserMessage: info.lastUserMessage,
lastAssistantMessage: info.lastAssistantMessage,
Expand Down Expand Up @@ -1166,14 +1228,16 @@ export const runCommandInTerminal = (
switch (terminalApp) {
case 'ghostty': {
const tmpScript = '/tmp/codev-ghostty-launch.scpt';
// Escape double quotes in claudeCmd for AppleScript string embedding
const escapedCmd = claudeCmd.replace(/"/g, '\\"');
const launchScript = terminalMode === 'window'
? `tell application "Ghostty"
set cfg to new surface configuration from {initial working directory:"${projectPath}", initial input:"${claudeCmd}\\n"}
set cfg to new surface configuration from {initial working directory:"${projectPath}", initial input:"${escapedCmd}\\n"}
new window with configuration cfg
activate
end tell`
: `tell application "Ghostty"
set cfg to new surface configuration from {initial working directory:"${projectPath}", initial input:"${claudeCmd}\\n"}
set cfg to new surface configuration from {initial working directory:"${projectPath}", initial input:"${escapedCmd}\\n"}
if (count windows) > 0 then
activate
new tab in front window with configuration cfg
Expand Down Expand Up @@ -1317,6 +1381,7 @@ export const launchNewClaudeSession = (
projectPath: string,
terminalApp: string = 'iterm2',
terminalMode: string = 'tab',
worktreeName?: string,
): void => {
if (terminalApp === 'vscode') {
const { execFile } = require('child_process');
Expand Down Expand Up @@ -1344,7 +1409,24 @@ export const launchNewClaudeSession = (
}
return;
}
runCommandInTerminal(`cd "${projectPath}" && claude`, 'claude', projectPath, terminalApp, terminalMode);
// For worktree sessions: also pass -n so claude sets a custom title
// (matches the worktree name). This lets AppleScript title-match find the
// correct terminal tab in Ghostty (which lacks per-tab TTY exposure).
// Validate worktreeName to prevent shell injection — it's interpolated
// into a string that ends up in `do script "..."` AppleScript and an
// interactive shell command.
if (worktreeName && !isValidWorktreeName(worktreeName)) {
console.error(
'[launchNewClaudeSession] rejected invalid worktreeName:',
JSON.stringify(worktreeName),
);
return;
}
const shortCmd = worktreeName
? `claude -w "${worktreeName}" -n "${worktreeName}"`
Comment thread
grimmerk marked this conversation as resolved.
: 'claude';
const fullCmd = `cd "${projectPath}" && ${shortCmd}`;
runCommandInTerminal(fullCmd, shortCmd, projectPath, terminalApp, terminalMode);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

/**
Expand Down Expand Up @@ -1698,14 +1780,17 @@ export const openSessionInGhostty = (
const { exec } = require('child_process');

if (isActive) {
// Two-layer matching: title first (precise), then cwd fallback
// Two-layer matching: title first (precise), then cwd fallback.
// IMPORTANT: only `activate` after a match is found — otherwise Ghostty
// jumps to the last-active window (visually "wrong window") on miss.
const titleMatch = customTitle
? `
-- Layer 1: title matching
repeat with w in windows
repeat with t in tabs of w
repeat with term in terminals of t
if name of term contains "${customTitle.replace(/"/g, '\\"')}" then
activate
focus term
return "found-by-title"
end if
Expand All @@ -1716,13 +1801,13 @@ export const openSessionInGhostty = (

const tmpScript = '/tmp/codev-ghostty-switch.scpt';
const switchScript = `tell application "Ghostty"
activate
${titleMatch}
-- Layer 2: cwd matching (fallback)
repeat with w in windows
repeat with t in tabs of w
repeat with term in terminals of t
if working directory of term is "${projectPath}" then
activate
focus term
return "found-by-cwd"
end if
Expand Down
1 change: 1 addition & 0 deletions src/electron-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ interface IElectronAPI {
refreshSessionPreview: (sessions: any[]) => Promise<Record<string, { lastUserMessage: string; lastAssistantMessage: string }>>;
openClaudeSession: (sessionId: string, projectPath: string, isActive: boolean, activePid?: number, customTitle?: string) => void;
launchNewClaudeSession: (projectPath: string) => void;
launchNewClaudeSessionWorktree: (projectPath: string, worktreeName: string) => void;
launchNewClaudeSessionInCodev: (projectPath: string) => void;
copyClaudeSessionCommand: (sessionId: string, projectPath: string) => void;
loadSessionEnrichment: (sessions: any[]) => Promise<{ titles: Record<string, string>; branches: Record<string, string>; prLinks: Record<string, { prNumber: number; prUrl: string }> }>;
Expand Down
Loading