source dump of claude code
at main 191 lines 6.0 kB view raw
1/** 2 * Built-in terminal panel toggled with Meta+J. 3 * 4 * Uses tmux for shell persistence: a separate tmux server with a per-instance 5 * socket (e.g., "claude-panel-a1b2c3d4") holds the shell session. Each Claude 6 * Code instance gets its own isolated terminal panel that persists within the 7 * session but is destroyed when the instance exits. 8 * 9 * Meta+J is bound to detach-client inside tmux, so pressing it returns to 10 * Claude Code while the shell keeps running. Next toggle re-attaches to the 11 * same session. 12 * 13 * When tmux is not available, falls back to a non-persistent shell via spawnSync. 14 * 15 * Uses the same suspend-Ink pattern as the external editor (promptEditor.ts). 16 */ 17 18import { spawn, spawnSync } from 'child_process' 19import { getSessionId } from '../bootstrap/state.js' 20import instances from '../ink/instances.js' 21import { registerCleanup } from './cleanupRegistry.js' 22import { pwd } from './cwd.js' 23import { logForDebugging } from './debug.js' 24 25const TMUX_SESSION = 'panel' 26 27/** 28 * Get the tmux socket name for the terminal panel. 29 * Uses a unique socket per Claude Code instance (based on session ID) 30 * so that each instance has its own isolated terminal panel. 31 */ 32export function getTerminalPanelSocket(): string { 33 // Use first 8 chars of session UUID for uniqueness while keeping name short 34 const sessionId = getSessionId() 35 return `claude-panel-${sessionId.slice(0, 8)}` 36} 37 38let instance: TerminalPanel | undefined 39 40/** 41 * Return the singleton TerminalPanel, creating it lazily on first use. 42 */ 43export function getTerminalPanel(): TerminalPanel { 44 if (!instance) { 45 instance = new TerminalPanel() 46 } 47 return instance 48} 49 50class TerminalPanel { 51 private hasTmux: boolean | undefined 52 private cleanupRegistered = false 53 54 // ── public API ──────────────────────────────────────────────────── 55 56 toggle(): void { 57 this.showShell() 58 } 59 60 // ── tmux helpers ────────────────────────────────────────────────── 61 62 private checkTmux(): boolean { 63 if (this.hasTmux !== undefined) return this.hasTmux 64 const result = spawnSync('tmux', ['-V'], { encoding: 'utf-8' }) 65 this.hasTmux = result.status === 0 66 if (!this.hasTmux) { 67 logForDebugging( 68 'Terminal panel: tmux not found, falling back to non-persistent shell', 69 ) 70 } 71 return this.hasTmux 72 } 73 74 private hasSession(): boolean { 75 const result = spawnSync( 76 'tmux', 77 ['-L', getTerminalPanelSocket(), 'has-session', '-t', TMUX_SESSION], 78 { encoding: 'utf-8' }, 79 ) 80 return result.status === 0 81 } 82 83 private createSession(): boolean { 84 const shell = process.env.SHELL || '/bin/bash' 85 const cwd = pwd() 86 const socket = getTerminalPanelSocket() 87 88 const result = spawnSync( 89 'tmux', 90 [ 91 '-L', 92 socket, 93 'new-session', 94 '-d', 95 '-s', 96 TMUX_SESSION, 97 '-c', 98 cwd, 99 shell, 100 '-l', 101 ], 102 { encoding: 'utf-8' }, 103 ) 104 105 if (result.status !== 0) { 106 logForDebugging( 107 `Terminal panel: failed to create tmux session: ${result.stderr}`, 108 ) 109 return false 110 } 111 112 // Bind Meta+J (toggles back to Claude Code from inside the terminal) 113 // and configure the status bar hint. Chained with ';' to collapse 114 // 5 spawnSync calls into 1. 115 // biome-ignore format: one tmux command per line 116 spawnSync('tmux', [ 117 '-L', socket, 118 'bind-key', '-n', 'M-j', 'detach-client', ';', 119 'set-option', '-g', 'status-style', 'bg=default', ';', 120 'set-option', '-g', 'status-left', '', ';', 121 'set-option', '-g', 'status-right', ' Alt+J to return to Claude ', ';', 122 'set-option', '-g', 'status-right-style', 'fg=brightblack', 123 ]) 124 125 if (!this.cleanupRegistered) { 126 this.cleanupRegistered = true 127 registerCleanup(async () => { 128 // Detached async spawn — spawnSync here would block the event loop 129 // and serialize the entire cleanup Promise.all in gracefulShutdown. 130 // .on('error') swallows ENOENT if tmux disappears between session 131 // creation and cleanup — prevents spurious uncaughtException noise. 132 spawn('tmux', ['-L', socket, 'kill-server'], { 133 detached: true, 134 stdio: 'ignore', 135 }) 136 .on('error', () => {}) 137 .unref() 138 }) 139 } 140 141 return true 142 } 143 144 private attachSession(): void { 145 spawnSync( 146 'tmux', 147 ['-L', getTerminalPanelSocket(), 'attach-session', '-t', TMUX_SESSION], 148 { stdio: 'inherit' }, 149 ) 150 } 151 152 // ── show shell ──────────────────────────────────────────────────── 153 154 private showShell(): void { 155 const inkInstance = instances.get(process.stdout) 156 if (!inkInstance) { 157 logForDebugging('Terminal panel: no Ink instance found, aborting') 158 return 159 } 160 161 inkInstance.enterAlternateScreen() 162 try { 163 if (this.checkTmux() && this.ensureSession()) { 164 this.attachSession() 165 } else { 166 this.runShellDirect() 167 } 168 } finally { 169 inkInstance.exitAlternateScreen() 170 } 171 } 172 173 // ── helpers ─────────────────────────────────────────────────────── 174 175 /** Ensure a tmux session exists, creating one if needed. */ 176 private ensureSession(): boolean { 177 if (this.hasSession()) return true 178 return this.createSession() 179 } 180 181 /** Fallback when tmux is not available — runs a non-persistent shell. */ 182 private runShellDirect(): void { 183 const shell = process.env.SHELL || '/bin/bash' 184 const cwd = pwd() 185 spawnSync(shell, ['-i', '-l'], { 186 stdio: 'inherit', 187 cwd, 188 env: process.env, 189 }) 190 } 191}