/ utils / terminalPanel.ts
terminalPanel.ts
  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  
 18  import { spawn, spawnSync } from 'child_process'
 19  import { getSessionId } from '../bootstrap/state.js'
 20  import instances from '../ink/instances.js'
 21  import { registerCleanup } from './cleanupRegistry.js'
 22  import { pwd } from './cwd.js'
 23  import { logForDebugging } from './debug.js'
 24  
 25  const 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   */
 32  export 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  
 38  let instance: TerminalPanel | undefined
 39  
 40  /**
 41   * Return the singleton TerminalPanel, creating it lazily on first use.
 42   */
 43  export function getTerminalPanel(): TerminalPanel {
 44    if (!instance) {
 45      instance = new TerminalPanel()
 46    }
 47    return instance
 48  }
 49  
 50  class 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  }