fix(tui): restart dashboard chat on idle exit hotkeys

This commit is contained in:
Shannon Sands 2026-06-19 16:11:55 +10:00 committed by kshitij
parent a64fc490fe
commit 12dfcfdf73
13 changed files with 207 additions and 18 deletions

View file

@ -10830,6 +10830,7 @@ def _resolve_chat_argv(
# the dashboard PTY path.
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
env.setdefault("HERMES_TUI_INLINE", "1")
env["HERMES_TUI_DASHBOARD"] = "1"
if profile_dir is not None:
env["HERMES_HOME"] = str(profile_dir)

View file

@ -5062,6 +5062,7 @@ class TestPtyWebSocket:
_argv, _cwd, env = self.ws_module._resolve_chat_argv()
assert env["HERMES_TUI_DASHBOARD"] == "1"
assert env["HERMES_TUI_INLINE"] == "1"
assert env["HERMES_TUI_DISABLE_MOUSE"] == "1"

View file

@ -187,6 +187,46 @@ describe('GatewayClient websocket attach mode', () => {
gw.kill()
})
it('publishes local dashboard-control events to the sidecar websocket', async () => {
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
process.env.HERMES_TUI_SIDECAR_URL = 'ws://gateway.test/api/pub?token=abc&channel=demo'
const gw = new GatewayClient()
const seen: string[] = []
gw.on('event', ev => seen.push(ev.type))
gw.start()
const gatewaySocket = FakeWebSocket.instances[0]!
gatewaySocket.open()
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2))
const sidecarSocket = FakeWebSocket.instances[1]!
sidecarSocket.open()
gw.drain()
gw.publishLocalEvent({
payload: { reason: 'idle_exit_hotkey' },
session_id: 'sid-old',
type: 'dashboard.new_session_requested'
})
expect(seen).toContain('dashboard.new_session_requested')
expect(JSON.parse(sidecarSocket.sent.at(-1) ?? '{}')).toEqual({
jsonrpc: '2.0',
method: 'event',
params: {
payload: { reason: 'idle_exit_hotkey' },
session_id: 'sid-old',
type: 'dashboard.new_session_requested'
}
})
gw.kill()
})
it('emits exit when attached websocket closes', () => {
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
const gw = new GatewayClient()

View file

@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { shouldExitForSignal } from '../lib/gracefulExit.js'
describe('shouldExitForSignal', () => {
it('ignores only the signals explicitly disabled for embedded dashboard chat', () => {
expect(shouldExitForSignal('SIGINT', ['SIGINT'])).toBe(false)
expect(shouldExitForSignal('SIGTERM', ['SIGINT'])).toBe(true)
expect(shouldExitForSignal('SIGHUP', ['SIGINT'])).toBe(true)
})
})

View file

@ -1,6 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import { applyVoiceRecordResponse, shouldFallThroughForScroll } from '../app/useInputHandlers.js'
import {
applyVoiceRecordResponse,
handleIdleHotkeyExit,
shouldAllowIdleHotkeyExit,
shouldFallThroughForScroll
} from '../app/useInputHandlers.js'
const baseKey = {
downArrow: false,
@ -42,6 +47,38 @@ describe('shouldFallThroughForScroll — keep transcript scrolling alive during
})
})
describe('shouldAllowIdleHotkeyExit', () => {
it('keeps idle exit hotkeys enabled in normal terminals', () => {
expect(shouldAllowIdleHotkeyExit(false)).toBe(true)
})
it('disables idle exit hotkeys in dashboard chat', () => {
expect(shouldAllowIdleHotkeyExit(true)).toBe(false)
})
})
describe('handleIdleHotkeyExit', () => {
it('exits in normal terminals', () => {
const actions = { die: vi.fn(), sys: vi.fn() }
handleIdleHotkeyExit(actions, false)
expect(actions.die).toHaveBeenCalledTimes(1)
expect(actions.sys).not.toHaveBeenCalled()
})
it('asks the dashboard for a fresh chat instead of leaving a ghost session', () => {
const actions = { die: vi.fn(), sys: vi.fn() }
const requestDashboardNewSession = vi.fn()
handleIdleHotkeyExit(actions, true, requestDashboardNewSession)
expect(actions.die).not.toHaveBeenCalled()
expect(requestDashboardNewSession).toHaveBeenCalledTimes(1)
expect(actions.sys).toHaveBeenCalledWith('starting a fresh dashboard chat...')
})
})
describe('applyVoiceRecordResponse', () => {
it('reverts optimistic REC state when the gateway reports voice busy', () => {
const setProcessing = vi.fn()

View file

@ -2,6 +2,7 @@ import { forceRedraw, useInput } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { DASHBOARD_TUI_MODE } from '../config/env.js'
import { TYPING_IDLE_MS } from '../config/timing.js'
import type {
ApprovalRespondResponse,
@ -15,13 +16,30 @@ import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionW
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
import { getInputSelection } from './inputSelectionStore.js'
import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
import type { InputHandlerActions, InputHandlerContext, InputHandlerResult } from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { patchTurnState } from './turnStore.js'
import { getUiState } from './uiStore.js'
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
const DASHBOARD_NEW_SESSION_MESSAGE = 'starting a fresh dashboard chat...'
export const shouldAllowIdleHotkeyExit = (dashboardTuiMode = DASHBOARD_TUI_MODE) => !dashboardTuiMode
export function handleIdleHotkeyExit(
actions: Pick<InputHandlerActions, 'die' | 'sys'>,
dashboardTuiMode = DASHBOARD_TUI_MODE,
requestDashboardNewSession?: () => void
) {
if (!shouldAllowIdleHotkeyExit(dashboardTuiMode)) {
requestDashboardNewSession?.()
return actions.sys(DASHBOARD_NEW_SESSION_MESSAGE)
}
return actions.die()
}
/**
* Approval / clarify / confirm overlays mount their own `useInput` handlers
@ -505,11 +523,23 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return cActions.clearIn()
}
return actions.die()
return handleIdleHotkeyExit(actions, DASHBOARD_TUI_MODE, () => {
gateway.gw.publishLocalEvent({
payload: { reason: 'idle_exit_hotkey' },
session_id: live.sid ?? undefined,
type: 'dashboard.new_session_requested'
})
})
}
if (isAction(key, ch, 'd')) {
return actions.die()
return handleIdleHotkeyExit(actions, DASHBOARD_TUI_MODE, () => {
gateway.gw.publishLocalEvent({
payload: { reason: 'idle_exit_hotkey' },
session_id: live.sid ?? undefined,
type: 'dashboard.new_session_requested'
})
})
}
if (isAction(key, ch, 'l')) {

View file

@ -1,4 +1,5 @@
import type { MouseTrackingMode } from '@hermes/ink'
import { isTermuxTuiMode } from '../lib/termux.js'
const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim())
@ -43,12 +44,19 @@ export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim()
// behavior.
const mouseTrackingOverride = parseToggle(process.env.HERMES_TUI_MOUSE_TRACKING)
const mouseTrackingDisabledLegacy = truthy(process.env.HERMES_TUI_DISABLE_MOUSE)
const resolvedBootMouseEnabled =
mouseTrackingOverride ?? (TERMUX_TUI_MODE ? false : !mouseTrackingDisabledLegacy)
export const MOUSE_TRACKING: MouseTrackingMode = resolvedBootMouseEnabled ? 'all' : 'off'
export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM)
// Set by the dashboard PTY launcher. This is intentionally narrower than
// INLINE_MODE: users can opt into inline terminal rendering locally, but the
// browser-embedded TUI has no healthy restart path after an idle exit.
export const DASHBOARD_TUI_MODE = truthy(process.env.HERMES_TUI_DASHBOARD)
// HERMES_DEV_CREDITS — dev-only live-spend readout (Δ status segment + "(dev credits)"
// banner). Throwaway dev scaffolding; the whole readout gates on this one flag.
export const DEV_CREDITS_MODE = truthy(process.env.HERMES_DEV_CREDITS)

View file

@ -5,7 +5,7 @@ import './lib/forceTruecolor.js'
import type { FrameEvent } from '@hermes/ink'
import { TERMUX_TUI_MODE } from './config/env.js'
import { DASHBOARD_TUI_MODE, TERMUX_TUI_MODE } from './config/env.js'
import { GatewayClient } from './gatewayClient.js'
import { setupGracefulExit } from './lib/gracefulExit.js'
import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js'
@ -76,7 +76,12 @@ setupGracefulExit({
recordParentLifecycle(`graceful-exit received signal=${signal} → killing gateway`)
resetTerminalModes()
process.stderr.write(`hermes-tui lifecycle: received ${signal}\n`)
}
},
// The dashboard chat tab has no in-page restart path after the PTY child
// exits. Ignore SIGINT there so Ctrl+C cannot kill the embedded TUI if raw
// mode briefly drops and the terminal driver turns the keystroke into a
// signal instead of input bytes. SIGTERM/SIGHUP still cleanly shut down.
ignoredSignals: DASHBOARD_TUI_MODE ? ['SIGINT'] : []
})
const stopMemoryMonitor = startMemoryMonitor({

View file

@ -307,6 +307,13 @@ export class GatewayClient extends EventEmitter {
}
}
publishLocalEvent(ev: GatewayEvent) {
const frame = JSON.stringify({ jsonrpc: '2.0', method: 'event', params: ev })
this.mirrorEventToSidecar(frame)
this.publish(ev)
}
private handleWebSocketFrame(raw: unknown) {
const text = asWireText(raw)

View file

@ -634,6 +634,7 @@ export type GatewayEvent =
}
| { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' }
| { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' }
| { payload?: { reason?: string }; session_id?: string; type: 'dashboard.new_session_requested' }
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
| {
payload?: { level?: 'info' | 'warn' | 'error'; message?: string }

View file

@ -1,11 +1,16 @@
interface SetupOptions {
cleanups?: (() => Promise<void> | void)[]
failsafeMs?: number
ignoredSignals?: GracefulSignal[]
onError?: (scope: 'uncaughtException' | 'unhandledRejection', err: unknown) => void
onSignal?: (signal: NodeJS.Signals) => void
}
const SIGNAL_EXIT_CODE: Record<'SIGHUP' | 'SIGINT' | 'SIGTERM', number> = {
export type GracefulSignal = 'SIGHUP' | 'SIGINT' | 'SIGTERM'
const SIGNALS: readonly GracefulSignal[] = ['SIGINT', 'SIGTERM', 'SIGHUP']
const SIGNAL_EXIT_CODE: Record<GracefulSignal, number> = {
SIGHUP: 129,
SIGINT: 130,
SIGTERM: 143
@ -13,7 +18,16 @@ const SIGNAL_EXIT_CODE: Record<'SIGHUP' | 'SIGINT' | 'SIGTERM', number> = {
let wired = false
export function setupGracefulExit({ cleanups = [], failsafeMs = 4000, onError, onSignal }: SetupOptions = {}) {
export const shouldExitForSignal = (signal: GracefulSignal, ignoredSignals: readonly GracefulSignal[] = []) =>
!ignoredSignals.includes(signal)
export function setupGracefulExit({
cleanups = [],
failsafeMs = 4000,
ignoredSignals = [],
onError,
onSignal
}: SetupOptions = {}) {
if (wired) {
return
}
@ -38,8 +52,14 @@ export function setupGracefulExit({ cleanups = [], failsafeMs = 4000, onError, o
void Promise.allSettled(cleanups.map(fn => Promise.resolve().then(fn))).finally(() => process.exit(code))
}
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
process.on(sig, () => exit(SIGNAL_EXIT_CODE[sig], sig))
for (const sig of SIGNALS) {
process.on(sig, () => {
if (!shouldExitForSignal(sig, ignoredSignals)) {
return
}
exit(SIGNAL_EXIT_CODE[sig], sig)
})
}
process.on('uncaughtException', err => onError?.('uncaughtException', err))

View file

@ -74,9 +74,15 @@ interface ChatSidebarProps {
/** Management profile from the dashboard switcher — scopes session.create. */
profile?: string;
className?: string;
onDashboardNewSessionRequest?: () => void;
}
export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
export function ChatSidebar({
channel,
profile,
className,
onDashboardNewSessionRequest,
}: ChatSidebarProps) {
// `version` bumps on reconnect; gw is derived so we never call setState
// for it inside an effect (React 19's set-state-in-effect rule). The
// counter is the dependency on purpose — it's not read in the memo body,
@ -112,9 +118,12 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
useEffect(() => {
let cancelled = false;
setSessionId(null);
setInfo({});
setError(null);
queueMicrotask(() => {
if (cancelled) return;
setSessionId(null);
setInfo({});
setError(null);
});
const offState = gw.onState(setState);
const offSessionInfo = gw.on<SessionInfo>("session.info", (ev) => {
@ -233,7 +242,9 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
const { type, payload } = frame.params;
if (type === "tool.start") {
if (type === "dashboard.new_session_requested") {
onDashboardNewSessionRequest?.();
} else if (type === "tool.start") {
const p = payload as
| { tool_id?: string; name?: string; context?: string }
| undefined;
@ -309,7 +320,7 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
unmounting = true;
ws?.close();
};
}, [channel, version]);
}, [channel, onDashboardNewSessionRequest, version]);
const reconnect = useCallback(() => {
setError(null);

View file

@ -153,6 +153,15 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
setBanner(null);
setReconnectNonce((n) => n + 1);
}, []);
const startFreshDashboardChat = useCallback(() => {
const next = new URLSearchParams(searchParams);
next.delete("resume");
setSearchParams(next, { replace: true });
setSessionEnded(false);
setBanner(null);
setReconnectNonce((n) => n + 1);
}, [searchParams, setSearchParams]);
// Raw state for the mobile side-sheet + a derived value that force-
// closes whenever the chat tab isn't active. The *derived* value is
// what side-effects (body-scroll lock, keydown listener, portal render)
@ -881,7 +890,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
"border-t border-current/10",
)}
>
<ChatSidebar channel={channel} profile={scopedProfile} />
<ChatSidebar
channel={channel}
profile={scopedProfile}
onDashboardNewSessionRequest={startFreshDashboardChat}
/>
</div>
</div>
</>,
@ -967,7 +980,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
className="flex min-h-0 shrink-0 flex-col overflow-hidden lg:h-full lg:w-80"
>
<div className="min-h-0 flex-1 overflow-hidden">
<ChatSidebar channel={channel} profile={scopedProfile} />
<ChatSidebar
channel={channel}
profile={scopedProfile}
onDashboardNewSessionRequest={startFreshDashboardChat}
/>
</div>
</div>
)}