From beafc55c7d99d87d6165665a19339dea774dec8b Mon Sep 17 00:00:00 2001 From: Jakub Wolniewicz Date: Mon, 6 Jul 2026 19:47:54 +0200 Subject: [PATCH 01/31] fix(desktop): dismiss stale prompt overlays --- .../src/components/prompt-overlays.test.tsx | 67 +++++++++++++++++++ .../src/components/prompt-overlays.tsx | 13 ++++ apps/desktop/src/lib/gateway-rpc.test.ts | 14 +++- apps/desktop/src/lib/gateway-rpc.ts | 7 ++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/components/prompt-overlays.test.tsx diff --git a/apps/desktop/src/components/prompt-overlays.test.tsx b/apps/desktop/src/components/prompt-overlays.test.tsx new file mode 100644 index 000000000000..7b8cb2148f5e --- /dev/null +++ b/apps/desktop/src/components/prompt-overlays.test.tsx @@ -0,0 +1,67 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' +import { $gateway } from '@/store/gateway' +import { notifyError } from '@/store/notifications' +import { $secretRequest, $sudoRequest, clearAllPrompts, setSecretRequest, setSudoRequest } from '@/store/prompts' +import { $activeSessionId } from '@/store/session' + +import { PromptOverlays } from './prompt-overlays' + +vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) +vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() })) + +function renderPrompts() { + render( + + + + ) +} + +afterEach(() => { + cleanup() + clearAllPrompts() + $activeSessionId.set(null) + $gateway.set(null) + vi.clearAllMocks() +}) + +describe('PromptOverlays', () => { + it('dismisses a stale sudo dialog when the gateway no longer has the password request', async () => { + const request = vi.fn().mockRejectedValue(new Error('no pending password request')) + + $activeSessionId.set('s1') + $gateway.set({ request } as never) + setSudoRequest({ requestId: 'sudo-1', sessionId: 's1' }) + + renderPrompts() + + expect(screen.getByText('Administrator password')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect($sudoRequest.get()).toBeNull()) + expect(request).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' }) + expect(notifyError).not.toHaveBeenCalled() + }) + + it('dismisses a stale secret dialog when the gateway no longer has the value request', async () => { + const request = vi.fn().mockRejectedValue(new Error('no pending value request')) + + $activeSessionId.set('s1') + $gateway.set({ request } as never) + setSecretRequest({ envVar: 'TEST_SECRET', prompt: 'Paste a secret', requestId: 'secret-1', sessionId: 's1' }) + + renderPrompts() + + expect(screen.getByText('TEST_SECRET')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect($secretRequest.get()).toBeNull()) + expect(request).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' }) + expect(notifyError).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx index a43303e1ced3..cf56d62e85ad 100644 --- a/apps/desktop/src/components/prompt-overlays.tsx +++ b/apps/desktop/src/components/prompt-overlays.tsx @@ -15,6 +15,7 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { useI18n } from '@/i18n' +import { isMissingPendingPromptRequest } from '@/lib/gateway-rpc' import { triggerHaptic } from '@/lib/haptics' import { KeyRound, Loader2, Lock } from '@/lib/icons' import { $gateway } from '@/store/gateway' @@ -69,6 +70,12 @@ function SudoDialog() { triggerHaptic('submit') clearSudoRequest(request.sessionId, request.requestId) } catch (error) { + if (isMissingPendingPromptRequest(error, 'password')) { + clearSudoRequest(request.sessionId, request.requestId) + + return + } + notifyError(error, copy.sudoSendFailed) setSubmitting(false) } @@ -165,6 +172,12 @@ function SecretDialog() { triggerHaptic('submit') clearSecretRequest(request.sessionId, request.requestId) } catch (error) { + if (isMissingPendingPromptRequest(error, 'value')) { + clearSecretRequest(request.sessionId, request.requestId) + + return + } + notifyError(error, copy.secretSendFailed) setSubmitting(false) } diff --git a/apps/desktop/src/lib/gateway-rpc.test.ts b/apps/desktop/src/lib/gateway-rpc.test.ts index 6da30b87b765..6c84c12ecaa8 100644 --- a/apps/desktop/src/lib/gateway-rpc.test.ts +++ b/apps/desktop/src/lib/gateway-rpc.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { isMissingRpcMethod } from './gateway-rpc' +import { isMissingPendingPromptRequest, isMissingRpcMethod } from './gateway-rpc' describe('isMissingRpcMethod', () => { it('detects JSON-RPC method-not-found errors', () => { @@ -14,3 +14,15 @@ describe('isMissingRpcMethod', () => { expect(isMissingRpcMethod(new Error('no such project'))).toBe(false) }) }) + +describe('isMissingPendingPromptRequest', () => { + it('detects stale prompt response errors from the gateway', () => { + expect(isMissingPendingPromptRequest(new Error('no pending password request'), 'password')).toBe(true) + expect(isMissingPendingPromptRequest(new Error('RPC failed: no pending value request'), 'value')).toBe(true) + }) + + it('ignores unrelated gateway failures', () => { + expect(isMissingPendingPromptRequest(new Error('gateway not connected'), 'password')).toBe(false) + expect(isMissingPendingPromptRequest(new Error('no pending value request'), 'password')).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/gateway-rpc.ts b/apps/desktop/src/lib/gateway-rpc.ts index a209aefbd001..6cf298402f32 100644 --- a/apps/desktop/src/lib/gateway-rpc.ts +++ b/apps/desktop/src/lib/gateway-rpc.ts @@ -4,3 +4,10 @@ export function isMissingRpcMethod(error: unknown): boolean { return /method not found|-32601|unknown method|no such method/i.test(message) } + +/** True when a prompt response raced a backend-side timeout / completion. */ +export function isMissingPendingPromptRequest(error: unknown, key: string): boolean { + const message = error instanceof Error ? error.message : String(error) + + return message.toLowerCase().includes(`no pending ${key.toLowerCase()} request`) +} From f618984c644ea8f6219fd963cf2fdb423b1d4889 Mon Sep 17 00:00:00 2001 From: embwl0x Date: Fri, 10 Jul 2026 01:29:31 -0400 Subject: [PATCH 02/31] fix(tui): dismiss expired sensitive prompts --- tests/tui_gateway/test_protocol.py | 42 +++++++++++++++++ tui_gateway/server.py | 25 +++++++--- ui-tui/README.md | 4 +- .../createGatewayEventHandler.test.ts | 18 ++++++++ ui-tui/src/__tests__/useInputHandlers.test.ts | 32 +++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 10 ++++ ui-tui/src/app/useInputHandlers.ts | 46 ++++++++++++++----- ui-tui/src/app/useMainApp.ts | 16 ++++++- ui-tui/src/gatewayTypes.ts | 1 + .../programmatic-integration.md | 2 +- 10 files changed, 174 insertions(+), 22 deletions(-) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 5aa3daa00ec5..476ea5b5b540 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -264,6 +264,48 @@ def test_block_and_respond(capture): assert result[0] == "my_answer" +@pytest.mark.parametrize("event", ["secret.request", "sudo.request"]) +def test_sensitive_prompt_timeout_emits_expiry(capture, event): + server, buf = capture + + assert server._block(event, "s1", {}, timeout=0) == "" + + messages = [json.loads(line) for line in buf.getvalue().splitlines()] + request, expiry = [message["params"] for message in messages] + assert request["type"] == event + assert expiry["type"] == event.removesuffix(".request") + ".expire" + assert expiry["session_id"] == "s1" + assert expiry["payload"]["request_id"] == request["payload"]["request_id"] + + +@pytest.mark.parametrize( + ("method", "value_key"), + [("secret.respond", "value"), ("sudo.respond", "password")], +) +def test_late_sensitive_prompt_response_is_idempotent(server, method, value_key): + response = server.handle_request( + { + "id": "late-response", + "method": method, + "params": {"request_id": "expired-request", value_key: ""}, + } + ) + + assert response["result"] == {"status": "expired"} + + +def test_late_clarify_response_remains_protocol_error(server): + response = server.handle_request( + { + "id": "late-clarify", + "method": "clarify.respond", + "params": {"request_id": "expired-request", "answer": ""}, + } + ) + + assert response["error"]["code"] == 4009 + + def test_clear_pending(server): ev = threading.Event() # _pending values are (sid, Event) tuples diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4e2fa3eadeeb..c27024ef7cfb 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2035,15 +2035,26 @@ def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str: _pending[rid] = (sid, ev) payload["request_id"] = rid _pending_prompt_payloads[rid] = (event, dict(payload)) + answered = False + answer = "" + answer_present = False try: _emit(event, sid, payload) - ev.wait(timeout=timeout) + answered = ev.wait(timeout=timeout) finally: with _prompt_lock: _pending.pop(rid, None) _pending_prompt_payloads.pop(rid, None) - with _prompt_lock: - return _answers.pop(rid, "") + answer_present = rid in _answers + answer = _answers.pop(rid, "") + + if not answered and not answer_present and event in {"secret.request", "sudo.request"}: + _emit( + f"{event.removesuffix('.request')}.expire", + sid, + {"request_id": rid}, + ) + return answer def _clear_pending(sid: str | None = None) -> None: @@ -10160,11 +10171,13 @@ def _(rid, params: dict) -> dict: # ── Methods: respond ───────────────────────────────────────────────── -def _respond(rid, params, key): +def _respond(rid, params, key, *, allow_expired=False): r = params.get("request_id", "") with _prompt_lock: entry = _pending.get(r) if not entry: + if allow_expired and r: + return _ok(rid, {"status": "expired"}) return _err(rid, 4009, f"no pending {key} request") _, ev = entry _answers[r] = params.get(key, "") @@ -10185,12 +10198,12 @@ def _(rid, params: dict) -> dict: @method("sudo.respond") def _(rid, params: dict) -> dict: - return _respond(rid, params, "password") + return _respond(rid, params, "password", allow_expired=True) @method("secret.respond") def _(rid, params: dict) -> dict: - return _respond(rid, params, "value") + return _respond(rid, params, "value", allow_expired=True) @method("approval.respond") diff --git a/ui-tui/README.md b/ui-tui/README.md index 159db8293b61..fe5ab7c8db1d 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -287,7 +287,9 @@ Primary event types the client handles today: | `clarify.request` | `{ question, choices?, request_id }` | | `approval.request` | `{ command, description, allow_permanent? }` | | `sudo.request` | `{ request_id }` | +| `sudo.expire` | `{ request_id }` clears a timed-out sudo prompt | | `secret.request` | `{ prompt, env_var, request_id }` | +| `secret.expire` | `{ request_id }` clears a timed-out secret prompt | | `background.complete` | `{ task_id, text }` | | `billing.step_up.verification` | `{ verification_url, user_code }` | | `review.summary` | `{ text }` | @@ -487,4 +489,4 @@ tui_gateway/ server.py RPC handlers and session logic render.py optional rich/ANSI bridge slash_worker.py persistent HermesCLI subprocess for slash commands -``` \ No newline at end of file +``` diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index fad5f6b4564f..ba36ef6b4960 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -1307,6 +1307,24 @@ describe('createGatewayEventHandler', () => { expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false) }) + it('clears only the matching sensitive prompt when the gateway expires it', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + patchOverlayState({ + secret: { envVar: 'NEW_KEY', prompt: 'Enter new key', requestId: 'secret-new' }, + sudo: { requestId: 'sudo-1' } + }) + + onEvent({ payload: { request_id: 'secret-old' }, type: 'secret.expire' } as any) + expect(getOverlayState().secret?.requestId).toBe('secret-new') + + onEvent({ payload: { request_id: 'secret-new' }, type: 'secret.expire' } as any) + expect(getOverlayState().secret).toBeNull() + + onEvent({ payload: { request_id: 'sudo-1' }, type: 'sudo.expire' } as any) + expect(getOverlayState().sudo).toBeNull() + }) + // ── Credits notice (Strategy B) ────────────────────────────────────── describe('credits notice', () => { it('shows a notice immediately when idle (no turn in flight)', () => { diff --git a/ui-tui/src/__tests__/useInputHandlers.test.ts b/ui-tui/src/__tests__/useInputHandlers.test.ts index fa9372d5356e..ef9e676f73e1 100644 --- a/ui-tui/src/__tests__/useInputHandlers.test.ts +++ b/ui-tui/src/__tests__/useInputHandlers.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' +import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js' import { applyVoiceRecordResponse, + dismissSensitivePrompt, handleIdleHotkeyExit, shouldAllowIdleHotkeyExit, shouldFallThroughForScroll @@ -112,3 +114,33 @@ describe('applyVoiceRecordResponse', () => { expect(setProcessing).toHaveBeenCalledWith(false) }) }) + +describe('dismissSensitivePrompt', () => { + it('clears a sudo overlay before a stale cancel RPC resolves', async () => { + resetOverlayState() + patchOverlayState({ sudo: { requestId: 'sudo-1' } }) + const rpc = vi.fn().mockResolvedValue(null) + const sys = vi.fn() + + const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys) + + expect(getOverlayState().sudo).toBeNull() + expect(sys).toHaveBeenCalledWith('sudo cancelled') + expect(rpc).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' }) + await pending + }) + + it('clears a secret overlay before a stale cancel RPC resolves', async () => { + resetOverlayState() + patchOverlayState({ secret: { envVar: 'API_KEY', prompt: 'Enter API key', requestId: 'secret-1' } }) + const rpc = vi.fn().mockResolvedValue(null) + const sys = vi.fn() + + const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys) + + expect(getOverlayState().secret).toBeNull() + expect(sys).toHaveBeenCalledWith('secret entry cancelled') + expect(rpc).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' }) + await pending + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index d9cbf30663ea..8b6ea8b3a984 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -799,6 +799,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return + case 'sudo.expire': + patchOverlayState(prev => (prev.sudo?.requestId === ev.payload.request_id ? { ...prev, sudo: null } : prev)) + + return + + case 'secret.expire': + patchOverlayState(prev => (prev.secret?.requestId === ev.payload.request_id ? { ...prev, secret: null } : prev)) + + return + case 'background.complete': dropBgTask(ev.payload.task_id) sys(`[bg ${ev.payload.task_id}] ${ev.payload.text}`) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 2f95c565e85a..3461cd241a88 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -16,7 +16,13 @@ import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionW import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js' import { getInputSelection } from './inputSelectionStore.js' -import type { InputHandlerActions, InputHandlerContext, InputHandlerResult } from './interfaces.js' +import type { + GatewayRpc, + InputHandlerActions, + InputHandlerContext, + InputHandlerResult, + OverlayState +} from './interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' import { patchTurnState } from './turnStore.js' @@ -97,6 +103,30 @@ export function applyVoiceRecordResponse( } } +export function dismissSensitivePrompt( + overlay: Pick, + rpc: GatewayRpc, + sys: (text: string) => void +) { + if (overlay.sudo) { + const requestId = overlay.sudo.requestId + + patchOverlayState({ sudo: null }) + sys('sudo cancelled') + + return rpc('sudo.respond', { password: '', request_id: requestId }) + } + + if (overlay.secret) { + const requestId = overlay.secret.requestId + + patchOverlayState({ secret: null }) + sys('secret entry cancelled') + + return rpc('secret.respond', { request_id: requestId, value: '' }) + } +} + export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const { actions, composer, gateway, terminal, voice, wheelStep } = ctx const { actions: cActions, refs: cRefs, state: cState } = composer @@ -149,16 +179,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { .then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' }))) } - if (overlay.sudo) { - return gateway - .rpc('sudo.respond', { password: '', request_id: overlay.sudo.requestId }) - .then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled'))) - } - - if (overlay.secret) { - return gateway - .rpc('secret.respond', { request_id: overlay.secret.requestId, value: '' }) - .then(r => r && (patchOverlayState({ secret: null }), actions.sys('secret entry cancelled'))) + if (overlay.sudo || overlay.secret) { + return dismissSensitivePrompt(overlay, gateway.rpc, actions.sys) } if (overlay.modelPicker) { @@ -373,7 +395,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } - if (isCtrl(key, ch, 'c')) { + if (isCtrl(key, ch, 'c') || (key.escape && (overlay.secret || overlay.sudo))) { cancelOverlayFromCtrlC() } else if (key.escape && overlay.sessions) { patchOverlayState({ sessions: false }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 33ab9b2f3e32..26c8a95c1c33 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -916,7 +916,13 @@ export function useMainApp(gw: GatewayClient) { return } - return respondWith('sudo.respond', { password: pw, request_id: overlay.sudo.requestId }, () => { + const requestId = overlay.sudo.requestId + + if (!pw) { + patchOverlayState({ sudo: null }) + } + + return respondWith('sudo.respond', { password: pw, request_id: requestId }, () => { patchOverlayState({ sudo: null }) patchUiState({ status: 'running…' }) }) @@ -930,7 +936,13 @@ export function useMainApp(gw: GatewayClient) { return } - return respondWith('secret.respond', { request_id: overlay.secret.requestId, value }, () => { + const requestId = overlay.secret.requestId + + if (!value) { + patchOverlayState({ secret: null }) + } + + return respondWith('secret.respond', { request_id: requestId, value }, () => { patchOverlayState({ secret: null }) patchUiState({ status: 'running…' }) }) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index ee6b8d78c451..8e784f979c85 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -697,6 +697,7 @@ export type GatewayEvent = } | { payload: { request_id: string }; session_id?: string; type: 'sudo.request' } | { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' } + | { payload: { request_id: string }; session_id?: string; type: 'secret.expire' | 'sudo.expire' } | { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' } | { payload?: { text?: string }; session_id?: string; type: 'review.summary' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' } diff --git a/website/docs/developer-guide/programmatic-integration.md b/website/docs/developer-guide/programmatic-integration.md index d21edbf85c38..16963bdd08f9 100644 --- a/website/docs/developer-guide/programmatic-integration.md +++ b/website/docs/developer-guide/programmatic-integration.md @@ -57,7 +57,7 @@ terminal.resize clipboard.paste image.attach ### Events streamed back -`message.delta`, `message.complete`, `tool.start`, `tool.progress`, `tool.complete`, `approval.request`, `clarify.request`, `sudo.request`, `secret.request`, `gateway.ready`, plus session lifecycle and error events. +`message.delta`, `message.complete`, `tool.start`, `tool.progress`, `tool.complete`, `approval.request`, `clarify.request`, `sudo.request`, `sudo.expire`, `secret.request`, `secret.expire`, `gateway.ready`, plus session lifecycle and error events. Expiry events carry the original `{ request_id }`; external hosts should clear only the matching pending prompt. ### Pi-style RPC mapping From 9e7fe2dd014e7c12859dec12f70a1d20281c125b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Jul 2026 05:27:52 -0400 Subject: [PATCH 03/31] fix(desktop): keep answered clarify Q&A visible in the transcript Answered clarifies were collapsing into a generic tool row, hiding the choice. Settle into a Q&A panel instead, and route freeform input through the shared Textarea chrome. --- .../components/assistant-ui/clarify-tool.tsx | 149 +++++++++++++----- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/lib/icons.ts | 2 + 7 files changed, 115 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index ed3ee6be8d47..967c9aac2e4c 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -19,55 +19,74 @@ import { Kbd } from '@/components/ui/kbd' import { Textarea } from '@/components/ui/textarea' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { Loader2, MessageQuestion } from '@/lib/icons' +import { CircleLetterA, 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' +import { parseMaybeObject } from './tool/fallback-model/format' interface ClarifyArgs { question?: string choices?: string[] | null } -function readClarifyArgs(args: unknown): ClarifyArgs { - if (!args || typeof args !== 'object') { - return {} - } +interface ClarifyResult { + question?: string + answer?: string + error?: string +} - const row = args as Record +function stringField(row: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = row[key] + + if (typeof value === 'string') { + return value + } + } +} + +function readClarifyArgs(args: unknown): ClarifyArgs { + const row = parseMaybeObject(args) const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null return { - question: typeof row.question === 'string' ? row.question : undefined, + question: stringField(row, 'question'), choices: choices && choices.length > 0 ? choices : null } } -// 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. +/** Parse clarify tool JSON (`question` + `user_response`). */ +export function readClarifyResult(result: unknown): ClarifyResult { + const row = parseMaybeObject(result) + + if (Object.keys(row).length === 0) { + return typeof result === 'string' && result.trim() ? { answer: result.trim() } : {} + } + + return { + question: stringField(row, 'question'), + answer: stringField(row, 'user_response', 'answer'), + error: stringField(row, 'error') + } +} + 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' +// field-sizing on top of Textarea's shared chrome; kill min-h-16 for one-liners. +const CLARIFY_TEXTAREA_CLASS = 'field-sizing-content max-h-40 min-h-0 resize-none' -// 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 = '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)' +const CLARIFY_ICON_CLASS = 'mt-px size-4 shrink-0 text-(--ui-text-tertiary)' + function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>) { return (
@@ -76,10 +95,20 @@ function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>) ) } -// 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 ClarifyLine({ + children, + className, + icon: Icon, + ...props +}: ComponentProps<'div'> & { icon: typeof MessageQuestion }) { + return ( +
+
{children}
+ +
+ ) +} + function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean; selected: boolean }) { return ( { + // Answered → settled Q&A (ToolFallback collapsed the answer away). + if (props.result !== undefined) { + return + } + + return +} + +function ClarifyToolLive(props: ToolCallMessagePartProps) { 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 - - if (!isPending) { + // Stopped mid-prompt with no result — don't leave a dead interactive panel. + if (!messageRunning) { return } return } +function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) { + const { t } = useI18n() + const copy = t.assistant.clarify + const fromArgs = useMemo(() => readClarifyArgs(args), [args]) + const fromResult = useMemo(() => readClarifyResult(result), [result]) + + const question = fromResult.question || fromArgs.question || '' + const answer = fromResult.answer + const error = fromResult.error + const skipped = !error && answer !== undefined && !answer.trim() + const answerText = error || (skipped ? copy.skipped : (answer ?? '').trim()) + + return ( + + {question ? ( + + {question} + + ) : null} + {answerText ? ( + +

+ {answerText} +

+
+ ) : null} +
+ ) +} + function ClarifyToolPending({ args }: ToolCallMessagePartProps) { const { t } = useI18n() const copy = t.assistant.clarify @@ -175,8 +245,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { }) triggerHaptic('submit') clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId) - // The matching tool.complete will land shortly after, swapping this - // panel for the ToolFallback view above. + // tool.complete lands next → ClarifyToolSettled. } catch (error) { notifyError(error, copy.sendFailed) setSubmitting(false) @@ -327,17 +396,13 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { {choice} ))} - {/* "Other" is an inline content-sizing field, not a separate view. */} -