feat(desktop): resolve the user's terminal emulator and TUI resume argv

Pure helpers for handing a session to an external terminal: the
`--tui --resume <id>` argv, a launcher script that carries the resolved
runtime's command and PYTHONPATH, and per-platform emulator resolution.

macOS opens the .command with no -a so LaunchServices routes it to whichever
app the user bound to shell scripts; Linux leads with Debian's
x-terminal-emulator alternative before the concrete emulators; Windows prefers
Windows Terminal over a cmd console.
This commit is contained in:
Brooklyn Nicholson
2026-08-14 15:42:11 -05:00
committed by brooklyn!
parent 4d6f4a6fe7
commit 237abc1e9e
2 changed files with 303 additions and 0 deletions
@@ -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)
})
+173
View File
@@ -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 <id>` 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<string, string | undefined> = {},
hermesHome?: string
): Record<string, string> {
const out: Record<string, string> = {}
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<string, string>
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
}