π File detail
utils/terminalPanel.ts
π― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes getTerminalPanelSocket and getTerminalPanel β mainly functions, hooks, or classes. Dependencies touch subprocess spawning. It composes internal code from bootstrap, ink, cleanupRegistry, cwd, and debug (relative imports). What the file header says: Built-in terminal panel toggled with Meta+J. Uses tmux for shell persistence: a separate tmux server with a per-instance socket (e.g., "claude-panel-a1b2c3d4") holds the shell session. Each Claude Code instance gets its own isolated terminal panel that persists within the session.
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
Built-in terminal panel toggled with Meta+J. Uses tmux for shell persistence: a separate tmux server with a per-instance socket (e.g., "claude-panel-a1b2c3d4") holds the shell session. Each Claude Code instance gets its own isolated terminal panel that persists within the session but is destroyed when the instance exits. Meta+J is bound to detach-client inside tmux, so pressing it returns to Claude Code while the shell keeps running. Next toggle re-attaches to the same session. When tmux is not available, falls back to a non-persistent shell via spawnSync. Uses the same suspend-Ink pattern as the external editor (promptEditor.ts).
π€ Exports (heuristic)
getTerminalPanelSocketgetTerminalPanel
π External import roots
Package roots from from "β¦" (relative paths omitted).
child_process
π₯οΈ Source preview
/**
* Built-in terminal panel toggled with Meta+J.
*
* Uses tmux for shell persistence: a separate tmux server with a per-instance
* socket (e.g., "claude-panel-a1b2c3d4") holds the shell session. Each Claude
* Code instance gets its own isolated terminal panel that persists within the
* session but is destroyed when the instance exits.
*
* Meta+J is bound to detach-client inside tmux, so pressing it returns to
* Claude Code while the shell keeps running. Next toggle re-attaches to the
* same session.
*
* When tmux is not available, falls back to a non-persistent shell via spawnSync.
*
* Uses the same suspend-Ink pattern as the external editor (promptEditor.ts).
*/
import { spawn, spawnSync } from 'child_process'
import { getSessionId } from '../bootstrap/state.js'
import instances from '../ink/instances.js'
import { registerCleanup } from './cleanupRegistry.js'
import { pwd } from './cwd.js'
import { logForDebugging } from './debug.js'
const TMUX_SESSION = 'panel'
/**
* Get the tmux socket name for the terminal panel.
* Uses a unique socket per Claude Code instance (based on session ID)
* so that each instance has its own isolated terminal panel.
*/
export function getTerminalPanelSocket(): string {
// Use first 8 chars of session UUID for uniqueness while keeping name short
const sessionId = getSessionId()
return `claude-panel-${sessionId.slice(0, 8)}`
}
let instance: TerminalPanel | undefined
/**
* Return the singleton TerminalPanel, creating it lazily on first use.
*/
export function getTerminalPanel(): TerminalPanel {
if (!instance) {
instance = new TerminalPanel()
}
return instance
}
class TerminalPanel {
private hasTmux: boolean | undefined
private cleanupRegistered = false
// ββ public API ββββββββββββββββββββββββββββββββββββββββββββββββββββ
toggle(): void {
this.showShell()
}
// ββ tmux helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
private checkTmux(): boolean {
if (this.hasTmux !== undefined) return this.hasTmux
const result = spawnSync('tmux', ['-V'], { encoding: 'utf-8' })
this.hasTmux = result.status === 0
if (!this.hasTmux) {
logForDebugging(
'Terminal panel: tmux not found, falling back to non-persistent shell',
)
}
return this.hasTmux
}
private hasSession(): boolean {
const result = spawnSync(
'tmux',
['-L', getTerminalPanelSocket(), 'has-session', '-t', TMUX_SESSION],
{ encoding: 'utf-8' },
)
return result.status === 0
}
private createSession(): boolean {
const shell = process.env.SHELL || '/bin/bash'
const cwd = pwd()
const socket = getTerminalPanelSocket()
const result = spawnSync(
'tmux',
[
'-L',
socket,
'new-session',
'-d',
'-s',
TMUX_SESSION,
'-c',
cwd,
shell,
'-l',
],
{ encoding: 'utf-8' },
)
if (result.status !== 0) {
logForDebugging(
`Terminal panel: failed to create tmux session: ${result.stderr}`,
)
return false
}
// Bind Meta+J (toggles back to Claude Code from inside the terminal)
// and configure the status bar hint. Chained with ';' to collapse
// 5 spawnSync calls into 1.
// biome-ignore format: one tmux command per line
spawnSync('tmux', [
'-L', socket,
'bind-key', '-n', 'M-j', 'detach-client', ';',
'set-option', '-g', 'status-style', 'bg=default', ';',
'set-option', '-g', 'status-left', '', ';',
'set-option', '-g', 'status-right', ' Alt+J to return to Claude ', ';',
'set-option', '-g', 'status-right-style', 'fg=brightblack',
])
if (!this.cleanupRegistered) {
this.cleanupRegistered = true
registerCleanup(async () => {
// Detached async spawn β spawnSync here would block the event loop
// and serialize the entire cleanup Promise.all in gracefulShutdown.
// .on('error') swallows ENOENT if tmux disappears between session
// creation and cleanup β prevents spurious uncaughtException noise.
spawn('tmux', ['-L', socket, 'kill-server'], {
detached: true,
stdio: 'ignore',
})
.on('error', () => {})
.unref()
})
}
return true
}
private attachSession(): void {
spawnSync(
'tmux',
['-L', getTerminalPanelSocket(), 'attach-session', '-t', TMUX_SESSION],
{ stdio: 'inherit' },
)
}
// ββ show shell ββββββββββββββββββββββββββββββββββββββββββββββββββββ
private showShell(): void {
const inkInstance = instances.get(process.stdout)
if (!inkInstance) {
logForDebugging('Terminal panel: no Ink instance found, aborting')
return
}
inkInstance.enterAlternateScreen()
try {
if (this.checkTmux() && this.ensureSession()) {
this.attachSession()
} else {
this.runShellDirect()
}
} finally {
inkInstance.exitAlternateScreen()
}
}
// ββ helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/** Ensure a tmux session exists, creating one if needed. */
private ensureSession(): boolean {
if (this.hasSession()) return true
return this.createSession()
}
/** Fallback when tmux is not available β runs a non-persistent shell. */
private runShellDirect(): void {
const shell = process.env.SHELL || '/bin/bash'
const cwd = pwd()
spawnSync(shell, ['-i', '-l'], {
stdio: 'inherit',
cwd,
env: process.env,
})
}
}