Skip to content

Commit b4b1d12

Browse files
authored
Merge pull request #53 from grimmerk/feat/terminal-cmux-ghostty
feat: add cmux and Ghostty terminal support for sessions
2 parents 95de7fb + 0eba5d0 commit b4b1d12

7 files changed

Lines changed: 408 additions & 15 deletions

File tree

docs/claude-session-integration-design.md

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -290,15 +290,76 @@ In the Settings popup:
290290

291291
## Terminal Support Matrix
292292

293-
| Terminal | Detect | Switch | Launch |
294-
|----------|--------|--------|--------|
295-
| iTerm2 ✅ | `ps` + `lsof` + tty | AppleScript tty focus | AppleScript: new tab/window + execute |
296-
| Terminal.app | `ps` + tty | AppleScript focus | AppleScript: new tab + execute |
297-
| Ghostty | `ps` + parent check | AppleScript activate | Activate + paste command / clipboard |
298-
| cmux | `ps` + `cmux list-panes` | `cmux select-workspace` | Activate + paste command / clipboard |
299-
| Custom ||| User command template / clipboard |
300-
301-
**MVP: iTerm2 only.** Others planned for Phase 2+.
293+
| Terminal | Detect | Switch | Launch | External Access |
294+
|----------|--------|--------|--------|----------------|
295+
| iTerm2 ✅ | `ps` + `lsof` + tty | AppleScript tty focus | AppleScript: new tab/window + execute | No restriction |
296+
| 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) |
297+
| Ghostty ✅ | `ps` + parent tree | AppleScript `working directory` match + `focus` | AppleScript: `new tab`/`new window` with `surface configuration` | No restriction |
298+
| Terminal.app | `ps` + tty | AppleScript focus | AppleScript: new tab + execute | No restriction |
299+
| Custom ||| User command template / clipboard ||
300+
301+
### Auto-Detection of Terminal App
302+
303+
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:
304+
- Clicking an iTerm2 session uses iTerm2 switch logic (even if settings say cmux)
305+
- Clicking a cmux session uses cmux switch logic (even if settings say iTerm2)
306+
- Settings terminal only affects **launching** non-active sessions
307+
- Active sessions show a small uppercase badge (ITERM2, CMUX, GHOSTTY) in the UI
308+
309+
### cmux Integration Details
310+
311+
**CLI commands available:**
312+
- `cmux new-workspace --cwd <path> --command "claude --resume <id>"` — create new workspace with command
313+
- `cmux select-workspace --workspace <id>` — switch to workspace
314+
- `cmux focus-panel --panel surface:N` — switch to specific tab within workspace
315+
- `cmux send "text"` / `cmux send-key enter` — send text/keys to focused terminal
316+
- `cmux list-workspaces [--json]` / `cmux list-pane-surfaces --pane pane:N` — inspect topology
317+
318+
**Socket access restriction:**
319+
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:
320+
321+
| Mode | Access | How to enable |
322+
|------|--------|---------------|
323+
| `cmuxOnly` (default) | cmux child processes only | Default |
324+
| `automation` | Automation-friendly access | cmux Settings UI |
325+
| `allowAll` | Any local process | `CMUX_SOCKET_MODE=allowAll` or Settings UI |
326+
| `password` | Password-authenticated | Settings UI |
327+
| `off` | Disabled | Settings UI |
328+
329+
**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.
330+
331+
**Switch strategy for cmux:**
332+
1. `ps aux` to find claude process PID
333+
2. Try connecting to `/tmp/cmux.sock` — if access denied, fallback to clipboard
334+
3. If connected: `cmux list-workspaces --json` to find which workspace has the session
335+
4. `cmux select-workspace` + `cmux focus-panel` to switch to correct tab
336+
337+
**Launch strategy for cmux:**
338+
1. Try `cmux new-workspace --cwd <project> --command "claude --resume <id>"`
339+
2. If socket access denied: activate cmux + copy command to clipboard
340+
341+
### Ghostty Integration Details
342+
343+
Ghostty has full AppleScript support via `Ghostty.sdef`:
344+
345+
**AppleScript capabilities:**
346+
- `terminal.working directory` — per-terminal cwd (for switch matching)
347+
- `focus` — focus a specific terminal, bringing its window to front
348+
- `select tab` — select a tab in its window
349+
- `new tab` / `new window` — create with optional `surface configuration`
350+
- `surface configuration` — record type with `command`, `initial working directory`, `initial input`, `wait after command`, `environment variables`
351+
- `input text` — send text to a terminal as if pasted
352+
- `send key` — send keyboard events
353+
354+
**Switch:** Iterate all windows → tabs → terminals, match `working directory` to project path, call `focus`.
355+
356+
**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.
357+
358+
**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).
359+
360+
### Branch Name: Why Not `git branch --show-current`
361+
362+
`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.
302363

303364
## Phase Plan
304365

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"name": "CodeV",
33
"productName": "CodeV",
4-
"version": "1.0.34",
5-
"description": "My Electron application description",
4+
"version": "1.0.35",
5+
"description": "Quick switcher for VS Code, Cursor, and Claude Code sessions",
66
"main": ".webpack/main",
77
"scripts": {
88
"db": "prisma migrate dev",

src/claude-session-utility.ts

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface ClaudeSession {
1919
messageCount: number;
2020
isActive: boolean; // whether a claude process is running for this session
2121
activePid?: number;
22+
terminalApp?: string; // detected terminal: 'iterm2', 'cmux', 'ghostty', etc.
2223
}
2324

2425
interface HistoryLine {
@@ -167,6 +168,35 @@ export const searchClaudeSessions = (query: string, limit = 50): ClaudeSession[]
167168
* to find which project directory it's working in, then look up the latest
168169
* session for that project from history.jsonl
169170
*/
171+
/**
172+
* Detect which terminal app a process is running in by walking parent process tree.
173+
* Returns 'iterm2', 'cmux', 'ghostty', 'terminal', or 'unknown'.
174+
*/
175+
export const detectTerminalApp = async (pid: number): Promise<string> => {
176+
const { exec } = require('child_process');
177+
const execPromise = (cmd: string): Promise<string> =>
178+
new Promise((resolve) => {
179+
exec(cmd, { encoding: 'utf-8', timeout: 2000 }, (_e: any, out: string) => resolve(out || ''));
180+
});
181+
182+
let currentPid = pid;
183+
for (let i = 0; i < 20; i++) {
184+
const comm = (await execPromise(`ps -o comm= -p ${currentPid} 2>/dev/null`)).trim();
185+
if (!comm) break;
186+
187+
const commLower = comm.toLowerCase();
188+
if (commLower.includes('iterm') || commLower.includes('iterm2')) return 'iterm2';
189+
if (commLower.includes('cmux')) return 'cmux';
190+
if (commLower.includes('ghostty')) return 'ghostty';
191+
if (commLower.includes('terminal.app') || (commLower === 'terminal')) return 'terminal';
192+
193+
const ppid = parseInt((await execPromise(`ps -o ppid= -p ${currentPid} 2>/dev/null`)).trim(), 10);
194+
if (!ppid || ppid <= 1) break;
195+
currentPid = ppid;
196+
}
197+
return 'unknown';
198+
};
199+
170200
export const detectActiveSessions = async (): Promise<Map<string, number>> => {
171201
const now = Date.now();
172202
if (cachedActiveMap && (now - activeCacheTimestamp) < ACTIVE_CACHE_TTL_MS) {
@@ -228,6 +258,42 @@ export const detectActiveSessions = async (): Promise<Map<string, number>> => {
228258
return activeMap;
229259
};
230260

261+
/**
262+
* Open a Claude Code session in the configured terminal.
263+
*/
264+
export const openSession = async (
265+
sessionId: string,
266+
projectPath: string,
267+
isActive: boolean,
268+
activePid?: number,
269+
terminalApp: string = 'iterm2',
270+
terminalMode: string = 'tab',
271+
): Promise<void> => {
272+
let effectiveTerminal = terminalApp;
273+
274+
// For active sessions, auto-detect which terminal they're running in
275+
if (isActive && activePid) {
276+
const detected = await detectTerminalApp(activePid);
277+
if (detected !== 'unknown') {
278+
effectiveTerminal = detected;
279+
console.log(`[openSession] auto-detected terminal: ${detected} for pid ${activePid}`);
280+
}
281+
}
282+
283+
switch (effectiveTerminal) {
284+
case 'cmux':
285+
openSessionInCmux(sessionId, projectPath, isActive, activePid);
286+
break;
287+
case 'ghostty':
288+
openSessionInGhostty(sessionId, projectPath, isActive, terminalMode);
289+
break;
290+
case 'iterm2':
291+
default:
292+
openSessionInITerm2(sessionId, projectPath, isActive, activePid, terminalMode);
293+
break;
294+
}
295+
};
296+
231297
/**
232298
* Open a Claude Code session in iTerm2
233299
* If the session is already active, switch to its tab
@@ -425,6 +491,176 @@ export const loadLastAssistantResponses = async (
425491
return responses;
426492
};
427493

494+
/**
495+
* Open a Claude Code session in Ghostty.
496+
* Full AppleScript support: working directory matching, focus, new tab with command.
497+
*/
498+
export const openSessionInGhostty = (
499+
sessionId: string,
500+
projectPath: string,
501+
isActive: boolean,
502+
terminalMode: string = 'tab',
503+
): void => {
504+
const { exec } = require('child_process');
505+
506+
if (isActive) {
507+
// Switch to existing terminal by matching working directory
508+
const tmpScript = '/tmp/codev-ghostty-switch.scpt';
509+
const switchScript = `tell application "Ghostty"
510+
activate
511+
repeat with w in windows
512+
repeat with t in tabs of w
513+
repeat with term in terminals of t
514+
if working directory of term is "${projectPath}" then
515+
focus term
516+
return "found"
517+
end if
518+
end repeat
519+
end repeat
520+
end repeat
521+
return "not found"
522+
end tell`;
523+
fs.writeFileSync(tmpScript, switchScript);
524+
exec(`osascript ${tmpScript}`, { encoding: 'utf-8', timeout: 5000 }, (error: any, stdout: string) => {
525+
const result = (stdout || '').trim();
526+
console.log('[ghostty] switch result:', result);
527+
if (result !== 'found') {
528+
// Fallback: clipboard + activate
529+
copyResumeCommand(sessionId, projectPath);
530+
}
531+
try { fs.unlinkSync(tmpScript); } catch {}
532+
});
533+
} else {
534+
// Launch new tab with command via surface configuration
535+
// Use initial working directory for cd, and initialInput to type the resume command
536+
const tmpScript = '/tmp/codev-ghostty-launch.scpt';
537+
const resumeCmd = `claude --resume ${sessionId}`;
538+
const launchScript = terminalMode === 'window'
539+
? `tell application "Ghostty"
540+
activate
541+
set cfg to new surface configuration from {initial working directory:"${projectPath}", initial input:"${resumeCmd}\\n"}
542+
new window with configuration cfg
543+
end tell`
544+
: `tell application "Ghostty"
545+
activate
546+
set cfg to new surface configuration from {initial working directory:"${projectPath}", initial input:"${resumeCmd}\\n"}
547+
if (count windows) > 0 then
548+
new tab in front window with configuration cfg
549+
else
550+
new window with configuration cfg
551+
end if
552+
end tell`;
553+
fs.writeFileSync(tmpScript, launchScript);
554+
exec(`osascript ${tmpScript}`, { encoding: 'utf-8', timeout: 5000 }, (error: any) => {
555+
if (error) {
556+
console.error('[ghostty] launch error:', error.message);
557+
// Fallback: clipboard
558+
copyResumeCommand(sessionId, projectPath);
559+
}
560+
try { fs.unlinkSync(tmpScript); } catch {}
561+
});
562+
}
563+
};
564+
565+
/**
566+
* Open a Claude Code session in cmux.
567+
* Requires cmux socket mode set to 'automation' or 'allowAll'.
568+
* Falls back to clipboard if socket access denied.
569+
*/
570+
const CMUX_CLI = '/Applications/cmux.app/Contents/Resources/bin/cmux';
571+
572+
export const openSessionInCmux = (
573+
sessionId: string,
574+
projectPath: string,
575+
isActive: boolean,
576+
activePid?: number,
577+
): void => {
578+
const { exec } = require('child_process');
579+
const command = `cd "${projectPath}" && claude --resume ${sessionId}`;
580+
581+
console.log('[cmux] openSession:', { sessionId, projectPath, isActive, activePid });
582+
if (isActive) {
583+
// NOTE: cmux has AppleScript dictionary with terminal.workingDirectory and focus,
584+
// but testing shows count windows returns 0 — AppleScript interface may be buggy.
585+
// Using CLI (sidebar-state + tree) approach instead.
586+
const execPromise = (cmd: string): Promise<string> =>
587+
new Promise((resolve) => {
588+
exec(cmd, { encoding: 'utf-8', timeout: 3000, maxBuffer: 1024 * 1024 }, (_e: any, out: string) => resolve(out || ''));
589+
});
590+
591+
(async () => {
592+
const wsListOutput = await execPromise(`${CMUX_CLI} list-workspaces 2>/dev/null`);
593+
if (!wsListOutput) {
594+
copyResumeCommand(sessionId, projectPath);
595+
exec('osascript -e \'tell application "cmux" to activate\'');
596+
return;
597+
}
598+
599+
const wsIds = wsListOutput.match(/workspace:\d+/g) || [];
600+
601+
// Pass 1: parallel sidebar-state cwd match
602+
const cwdResults = await Promise.all(wsIds.map(async (wsId: string) => {
603+
const state = await execPromise(`${CMUX_CLI} sidebar-state --workspace ${wsId} 2>/dev/null`);
604+
const cwdMatch = state.match(/^cwd=(.+)$/m);
605+
const focusedCwdMatch = state.match(/^focused_cwd=(.+)$/m);
606+
return { wsId, cwd: cwdMatch?.[1], focusedCwd: focusedCwdMatch?.[1] };
607+
}));
608+
609+
const cwdHit = cwdResults.find((r: any) => r.cwd === projectPath || r.focusedCwd === projectPath);
610+
if (cwdHit) {
611+
console.log('[cmux] matched workspace by cwd:', cwdHit.wsId);
612+
exec(`${CMUX_CLI} select-workspace --workspace ${cwdHit.wsId}`);
613+
exec('osascript -e \'tell application "cmux" to activate\'');
614+
return;
615+
}
616+
617+
// Pass 2: tree surface title match
618+
const projectName = path.basename(projectPath);
619+
if (projectName && projectName !== path.basename(os.homedir())) {
620+
const treeOutput = await execPromise(`${CMUX_CLI} tree --all 2>/dev/null`);
621+
const treeLines = treeOutput.split('\n');
622+
let currentWorkspace: string | null = null;
623+
for (const line of treeLines) {
624+
const wsMatch = line.match(/workspace (workspace:\d+)/);
625+
if (wsMatch) currentWorkspace = wsMatch[1];
626+
const surfaceMatch = line.match(/surface (surface:\d+)/);
627+
if (surfaceMatch && currentWorkspace && line.toLowerCase().includes(projectName.toLowerCase())) {
628+
console.log('[cmux] matched by tree surface title:', currentWorkspace);
629+
exec(`${CMUX_CLI} select-workspace --workspace ${currentWorkspace}`);
630+
exec('osascript -e \'tell application "cmux" to activate\'');
631+
return;
632+
}
633+
}
634+
}
635+
636+
console.log('[cmux] no match found, activating cmux');
637+
exec('osascript -e \'tell application "cmux" to activate\'');
638+
})();
639+
} else {
640+
// Launch new workspace with command
641+
const cmuxCmd = `${CMUX_CLI} new-workspace --cwd "${projectPath}" --command "claude --resume ${sessionId}"`;
642+
console.log('[cmux] launch cmd:', cmuxCmd);
643+
exec(cmuxCmd,
644+
{ encoding: 'utf-8', timeout: 5000 },
645+
(error: any, stdout: string, stderr: string) => {
646+
console.log('[cmux] launch result:', { error: error?.message, stdout, stderr });
647+
if (error) {
648+
console.error('cmux new-workspace failed, falling back to clipboard:', error.message);
649+
copyResumeCommand(sessionId, projectPath);
650+
exec('osascript -e \'tell application "cmux" to activate\'');
651+
} else {
652+
// Select the newly created workspace and activate cmux
653+
const wsMatch = stdout.match(/workspace:\d+/);
654+
if (wsMatch) {
655+
exec(`${CMUX_CLI} select-workspace --workspace ${wsMatch[0]}`);
656+
}
657+
exec('osascript -e \'tell application "cmux" to activate\'');
658+
}
659+
}
660+
);
661+
}
662+
};
663+
428664
/**
429665
* Copy resume command to clipboard (fallback for unsupported terminals)
430666
*/

0 commit comments

Comments
 (0)