diff --git a/apps/desktop/electron/external-terminal.test.ts b/apps/desktop/electron/external-terminal.test.ts new file mode 100644 index 0000000000..a986d6103f --- /dev/null +++ b/apps/desktop/electron/external-terminal.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + buildTerminalScript, + posixQuote, + resolveTerminalLaunch, + terminalScriptEnv, + terminalScriptExtension, + tuiResumeArgs, + windowsQuote +} from './external-terminal' + +const never = () => null +const always = (command: string) => `/usr/bin/${command}` + +test('tuiResumeArgs resumes the session in the TUI', () => { + assert.deepEqual(tuiResumeArgs('20260814_101010_abc123'), ['--tui', '--resume', '20260814_101010_abc123']) +}) + +test('tuiResumeArgs pins the profile ahead of the mode flag', () => { + assert.deepEqual(tuiResumeArgs('sess', 'work'), ['--profile', 'work', '--tui', '--resume', 'sess']) +}) + +test('posixQuote survives embedded single quotes', () => { + assert.equal(posixQuote("/tmp/o'brien"), `'/tmp/o'\\''brien'`) +}) + +test('windowsQuote doubles embedded quotes', () => { + assert.equal(windowsQuote('C:\\a "b"'), '"C:\\a ""b"""') +}) + +test('terminalScriptEnv drops PATH in any casing and keeps the rest', () => { + const env = terminalScriptEnv({ Path: 'C:\\junk', PATH: '/junk', PYTHONPATH: '/repo', PYTHONUTF8: '1' }, '/home/b/.hermes') + + assert.deepEqual(env, { PYTHONPATH: '/repo', PYTHONUTF8: '1', HERMES_HOME: '/home/b/.hermes' }) +}) + +test('terminalScriptEnv skips empty values and an absent home', () => { + assert.deepEqual(terminalScriptEnv({ PYTHONPATH: '' }), {}) +}) + +test('buildTerminalScript execs the resolved runtime with its env', () => { + const script = buildTerminalScript({ + args: ['-m', 'hermes_cli.main', '--tui', '--resume', 'sess'], + command: '/home/b/.hermes/hermes-agent/venv/bin/python', + cwd: "/home/b/o'brien", + env: { PYTHONPATH: '/home/b/.hermes/hermes-agent' }, + platform: 'darwin' + }) + + assert.equal( + script, + [ + '#!/bin/sh', + `cd '/home/b/o'\\''brien' || exit 1`, + `export PYTHONPATH='/home/b/.hermes/hermes-agent'`, + `exec '/home/b/.hermes/hermes-agent/venv/bin/python' '-m' 'hermes_cli.main' '--tui' '--resume' 'sess'`, + '' + ].join('\n') + ) +}) + +test('buildTerminalScript emits a cmd script on Windows', () => { + const script = buildTerminalScript({ + args: ['--tui', '--resume', 'sess'], + command: 'C:\\hermes\\venv\\Scripts\\hermes.exe', + cwd: 'C:\\Users\\b', + env: { PYTHONUTF8: '1' }, + platform: 'win32' + }) + + assert.deepEqual(script.split('\r\n'), [ + '@echo off', + 'cd /d "C:\\Users\\b"', + 'set "PYTHONUTF8=1"', + '"C:\\hermes\\venv\\Scripts\\hermes.exe" "--tui" "--resume" "sess"', + '' + ]) +}) + +test('terminalScriptExtension matches what the platform binds to a terminal', () => { + assert.equal(terminalScriptExtension('darwin'), '.command') + assert.equal(terminalScriptExtension('win32'), '.cmd') + assert.equal(terminalScriptExtension('linux'), '.sh') +}) + +test('macOS opens the script with no -a so LaunchServices picks the user handler', () => { + assert.deepEqual(resolveTerminalLaunch({ findOnPath: never, platform: 'darwin', scriptPath: '/tmp/x.command' }), { + command: 'open', + args: ['/tmp/x.command'] + }) +}) + +test('Windows prefers Windows Terminal and falls back to a cmd console', () => { + assert.deepEqual( + resolveTerminalLaunch({ + findOnPath: command => (command === 'wt.exe' ? 'C:\\wt.exe' : null), + platform: 'win32', + scriptPath: 'C:\\x.cmd' + }), + { command: 'C:\\wt.exe', args: ['cmd.exe', '/k', 'C:\\x.cmd'] } + ) + + assert.deepEqual(resolveTerminalLaunch({ findOnPath: never, platform: 'win32', scriptPath: 'C:\\x.cmd' }), { + command: 'cmd.exe', + args: ['/c', 'start', '', 'cmd.exe', '/k', 'C:\\x.cmd'] + }) +}) + +test("Linux leads with the user's x-terminal-emulator alternative", () => { + assert.deepEqual(resolveTerminalLaunch({ findOnPath: always, platform: 'linux', scriptPath: '/tmp/x.sh' }), { + command: '/usr/bin/x-terminal-emulator', + args: ['-e', '/bin/sh', '/tmp/x.sh'] + }) +}) + +test('Linux falls down the emulator ladder and omits a flagless terminal', () => { + const onlyKitty = (command: string) => (command === 'kitty' ? '/usr/bin/kitty' : null) + + assert.deepEqual(resolveTerminalLaunch({ findOnPath: onlyKitty, platform: 'linux', scriptPath: '/tmp/x.sh' }), { + command: '/usr/bin/kitty', + args: ['/bin/sh', '/tmp/x.sh'] + }) +}) + +test('Linux with no emulator installed reports no launch', () => { + assert.equal(resolveTerminalLaunch({ findOnPath: never, platform: 'linux', scriptPath: '/tmp/x.sh' }), null) +}) diff --git a/apps/desktop/electron/external-terminal.ts b/apps/desktop/electron/external-terminal.ts new file mode 100644 index 0000000000..07cfe53a17 --- /dev/null +++ b/apps/desktop/electron/external-terminal.ts @@ -0,0 +1,173 @@ +// Launching the Hermes TUI in the user's OWN terminal emulator. +// +// This is deliberately NOT the in-app terminal pane: the point of the verb is +// to hand a session to the terminal the user already lives in, running +// `hermes --tui --resume ` there. Two problems have to be solved for that +// to work anywhere: +// +// 1. WHAT to run. The desktop's Hermes runtime is often a venv Python invoked +// as `python -m hermes_cli.main`, not a `hermes` on PATH — so the command +// and its PYTHONPATH have to be carried over verbatim. We write them into a +// small launcher script instead of trying to quote a nested command through +// a terminal emulator's `-e` argument, which every emulator parses +// differently. +// 2. WHERE to run it. There is no portable "default terminal" API, so each +// platform gets its own resolution: +// - macOS: `open` the `.command` script with NO `-a`, letting +// LaunchServices route it to whichever app the user has bound to shell +// scripts (Terminal.app by default, iTerm2/Ghostty/WezTerm when they've +// claimed it). That is the closest thing macOS has to "their terminal". +// - Linux: an ordered ladder of emulators, led by Debian's +// `x-terminal-emulator` alternative — which IS the user's configured +// choice — before falling back to the common concrete emulators. +// - Windows: Windows Terminal when installed, else a `cmd.exe` console. +// +// Everything here is pure so it can be unit-tested without Electron; the side +// effects (writing the script, spawning) live in main.ts. + +/** Argv for resuming a session in the TUI, profile-pinned when we know it. */ +export function tuiResumeArgs(sessionId: string, profile?: string): string[] { + const head = profile ? ['--profile', profile] : [] + + return [...head, '--tui', '--resume', sessionId] +} + +/** Single-quote a value for /bin/sh (the POSIX launcher script). */ +export function posixQuote(value: string): string { + return `'${String(value ?? '').replaceAll("'", `'\\''`)}'` +} + +/** Quote a value for a cmd.exe script line. */ +export function windowsQuote(value: string): string { + return `"${String(value ?? '').replaceAll('"', '""')}"` +} + +/** + * The environment the launcher script exports. + * + * PATH is deliberately dropped: the script runs inside a login shell that + * already has the user's own PATH, and the desktop's PATH (assembled for a + * headless child) is the wrong answer for an interactive terminal. The Hermes + * command is invoked by absolute path, so nothing here depends on PATH. + */ +export function terminalScriptEnv( + backendEnv: Record = {}, + hermesHome?: string +): Record { + const out: Record = {} + + for (const [key, value] of Object.entries(backendEnv)) { + if (key.toUpperCase() === 'PATH' || value === undefined || value === '') { + continue + } + + out[key] = value + } + + if (hermesHome) { + out.HERMES_HOME = hermesHome + } + + return out +} + +export interface TerminalScriptSpec { + command: string + args: string[] + cwd: string + env?: Record + platform?: NodeJS.Platform +} + +/** + * The launcher script contents. `exec` on POSIX so the terminal window belongs + * to the TUI itself rather than an idle shell wrapping it. + */ +export function buildTerminalScript({ command, args, cwd, env = {}, platform = process.platform }: TerminalScriptSpec) { + const entries = Object.entries(env) + + if (platform === 'win32') { + return [ + '@echo off', + `cd /d ${windowsQuote(cwd)}`, + ...entries.map(([key, value]) => `set ${windowsQuote(`${key}=${value}`)}`), + [command, ...args].map(windowsQuote).join(' '), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + `cd ${posixQuote(cwd)} || exit 1`, + ...entries.map(([key, value]) => `export ${key}=${posixQuote(value)}`), + `exec ${[command, ...args].map(posixQuote).join(' ')}`, + '' + ].join('\n') +} + +export function terminalScriptExtension(platform: NodeJS.Platform = process.platform): string { + if (platform === 'win32') { + return '.cmd' + } + + // `.command` is the UTI macOS binds to a terminal app; on Linux the + // extension is cosmetic (we always name the interpreter explicitly). + return platform === 'darwin' ? '.command' : '.sh' +} + +// Linux emulators in resolution order, with the flag that precedes a program +// to run. `x-terminal-emulator` is Debian/Ubuntu's alternatives symlink to the +// user's chosen terminal, so it leads; the rest are the common concretes. +const LINUX_TERMINALS: Array<{ command: string; flag: string }> = [ + { command: 'x-terminal-emulator', flag: '-e' }, + { command: 'gnome-terminal', flag: '--' }, + { command: 'konsole', flag: '-e' }, + { command: 'xfce4-terminal', flag: '-x' }, + { command: 'tilix', flag: '-e' }, + { command: 'kitty', flag: '' }, + { command: 'alacritty', flag: '-e' }, + { command: 'wezterm', flag: '-e' }, + { command: 'foot', flag: '' }, + { command: 'xterm', flag: '-e' } +] + +export interface TerminalLaunchOptions { + scriptPath: string + findOnPath: (command: string) => null | string + platform?: NodeJS.Platform +} + +/** + * Resolve the argv that opens `scriptPath` in a terminal window, or null when + * no terminal emulator could be found (Linux boxes with none installed). + */ +export function resolveTerminalLaunch({ + scriptPath, + findOnPath, + platform = process.platform +}: TerminalLaunchOptions): { command: string; args: string[] } | null { + if (platform === 'darwin') { + // No `-a`: LaunchServices picks the user's handler for shell scripts. + return { command: 'open', args: [scriptPath] } + } + + if (platform === 'win32') { + const windowsTerminal = findOnPath('wt.exe') + + if (windowsTerminal) { + return { command: windowsTerminal, args: ['cmd.exe', '/k', scriptPath] } + } + + return { command: 'cmd.exe', args: ['/c', 'start', '', 'cmd.exe', '/k', scriptPath] } + } + + for (const { command, flag } of LINUX_TERMINALS) { + const resolved = findOnPath(command) + + if (resolved) { + return { command: resolved, args: [...(flag ? [flag] : []), '/bin/sh', scriptPath] } + } + } + + return null +}