opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine

hermes --tui launches the native OpenTUI engine (Bun) when
HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config);
Ink stays the default and the shipping path is untouched.

- _resolve_tui_engine() (env > config > ink); refuses opentui on
  Windows/Termux (no Bun) -> falls back to ink with a notice.
- _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step).
- _bun_bin() with HERMES_BUN override.
- Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host
  must not bootstrap Node).
- Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun
  is JSC; the V8 flag errors/ignores).

Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI ->
real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
alt-glitch
2026-06-08 11:11:54 +00:00
parent 24f74eb888
commit 2bd9c9b881
741 changed files with 17733 additions and 79889 deletions
+6 -11
View File
@@ -219,6 +219,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
agentsNudgedThisTurn = false
}
// Kick off the config fetch eagerly at handler creation so the flag is
// resolved well before the first delegation of any real session (which
// only happens after gateway.ready + a user turn).
ensureAgentsNudgeConfig()
const refreshDelegationStatus = (force = false) => {
const now = Date.now()
@@ -307,12 +312,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
applySkin(skin)
}
// Kick off the config fetch once the gateway is actually ready. If handler
// construction does this during React render, a startup transport error can
// report through sys(), mutate transcript state, and trip React's
// "too many re-renders" guard in embedded dashboard PTYs.
ensureAgentsNudgeConfig()
rpc<CommandsCatalogResponse>('commands.catalog', {})
.then(r => {
if (!r?.pairs) {
@@ -729,12 +728,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
case 'approval.request': {
const description = String(ev.payload.description ?? 'dangerous command')
// Only an explicit false (tirith warning) drops the permanent-allow option.
const allowPermanent = ev.payload.allow_permanent !== false
patchOverlayState({
approval: { allowPermanent, command: String(ev.payload.command ?? ''), description }
})
patchOverlayState({ approval: { command: String(ev.payload.command ?? ''), description } })
setStatus('approval needed')
return
-3
View File
@@ -93,7 +93,6 @@ export interface OverlayState {
confirm: ConfirmReq | null
modelPicker: boolean
pager: null | PagerState
pluginsHub: boolean
secret: null | SecretReq
sessions: boolean
skillsHub: boolean
@@ -128,7 +127,6 @@ export interface UiState {
pasteCollapseChars: number
sections: SectionVisibility
sessionTitle: string
showCost: boolean
showReasoning: boolean
indicatorStyle: IndicatorStyle
@@ -368,7 +366,6 @@ export interface AppLayoutProgressProps {
export interface AppLayoutStatusProps {
cwdLabel: string
goodVibesTick: number
lastTurnEndedAt: null | number
sessionStartedAt: null | number
showStickyPrompt: boolean
statusColor: string
+2 -6
View File
@@ -10,7 +10,6 @@ const buildOverlayState = (): OverlayState => ({
confirm: null,
modelPicker: false,
pager: null,
pluginsHub: false,
secret: null,
sessions: false,
skillsHub: false,
@@ -21,10 +20,8 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed(
$overlayState,
({ agents, approval, clarify, confirm, modelPicker, pager, pluginsHub, secret, sessions, skillsHub, sudo }) =>
Boolean(
agents || approval || clarify || confirm || modelPicker || pager || pluginsHub || secret || sessions || skillsHub || sudo
)
({ agents, approval, clarify, confirm, modelPicker, pager, secret, sessions, skillsHub, sudo }) =>
Boolean(agents || approval || clarify || confirm || modelPicker || pager || secret || sessions || skillsHub || sudo)
)
export const getOverlayState = () => $overlayState.get()
@@ -49,7 +46,6 @@ export const resetFlowOverlays = () =>
agents: $overlayState.get().agents,
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
modelPicker: $overlayState.get().modelPicker,
pluginsHub: $overlayState.get().pluginsHub,
sessions: $overlayState.get().sessions,
skillsHub: $overlayState.get().skillsHub
})
-28
View File
@@ -652,34 +652,6 @@ export const opsCommands: SlashCommand[] = [
}
},
{
help: 'view & toggle plugins (no arg opens the hub; enable/disable <name> for direct toggle)',
name: 'plugins',
run: (arg, ctx, cmd) => {
// No argument → open the interactive Plugins Hub overlay. Any
// subcommand (enable/disable/list/install/…) falls through to the
// text slash worker so it stays at parity with `hermes plugins`.
if (!arg.trim()) {
return patchOverlayState({ pluginsHub: true })
}
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
.then(r => {
if (ctx.stale()) {
return
}
const body = r?.output || '/plugins: no output'
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
long ? ctx.transcript.page(text, 'Plugins') : ctx.transcript.sys(text)
})
.catch(ctx.guardedErr)
}
},
{
help: 'enable or disable tools (client-side history reset on change)',
name: 'tools',
+2 -19
View File
@@ -72,25 +72,10 @@ export const sessionCommands: SlashCommand[] = [
return patchOverlayState({ modelPicker: true })
}
const switchModel = (confirmExpensiveModel = false) => ctx.gateway
.rpc<ConfigSetResponse>('config.set', { confirm_expensive_model: confirmExpensiveModel, key: 'model', session_id: ctx.sid, value: modelValueForConfigSet(arg) })
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'model', session_id: ctx.sid, value: modelValueForConfigSet(arg) })
.then(
ctx.guarded<ConfigSetResponse>(r => {
if (r.confirm_required) {
patchOverlayState({
confirm: {
cancelLabel: 'Cancel',
confirmLabel: 'Switch anyway',
danger: true,
detail: r.confirm_message || r.warning || 'This model has unusually high known pricing.',
onConfirm: () => switchModel(true),
title: 'Expensive model selection'
}
})
return
}
if (!r.value) {
return ctx.transcript.sys('error: invalid response: model switch')
}
@@ -104,8 +89,6 @@ export const sessionCommands: SlashCommand[] = [
}))
})
)
switchModel()
}
},
-1
View File
@@ -22,7 +22,6 @@ const buildUiState = (): UiState => ({
pasteCollapseLines: 5,
pasteCollapseChars: 2000,
sections: {},
sessionTitle: '',
showCost: false,
showReasoning: false,
sid: null,
-4
View File
@@ -151,10 +151,6 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return patchOverlayState({ skillsHub: false })
}
if (overlay.pluginsHub) {
return patchOverlayState({ pluginsHub: false })
}
if (overlay.sessions) {
return patchOverlayState({ sessions: false })
}
+7 -27
View File
@@ -7,7 +7,7 @@ import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js'
import { SECTION_NAMES, sectionMode } from '../domain/details.js'
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js'
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
import { type GatewayClient } from '../gatewayClient.js'
import type {
ClarifyRespondResponse,
@@ -173,7 +173,6 @@ export function useMainApp(gw: GatewayClient) {
const [voiceRecordKey, setVoiceRecordKey] = useState<ParsedVoiceRecordKey>(DEFAULT_VOICE_RECORD_KEY)
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
const [turnStartedAt, setTurnStartedAt] = useState<null | number>(null)
const [lastTurnEndedAt, setLastTurnEndedAt] = useState<null | number>(null)
const [goodVibesTick, setGoodVibesTick] = useState(0)
const [bellOnComplete, setBellOnComplete] = useState(false)
@@ -501,14 +500,10 @@ export function useMainApp(gw: GatewayClient) {
useEffect(() => {
if (ui.busy) {
setTurnStartedAt(prev => prev ?? Date.now())
} else if (turnStartedAt != null) {
// Only stamp the idle marker when a turn was actually live — busy is
// also false on mount and we don't want a phantom "done" timestamp
// before the first turn has completed.
setLastTurnEndedAt(Date.now())
} else {
setTurnStartedAt(null)
}
}, [ui.busy, turnStartedAt])
}, [ui.busy])
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid })
@@ -529,22 +524,12 @@ export function useMainApp(gw: GatewayClient) {
if (!stopped && result?.sessions) {
const liveSessionCount = result.sessions.length
// Surface the current session's (auto-)title for the terminal
// titlebar. The active_list poll already carries it, so no extra
// round-trip is needed.
const currentSid = getUiState().sid
const sessionTitle =
result.sessions.find(s => s.current || s.id === currentSid)?.title?.trim() ?? ''
// Only patch when something actually changed. patchUiState always
// Only patch when the count actually changed. patchUiState always
// produces a new state object, which notifies every $uiState
// subscriber; patching unconditionally on each 1.5s poll re-renders
// the whole TUI and causes idle flicker.
const prev = getUiState()
if (prev.liveSessionCount !== liveSessionCount || prev.sessionTitle !== sessionTitle) {
patchUiState({ liveSessionCount, sessionTitle })
if (getUiState().liveSessionCount !== liveSessionCount) {
patchUiState({ liveSessionCount })
}
}
})
@@ -561,16 +546,13 @@ export function useMainApp(gw: GatewayClient) {
}, [gw, ui.sid])
// Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle.
// Format: `<marker> <session name> · <model> · <cwd>` — name/cwd omitted when absent.
const model = ui.info?.model?.replace(/^.*\//, '') ?? ''
const marker = overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓'
const tabCwd = ui.info?.cwd
useTerminalTitle(
model ? composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : '') : 'Hermes'
)
useTerminalTitle(model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'Hermes')
useEffect(() => {
if (!ui.sid || !stdout) {
@@ -1095,7 +1077,6 @@ export function useMainApp(gw: GatewayClient) {
// essentials and truncates this further on narrow terminals.
cwdLabel: fmtCwdBranch(cwd, gitBranch, 28),
goodVibesTick,
lastTurnEndedAt: ui.sid ? lastTurnEndedAt : null,
sessionStartedAt: ui.sid ? sessionStartedAt : null,
showStickyPrompt: !!stickyPrompt,
statusColor: statusColorOf(ui.status, ui.theme.color),
@@ -1109,7 +1090,6 @@ export function useMainApp(gw: GatewayClient) {
cwd,
gitBranch,
goodVibesTick,
lastTurnEndedAt,
sessionStartedAt,
stickyPrompt,
turnStartedAt,