terminalPanel.ts
utils/terminalPanel.ts
No strong subsystem tag
192
Lines
6014
Bytes
2
Exports
6
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 192 lines, 6 detected imports, and 2 detected exports.
Important relationships
Detected exports
getTerminalPanelSocketgetTerminalPanel
Keywords
tmuxshellspawnsyncinstanceprivateterminalpanelsessionsocketresult
Detected imports
child_process../bootstrap/state.js../ink/instances.js./cleanupRegistry.js./cwd.js./debug.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
/**
* 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,
})
}
}