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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ const buildUiState = (): UiState => ({
|
||||
pasteCollapseLines: 5,
|
||||
pasteCollapseChars: 2000,
|
||||
sections: {},
|
||||
sessionTitle: '',
|
||||
showCost: false,
|
||||
showReasoning: false,
|
||||
sid: null,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user