diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b2544ce9d77..ba6f4277deb 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index e0ad77dfc8a..e65a28101cd 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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" diff --git a/ui-tui/src/__tests__/gatewayClient.test.ts b/ui-tui/src/__tests__/gatewayClient.test.ts index a872a008ddb..43d96add35a 100644 --- a/ui-tui/src/__tests__/gatewayClient.test.ts +++ b/ui-tui/src/__tests__/gatewayClient.test.ts @@ -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() diff --git a/ui-tui/src/__tests__/gracefulExit.test.ts b/ui-tui/src/__tests__/gracefulExit.test.ts new file mode 100644 index 00000000000..6c805dfce7c --- /dev/null +++ b/ui-tui/src/__tests__/gracefulExit.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/__tests__/useInputHandlers.test.ts b/ui-tui/src/__tests__/useInputHandlers.test.ts index 0d3fd69c1ed..fa9372d5356 100644 --- a/ui-tui/src/__tests__/useInputHandlers.test.ts +++ b/ui-tui/src/__tests__/useInputHandlers.test.ts @@ -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() diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 20d3493f547..f19cccfe5b5 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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, + 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')) { diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts index 3b5b9bee4d4..843512ed76a 100644 --- a/ui-tui/src/config/env.ts +++ b/ui-tui/src/config/env.ts @@ -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) diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index 22fee6bccbd..de60d966760 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -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({ diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 5dfbe880fb1..88ddc0fcdc3 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -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) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 016171008c1..74a6f7627d1 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -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 } diff --git a/ui-tui/src/lib/gracefulExit.ts b/ui-tui/src/lib/gracefulExit.ts index 2896fd12651..089269ac1ae 100644 --- a/ui-tui/src/lib/gracefulExit.ts +++ b/ui-tui/src/lib/gracefulExit.ts @@ -1,11 +1,16 @@ interface SetupOptions { cleanups?: (() => Promise | 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 = { 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)) diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 1a53741d8fd..e6e3437781a 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -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("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); diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 4e3a6c23151..dcb006e0da2 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -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", )} > - + , @@ -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" >
- +
)}