diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 4ff18177a453..5118536a8d0e 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1087,6 +1087,10 @@ # behavior of showing tool-call summaries inline. "resume_skip_tool_only": True, "busy_input_mode": "interrupt", # interrupt | queue | steer + # TUI-only: which key interrupts a running turn (dismiss completions + # first, then interrupt). Accepts bare keys ("escape", "esc") or + # modifier combos ("ctrl+g", "alt+i"). Default "escape". + "interrupt_key": "escape", # When busy_input_mode="steer", suppress only the visible # "Steered into current run" confirmation bubble by setting this false. # The mid-turn steering itself still happens. diff --git a/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx b/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx index d683e841acac..9e3c79cbf70e 100644 --- a/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx +++ b/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx @@ -11,7 +11,7 @@ import { patchUiState, resetUiState } from '../app/uiStore.js' import { StatusRule } from '../components/appChrome.js' import { AppLayout } from '../components/appLayout.js' import type { GatewayClient } from '../gatewayClient.js' -import { DEFAULT_VOICE_RECORD_KEY } from '../lib/platform.js' +import { DEFAULT_INTERRUPT_KEY, DEFAULT_VOICE_RECORD_KEY } from '../lib/platform.js' import { stripAnsi } from '../lib/text.js' import { DEFAULT_THEME } from '../theme.js' @@ -156,7 +156,8 @@ const layoutProps: AppLayoutProps = { queuedDisplay: [], submit: () => {}, updateInput: () => {}, - voiceRecordKey: DEFAULT_VOICE_RECORD_KEY + voiceRecordKey: DEFAULT_VOICE_RECORD_KEY, + interruptKey: DEFAULT_INTERRUPT_KEY }, mouseTracking: 'off', progress: { showProgressArea: false }, diff --git a/ui-tui/src/__tests__/interruptKey.test.ts b/ui-tui/src/__tests__/interruptKey.test.ts new file mode 100644 index 000000000000..5eb853e4f96f --- /dev/null +++ b/ui-tui/src/__tests__/interruptKey.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_INTERRUPT_KEY, + formatInterruptKey, + isInterruptKey, + parseInterruptKey +} from '../lib/platform.js' + +const key = (overrides: Record = {}) => + ({ ctrl: false, meta: false, alt: false, shift: false, escape: false, ...overrides }) as any + +describe('parseInterruptKey', () => { + it('returns default (escape) for empty/null/undefined input', () => { + expect(parseInterruptKey('')).toEqual(DEFAULT_INTERRUPT_KEY) + expect(parseInterruptKey(null)).toEqual(DEFAULT_INTERRUPT_KEY) + expect(parseInterruptKey(undefined)).toEqual(DEFAULT_INTERRUPT_KEY) + }) + + it('normalizes "esc" and "escape" to the default escape key', () => { + expect(parseInterruptKey('escape')).toEqual(DEFAULT_INTERRUPT_KEY) + expect(parseInterruptKey('Esc')).toEqual(DEFAULT_INTERRUPT_KEY) + expect(parseInterruptKey('ESCAPE')).toEqual(DEFAULT_INTERRUPT_KEY) + expect(parseInterruptKey(' esc ')).toEqual(DEFAULT_INTERRUPT_KEY) + }) + + it('parses modifier combos like ctrl+g', () => { + const parsed = parseInterruptKey('ctrl+g') + + expect(parsed.ch).toBe('g') + expect(parsed.mod).toBe('ctrl') + expect(parsed.raw).toBe('ctrl+g') + }) + + it('parses alt+i', () => { + const parsed = parseInterruptKey('alt+i') + + expect(parsed.ch).toBe('i') + expect(parsed.mod).toBe('alt') + expect(parsed.raw).toBe('alt+i') + }) + + it('falls back to default for invalid input', () => { + expect(parseInterruptKey('not+a+valid+combo')).toEqual(DEFAULT_INTERRUPT_KEY) + expect(parseInterruptKey(123)).toEqual(DEFAULT_INTERRUPT_KEY) + }) + + it('accepts ctrl+l (not reserved for interrupt unlike voice)', () => { + const parsed = parseInterruptKey('ctrl+l') + + expect(parsed.ch).toBe('l') + expect(parsed.mod).toBe('ctrl') + expect(parsed.raw).toBe('ctrl+l') + }) + + it('still rejects ctrl+c (SIGINT)', () => { + expect(parseInterruptKey('ctrl+c')).toEqual(DEFAULT_INTERRUPT_KEY) + }) +}) + +describe('isInterruptKey', () => { + it('matches bare Escape for the default config', () => { + expect(isInterruptKey(key({ escape: true }), '', DEFAULT_INTERRUPT_KEY)).toBe(true) + }) + + it('does not match Escape with modifiers held for default config', () => { + expect(isInterruptKey(key({ escape: true, ctrl: true }), '', DEFAULT_INTERRUPT_KEY)).toBe(false) + expect(isInterruptKey(key({ escape: true, alt: true }), '', DEFAULT_INTERRUPT_KEY)).toBe(false) + }) + + it('matches ctrl+g when configured', () => { + const cfg = parseInterruptKey('ctrl+g') + + expect(isInterruptKey(key({ ctrl: true }), 'g', cfg)).toBe(true) + }) + + it('does not match bare g when ctrl+g is configured', () => { + const cfg = parseInterruptKey('ctrl+g') + + expect(isInterruptKey(key(), 'g', cfg)).toBe(false) + }) + + it('does not match ctrl+g when escape is configured', () => { + expect(isInterruptKey(key({ ctrl: true }), 'g', DEFAULT_INTERRUPT_KEY)).toBe(false) + }) + + it('does not match Cmd+B (super) for ctrl+b configured interrupt key', () => { + const cfg = parseInterruptKey('ctrl+b') + + expect(isInterruptKey(key({ super: true }), 'b', cfg)).toBe(false) + }) +}) + +describe('formatInterruptKey', () => { + it('formats default as Esc', () => { + expect(formatInterruptKey(DEFAULT_INTERRUPT_KEY)).toBe('Esc') + }) + + it('formats ctrl+g as Ctrl+G', () => { + expect(formatInterruptKey(parseInterruptKey('ctrl+g'))).toBe('Ctrl+G') + }) +}) diff --git a/ui-tui/src/__tests__/textInputPassThrough.test.ts b/ui-tui/src/__tests__/textInputPassThrough.test.ts index ff8c29ebcd13..f93f960f5357 100644 --- a/ui-tui/src/__tests__/textInputPassThrough.test.ts +++ b/ui-tui/src/__tests__/textInputPassThrough.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { shouldPassThroughToGlobalHandler, shouldPreserveCtrlJNewline } from '../components/textInput.js' -import { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } from '../lib/platform.js' +import { DEFAULT_VOICE_RECORD_KEY, parseInterruptKey, parseVoiceRecordKey } from '../lib/platform.js' const key = (overrides: Record = {}) => ({ ctrl: false, meta: false, ...overrides }) as any @@ -50,4 +50,18 @@ describe('shouldPassThroughToGlobalHandler', () => { expect(shouldPassThroughToGlobalHandler('', key({ pageUp: true }))).toBe(true) expect(shouldPassThroughToGlobalHandler('', key({ pageDown: true }))).toBe(true) }) + + it('passes through a custom interrupt key (ctrl+g) so composer does not consume it', () => { + const interruptCfg = parseInterruptKey('ctrl+g') + + expect( + shouldPassThroughToGlobalHandler('g', key({ ctrl: true }), DEFAULT_VOICE_RECORD_KEY, interruptCfg) + ).toBe(true) + }) + + it('does not pass through interrupt key without modifier when typing normally', () => { + const interruptCfg = parseInterruptKey('ctrl+g') + + expect(shouldPassThroughToGlobalHandler('g', key(), DEFAULT_VOICE_RECORD_KEY, interruptCfg)).toBe(false) + }) }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index c448df0259b7..65cc3f69686a 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -13,7 +13,7 @@ import type { SubscriptionUpgradeResponse } from '../gatewayTypes.js' import type { QueueItem } from '../hooks/useQueue.js' -import type { ParsedVoiceRecordKey } from '../lib/platform.js' +import type { ParsedInterruptKey, ParsedVoiceRecordKey } from '../lib/platform.js' import type { RpcResult } from '../lib/rpc.js' import type { ActiveWidget } from '../sdk/types.js' import type { Theme } from '../theme.js' @@ -368,6 +368,7 @@ export interface ComposerActions { attachClipboardImage: () => void /** Attach an image by path in as a token. */ attachImagePath: (path: string) => void + clearCompletions: () => void clearIn: () => void dequeue: () => string | undefined enqueue: (text: string, display?: string) => void @@ -438,6 +439,7 @@ export interface InputHandlerContext { state: ComposerState } gateway: GatewayServices + interruptKey: ParsedInterruptKey terminal: { hasSelection: boolean scrollRef: RefObject @@ -568,6 +570,7 @@ export interface AppLayoutComposerProps { handleTextPaste: (event: PasteEvent) => MaybePromise input: string inputBuf: string[] + interruptKey: ParsedInterruptKey pagerPageSize: number queueEditIdx: null | number queuedDisplay: string[] diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 8454ebd61fcc..ebba977a07a8 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -142,7 +142,7 @@ export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions } = useQueue() const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() - const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, isBlocked, gw) + const { clearCompletions, completions, compIdx, setCompIdx, compReplace } = useCompletion(input, isBlocked, gw) const clearIn = useCallback(() => { setInput('') @@ -424,6 +424,7 @@ export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions () => ({ attachClipboardImage, attachImagePath, + clearCompletions, clearIn, dequeue, enqueue, @@ -444,6 +445,7 @@ export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions [ attachClipboardImage, attachImagePath, + clearCompletions, clearIn, dequeue, enqueue, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index e8dd8b1334c6..1f5cce922064 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -4,7 +4,14 @@ import { useEffect, useRef } from 'react' import { resolveDetailsMode, resolveSections } from '../domain/details.js' import type { GatewayClient } from '../gatewayClient.js' import type { ConfigFullResponse, ConfigMtimeResponse, ReloadMcpResponse } from '../gatewayTypes.js' -import { DEFAULT_VOICE_RECORD_KEY, type ParsedVoiceRecordKey, parseVoiceRecordKey } from '../lib/platform.js' +import { + DEFAULT_INTERRUPT_KEY, + DEFAULT_VOICE_RECORD_KEY, + type ParsedInterruptKey, + type ParsedVoiceRecordKey, + parseInterruptKey, + parseVoiceRecordKey +} from '../lib/platform.js' import { asRpcResult } from '../lib/rpc.js' import { applyConfiguredTuiTheme } from './createGatewayEventHandler.js' @@ -239,10 +246,11 @@ const _pasteCollapseCharsFromConfig = (cfg: ConfigFullResponse | null): number = export async function hydrateFullConfig( gw: GatewayClient, setBell: (v: boolean) => void, - setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void + setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void, + setInterruptKey?: (v: ParsedInterruptKey) => void ): Promise { const cfg = await quietRpc(gw, 'config.get', { key: 'full' }) - applyDisplay(cfg, setBell, setVoiceRecordKey) + applyDisplay(cfg, setBell, setVoiceRecordKey, setInterruptKey) return cfg } @@ -250,7 +258,8 @@ export async function hydrateFullConfig( export const applyDisplay = ( cfg: ConfigFullResponse | null, setBell: (v: boolean) => void, - setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void + setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void, + setInterruptKey?: (v: ParsedInterruptKey) => void ) => { const d = cfg?.config?.display ?? {} @@ -265,8 +274,23 @@ export const applyDisplay = ( // (Copilot round-8 review on #19835). The mtime-poll loop advances // ``mtimeRef`` before this call, so staying silent on null preserves // the last-good state and lets the next successful poll refresh it. + const voiceKey = cfg ? _voiceRecordKeyFromConfig(cfg) : DEFAULT_VOICE_RECORD_KEY + if (setVoiceRecordKey && cfg) { - setVoiceRecordKey(_voiceRecordKeyFromConfig(cfg)) + setVoiceRecordKey(voiceKey) + } + + if (setInterruptKey && cfg) { + const raw = cfg?.config?.display?.interrupt_key + const parsed = parseInterruptKey(raw) + + // Fall back to default if the interrupt key collides with the voice + // record key — otherwise voice toggle becomes permanently unreachable. + if (parsed.raw === voiceKey.raw) { + setInterruptKey(DEFAULT_INTERRUPT_KEY) + } else { + setInterruptKey(parsed) + } } patchUiState({ @@ -291,6 +315,7 @@ export const applyDisplay = ( export function useConfigSync({ gw, setBellOnComplete, + setInterruptKey, setVoiceEnabled, setVoiceRecordKey, sid @@ -316,8 +341,8 @@ export function useConfigSync({ // mcp_rev) look like an MCP change and fire a needless reload.mcp. mcpRevRef.current.accepted = String(r?.mcp_rev ?? '') }) - void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey) - }, [gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid]) + void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey, setInterruptKey) + }, [gw, setBellOnComplete, setInterruptKey, setVoiceEnabled, setVoiceRecordKey, sid]) useEffect(() => { if (!sid) { @@ -364,17 +389,18 @@ export function useConfigSync({ ) } - void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey) + void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey, setInterruptKey) }) }, MTIME_POLL_MS) return () => clearInterval(id) - }, [gw, setBellOnComplete, setVoiceRecordKey, sid]) + }, [gw, setBellOnComplete, setVoiceRecordKey, setInterruptKey, sid]) } export interface UseConfigSyncOptions { gw: GatewayClient setBellOnComplete: (v: boolean) => void + setInterruptKey?: (v: ParsedInterruptKey) => void setVoiceEnabled: (v: boolean) => void setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void sid: null | string diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 97cb704b0b23..a6a20f3d6a03 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -12,7 +12,7 @@ import type { SudoRespondResponse, VoiceRecordResponse } from '../gatewayTypes.js' -import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js' +import { isAction, isCopyShortcut, isMac, isInterruptKey, isVoiceToggleKey } from '../lib/platform.js' import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js' import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js' import { closeWidget, dispatchWidgetInput } from '../sdk/host.js' @@ -132,7 +132,7 @@ export function dismissSensitivePrompt( const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { - const { actions, composer, gateway, terminal, voice, wheelStep } = ctx + const { actions, composer, gateway, interruptKey, terminal, voice, wheelStep } = ctx const { actions: cActions, refs: cRefs, state: cState } = composer const overlay = useStore($overlayState) @@ -529,6 +529,21 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return clearSelection() } + // Interrupt key (default: Esc) dismisses completions first; a subsequent + // press (with no completions showing) interrupts the running turn. + if (isInterruptKey(key, ch, interruptKey) && cState.completions.length) { + return cActions.clearCompletions() + } + + if (isInterruptKey(key, ch, interruptKey) && live.busy && live.sid) { + return turnController.interruptTurn({ + appendMessage: actions.appendMessage, + gw: gateway.gw, + sid: live.sid, + sys: actions.sys + }) + } + if (key.upArrow && !cState.inputBuf.length) { const inputSel = getInputSelection() const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 283ebe5a119b..9dd707148a81 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -31,7 +31,13 @@ import { useGitBranch } from '../hooks/useGitBranch.js' import { pruneVirtualHeightCache, useVirtualHistory } from '../hooks/useVirtualHistory.js' import { composerPromptWidth } from '../lib/inputMetrics.js' import { appendTranscriptMessage, capTranscriptHistory } from '../lib/messages.js' -import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js' +import { + DEFAULT_INTERRUPT_KEY, + DEFAULT_VOICE_RECORD_KEY, + isMac, + type ParsedInterruptKey, + type ParsedVoiceRecordKey +} from '../lib/platform.js' import { createResizeCoalescer } from '../lib/resizeCoalescer.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { terminalParityHints } from '../lib/terminalParity.js' @@ -194,6 +200,7 @@ export function useMainApp(gw: GatewayClient) { const [voiceRecording, setVoiceRecording] = useState(false) const [voiceProcessing, setVoiceProcessing] = useState(false) const [voiceRecordKey, setVoiceRecordKey] = useState(DEFAULT_VOICE_RECORD_KEY) + const [interruptKey, setInterruptKey] = useState(DEFAULT_INTERRUPT_KEY) const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [dashboardFreshSessionId, setDashboardFreshSessionId] = useState(null) const [turnStartedAt, setTurnStartedAt] = useState(null) @@ -565,7 +572,7 @@ export function useMainApp(gw: GatewayClient) { } }, [ui.busy, turnStartedAt]) - useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid }) + useConfigSync({ gw, setBellOnComplete, setInterruptKey, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid }) useBatteryPoll(gw) useEffect(() => { @@ -758,6 +765,7 @@ export function useMainApp(gw: GatewayClient) { }, composer: { actions: composerActions, refs: composerRefs, state: composerState }, gateway, + interruptKey, terminal: { hasSelection, scrollRef, scrollWithSelection, selection, stdout }, voice: { enabled: voiceEnabled, @@ -1144,6 +1152,7 @@ export function useMainApp(gw: GatewayClient) { handleTextPaste: composerActions.handleTextPaste, input: composerState.input, inputBuf: composerState.inputBuf, + interruptKey, pagerPageSize, queueEditIdx: composerState.queueEditIdx, queuedDisplay: composerState.queuedDisplay, @@ -1151,7 +1160,7 @@ export function useMainApp(gw: GatewayClient) { updateInput, voiceRecordKey }), - [cols, composerActions, composerState, empty, pagerPageSize, submit, updateInput, voiceRecordKey] + [cols, composerActions, composerState, empty, interruptKey, pagerPageSize, submit, updateInput, voiceRecordKey] ) // Pass current progress through unfrozen — streaming update throttling diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 660fcb881d16..172d6b4883df 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -21,6 +21,7 @@ import { stableComposerColumns } from '../lib/inputMetrics.js' import { PerfPane } from '../lib/perfPane.js' +import { formatInterruptKey } from '../lib/platform.js' import { composerPromptText } from '../lib/prompt.js' import { ActiveWidgetSlot, AmbientDock, AmbientRail, useAmbientRailWidth } from '../sdk/host.js' @@ -419,11 +420,12 @@ const ComposerPane = memo(function ComposerPane({ onChange: (v: string) => void @@ -1536,7 +1540,8 @@ export function decideRightClickAction( export const shouldPassThroughToGlobalHandler = ( input: string, key: Key, - voiceRecordKey: ParsedVoiceRecordKey = DEFAULT_VOICE_RECORD_KEY + voiceRecordKey: ParsedVoiceRecordKey = DEFAULT_VOICE_RECORD_KEY, + interruptKey?: ParsedInterruptKey ): boolean => (key.ctrl && input === 'c') || (key.ctrl && input === 'x') || @@ -1546,6 +1551,7 @@ export const shouldPassThroughToGlobalHandler = ( key.pageUp || key.pageDown || key.escape || + (interruptKey ? isInterruptKey(key, input, interruptKey) : false) || isVoiceToggleKey(key, input, voiceRecordKey) export interface TextInputMouseApi { diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index d243b84d85ec..2eb2145c21a1 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -21,6 +21,7 @@ export const HOTKEYS: [string, string][] = [ [action + '+G / Alt+G', 'open $EDITOR (Alt+G fallback for VSCode/Cursor)'], [action + '+L', 'redraw / repaint'], [paste + '+V / /paste', 'paste text; /paste attaches clipboard image'], + ['Esc (or interrupt_key)', 'dismiss completions / interrupt (when busy) / cancel queue edit / clear selection'], ['Esc Esc', 'discard draft (recall with ↑)'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index c5bb9ff0d370..b84abb740795 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -84,6 +84,7 @@ export interface ConfigDisplayConfig { /** Focus view (/focus) — display-only reduced-output mode. */ focus_view?: boolean inline_diffs?: boolean + interrupt_key?: unknown mouse_tracking?: boolean | null | number | string sections?: Record show_cost?: boolean diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index cb9572f26150..77aef3d8d6e4 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { CompletionItem } from '../app/interfaces.js' import { inlineSlashTrigger, looksLikeSlashCommand } from '../domain/slash.js' @@ -80,16 +80,16 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient const [compReplace, setCompReplace] = useState(0) const ref = useRef('') - useEffect(() => { - const clear = () => { - setCompletions(prev => (prev.length ? [] : prev)) - setCompIdx(prev => (prev ? 0 : prev)) - setCompReplace(prev => (prev ? 0 : prev)) - } + const clearCompletions = useCallback(() => { + setCompletions(prev => (prev.length ? [] : prev)) + setCompIdx(prev => (prev ? 0 : prev)) + setCompReplace(prev => (prev ? 0 : prev)) + }, []) + useEffect(() => { if (blocked) { ref.current = '' - clear() + clearCompletions() return } @@ -103,7 +103,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient const request = completionRequestForInput(input) if (!request) { - clear() + clearCompletions() return } @@ -161,7 +161,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient }, 60) return () => clearTimeout(t) - }, [blocked, gw, input]) + }, [blocked, clearCompletions, gw, input]) - return { completions, compIdx, setCompIdx, compReplace } + return { clearCompletions, completions, compIdx, setCompIdx, compReplace } } diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index 60f6758684ce..5d11cac8ac14 100644 --- a/ui-tui/src/lib/platform.ts +++ b/ui-tui/src/lib/platform.ts @@ -412,3 +412,116 @@ export const isVoiceToggleKey = ( return key.super === true && !key.ctrl && !key.alt && !key.meta } } + +// --- Interrupt key (display.interrupt_key) --- + +export interface ParsedInterruptKey { + ch: string + mod: 'alt' | 'ctrl' | 'super' + named?: string + raw: string +} + +export const DEFAULT_INTERRUPT_KEY: ParsedInterruptKey = { + ch: '', + mod: 'ctrl', + raw: 'escape' +} + +export const parseInterruptKey = (raw: unknown): ParsedInterruptKey => { + if (typeof raw !== 'string' || !raw.trim()) { + return DEFAULT_INTERRUPT_KEY + } + + const normalized = raw.trim().toLowerCase() + + if (normalized === 'escape' || normalized === 'esc') { + return DEFAULT_INTERRUPT_KEY + } + + const parts = normalized + .split('+') + .map(p => p.trim()) + .filter(Boolean) + + if (!parts.length) { + return DEFAULT_INTERRUPT_KEY + } + + const last = parts[parts.length - 1] + const modCandidates = parts.slice(0, -1) + + if (modCandidates.length !== 1) { + return DEFAULT_INTERRUPT_KEY + } + + const mod = _MOD_ALIASES[modCandidates[0]] + + if (!mod) { + return DEFAULT_INTERRUPT_KEY + } + + // Only reject ctrl+c — it's the universal SIGINT / exit chord in the TUI. + if (mod === 'ctrl' && last === 'c') { + return DEFAULT_INTERRUPT_KEY + } + + if (last.length === 1) { + return { ch: last, mod, raw: normalized } + } + + const named = _NAMED_KEY_ALIASES[last] + + if (named) { + return { ch: named, mod, named, raw: normalized } + } + + return DEFAULT_INTERRUPT_KEY +} + +export const isInterruptKey = ( + key: RuntimeKeyEvent, + ch: string, + configured: ParsedInterruptKey = DEFAULT_INTERRUPT_KEY +): boolean => { + if (configured.raw === 'escape') { + return !!key.escape && !key.ctrl && !key.alt && key.super !== true && !key.shift + } + + // Standalone matching — no macOS Cmd fallback (unlike voice toggle). + if (configured.named) { + if (!_matchesNamedKey(configured.named as VoiceRecordKeyNamed, key, ch)) { + return false + } + } else if (ch.toLowerCase() !== configured.ch) { + return false + } + + if (key.shift === true) { + return false + } + + switch (configured.mod) { + case 'alt': + return (key.alt === true || (key.meta && key.escape !== true)) && !key.ctrl && key.super !== true + + case 'ctrl': + return !!key.ctrl && !key.alt && !key.meta && key.super !== true + + case 'super': + return key.super === true && !key.ctrl && !key.alt && !key.meta + } +} + +export const formatInterruptKey = (parsed: ParsedInterruptKey): string => { + if (parsed.raw === 'escape') { + return 'Esc' + } + + const modLabel = + parsed.mod === 'super' ? (isMac ? 'Cmd' : 'Super') : parsed.mod[0].toUpperCase() + parsed.mod.slice(1) + + const keyLabel = parsed.named ? parsed.named[0].toUpperCase() + parsed.named.slice(1) : parsed.ch.toUpperCase() + + return `${modLabel}+${keyLabel}` +}