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 (
-
-