Files
helder/src/main/pty-service.ts

100 lines
3.6 KiB
TypeScript

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<number, import('node-pty').IPty>()
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()
}