mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
fix(tui): restart dashboard chat on idle exit hotkeys
This commit is contained in:
parent
a64fc490fe
commit
12dfcfdf73
13 changed files with 207 additions and 18 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
11
ui-tui/src/__tests__/gracefulExit.test.ts
Normal file
11
ui-tui/src/__tests__/gracefulExit.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue