diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts index 56825174419..b46427b09e0 100644 --- a/ui-tui/src/__tests__/useConfigSync.test.ts +++ b/ui-tui/src/__tests__/useConfigSync.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { $uiState, resetUiState } from '../app/uiStore.js' -import { applyDisplay, normalizeStatusBar } from '../app/useConfigSync.js' +import { applyDisplay, normalizeBusyInputMode, normalizeStatusBar } from '../app/useConfigSync.js' describe('applyDisplay', () => { beforeEach(() => { @@ -160,3 +160,55 @@ describe('normalizeStatusBar', () => { expect(normalizeStatusBar('OFF')).toBe('off') }) }) + +describe('normalizeBusyInputMode', () => { + it('passes through the canonical CLI parity values', () => { + expect(normalizeBusyInputMode('queue')).toBe('queue') + expect(normalizeBusyInputMode('steer')).toBe('steer') + expect(normalizeBusyInputMode('interrupt')).toBe('interrupt') + }) + + it('trims and lowercases input', () => { + expect(normalizeBusyInputMode(' Queue ')).toBe('queue') + expect(normalizeBusyInputMode('STEER')).toBe('steer') + }) + + it('defaults to queue for missing/unknown values (TUI-only override)', () => { + // CLI / messaging adapters keep `interrupt` as the framework default + // (see hermes_cli/config.py + tui_gateway/server.py::_load_busy_input_mode); + // the TUI ships `queue` because typing a follow-up while the agent + // streams is the common authoring pattern and an unintended interrupt + // loses work. + expect(normalizeBusyInputMode(undefined)).toBe('queue') + expect(normalizeBusyInputMode(null)).toBe('queue') + expect(normalizeBusyInputMode('')).toBe('queue') + expect(normalizeBusyInputMode('drop')).toBe('queue') + expect(normalizeBusyInputMode(42)).toBe('queue') + }) +}) + +describe('applyDisplay → busy_input_mode', () => { + beforeEach(() => { + resetUiState() + }) + + it('threads display.busy_input_mode into $uiState', () => { + const setBell = vi.fn() + + applyDisplay({ config: { display: { busy_input_mode: 'queue' } } }, setBell) + expect($uiState.get().busyInputMode).toBe('queue') + + applyDisplay({ config: { display: { busy_input_mode: 'steer' } } }, setBell) + expect($uiState.get().busyInputMode).toBe('steer') + }) + + it('falls back to queue when value is missing or invalid (TUI-only default)', () => { + const setBell = vi.fn() + + applyDisplay({ config: { display: {} } }, setBell) + expect($uiState.get().busyInputMode).toBe('queue') + + applyDisplay({ config: { display: { busy_input_mode: 'drop' } } }, setBell) + expect($uiState.get().busyInputMode).toBe('queue') + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 9b987f87d36..fb75656eccc 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -27,6 +27,8 @@ export interface StateSetter { export type StatusBarMode = 'bottom' | 'off' | 'top' +export type BusyInputMode = 'interrupt' | 'queue' | 'steer' + export interface SelectionApi { captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void clearSelection: () => void @@ -85,6 +87,7 @@ export interface TranscriptRow { export interface UiState { bgTasks: Set busy: boolean + busyInputMode: BusyInputMode compact: boolean detailsMode: DetailsMode detailsModeCommandOverride: boolean diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 1b3a841e18c..f937e0cb52d 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -9,6 +9,7 @@ import type { UiState } from './interfaces.js' const buildUiState = (): UiState => ({ bgTasks: new Set(), busy: false, + busyInputMode: 'queue', compact: false, detailsMode: 'collapsed', detailsModeCommandOverride: false, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 26d02d62046..4d61bdb1d13 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -10,7 +10,7 @@ import type { } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' -import type { StatusBarMode } from './interfaces.js' +import type { BusyInputMode, StatusBarMode } from './interfaces.js' import { turnController } from './turnController.js' import { patchUiState } from './uiStore.js' @@ -24,6 +24,27 @@ const STATUSBAR_ALIAS: Record = { export const normalizeStatusBar = (raw: unknown): StatusBarMode => raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top' +const BUSY_MODES = new Set(['interrupt', 'queue', 'steer']) + +// TUI defaults to `queue` even though the framework default +// (`hermes_cli/config.py`) is `interrupt`. Rationale: in a full-screen +// TUI you're typically authoring the next prompt while the agent is +// still streaming, and an unintended interrupt loses work. Set +// `display.busy_input_mode: interrupt` (or `steer`) explicitly to +// opt out per-config; CLI / messaging adapters keep their `interrupt` +// default unchanged. +const TUI_BUSY_DEFAULT: BusyInputMode = 'queue' + +export const normalizeBusyInputMode = (raw: unknown): BusyInputMode => { + if (typeof raw !== 'string') { + return TUI_BUSY_DEFAULT + } + + const v = raw.trim().toLowerCase() as BusyInputMode + + return BUSY_MODES.has(v) ? v : TUI_BUSY_DEFAULT +} + const MTIME_POLL_MS = 5000 const quietRpc = async = Record>( @@ -43,6 +64,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea setBell(!!d.bell_on_complete) patchUiState({ + busyInputMode: normalizeBusyInputMode(d.busy_input_mode), compact: !!d.tui_compact, detailsMode: resolveDetailsMode(d), detailsModeCommandOverride: false, diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index f2468f27e62..2c2c6d48d93 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -4,7 +4,12 @@ import { TYPING_IDLE_MS } from '../config/timing.js' import { attachedImageNotice } from '../domain/messages.js' import { looksLikeSlashCommand } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' -import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' +import type { + InputDetectDropResponse, + PromptSubmitResponse, + SessionSteerResponse, + ShellExecResponse +} from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js' import { PASTE_SNIPPET_RE } from '../protocol/paste.js' @@ -207,6 +212,70 @@ export function useSubmission(opts: UseSubmissionOptions) { [interpolate, send, shellExec] ) + // Honors `display.busy_input_mode` from config.yaml (CLI parity): + // - 'queue' (legacy): append to queueRef; drains on busy → false + // - 'steer' : inject into the current turn via session.steer; falls + // back to queue when steer is rejected (no agent / no + // tool window). + // - 'interrupt' (default): cancel the in-flight turn, then send the + // new text as a fresh prompt so it actually moves. + // + // `opts.fallbackToFront` controls whether a steer fallback re-inserts + // at the front of the queue (used by the queue-edit path to preserve + // a picked item's position); the mainline submit path always appends. + const handleBusyInput = useCallback( + (full: string, opts: { fallbackToFront?: boolean } = {}) => { + const live = getUiState() + const mode = live.busyInputMode + const fallback = (note: string) => { + if (opts.fallbackToFront) { + composerRefs.queueRef.current.unshift(full) + composerActions.syncQueue() + } else { + composerActions.enqueue(full) + } + sys(note) + } + + if (mode === 'queue') { + return composerActions.enqueue(full) + } + + if (mode === 'steer' && live.sid) { + gw.request('session.steer', { session_id: live.sid, text: full }) + .then(raw => { + const r = asRpcResult(raw) + + if (r?.status !== 'queued') { + fallback('steer rejected — message queued for next turn') + } + }) + .catch(() => fallback('steer failed — message queued for next turn')) + + return + } + + // 'interrupt' (default): tear down the current turn, then send. + // `interruptTurn` fires `session.interrupt` without awaiting; if + // the gateway is still mid-response when `prompt.submit` lands, + // `send()`'s catch path re-queues with a "queued: ..." sys note + // (`isSessionBusyError`) — so a lost race degrades to queue + // semantics, not a dropped message. + if (live.sid) { + turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }) + } + + if (hasInterpolation(full)) { + patchUiState({ busy: true }) + + return interpolate(full, send) + } + + send(full) + }, + [appendMessage, composerActions, composerRefs, gw, interpolate, send, sys] + ) + const dispatchSubmission = useCallback( (full: string) => { if (!full.trim()) { @@ -252,9 +321,16 @@ export function useSubmission(opts: UseSubmissionOptions) { } if (getUiState().busy) { - composerRefs.queueRef.current.unshift(picked) + // 'interrupt' / 'steer' should reach the live turn instead of + // silently going back to the queue. handleBusyInput resolves + // mode-specific behavior (interrupt-and-send, steer, or queue). + if (getUiState().busyInputMode === 'queue') { + composerRefs.queueRef.current.unshift(picked) - return composerActions.syncQueue() + return composerActions.syncQueue() + } + + return handleBusyInput(picked, { fallbackToFront: true }) } return sendQueued(picked) @@ -263,7 +339,7 @@ export function useSubmission(opts: UseSubmissionOptions) { composerActions.pushHistory(full) if (getUiState().busy) { - return composerActions.enqueue(full) + return handleBusyInput(full) } if (hasInterpolation(full)) { @@ -274,7 +350,17 @@ export function useSubmission(opts: UseSubmissionOptions) { send(full) }, - [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef] + [ + appendMessage, + composerActions, + composerRefs, + handleBusyInput, + interpolate, + send, + sendQueued, + shellExec, + slashRef + ] ) const submit = useCallback( diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 6a4fa2ae025..b2202af9f23 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -53,6 +53,7 @@ export type CommandDispatchResponse = export interface ConfigDisplayConfig { bell_on_complete?: boolean + busy_input_mode?: string details_mode?: string inline_diffs?: boolean sections?: Record