Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
efa68a3
docs: add cmux/Ghostty terminal integration research
grimmerk Mar 19, 2026
8558242
feat: add cmux and Ghostty terminal support
grimmerk Mar 19, 2026
486346b
fix: select + activate cmux workspace after launch
grimmerk Mar 19, 2026
0d3b77f
fix: use sidebar-state cwd for precise cmux workspace matching
grimmerk Mar 19, 2026
0558da0
fix: cmux switch with tree fallback for multi-tab workspaces
grimmerk Mar 19, 2026
3bf28d1
perf: parallel sidebar-state queries for cmux switch
grimmerk Mar 19, 2026
39afc44
fix: revert cmux switch to CLI approach (AppleScript returns 0 windows)
grimmerk Mar 19, 2026
e91fe52
feat: auto-detect terminal app for active sessions + badge
grimmerk Mar 19, 2026
1b39261
feat: full Ghostty AppleScript support for launch + switch
grimmerk Mar 19, 2026
2eb9fc5
fix: Ghostty AppleScript surface configuration syntax
grimmerk Mar 19, 2026
dcd1d43
fix: Ghostty launch uses initialInput instead of command
grimmerk Mar 19, 2026
cd065b0
fix: Ghostty label + Open Mode for Ghostty + new window support
grimmerk Mar 19, 2026
fc0c506
docs: update Ghostty to full support + add auto-detection section
grimmerk Mar 19, 2026
49a6bd9
fix: only show session settings in Sessions mode (#54)
grimmerk Mar 19, 2026
063b36b
style: rename Session Terminal to Launch Terminal
grimmerk Mar 19, 2026
8cbd0c3
style: rename Open Mode to Launch Mode for consistency
grimmerk Mar 19, 2026
ccbd9b1
style: reorder terminal options (Ghostty before cmux)
grimmerk Mar 19, 2026
e24ff3d
chore: bump version to 1.0.35
grimmerk Mar 19, 2026
0eba5d0
chore: update package description
grimmerk Mar 19, 2026
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
79 changes: 70 additions & 9 deletions docs/claude-session-integration-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,15 +290,76 @@ In the Settings popup:

## Terminal Support Matrix

| Terminal | Detect | Switch | Launch |
|----------|--------|--------|--------|
| iTerm2 ✅ | `ps` + `lsof` + tty | AppleScript tty focus | AppleScript: new tab/window + execute |
| Terminal.app | `ps` + tty | AppleScript focus | AppleScript: new tab + execute |
| Ghostty | `ps` + parent check | AppleScript activate | Activate + paste command / clipboard |
| cmux | `ps` + `cmux list-panes` | `cmux select-workspace` | Activate + paste command / clipboard |
| Custom | — | — | User command template / clipboard |

**MVP: iTerm2 only.** Others planned for Phase 2+.
| Terminal | Detect | Switch | Launch | External Access |
|----------|--------|--------|--------|----------------|
| iTerm2 ✅ | `ps` + `lsof` + tty | AppleScript tty focus | AppleScript: new tab/window + execute | No restriction |
| cmux ⚠️ | `ps` + `lsof` | `sidebar-state` cwd → `tree` title fallback | `cmux new-workspace --cwd --command` | Limited: requires socket `automation`/`allowAll` mode; same-cwd workspaces may mismatch (no per-surface PID/tty in API) |
| Ghostty ✅ | `ps` + parent tree | AppleScript `working directory` match + `focus` | AppleScript: `new tab`/`new window` with `surface configuration` | No restriction |
| Terminal.app | `ps` + tty | AppleScript focus | AppleScript: new tab + execute | No restriction |
| Custom | — | — | User command template / clipboard | — |

### Auto-Detection of Terminal App

For active sessions, CodeV walks the parent process tree (`ps -o comm=` → `ps -o ppid=`, up to 20 levels) to detect which terminal the claude process is running in. This means:
- Clicking an iTerm2 session uses iTerm2 switch logic (even if settings say cmux)
- Clicking a cmux session uses cmux switch logic (even if settings say iTerm2)
- Settings terminal only affects **launching** non-active sessions
- Active sessions show a small uppercase badge (ITERM2, CMUX, GHOSTTY) in the UI

### cmux Integration Details

**CLI commands available:**
- `cmux new-workspace --cwd <path> --command "claude --resume <id>"` — create new workspace with command
- `cmux select-workspace --workspace <id>` — switch to workspace
- `cmux focus-panel --panel surface:N` — switch to specific tab within workspace
- `cmux send "text"` / `cmux send-key enter` — send text/keys to focused terminal
- `cmux list-workspaces [--json]` / `cmux list-pane-surfaces --pane pane:N` — inspect topology

**Socket access restriction:**
cmux CLI communicates via Unix socket (`/tmp/cmux.sock`). By default, only processes started inside cmux can connect (`cmuxOnly` mode). External apps like CodeV need the user to change the socket mode:

| Mode | Access | How to enable |
|------|--------|---------------|
| `cmuxOnly` (default) | cmux child processes only | Default |
| `automation` | Automation-friendly access | cmux Settings UI |
| `allowAll` | Any local process | `CMUX_SOCKET_MODE=allowAll` or Settings UI |
| `password` | Password-authenticated | Settings UI |
| `off` | Disabled | Settings UI |

**Recommended:** Ask user to set `automation` or `allowAll` mode in cmux Settings. Security impact is minimal — only local processes on the same machine can connect.

**Switch strategy for cmux:**
1. `ps aux` to find claude process PID
2. Try connecting to `/tmp/cmux.sock` — if access denied, fallback to clipboard
3. If connected: `cmux list-workspaces --json` to find which workspace has the session
4. `cmux select-workspace` + `cmux focus-panel` to switch to correct tab

**Launch strategy for cmux:**
1. Try `cmux new-workspace --cwd <project> --command "claude --resume <id>"`
2. If socket access denied: activate cmux + copy command to clipboard

### Ghostty Integration Details

Ghostty has full AppleScript support via `Ghostty.sdef`:

**AppleScript capabilities:**
- `terminal.working directory` — per-terminal cwd (for switch matching)
- `focus` — focus a specific terminal, bringing its window to front
- `select tab` — select a tab in its window
- `new tab` / `new window` — create with optional `surface configuration`
- `surface configuration` — record type with `command`, `initial working directory`, `initial input`, `wait after command`, `environment variables`
- `input text` — send text to a terminal as if pasted
- `send key` — send keyboard events

**Switch:** Iterate all windows → tabs → terminals, match `working directory` to project path, call `focus`.

**Launch:** `new tab`/`new window` with `surface configuration from {initial working directory, initial input:"claude --resume <id>\n"}`. Uses `initial input` (not `command`) because `command` is passed directly to `exec` without shell interpretation.

**Note:** Ghostty CLI `+new-window` is not supported on macOS, but AppleScript `new window` works. The `.sdef` is similar to cmux's, but Ghostty's AppleScript actually works (cmux's `count windows` returns 0).

### Branch Name: Why Not `git branch --show-current`

`git branch --show-current` returns the repo's **current** branch, but a session may have been created on a different branch that has since been switched away. The JSONL `gitBranch` field preserves the branch at the time of each session entry, which is the correct value to display.

## Phase Plan

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "CodeV",
"productName": "CodeV",
"version": "1.0.34",
"description": "My Electron application description",
"version": "1.0.35",
"description": "Quick switcher for VS Code, Cursor, and Claude Code sessions",
"main": ".webpack/main",
"scripts": {
"db": "prisma migrate dev",
Expand Down
236 changes: 236 additions & 0 deletions src/claude-session-utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface ClaudeSession {
messageCount: number;
isActive: boolean; // whether a claude process is running for this session
activePid?: number;
terminalApp?: string; // detected terminal: 'iterm2', 'cmux', 'ghostty', etc.
}

interface HistoryLine {
Expand Down Expand Up @@ -167,6 +168,35 @@ export const searchClaudeSessions = (query: string, limit = 50): ClaudeSession[]
* to find which project directory it's working in, then look up the latest
* session for that project from history.jsonl
*/
/**
* Detect which terminal app a process is running in by walking parent process tree.
* Returns 'iterm2', 'cmux', 'ghostty', 'terminal', or 'unknown'.
*/
export const detectTerminalApp = async (pid: number): Promise<string> => {
const { exec } = require('child_process');
const execPromise = (cmd: string): Promise<string> =>
new Promise((resolve) => {
exec(cmd, { encoding: 'utf-8', timeout: 2000 }, (_e: any, out: string) => resolve(out || ''));
});

let currentPid = pid;
for (let i = 0; i < 20; i++) {
const comm = (await execPromise(`ps -o comm= -p ${currentPid} 2>/dev/null`)).trim();
if (!comm) break;

const commLower = comm.toLowerCase();
if (commLower.includes('iterm') || commLower.includes('iterm2')) return 'iterm2';
if (commLower.includes('cmux')) return 'cmux';
if (commLower.includes('ghostty')) return 'ghostty';
if (commLower.includes('terminal.app') || (commLower === 'terminal')) return 'terminal';

const ppid = parseInt((await execPromise(`ps -o ppid= -p ${currentPid} 2>/dev/null`)).trim(), 10);
if (!ppid || ppid <= 1) break;
currentPid = ppid;
}
return 'unknown';
};

export const detectActiveSessions = async (): Promise<Map<string, number>> => {
const now = Date.now();
if (cachedActiveMap && (now - activeCacheTimestamp) < ACTIVE_CACHE_TTL_MS) {
Expand Down Expand Up @@ -228,6 +258,42 @@ export const detectActiveSessions = async (): Promise<Map<string, number>> => {
return activeMap;
};

/**
* Open a Claude Code session in the configured terminal.
*/
export const openSession = async (
sessionId: string,
projectPath: string,
isActive: boolean,
activePid?: number,
terminalApp: string = 'iterm2',
terminalMode: string = 'tab',
): Promise<void> => {
let effectiveTerminal = terminalApp;

// For active sessions, auto-detect which terminal they're running in
if (isActive && activePid) {
const detected = await detectTerminalApp(activePid);
if (detected !== 'unknown') {
effectiveTerminal = detected;
console.log(`[openSession] auto-detected terminal: ${detected} for pid ${activePid}`);
}
}

switch (effectiveTerminal) {
case 'cmux':
openSessionInCmux(sessionId, projectPath, isActive, activePid);
break;
case 'ghostty':
openSessionInGhostty(sessionId, projectPath, isActive, terminalMode);
break;
case 'iterm2':
default:
openSessionInITerm2(sessionId, projectPath, isActive, activePid, terminalMode);
break;
}
};

/**
* Open a Claude Code session in iTerm2
* If the session is already active, switch to its tab
Expand Down Expand Up @@ -425,6 +491,176 @@ export const loadLastAssistantResponses = async (
return responses;
};

/**
* Open a Claude Code session in Ghostty.
* Full AppleScript support: working directory matching, focus, new tab with command.
*/
export const openSessionInGhostty = (
sessionId: string,
projectPath: string,
isActive: boolean,
terminalMode: string = 'tab',
): void => {
const { exec } = require('child_process');

if (isActive) {
// Switch to existing terminal by matching working directory
const tmpScript = '/tmp/codev-ghostty-switch.scpt';
const switchScript = `tell application "Ghostty"
activate
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
focus term
return "found"
end if
end repeat
end repeat
end repeat
return "not found"
end tell`;
fs.writeFileSync(tmpScript, switchScript);
exec(`osascript ${tmpScript}`, { encoding: 'utf-8', timeout: 5000 }, (error: any, stdout: string) => {
const result = (stdout || '').trim();
console.log('[ghostty] switch result:', result);
if (result !== 'found') {
// Fallback: clipboard + activate
copyResumeCommand(sessionId, projectPath);
}
try { fs.unlinkSync(tmpScript); } catch {}
});
} else {
// Launch new tab with command via surface configuration
// Use initial working directory for cd, and initialInput to type the resume command
const tmpScript = '/tmp/codev-ghostty-launch.scpt';
const resumeCmd = `claude --resume ${sessionId}`;
const launchScript = terminalMode === 'window'
? `tell application "Ghostty"
activate
set cfg to new surface configuration from {initial working directory:"${projectPath}", initial input:"${resumeCmd}\\n"}
new window with configuration cfg
end tell`
: `tell application "Ghostty"
activate
set cfg to new surface configuration from {initial working directory:"${projectPath}", initial input:"${resumeCmd}\\n"}
if (count windows) > 0 then
new tab in front window with configuration cfg
else
new window with configuration cfg
end if
end tell`;
fs.writeFileSync(tmpScript, launchScript);
exec(`osascript ${tmpScript}`, { encoding: 'utf-8', timeout: 5000 }, (error: any) => {
if (error) {
console.error('[ghostty] launch error:', error.message);
// Fallback: clipboard
copyResumeCommand(sessionId, projectPath);
}
try { fs.unlinkSync(tmpScript); } catch {}
});
}
};

/**
* Open a Claude Code session in cmux.
* Requires cmux socket mode set to 'automation' or 'allowAll'.
* Falls back to clipboard if socket access denied.
*/
const CMUX_CLI = '/Applications/cmux.app/Contents/Resources/bin/cmux';

export const openSessionInCmux = (
sessionId: string,
projectPath: string,
isActive: boolean,
activePid?: number,
): void => {
const { exec } = require('child_process');
const command = `cd "${projectPath}" && claude --resume ${sessionId}`;

console.log('[cmux] openSession:', { sessionId, projectPath, isActive, activePid });
if (isActive) {
// NOTE: cmux has AppleScript dictionary with terminal.workingDirectory and focus,
// but testing shows count windows returns 0 — AppleScript interface may be buggy.
// Using CLI (sidebar-state + tree) approach instead.
const execPromise = (cmd: string): Promise<string> =>
new Promise((resolve) => {
exec(cmd, { encoding: 'utf-8', timeout: 3000, maxBuffer: 1024 * 1024 }, (_e: any, out: string) => resolve(out || ''));
});

(async () => {
const wsListOutput = await execPromise(`${CMUX_CLI} list-workspaces 2>/dev/null`);
if (!wsListOutput) {
copyResumeCommand(sessionId, projectPath);
exec('osascript -e \'tell application "cmux" to activate\'');
return;
}

const wsIds = wsListOutput.match(/workspace:\d+/g) || [];

// Pass 1: parallel sidebar-state cwd match
const cwdResults = await Promise.all(wsIds.map(async (wsId: string) => {
const state = await execPromise(`${CMUX_CLI} sidebar-state --workspace ${wsId} 2>/dev/null`);
const cwdMatch = state.match(/^cwd=(.+)$/m);
const focusedCwdMatch = state.match(/^focused_cwd=(.+)$/m);
return { wsId, cwd: cwdMatch?.[1], focusedCwd: focusedCwdMatch?.[1] };
}));

const cwdHit = cwdResults.find((r: any) => r.cwd === projectPath || r.focusedCwd === projectPath);
if (cwdHit) {
console.log('[cmux] matched workspace by cwd:', cwdHit.wsId);
exec(`${CMUX_CLI} select-workspace --workspace ${cwdHit.wsId}`);
exec('osascript -e \'tell application "cmux" to activate\'');
return;
}

// Pass 2: tree surface title match
const projectName = path.basename(projectPath);
if (projectName && projectName !== path.basename(os.homedir())) {
const treeOutput = await execPromise(`${CMUX_CLI} tree --all 2>/dev/null`);
const treeLines = treeOutput.split('\n');
let currentWorkspace: string | null = null;
for (const line of treeLines) {
const wsMatch = line.match(/workspace (workspace:\d+)/);
if (wsMatch) currentWorkspace = wsMatch[1];
const surfaceMatch = line.match(/surface (surface:\d+)/);
if (surfaceMatch && currentWorkspace && line.toLowerCase().includes(projectName.toLowerCase())) {
console.log('[cmux] matched by tree surface title:', currentWorkspace);
exec(`${CMUX_CLI} select-workspace --workspace ${currentWorkspace}`);
exec('osascript -e \'tell application "cmux" to activate\'');
return;
}
}
}

console.log('[cmux] no match found, activating cmux');
exec('osascript -e \'tell application "cmux" to activate\'');
})();
} else {
// Launch new workspace with command
const cmuxCmd = `${CMUX_CLI} new-workspace --cwd "${projectPath}" --command "claude --resume ${sessionId}"`;
console.log('[cmux] launch cmd:', cmuxCmd);
exec(cmuxCmd,
{ encoding: 'utf-8', timeout: 5000 },
(error: any, stdout: string, stderr: string) => {
console.log('[cmux] launch result:', { error: error?.message, stdout, stderr });
if (error) {
console.error('cmux new-workspace failed, falling back to clipboard:', error.message);
copyResumeCommand(sessionId, projectPath);
exec('osascript -e \'tell application "cmux" to activate\'');
} else {
// Select the newly created workspace and activate cmux
const wsMatch = stdout.match(/workspace:\d+/);
if (wsMatch) {
exec(`${CMUX_CLI} select-workspace --workspace ${wsMatch[0]}`);
}
exec('osascript -e \'tell application "cmux" to activate\'');
}
}
);
}
};

/**
* Copy resume command to clipboard (fallback for unsupported terminals)
*/
Expand Down
Loading
Loading