feat(desktop): honor busy input mode

This commit is contained in:
ethernet 2026-07-23 00:40:45 -04:00
parent de5ece9944
commit 742ecb527a
19 changed files with 263 additions and 95 deletions

View file

@ -86,7 +86,7 @@ test.describe('chat interaction with mock backend', () => {
await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
})
test('offers stop, steer, and queue actions while busy', async ({}, testInfo) => {
test('offers stop, redirect, and queue actions while busy', async ({}, testInfo) => {
const page = fixture!.page
const composer = page.locator('[contenteditable="true"]').first()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
@ -106,7 +106,7 @@ test.describe('chat interaction with mock backend', () => {
await composer.click()
await composer.type('please answer tersely')
await expect(primary).toHaveAttribute('aria-label', /Steer/)
await expect(primary).toHaveAttribute('aria-label', /Redirect/)
await expect(dictation).toBeVisible()
await expect(speakReplies).toBeVisible()
await expect(queue).toBeVisible()
@ -120,10 +120,10 @@ test.describe('chat interaction with mock backend', () => {
expect(controlLabels.indexOf('Voice dictation')).toBeLessThan(speakRepliesIndex)
expect(speakRepliesIndex).toBeLessThan(controlLabels.indexOf('Queue message'))
expect(controlLabels.indexOf('Queue message')).toBeLessThan(
controlLabels.findIndex(label => label?.startsWith('Steer'))
controlLabels.findIndex(label => label?.startsWith('Redirect'))
)
await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') })
await expect(primary.locator('svg.tabler-icon-steering-wheel')).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('busy-composer-redirect.png') })
await expect(primary.locator('svg.tabler-icon-git-branch')).toBeVisible()
await queue.click()
await expect(primary).toHaveAttribute('aria-label', 'Stop')

View file

@ -29,14 +29,13 @@ async function send(page: Page, text: string): Promise<void> {
await page.keyboard.press('Enter')
}
async function steer(page: Page, text: string): Promise<void> {
async function redirect(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
await composer.type(text, { delay: 5 })
await expect(primary).toHaveAttribute('aria-label', /Steer/)
await primary.click()
}
@ -157,9 +156,9 @@ test.describe('correction session switch', () => {
await waitForTranscriptText(page, TOOL_STARTED)
await waitForTranscriptText(page, ORIGINAL_PROMPT)
// The historical session redirects while a foreground terminal task is
// running. Use the visible Steer action to cover the real composer path.
await steer(page, CORRECTION)
// Redirect immediately while the foreground terminal task is still live.
// Waiting for an old control label here races the action into a plain Send.
await redirect(page, CORRECTION)
await waitForTranscriptText(page, CORRECTION)
const orderBeforeSwitch = relevantOrder(await transcriptTextOrder(page))

View file

@ -12,9 +12,9 @@ import { expect, test, type Page } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { MOCK_REPLY } from './mock-server'
const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE'
const ACTIVE_PROMPT = 'write me a short story'
const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED'
const STEER_PROMPT = 'E2E_STEER_TURN_BOUNDARY_CORRECTION'
const STEER_PROMPT = 'actually just say hi'
async function send(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
@ -100,7 +100,7 @@ test.describe('queued prompt turn boundary', () => {
await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1)
})
test('places a steer prompt before the reply it redirects', async () => {
test('redirect replaces the live turn once and restarts inference with the correction', async () => {
const { mock, page } = fixture!
await send(page, ACTIVE_PROMPT)
@ -114,6 +114,9 @@ test.describe('queued prompt turn boundary', () => {
{ timeout: 60_000 }
)
expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY])
const messages = await transcriptMessageOrder(page)
expect(steerTurnOrder(messages)).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY])
await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === ACTIVE_PROMPT || prompt === STEER_PROMPT))
.toEqual([ACTIVE_PROMPT, STEER_PROMPT])
})
})

View file

@ -130,10 +130,13 @@ auxiliary:
await fixture.mock.waitForHeldCompletion()
await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible()
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(queued)
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
await expect(primary).toHaveAttribute('aria-label', 'Queue message')
await send(page, queued)
await page.keyboard.press('Enter')
await expect(page.getByText('1 Queued')).toBeVisible()
expect(fixture.mock.heldCompletionCount()).toBe(1)
expect(receivedUserTexts()).not.toContain(queued)

View file

@ -0,0 +1,44 @@
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
export type BusyComposerAction = 'redirect' | 'queue' | 'steer' | 'stop'
export const normalizeBusyInputMode = (value: unknown): BusyInputMode =>
value === 'queue' || value === 'steer' ? value : 'interrupt'
/**
* Maps the persisted cross-surface busy-input policy to Desktop's visible
* composer action. `interrupt` is the historical config spelling; capable
* desktop sessions implement it as an active-turn redirect.
*/
export function resolveBusyComposerAction({
busy,
canRedirect,
canSteer,
compacting,
hasPayload,
mode
}: {
busy: boolean
canRedirect: boolean
canSteer: boolean
compacting: boolean
hasPayload: boolean
mode: BusyInputMode
}): BusyComposerAction {
if (!busy) {
return 'stop'
}
if (mode === 'queue') {
return hasPayload ? 'queue' : 'stop'
}
if (!compacting && mode === 'interrupt' && canRedirect) {
return 'redirect'
}
if (!compacting && mode === 'steer' && canSteer) {
return 'steer'
}
return hasPayload ? 'queue' : 'stop'
}

View file

@ -71,9 +71,21 @@ describe('ComposerControls shortcut tooltips', () => {
await expectShortcutTooltip('Steer the current run', '↵')
})
it('shows Ctrl+Enter for Queue', async () => {
it('shows Enter for Queue when queue is the configured busy action', async () => {
renderControls({ busy: true, busyAction: 'queue' })
await expectShortcutTooltip('Queue message', '↵')
})
it('shows Redirect on Enter', async () => {
renderControls({ busy: true, busyAction: 'redirect' })
await expectShortcutTooltip('Redirect the current run', '↵')
})
it('shows the secondary Queue shortcut beside Redirect', async () => {
renderControls({ busy: true, busyAction: 'redirect' })
await expectShortcutTooltip('Queue message', 'Ctrl+↵')
})
})

View file

@ -3,7 +3,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
import { AudioLines, GitBranch, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
@ -53,7 +53,7 @@ export function ComposerControls({
}: {
autoSpeak: boolean
busy: boolean
busyAction: 'steer' | 'queue' | 'stop'
busyAction: 'redirect' | 'steer' | 'queue' | 'stop'
canSubmit: boolean
compactModelPill?: boolean
conversation: ConversationProps
@ -73,15 +73,16 @@ export function ComposerControls({
}
const showVoicePrimary = !busy && !hasComposerPayload
const busyLabel = busyAction === 'queue' ? c.queueMessage : busyAction === 'steer' ? c.steer : c.stop
const busyLabel =
busyAction === 'queue' ? c.queueMessage : busyAction === 'redirect' ? c.redirect : busyAction === 'steer' ? c.steer : c.stop
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
{busyAction === 'steer' ? (
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
{busyAction === 'redirect' || busyAction === 'steer' ? (
<Tip label={<TipKeybindLabel actionId="composer.queue" combo="mod+enter" text={c.queueMessage} />}>
<Button
aria-label={c.queueMessage}
className={GHOST_ICON_BTN}
@ -116,13 +117,12 @@ export function ComposerControls({
label={
busy ? (
<TipKeybindLabel
actionId={
busyAction === 'steer'
? 'composer.steer'
: busyAction === 'queue'
? 'composer.queue'
: 'composer.send'
}
actionId={busyAction === 'redirect' ? 'composer.redirect' : busyAction === 'steer'
? 'composer.steer'
: busyAction === 'queue'
? 'composer.queue'
: 'composer.send'}
combo="enter"
text={busyLabel}
/>
) : (
@ -139,6 +139,8 @@ export function ComposerControls({
{busy ? (
busyAction === 'queue' ? (
<Layers3 className={iconSize.sm} />
) : busyAction === 'redirect' ? (
<GitBranch className={iconSize.sm} />
) : busyAction === 'steer' ? (
<SteeringWheel className={iconSize.sm} />
) : (

View file

@ -8,6 +8,7 @@ import { useComposerSubmit } from './use-composer-submit'
interface SubmitHarnessOptions {
attachments?: ComposerAttachment[]
busy?: boolean
busyInputMode?: 'interrupt' | 'queue' | 'steer'
compacting?: boolean
text?: string
}
@ -15,6 +16,7 @@ interface SubmitHarnessOptions {
function renderSubmitHook({
attachments = [],
busy = false,
busyInputMode = 'interrupt',
compacting = false,
text = ''
}: SubmitHarnessOptions = {}) {
@ -25,6 +27,7 @@ function renderSubmitHook({
const editorRef = { current: editor }
const onCancel = vi.fn()
const onSteer = vi.fn(async () => true)
const onToolSteer = vi.fn(async () => true)
const onSubmit = vi.fn(async () => true)
const queueCurrentDraft = vi.fn(() => true)
@ -39,6 +42,7 @@ function renderSubmitHook({
activeQueueSessionKeyRef: { current: 'stored-session' },
attachments,
busy,
busyInputMode,
compacting,
clearDraft,
disabled: false,
@ -52,6 +56,7 @@ function renderSubmitHook({
onCancel,
onSteer,
onSubmit,
onToolSteer,
queueCurrentDraft,
queueEdit: null,
queuedPrompts: [],
@ -61,7 +66,7 @@ function renderSubmitHook({
})
)
return { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft }
return { clearDraft, hook, onCancel, onSteer, onSubmit, onToolSteer, queueCurrentDraft }
}
describe('useComposerSubmit busy-turn routing', () => {
@ -70,7 +75,7 @@ describe('useComposerSubmit busy-turn routing', () => {
vi.restoreAllMocks()
})
it('steers a plain-text follow-up instead of queueing or stopping', async () => {
it('redirects a plain-text follow-up in interrupt mode instead of queueing or stopping', async () => {
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
busy: true,
text: 'change course'
@ -86,6 +91,38 @@ describe('useComposerSubmit busy-turn routing', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
it('queues a plain-text follow-up in queue mode', () => {
const { hook, onSteer, onToolSteer, queueCurrentDraft } = renderSubmitHook({
busy: true,
busyInputMode: 'queue',
text: 'wait for it'
})
act(() => {
hook.result.current.submitDraft()
})
expect(queueCurrentDraft).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
expect(onToolSteer).not.toHaveBeenCalled()
})
it('uses tool-boundary steering in steer mode', async () => {
const { hook, onSteer, onToolSteer, queueCurrentDraft } = renderSubmitHook({
busy: true,
busyInputMode: 'steer',
text: 'after the next tool call'
})
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() => expect(onToolSteer).toHaveBeenCalledWith('after the next tool call'))
expect(onSteer).not.toHaveBeenCalled()
expect(queueCurrentDraft).not.toHaveBeenCalled()
})
it('queues a plain-text follow-up while the active turn is compacting', () => {
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
busy: true,

View file

@ -6,6 +6,7 @@ import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue'
import type { BusyInputMode } from '../busy-input-mode'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { onComposerSubmitRequest } from '../focus'
import { composerPlainText } from '../rich-editor'
@ -17,6 +18,7 @@ interface UseComposerSubmitArgs {
activeQueueSessionKeyRef: RefObject<string | null>
attachments: ComposerAttachment[]
busy: boolean
busyInputMode: BusyInputMode
compacting: boolean
clearDraft: () => void
disabled: boolean
@ -30,6 +32,7 @@ interface UseComposerSubmitArgs {
onCancel: ChatBarProps['onCancel']
onSteer: ChatBarProps['onSteer']
onSubmit: ChatBarProps['onSubmit']
onToolSteer: ChatBarProps['onToolSteer']
queueCurrentDraft: () => boolean
queueEdit: QueueEditState | null
queuedPrompts: QueuedPromptEntry[]
@ -43,15 +46,14 @@ interface UseComposerSubmitArgs {
* queue meet. `submitDraft` is the one decision tree (queue-edit save · slash-
* now-while-busy · queue · drain · send · stop); `dispatchSubmit` is the shared
* send-with-restore primitive (re-loads + re-stashes the draft if the gateway
* rejects, so nothing is ever lost); `steerDraft` redirects the live turn. Reads
* the draft + queue APIs; owns no state of its own beyond the stable
* external-submit listener ref.
* rejects, so nothing is ever lost).
*/
export function useComposerSubmit({
activeQueueSessionKey,
activeQueueSessionKeyRef,
attachments,
busy,
busyInputMode,
compacting,
clearDraft,
disabled,
@ -65,6 +67,7 @@ export function useComposerSubmit({
onCancel,
onSteer,
onSubmit,
onToolSteer,
queueCurrentDraft,
queueEdit,
queuedPrompts,
@ -110,6 +113,48 @@ export function useComposerSubmit({
[inputDisabled]
)
const queueFallback = (text: string) => {
if (activeQueueSessionKey) {
enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] })
}
}
const redirectDraft = () => {
const text = draftRef.current.trim()
if (!onSteer || !text || attachments.length > 0 || SLASH_COMMAND_RE.test(text)) {
queueCurrentDraft()
return
}
triggerHaptic('submit')
clearDraft()
void Promise.resolve(onSteer(text)).then(accepted => {
if (!accepted) {
queueFallback(text)
}
})
}
const toolSteerDraft = () => {
const text = draftRef.current.trim()
if (!onToolSteer || !text || attachments.length > 0 || SLASH_COMMAND_RE.test(text)) {
queueCurrentDraft()
return
}
triggerHaptic('submit')
clearDraft()
void Promise.resolve(onToolSteer(text)).then(accepted => {
if (!accepted) {
queueFallback(text)
}
})
}
const submitDraft = () => {
if (disabled) {
return
@ -140,29 +185,23 @@ export function useComposerSubmit({
if (queueEdit) {
exitQueuedEdit('save')
} else if (busy) {
// Slash commands should execute immediately even while the agent is
// busy — they're client-side operations (/yolo, /skin, /new, /help,
// etc.) or self-contained gateway RPCs (/status, /compress). onSubmit
// routes them to executeSlashCommand, which has its own per-command
// busy guard for commands that genuinely need an idle session (skill
// /send directives). Queuing them would make every slash command wait
// for the current turn to finish, which is how the TUI never behaves.
// Slash commands execute immediately even while the agent is busy.
if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
triggerHaptic('submit')
clearDraft()
dispatchSubmit(text)
} else if (!compacting && !attachments.length && text.trim()) {
// Cursor-style stop-and-correct: interrupt the live turn and redirect
// it with this text. redirect() preserves the shown reasoning/work; if
// the turn already ended, steerDraft re-queues so nothing is lost.
steerDraft()
if (busyInputMode === 'queue') {
queueCurrentDraft()
} else if (busyInputMode === 'steer') {
toolSteerDraft()
} else {
redirectDraft()
}
} else if (payloadPresent) {
// Attachments can't ride a redirect (no tool-result image carriage) —
// queue the whole payload for the next turn.
// Attachments cannot ride either redirect or tool-boundary steering.
queueCurrentDraft()
} else {
// Stop button (the only way to reach here while busy with an empty
// composer — empty Enter is short-circuited in the keydown handler).
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
@ -180,28 +219,6 @@ export function useComposerSubmit({
focusInput()
}
// Redirect the live turn with a correction. The gateway either restarts the
// active model request with its displayed context or waits for the current
// tool boundary. If the turn already ended, queue the words instead.
const steerDraft = () => {
const text = draftRef.current.trim()
// Guard on live editor state, not the render-lagged `canSteer`: a redirect
// fired on a fast Enter must not be dropped because state hasn't synced.
if (!onSteer || !text || attachments.length > 0 || SLASH_COMMAND_RE.test(text)) {
return
}
triggerHaptic('submit')
clearDraft()
void Promise.resolve(onSteer(text)).then(accepted => {
if (!accepted && activeQueueSessionKey) {
enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] })
}
})
}
const queueDraft = () => {
if (disabled || !busy) {
return
@ -211,5 +228,5 @@ export function useComposerSubmit({
focusInput()
}
return { dispatchSubmit, queueDraft, steerDraft, submitDraft }
return { dispatchSubmit, queueDraft, redirectDraft, toolSteerDraft, submitDraft }
}

View file

@ -2,6 +2,7 @@ import { ComposerPrimitive } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react'
import { useHermesConfigRecord } from '@/app/hooks/use-config-record'
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { Button } from '@/components/ui/button'
import { Slot as ContribSlot } from '@/contrib/react/slot'
@ -22,6 +23,7 @@ import { $autoSpeakReplies } from '@/store/voice-prefs'
import { useTheme } from '@/themes'
import { AttachmentList } from './attachments'
import { normalizeBusyInputMode, resolveBusyComposerAction } from './busy-input-mode'
import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils'
import { ContextMenu } from './context-menu'
import { COMPOSER_AREAS, runComposerMiddleware } from './contrib'
@ -104,6 +106,9 @@ export function ChatBar({
// Which live composer this instance IS (main | tile) — its attachment set,
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
const scope = useComposerScope()
const { data: configRecord } = useHermesConfigRecord()
const config = configRecord?.config as { display?: { busy_input_mode?: unknown } } | undefined
const busyInputMode = normalizeBusyInputMode(config?.display?.busy_input_mode)
const attachments = useStore(scope.attachments.$attachments)
const compacting = useStore($compactionActive)
const scrolledUp = useStore($threadScrolledUp)
@ -230,25 +235,34 @@ export function ChatBar({
const hasComposerPayload = hasText || attachments.length > 0
const canSubmit = busy || hasComposerPayload
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
// into a tool result) and never for a slash command (those execute inline).
const canSteer = busy && !compacting && !!onSteer && attachments.length === 0 && isSteerableText
// Redirect and tool-boundary steering are text-only. Attachments must queue.
const canRedirect = !!onSteer && attachments.length === 0 && isSteerableText
const canToolSteer = !!gateway && !!sessionId && attachments.length === 0 && isSteerableText
const busyAction = resolveBusyComposerAction({
busy,
canRedirect,
canSteer: canToolSteer,
compacting,
hasPayload: hasComposerPayload,
mode: busyInputMode
})
const onToolSteer = useCallback(async (text: string) => {
if (!gateway || !sessionId) {
return false
}
// While busy: text redirects the live turn (Cursor-style stop-and-correct),
// attachments queue for the next turn, an empty composer stops.
const busyAction: 'steer' | 'queue' | 'stop' = canSteer
? 'steer'
: compacting || hasComposerPayload
? 'queue'
: 'stop'
const result = (await gateway.request('session.steer', { session_id: sessionId, text })) as { status?: string }
return result.status === 'queued'
}, [gateway, sessionId])
// The submit engine — the orchestration seam where draft + queue meet. Owns
// the submit decision tree, the send-with-restore primitive, and steer.
const { queueDraft, steerDraft, submitDraft } = useComposerSubmit({
const { queueDraft, submitDraft } = useComposerSubmit({
activeQueueSessionKey,
activeQueueSessionKeyRef,
attachments,
busy,
busyInputMode,
compacting,
clearDraft,
disabled,
@ -264,6 +278,7 @@ export function ChatBar({
onCancel: haltRun,
onSteer,
onSubmit,
onToolSteer,
queueCurrentDraft,
queueEdit,
queuedPrompts,

View file

@ -51,7 +51,10 @@ export interface ChatBarProps {
onPickFolders?: () => void
onPickImages?: () => void
onRemoveAttachment?: (id: string) => void
/** Immediate Cursor-style active-turn correction. */
onSteer?: (text: string) => Promise<boolean> | boolean
/** Tool-boundary steering; does not cancel model generation. */
onToolSteer?: (text: string) => Promise<boolean> | boolean
onSubmit: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
onTranscribeAudio?: (audio: Blob) => Promise<string>
}

View file

@ -642,7 +642,13 @@ export const SECTIONS: DesktopConfigSection[] = [
id: 'chat',
label: 'Chat',
icon: MessageCircle,
keys: ['display.personality', 'timezone', 'display.show_reasoning', 'agent.image_input_mode']
keys: [
'display.personality',
'timezone',
'display.show_reasoning',
'display.busy_input_mode',
'agent.image_input_mode'
]
},
{
id: 'appearance',

View file

@ -310,6 +310,17 @@ describe('settings helpers', () => {
})
describe('sectionFieldEntries', () => {
it('surfaces busy input mode in Chat Settings as the backend-defined select', () => {
const schema = {
'display.busy_input_mode': { type: 'select' as const, options: ['interrupt', 'queue', 'steer'] }
}
const config: HermesConfigRecord = { display: { busy_input_mode: 'interrupt' } }
const field = new Map(sectionFieldEntries(schema, config).get('chat') ?? []).get('display.busy_input_mode')
expect(field).toEqual(schema['display.busy_input_mode'])
})
it('renders memory.provider from config even when the backend schema omits it', () => {
const schema = { 'memory.memory_enabled': { type: 'boolean' as const } }
const config: HermesConfigRecord = { memory: { memory_enabled: true, provider: '' } }

View file

@ -1,6 +1,8 @@
import { useStore } from '@nanostores/react'
import { useMemo, useState } from 'react'
import { useHermesConfigRecord } from '@/app/hooks/use-config-record'
import { normalizeBusyInputMode } from '@/app/chat/composer/busy-input-mode'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Kbd, KbdCombo } from '@/components/ui/kbd'
@ -36,6 +38,12 @@ export function KeybindSettings() {
const { t } = useI18n()
const bindings = useStore($bindings)
const k = t.keybinds
const { data: configRecord } = useHermesConfigRecord()
const config = configRecord?.config as { display?: { busy_input_mode?: unknown } } | undefined
const busyInputMode = normalizeBusyInputMode(config?.display?.busy_input_mode)
const busyActionId = busyInputMode === 'interrupt' ? 'composer.redirect' : `composer.${busyInputMode}`
const readonlyLabel = (shortcut: KeybindReadonly) =>
shortcut.id === 'composer.busyAction' ? (k.actions[busyActionId] ?? busyActionId) : (k.actions[shortcut.id] ?? shortcut.id)
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
// Subscribe so contributed actions appear/disappear live in the map.
useContributions(KEYBINDS_AREA)
@ -87,11 +95,11 @@ export function KeybindSettings() {
const lower = query.toLowerCase()
return KEYBIND_READONLY.filter(shortcut => {
const label = k.actions[shortcut.id] ?? shortcut.id
const label = readonlyLabel(shortcut)
return label.toLowerCase().includes(lower) || shortcut.id.includes(lower)
})
}, [isSearching, query, k.actions])
}, [isSearching, query, k.actions, busyActionId])
return (
<SettingsContent>
@ -132,7 +140,7 @@ export function KeybindSettings() {
<KeybindRow action={action} key={action.id} />
))}
{filteredReadonly?.map(shortcut => (
<ReadonlyRow key={shortcut.id} shortcut={shortcut} />
<ReadonlyRow key={shortcut.id} label={readonlyLabel(shortcut)} shortcut={shortcut} />
))}
</>
)}
@ -160,7 +168,7 @@ export function KeybindSettings() {
open={sectionOpen}
/>
{sectionOpen && actions.map(action => <KeybindRow action={action} key={action.id} />)}
{sectionOpen && readonly.map(shortcut => <ReadonlyRow key={shortcut.id} shortcut={shortcut} />)}
{sectionOpen && readonly.map(shortcut => <ReadonlyRow key={shortcut.id} label={readonlyLabel(shortcut)} shortcut={shortcut} />)}
</section>
)
})}
@ -255,10 +263,7 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
// Fixed shortcut: same layout as KeybindRow but the caps aren't interactive and
// the trailing reset slot stays empty (spacer keeps the columns aligned).
function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
const { t } = useI18n()
const k = t.keybinds
const label = k.actions[shortcut.id] ?? shortcut.id
function ReadonlyRow({ label, shortcut }: { label: string; shortcut: KeybindReadonly }) {
return (
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1">

View file

@ -2,6 +2,7 @@ import { Tooltip as TooltipPrimitive } from 'radix-ui'
import * as React from 'react'
import { useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
import { cn } from '@/lib/utils'
@ -147,6 +148,8 @@ function TipHintLabel({ text, hint }: TipHintLabelProps) {
interface TipKeybindLabelProps {
/** Keybind action id — pulls the label from i18n AND the combo from the store. */
actionId: string
/** A context-specific fixed combo, used when one action has multiple routes. */
combo?: string
/** Override the i18n label (for context-dependent text like "Show"/"Hide"). */
text?: string
}
@ -154,9 +157,10 @@ interface TipKeybindLabelProps {
/** TipHintLabel that auto-reads both its label and keybind from the action
* registry. Pass only `actionId` for the common case; pass `text` to override
* when the button's tooltip is context-dependent. */
function TipKeybindLabel({ actionId, text }: TipKeybindLabelProps) {
function TipKeybindLabel({ actionId, combo, text }: TipKeybindLabelProps) {
const { t } = useI18n()
const hint = useKeybindHint(actionId)
const boundHint = useKeybindHint(actionId)
const hint = combo ? formatCombo(combo) : boundHint
const label = text ?? t.keybinds.actions[actionId] ?? actionId

View file

@ -291,6 +291,8 @@ export const en: Translations = {
'profile.create': 'Create profile',
'composer.send': 'Send message',
'composer.newline': 'Insert newline',
'composer.busyAction': 'Run configured busy-input action',
'composer.redirect': 'Redirect the running turn',
'composer.steer': 'Steer the running turn',
'composer.queue': 'Queue message',
'composer.sendQueued': 'Send next queued turn',
@ -1796,6 +1798,7 @@ export const en: Translations = {
],
startVoice: 'Start voice conversation',
queueMessage: 'Queue message',
redirect: 'Redirect the current run',
steer: 'Steer the current run',
stop: 'Stop',
send: 'Send',

View file

@ -1496,6 +1496,7 @@ export interface Translations {
followUpPlaceholders: readonly string[]
startVoice: string
queueMessage: string
redirect: string
steer: string
stop: string
send: string

View file

@ -282,7 +282,9 @@ export const zh: Translations = {
'profile.create': '创建配置',
'composer.send': '发送消息',
'composer.newline': '插入换行',
'composer.steer': '引导正在运行的回合',
'composer.busyAction': '执行配置的忙碌输入操作',
'composer.redirect': '重定向当前运行',
'composer.steer': '引导当前运行',
'composer.queue': '消息排队',
'composer.sendQueued': '发送下一条排队消息',
'composer.mention': '引用文件、文件夹、网址',
@ -1985,6 +1987,7 @@ export const zh: Translations = {
],
startVoice: '开始语音对话',
queueMessage: '排队消息',
redirect: '重定向当前运行',
steer: '引导当前运行',
stop: '停止',
send: '发送',

View file

@ -197,7 +197,7 @@ export interface KeybindReadonly {
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
{ id: 'composer.send', category: 'composer', keys: ['enter'] },
{ id: 'composer.newline', category: 'composer', keys: ['shift+enter'] },
{ id: 'composer.steer', category: 'composer', keys: ['enter'] },
{ id: 'composer.busyAction', category: 'composer', keys: ['enter'] },
{ id: 'composer.queue', category: 'composer', keys: ['mod+enter'] },
{ id: 'composer.sendQueued', category: 'composer', keys: ['mod+shift+k'] },
{ id: 'composer.mention', category: 'composer', keys: ['@'] },