100 lines
3.6 KiB
TypeScript
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()
|
|
}
|