Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 },
Expand Down
102 changes: 102 additions & 0 deletions ui-tui/src/__tests__/interruptKey.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) =>
({ 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')
})
})
16 changes: 15 additions & 1 deletion ui-tui/src/__tests__/textInputPassThrough.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => ({ ctrl: false, meta: false, ...overrides }) as any

Expand Down Expand Up @@ -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)
})
})
5 changes: 4 additions & 1 deletion ui-tui/src/app/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -438,6 +439,7 @@ export interface InputHandlerContext {
state: ComposerState
}
gateway: GatewayServices
interruptKey: ParsedInterruptKey
terminal: {
hasSelection: boolean
scrollRef: RefObject<null | ScrollBoxHandle>
Expand Down Expand Up @@ -568,6 +570,7 @@ export interface AppLayoutComposerProps {
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
input: string
inputBuf: string[]
interruptKey: ParsedInterruptKey
pagerPageSize: number
queueEditIdx: null | number
queuedDisplay: string[]
Expand Down
4 changes: 3 additions & 1 deletion ui-tui/src/app/useComposerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
Expand Down Expand Up @@ -424,6 +424,7 @@ export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions
() => ({
attachClipboardImage,
attachImagePath,
clearCompletions,
clearIn,
dequeue,
enqueue,
Expand All @@ -444,6 +445,7 @@ export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions
[
attachClipboardImage,
attachImagePath,
clearCompletions,
clearIn,
dequeue,
enqueue,
Expand Down
44 changes: 35 additions & 9 deletions ui-tui/src/app/useConfigSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -239,18 +246,20 @@ 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<ConfigFullResponse | null> {
const cfg = await quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' })
applyDisplay(cfg, setBell, setVoiceRecordKey)
applyDisplay(cfg, setBell, setVoiceRecordKey, setInterruptKey)

return cfg
}

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 ?? {}

Expand All @@ -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({
Expand All @@ -291,6 +315,7 @@ export const applyDisplay = (
export function useConfigSync({
gw,
setBellOnComplete,
setInterruptKey,
setVoiceEnabled,
setVoiceRecordKey,
sid
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions ui-tui/src/app/useInputHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading