diff --git a/docs/claude-session-integration-design.md b/docs/claude-session-integration-design.md index 9066379..8305bf2 100644 --- a/docs/claude-session-integration-design.md +++ b/docs/claude-session-integration-design.md @@ -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 --command "claude --resume "` — create new workspace with command +- `cmux select-workspace --workspace ` — 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 --command "claude --resume "` +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 \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 diff --git a/package.json b/package.json index ce96480..cff9a0e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index 2079c7f..cc47fca 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -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 { @@ -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 => { + const { exec } = require('child_process'); + const execPromise = (cmd: string): Promise => + 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> => { const now = Date.now(); if (cachedActiveMap && (now - activeCacheTimestamp) < ACTIVE_CACHE_TTL_MS) { @@ -228,6 +258,42 @@ export const detectActiveSessions = async (): Promise> => { 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 => { + 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 @@ -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 => + 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) */ diff --git a/src/main.ts b/src/main.ts index 4d0a1da..f8fb51e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,7 +16,8 @@ import { readClaudeSessions, searchClaudeSessions, detectActiveSessions, - openSessionInITerm2, + detectTerminalApp, + openSession, copyResumeCommand, invalidateSessionCache, loadSessionEnrichment, @@ -1416,6 +1417,14 @@ ipcMain.on('set-login-item-settings', (_event, openAtLogin: boolean) => { app.setLoginItemSettings({ openAtLogin }); }); +ipcMain.handle('get-session-terminal-app', async () => { + return (await settings.get('session-terminal-app')) || 'iterm2'; +}); + +ipcMain.on('set-session-terminal-app', async (_event, app: string) => { + await settings.set('session-terminal-app', app); +}); + ipcMain.handle('get-session-terminal-mode', async () => { return (await settings.get('session-terminal-mode')) || 'tab'; }); @@ -1454,9 +1463,18 @@ ipcMain.handle('detect-active-sessions', async () => { return Object.fromEntries(activeMap); }); +ipcMain.handle('detect-terminal-apps', async (_event, pidMap: Record) => { + const results: Record = {}; + await Promise.all(Object.entries(pidMap).map(async ([sessionId, pid]) => { + results[sessionId] = await detectTerminalApp(pid); + })); + return results; +}); + ipcMain.on('open-claude-session', async (_event, sessionId: string, projectPath: string, isActive: boolean, activePid?: number) => { + const terminalApp = ((await settings.get('session-terminal-app')) || 'iterm2') as string; const terminalMode = ((await settings.get('session-terminal-mode')) || 'tab') as string; - openSessionInITerm2(sessionId, projectPath, isActive, activePid, terminalMode); + openSession(sessionId, projectPath, isActive, activePid, terminalApp, terminalMode); }); ipcMain.on('copy-claude-session-command', (_event, sessionId: string, projectPath: string) => { diff --git a/src/popup.tsx b/src/popup.tsx index 94e44ff..c49648f 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -78,14 +78,17 @@ const PopupDefaultExample = ({ workingFolderPath, saveCallback, openCallback, + switcherMode, }: { workingFolderPath?: string; saveCallback?: (key: string, value: string) => void; openCallback?: any; + switcherMode?: string; }) => { const [isOpen, setIsOpen] = useState(false); const [launchAtLogin, setLaunchAtLogin] = useState(false); const [appVersion, setAppVersion] = useState(''); + const [sessionTerminalApp, setSessionTerminalApp] = useState('iterm2'); const [sessionTerminalMode, setSessionTerminalMode] = useState('tab'); const [sessionDisplayMode, setSessionDisplayMode] = useState('first'); const [defaultSwitcherMode, setDefaultSwitcherMode] = useState('projects'); @@ -94,6 +97,9 @@ const PopupDefaultExample = ({ (window as any).electronAPI.getAppVersion().then((version: string) => { setAppVersion(version); }); + (window as any).electronAPI.getSessionTerminalApp().then((app: string) => { + setSessionTerminalApp(app || 'iterm2'); + }); (window as any).electronAPI.getSessionTerminalMode().then((mode: string) => { setSessionTerminalMode(mode || 'tab'); }); @@ -289,6 +295,7 @@ const PopupDefaultExample = ({ + {switcherMode === 'sessions' && (<> {/* Default Tab */}
- {/* Session Terminal Mode */} + {/* Session Terminal App */} +
+
+ Launch Terminal +
+ +
+ + {/* Session Terminal Mode (for iTerm2 and Ghostty) */} + {(sessionTerminalApp === 'iterm2' || sessionTerminalApp === 'ghostty') && (
- Session Terminal + Launch Mode
+ )} {/* Session Display Mode */}
+ )} + {/* App Info and Quit Section */}
ipcRenderer.send('ide-preference-changed', preferredIDE), getAppVersion: () => ipcRenderer.invoke('get-app-version'), + getSessionTerminalApp: () => ipcRenderer.invoke('get-session-terminal-app'), + setSessionTerminalApp: (app: string) => ipcRenderer.send('set-session-terminal-app', app), getSessionTerminalMode: () => ipcRenderer.invoke('get-session-terminal-mode'), setSessionTerminalMode: (mode: string) => ipcRenderer.send('set-session-terminal-mode', mode), getSessionDisplayMode: () => ipcRenderer.invoke('get-session-display-mode'), @@ -35,6 +37,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getClaudeSessions: (limit?: number) => ipcRenderer.invoke('get-claude-sessions', limit), searchClaudeSessions: (query: string) => ipcRenderer.invoke('search-claude-sessions', query), detectActiveSessions: () => ipcRenderer.invoke('detect-active-sessions'), + detectTerminalApps: (pidMap: Record) => ipcRenderer.invoke('detect-terminal-apps', pidMap), openClaudeSession: (sessionId: string, projectPath: string, isActive: boolean, activePid?: number) => ipcRenderer.send('open-claude-session', sessionId, projectPath, isActive, activePid), copyClaudeSessionCommand: (sessionId: string, projectPath: string) => diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 16d024a..e14f339 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -325,6 +325,7 @@ function SwitcherApp() { const [customTitles, setCustomTitles] = useState>({}); const [branches, setBranches] = useState>({}); const [assistantResponses, setAssistantResponses] = useState>({}); + const [terminalApps, setTerminalApps] = useState>({}); const modeRef = useRef('projects'); const activeStateRef = useRef>({}); @@ -387,6 +388,15 @@ function SwitcherApp() { } }); } + + // Step 2c: Detect terminal apps for active sessions + if (Object.keys(activeMap).length > 0) { + (window as any).electronAPI.detectTerminalApps(activeMap).then((apps: Record) => { + if (apps && Object.keys(apps).length > 0) { + setTerminalApps((prev: Record) => ({ ...prev, ...apps })); + } + }); + } } }); @@ -688,6 +698,7 @@ function SwitcherApp() {
{ if (key === 'sessionDisplayMode') { setSessionDisplayMode(value); @@ -850,6 +861,18 @@ function SwitcherApp() { )}
+ {session.isActive && terminalApps[session.sessionId] && terminalApps[session.sessionId] !== 'unknown' && ( + + {terminalApps[session.sessionId]} + + )} {session.messageCount} msgs