import { createRequire } from 'node:module' import type { WebContents } from 'electron' import { getRoot } from './project' import { getConfig } from './config' /** * Real PTYs. The agent pane is a shell that auto-launches the `claude` CLI; the * bottom pane is a plain shell. node-pty is a native module — loaded defensively * so the app still launches (with a friendly message) if it wasn't rebuilt for * this Electron via `npm run rebuild`. * * Shell + ai command/autoLaunch come from `.helder/config.json` (terminal.shell, * ai.command, ai.autoLaunch) via the config module. */ const require = createRequire(import.meta.url) type PtyModule = typeof import('node-pty') let pty: PtyModule | null = null try { pty = require('node-pty') as PtyModule } catch (e) { console.error('[helder] node-pty unavailable — run `npm run rebuild`:', (e as Error).message) } const terms = new Map() let seq = 0 /** * Env for spawned PTYs. Electron launched from a Homebrew/GUI context leaks * `npm_config_prefix` (e.g. "/opt/homebrew") into the child shell, which makes * nvm refuse to load ("nvm is not compatible with the npm_config_prefix * environment variable"). Strip it so the user's shell init runs cleanly. */ function ptyEnv(): { [key: string]: string } { const env = { ...process.env } as { [key: string]: string } delete env.npm_config_prefix delete env.npm_config_globalconfig return env } function defaultShell(): string { const configured = getConfig().terminal.shell if (configured) return configured if (process.platform === 'win32') return process.env.COMSPEC || 'powershell.exe' return process.env.SHELL || '/bin/zsh' } export function ptyAvailable(): boolean { return !!pty } export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number { if (!pty) return -1 const cwd = getRoot() || process.env.HOME || process.cwd() const shell = defaultShell() const ai = getConfig().ai const launchAgent = kind === 'agent' && ai.autoLaunch && process.platform !== 'win32' // For the agent pane we exec the `claude` CLI directly as the shell's command // (`zsh -i -c 'claude'`) instead of typing it into an interactive prompt — `-i` // still sources the user's rc (nvm etc.), but there's no prompt line and no // echoed command cluttering the pane; claude takes over a clean terminal. const args = launchAgent ? ['-i', '-c', ai.command] : [] const proc = pty.spawn(shell, args, { name: 'xterm-color', cols: cols || 80, rows: rows || 24, cwd, env: ptyEnv(), }) const id = ++seq terms.set(id, proc) proc.onData((data) => { if (!sender.isDestroyed()) sender.send('pty:data', { id, data }) }) proc.onExit(() => { terms.delete(id); if (!sender.isDestroyed()) sender.send('pty:exit', { id }) }) // Windows path keeps the type-into-shell launch (no `-i -c` semantics there). if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') { setTimeout(() => { try { proc.write(ai.command + '\r') } catch { /* exited */ } }, 350) } return id } export function writePty(id: number, data: string): void { terms.get(id)?.write(data) } export function resizePty(id: number, cols: number, rows: number): void { try { terms.get(id)?.resize(cols, rows) } catch { /* race with exit */ } } export function killPty(id: number): void { const p = terms.get(id) if (p) { try { p.kill() } catch { /* already gone */ } terms.delete(id) } } export function killAllPtys(): void { for (const p of terms.values()) { try { p.kill() } catch { /* noop */ } } terms.clear() }