diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index 890ba02840c..379b6732a88 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -63,6 +63,7 @@ import { $statusItemsBySession } from '@/store/composer-status'
import { notify } from '@/store/notifications'
import { $previewStatusBySession } from '@/store/preview-status'
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
+import { $activeSessionAwaitingInput } from '@/store/prompts'
import { toggleReview } from '@/store/review'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
@@ -229,6 +230,11 @@ export function ChatBar({
const statusItemsBySession = useStore($statusItemsBySession)
const previewStatusBySession = useStore($previewStatusBySession)
const scrolledUp = useStore($threadScrolledUp)
+ // The turn is parked on the user (clarify / approval / sudo / secret). Esc must
+ // not interrupt it — there's nothing actively running to stop, and stopping
+ // would discard a question the user may want to come back to. The blocking
+ // prompt owns its own dismissal (Skip, Reject, dialog close).
+ const awaitingInput = useStore($activeSessionAwaitingInput)
// Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N
// tiny window, subagent watch windows) always start docked and can't pop out:
// a floating composer makes no sense in a single-session side window, and it
@@ -1214,8 +1220,10 @@ export function ChatBar({
return
}
- // Otherwise Esc interrupts the running turn (Stop-button parity).
- if (busy) {
+ // Otherwise Esc interrupts the running turn (Stop-button parity) — unless
+ // the turn is parked waiting on the user, where Esc must not discard the
+ // pending prompt.
+ if (busy && !awaitingInput) {
event.preventDefault()
triggerHaptic('cancel')
void Promise.resolve(onCancel())
@@ -1779,7 +1787,10 @@ export function ChatBar({
const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {})
escCancelRef.current = (event: globalThis.KeyboardEvent) => {
- if (event.key !== 'Escape' || event.defaultPrevented || !busy) {
+ // `awaitingInput`: the turn is parked on a clarify / approval / sudo / secret
+ // prompt, which owns Esc (or is meant to persist) — never cancel the stream
+ // out from under it.
+ if (event.key !== 'Escape' || event.defaultPrevented || !busy || awaitingInput) {
return
}
diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts
index e4d9d03430b..09ba7591716 100644
--- a/apps/desktop/src/app/session/hooks/use-message-stream.ts
+++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts
@@ -27,7 +27,7 @@ import {
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { parseTodos } from '@/lib/todos'
-import { setClarifyRequest } from '@/store/clarify'
+import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
import { setSessionCompacting } from '@/store/compaction'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { $gateway } from '@/store/gateway'
@@ -913,6 +913,7 @@ export function useMessageStream({
// session so a background turn finishing can't wipe the active chat's
// prompt, and vice versa.
clearAllPrompts(sessionId)
+ clearClarifyRequest(undefined, sessionId)
setSessionCompacting(sessionId, false)
flushQueuedDeltas(sessionId)
@@ -1185,6 +1186,7 @@ export function useMessageStream({
// the failed turn (same intent as the message.complete clear).
if (sessionId) {
clearAllPrompts(sessionId)
+ clearClarifyRequest(undefined, sessionId)
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
}
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
index 513043d01b8..6e2829d6178 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
@@ -27,6 +27,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setSessionYolo } from '@/lib/yolo-session'
+import { clearClarifyRequest } from '@/store/clarify'
import { openCommandPalettePage } from '@/store/command-palette'
import {
$composerAttachments,
@@ -44,6 +45,7 @@ import { setPetScale } from '@/store/pet-gallery'
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
import { clearPreviewArtifacts } from '@/store/preview-status'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
+import { clearAllPrompts } from '@/store/prompts'
import {
$busy,
$connection,
@@ -1535,6 +1537,7 @@ export function usePromptActions({
awaitingResponse: false,
streamId: null,
pendingBranchGroup: null,
+ needsInput: false,
interrupted: true
}
})
@@ -1542,6 +1545,12 @@ export function usePromptActions({
clearSessionTodos(sessionId)
clearSessionSubagents(sessionId)
resetSessionBackground(sessionId)
+ // Stop ends the turn, so the gateway is no longer blocked on any prompt it
+ // raised. Drop this session's pending clarify / approval / sudo / secret so
+ // a dead panel (and the sidebar "needs input" dot) can't linger and accept
+ // an answer the backend will reject.
+ clearAllPrompts(sessionId)
+ clearClarifyRequest(undefined, sessionId)
try {
await requestGateway('session.interrupt', { session_id: sessionId })
diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
index 9c0e918cf74..d5f5b0de511 100644
--- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
+++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
@@ -1,21 +1,32 @@
'use client'
-import { type ToolCallMessagePartProps } from '@assistant-ui/react'
+import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
-import { type ComponentProps, type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
+import {
+ type ComponentProps,
+ type FormEvent,
+ type KeyboardEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState
+} from 'react'
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
-import { KbdCombo } from '@/components/ui/kbd'
+import { Kbd } from '@/components/ui/kbd'
import { Textarea } from '@/components/ui/textarea'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
-import { Check, HelpCircle, Loader2 } from '@/lib/icons'
+import { Loader2, MessageQuestion } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
+import { selectMessageRunning } from './tool-fallback-model'
+
interface ClarifyArgs {
question?: string
choices?: string[] | null
@@ -35,40 +46,64 @@ function readClarifyArgs(args: unknown): ClarifyArgs {
}
}
-// Choice and "Other" rows share a layout; only color/hover differs.
-const OPTION_ROW_CLASS = 'flex w-full items-start gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors'
+// Each option (and "Other") is keyed A, B, C… so it can be picked by pressing
+// that letter — the badge doubles as the shortcut hint.
+const letterFor = (index: number): string => String.fromCharCode(65 + index)
+// Choice and "Other" rows share a layout; only color differs. Mirrors a tool
+// row's compact rhythm so the panel reads as part of the transcript.
+const OPTION_ROW_CLASS =
+ 'flex w-full items-start gap-2 rounded-[0.25rem] px-1.5 py-1 text-left disabled:cursor-not-allowed disabled:opacity-50'
+
+// Content-sizing freeform field (CSS `field-sizing` — same primitive as the
+// commit bar and search field): starts at one line, grows with what's typed,
+// and never reflows the panel when focused. Bare so the "Other" row matches the
+// choice rows above it.
+const FREEFORM_INPUT_CLASS =
+ 'field-sizing-content max-h-40 min-h-0 w-full resize-none bg-transparent p-0 leading-(--conversation-line-height) text-(--ui-text-primary) outline-none placeholder:text-(--ui-text-tertiary) disabled:opacity-50'
+
+// Quiet inline panel that matches the surrounding tool rows: a single hairline
+// border in the shared stroke token, a soft surface fill, and a faint primary
+// accent that signals "this one needs you" without the loud animated ring.
const CLARIFY_SHELL_CLASS =
- 'relative mb-3 mt-2 rounded-[0.5rem] border border-border/70 bg-card/40 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
+ 'my-1.5 rounded-md border border-primary/20 bg-(--ui-chat-surface-background) text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)'
function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>) {
return (
-
{children}
)
}
-function RadioDot({ selected }: { selected: boolean }) {
+// Selection lives on the letter badge alone — a solid primary fill — not the
+// whole row, which stays a quiet hover target. `preview` is the focused-but-empty
+// "Other" state: the badge outlines in primary to show it's armed, then fills
+// once a value is actually typed.
+function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean; selected: boolean }) {
return (
-
- {selected && }
-
+ {char}
+
)
}
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
- const isPending = props.result === undefined
+ const messageRunning = useAuiState(selectMessageRunning)
+
+ // Only the live, still-blocked turn shows the interactive panel. Once the
+ // message stops running — answered, the turn ended, or the user hit Stop —
+ // fall back to the standard tool block so the Q/A settles like every other
+ // row instead of stranding a dead prompt the gateway no longer waits on.
+ const isPending = messageRunning && props.result === undefined
- // Once Hermes records an answer, fall back to the standard tool block so
- // the past Q/A renders consistently with every other tool in the thread.
if (!isPending) {
return
}
@@ -104,10 +139,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const hasChoices = choices.length > 0
- const [typing, setTyping] = useState(false)
const [draft, setDraft] = useState('')
const [submitting, setSubmitting] = useState(false)
const [selectedChoice, setSelectedChoice] = useState(null)
+ const [otherFocused, setOtherFocused] = useState(false)
const textareaRef = useRef(null)
// Race: tool.start fires a tick before clarify.request, so request_id
@@ -150,6 +185,30 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
[copy.gatewayDisconnected, copy.notReady, copy.sendFailed, gateway, matchingRequest, ready]
)
+ const trimmedDraft = draft.trim()
+ // The answer is whichever input is active: a picked choice, or typed text.
+ // Picking a choice no longer fires immediately — it selects, then the user
+ // confirms with Continue (or Enter from the field).
+ const pendingAnswer = selectedChoice ?? (trimmedDraft || null)
+
+ const selectChoice = useCallback((choice: string) => {
+ // Picking a choice and typing are mutually exclusive answers.
+ setDraft('')
+ setSelectedChoice(choice)
+ }, [])
+
+ const submitAnswer = useCallback(() => {
+ if (selectedChoice !== null) {
+ void respond(selectedChoice)
+
+ return
+ }
+
+ if (trimmedDraft) {
+ void respond(trimmedDraft)
+ }
+ }, [respond, selectedChoice, trimmedDraft])
+
const handleTextareaKey = useCallback(
(event: KeyboardEvent) => {
if (event.nativeEvent.isComposing) {
@@ -158,147 +217,166 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
- const trimmed = draft.trim()
-
- if (trimmed) {
- void respond(trimmed)
- }
+ submitAnswer()
}
},
- [draft, respond]
+ [submitAnswer]
)
- const handleSubmitFreeform = useCallback(
+ const handleSubmit = useCallback(
(event: FormEvent) => {
event.preventDefault()
- const trimmed = draft.trim()
-
- if (trimmed) {
- void respond(trimmed)
- }
+ submitAnswer()
},
- [draft, respond]
+ [submitAnswer]
)
+ // Letter shortcuts: A/B/C… pick the matching option, the trailing letter jumps
+ // into "Other", and Enter confirms the current pick. Stands down whenever a
+ // field is focused (you're typing, not navigating) so it never eats keystrokes
+ // meant for the composer or the Other box.
+ useEffect(() => {
+ if (!ready || !hasChoices || submitting) {
+ return
+ }
+
+ const onKeyDown = (event: globalThis.KeyboardEvent) => {
+ if (event.metaKey || event.ctrlKey || event.altKey || event.defaultPrevented) {
+ return
+ }
+
+ const active = document.activeElement as HTMLElement | null
+
+ if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
+ return
+ }
+
+ const key = event.key.toLowerCase()
+
+ if (key.length === 1 && key >= 'a' && key <= 'z') {
+ const index = key.charCodeAt(0) - 97
+
+ if (index < choices.length) {
+ event.preventDefault()
+ selectChoice(choices[index])
+ } else if (index === choices.length) {
+ event.preventDefault()
+ textareaRef.current?.focus()
+ }
+
+ return
+ }
+
+ if (event.key === 'Enter' && pendingAnswer) {
+ event.preventDefault()
+ submitAnswer()
+ }
+ }
+
+ window.addEventListener('keydown', onKeyDown)
+
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [choices, hasChoices, pendingAnswer, ready, selectChoice, submitAnswer, submitting])
+
if (loading) {
return (
-
-
+
+
)
}
+ const onDraftChange = (value: string) => {
+ setDraft(value)
+
+ // Typing is its own answer — drop any picked choice so the two inputs can't
+ // both look selected.
+ if (value.trim()) {
+ setSelectedChoice(null)
+ }
+ }
+
return (
-
-