init
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user