From ac67718ec0cafa4238fdf25279efbce5d9a1ff09 Mon Sep 17 00:00:00 2001 From: weishu Date: Thu, 18 Dec 2025 23:16:45 +0800 Subject: [PATCH] feat: implement AskUserQuestion tool with interactive UI and question flow Add comprehensive support for the AskUserQuestion tool including: - New footer component for handling user responses with multi-step question flow - Type definitions and parsing utilities for question and answer formats - View component for displaying questions and options - Integration with permission system to capture user answers - Support for single and multi-select questions with optional text input - Haptic feedback and error handling for better UX --- cli/src/api/types.ts | 4 +- cli/src/claude/utils/permissionHandler.ts | 92 +++- server/src/sync/syncEngine.ts | 9 +- server/src/web/routes/permissions.ts | 6 +- web/src/api/client.ts | 1 + web/src/chat/reducer.ts | 25 +- web/src/chat/types.ts | 1 + .../ToolCard/AskUserQuestionFooter.tsx | 392 ++++++++++++++++++ web/src/components/ToolCard/ToolCard.tsx | 29 +- .../components/ToolCard/askUserQuestion.ts | 80 ++++ web/src/components/ToolCard/icons.tsx | 10 + web/src/components/ToolCard/knownTools.tsx | 30 +- .../ToolCard/views/AskUserQuestionView.tsx | 49 +++ web/src/components/ToolCard/views/_all.tsx | 5 + .../components/ToolCard/views/_results.tsx | 56 +++ web/src/types/api.ts | 1 + 16 files changed, 758 insertions(+), 32 deletions(-) create mode 100644 web/src/components/ToolCard/AskUserQuestionFooter.tsx create mode 100644 web/src/components/ToolCard/askUserQuestion.ts create mode 100644 web/src/components/ToolCard/views/AskUserQuestionView.tsx diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index cac5b552..c098f293 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -80,6 +80,7 @@ export type AgentState = { mode?: string decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' allowTools?: string[] + answers?: Record } } } @@ -100,7 +101,8 @@ export const AgentStateSchema = z.object({ reason: z.string().optional(), mode: z.string().optional(), decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(), - allowTools: z.array(z.string()).optional() + allowTools: z.array(z.string()).optional(), + answers: z.record(z.string(), z.array(z.string())).optional() })).optional() }).passthrough() diff --git a/cli/src/claude/utils/permissionHandler.ts b/cli/src/claude/utils/permissionHandler.ts index 755585d6..4db505d0 100644 --- a/cli/src/claude/utils/permissionHandler.ts +++ b/cli/src/claude/utils/permissionHandler.ts @@ -22,9 +22,72 @@ interface PermissionResponse { reason?: string; mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; allowTools?: string[]; + answers?: Record; receivedAt?: number; } +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object'; +} + +function isAskUserQuestionToolName(toolName: string): boolean { + return toolName === 'AskUserQuestion' || toolName === 'ask_user_question'; +} + +function formatAskUserQuestionAnswers(answers: Record, input: unknown): string { + const questions = (() => { + if (!isObject(input)) return null; + const raw = input.questions; + if (!Array.isArray(raw)) return null; + return raw.filter((q) => isObject(q)); + })(); + + const keys = Object.keys(answers).sort((a, b) => { + const aNum = Number.parseInt(a, 10); + const bNum = Number.parseInt(b, 10); + if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum; + if (Number.isFinite(aNum)) return -1; + if (Number.isFinite(bNum)) return 1; + return a.localeCompare(b); + }); + + const lines = keys.map((key) => { + const idx = Number.parseInt(key, 10); + const q = questions && Number.isFinite(idx) ? questions[idx] : null; + const header = q && typeof q.header === 'string' && q.header.trim().length > 0 + ? q.header.trim() + : Number.isFinite(idx) + ? `Question ${idx + 1}` + : `Question ${key}`; + const value = answers[key] ?? []; + const joined = value.map((v) => String(v)).filter((v) => v.trim().length > 0).join(', '); + return `${header}: ${joined || '(no answer)'}`; + }); + + const rawJson = (() => { + try { + return JSON.stringify(answers); + } catch { + return null; + } + })(); + + const body = lines.length > 0 ? lines.join('\n') : '(no answers)'; + return rawJson + ? `User answered:\n${body}\n\nRaw answers JSON:\n${rawJson}` + : `User answered:\n${body}`; +} + +function buildAskUserQuestionUpdatedInput(input: unknown, answers: Record): Record { + if (!isObject(input)) { + return { answers }; + } + + return { + ...input, + answers + }; +} interface PendingRequest { resolve: (value: PermissionResult) => void; @@ -71,6 +134,9 @@ export class PermissionHandler { // Update allowed tools if (response.allowTools && response.allowTools.length > 0) { response.allowTools.forEach(tool => { + if (isAskUserQuestionToolName(tool)) { + return; + } if (tool.startsWith('Bash(') || tool === 'Bash') { this.parseBashPermission(tool); } else { @@ -85,6 +151,20 @@ export class PermissionHandler { } // Handle + if (isAskUserQuestionToolName(pending.toolName)) { + const answers = response.answers ?? {}; + if (Object.keys(answers).length === 0) { + pending.resolve({ behavior: 'deny', message: 'No answers were provided.' }); + return; + } + + pending.resolve({ + behavior: 'allow', + updatedInput: buildAskUserQuestionUpdatedInput(pending.input, answers) + }); + return; + } + if (pending.toolName === 'exit_plan_mode' || pending.toolName === 'ExitPlanMode') { // Handle exit_plan_mode specially logger.debug('Plan mode result received', response); @@ -114,9 +194,10 @@ export class PermissionHandler { * Creates the canCallTool callback for the SDK */ handleToolCall = async (toolName: string, input: unknown, mode: EnhancedMode, options: { signal: AbortSignal }): Promise => { + const isAskUserQuestion = isAskUserQuestionToolName(toolName); // Check if tool is explicitly allowed - if (toolName === 'Bash') { + if (!isAskUserQuestion && toolName === 'Bash') { const inputObj = input as { command?: string }; if (inputObj?.command) { // Check literal matches @@ -130,7 +211,7 @@ export class PermissionHandler { } } } - } else if (this.allowedTools.has(toolName)) { + } else if (!isAskUserQuestion && this.allowedTools.has(toolName)) { return { behavior: 'allow', updatedInput: input as Record }; } @@ -141,11 +222,11 @@ export class PermissionHandler { // Handle special cases // - if (this.permissionMode === 'bypassPermissions') { + if (!isAskUserQuestion && this.permissionMode === 'bypassPermissions') { return { behavior: 'allow', updatedInput: input as Record }; } - if (this.permissionMode === 'acceptEdits' && descriptor.edit) { + if (!isAskUserQuestion && this.permissionMode === 'acceptEdits' && descriptor.edit) { return { behavior: 'allow', updatedInput: input as Record }; } @@ -399,7 +480,8 @@ export class PermissionHandler { status: message.approved ? 'approved' : 'denied', reason: message.reason, mode: message.mode, - allowTools: message.allowTools + allowTools: message.allowTools, + answers: message.answers } } }; diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 4c074a90..e982c9ab 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -48,7 +48,8 @@ export const AgentStateSchema = z.object({ reason: z.string().optional(), mode: z.string().optional(), decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(), - allowTools: z.array(z.string()).optional() + allowTools: z.array(z.string()).optional(), + answers: z.record(z.string(), z.array(z.string())).optional() }).passthrough()).nullish() }).passthrough() @@ -591,14 +592,16 @@ export class SyncEngine { requestId: string, mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', allowTools?: string[], - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort', + answers?: Record ): Promise { await this.sessionRpc(sessionId, 'permission', { id: requestId, approved: true, mode, allowTools, - decision + decision, + answers }) } diff --git a/server/src/web/routes/permissions.ts b/server/src/web/routes/permissions.ts index 7c045460..3524534f 100644 --- a/server/src/web/routes/permissions.ts +++ b/server/src/web/routes/permissions.ts @@ -9,7 +9,8 @@ const decisionSchema = z.enum(['approved', 'approved_for_session', 'denied', 'ab const approveBodySchema = z.object({ mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional(), allowTools: z.array(z.string()).optional(), - decision: decisionSchema.optional() + decision: decisionSchema.optional(), + answers: z.record(z.string(), z.array(z.string())).optional() }) const denyBodySchema = z.object({ @@ -47,7 +48,8 @@ export function createPermissionsRoutes(getSyncEngine: () => SyncEngine | null): const mode = parsed.data.mode const allowTools = parsed.data.allowTools const decision = parsed.data.decision - await engine.approvePermission(sessionId, requestId, mode, allowTools, decision) + const answers = parsed.data.answers + await engine.approvePermission(sessionId, requestId, mode, allowTools, decision, answers) return c.json({ ok: true }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 388ef644..c4cd9e67 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -106,6 +106,7 @@ export class ApiClient { mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' allowTools?: string[] decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + answers?: Record } ): Promise { const body = typeof modeOrOptions === 'string' || modeOrOptions === undefined diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 7777db98..a34e89d0 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -127,18 +127,19 @@ function getPermissions(agentState: AgentState | null | undefined): Map date?: number createdAt?: number | null completedAt?: number | null diff --git a/web/src/components/ToolCard/AskUserQuestionFooter.tsx b/web/src/components/ToolCard/AskUserQuestionFooter.tsx new file mode 100644 index 00000000..d9f8347c --- /dev/null +++ b/web/src/components/ToolCard/AskUserQuestionFooter.tsx @@ -0,0 +1,392 @@ +import { useEffect, useMemo, useState } from 'react' +import type { ApiClient } from '@/api/client' +import type { ChatToolCall } from '@/chat/types' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { isAskUserQuestionToolName, parseAskUserQuestionInput, type AskUserQuestionQuestion } from '@/components/ToolCard/askUserQuestion' +import { cn } from '@/lib/utils' +import { usePlatform } from '@/hooks/usePlatform' + +function SelectionMark(props: { checked: boolean; mode: 'single' | 'multi' }) { + const mark = props.mode === 'multi' + ? (props.checked ? '☑' : '☐') + : (props.checked ? '●' : '○') + return ( + + {mark} + + ) +} + +function OptionRow(props: { + checked: boolean + mode: 'single' | 'multi' + disabled: boolean + title: string + description?: string | null + onClick: () => void +}) { + return ( + + ) +} + +function computeAnswersForQuestion( + question: AskUserQuestionQuestion, + selectedOptionIndices: number[], + otherSelected: boolean, + otherText: string +): string[] { + const answers: string[] = [] + + for (const idx of selectedOptionIndices) { + const opt = question.options[idx] + if (!opt) continue + const label = opt.label.trim() + if (label.length > 0) answers.push(label) + } + + const other = otherText.trim() + if (otherSelected && other.length > 0) { + answers.push(other) + } + + return answers +} + +export function AskUserQuestionFooter(props: { + api: ApiClient + sessionId: string + tool: ChatToolCall + disabled: boolean + onDone: () => void +}) { + const { haptic } = usePlatform() + const permission = props.tool.permission + const parsed = useMemo(() => parseAskUserQuestionInput(props.tool.input), [props.tool.input]) + const questions = parsed.questions + + const [step, setStep] = useState(0) + const [selectedByQuestion, setSelectedByQuestion] = useState([]) + const [otherSelectedByQuestion, setOtherSelectedByQuestion] = useState([]) + const [otherTextByQuestion, setOtherTextByQuestion] = useState([]) + const [fallbackText, setFallbackText] = useState('') + + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + setStep(0) + setSelectedByQuestion(questions.map(() => [])) + setOtherSelectedByQuestion(questions.map(() => false)) + setOtherTextByQuestion(questions.map(() => '')) + setFallbackText('') + setLoading(false) + setError(null) + }, [props.tool.id]) + + if (!permission || permission.status !== 'pending') return null + if (!isAskUserQuestionToolName(props.tool.name)) return null + + const run = async (action: () => Promise, hapticType: 'success' | 'error') => { + if (props.disabled) return + setError(null) + try { + await action() + haptic.notification(hapticType) + props.onDone() + } catch (e) { + haptic.notification('error') + setError(e instanceof Error ? e.message : 'Request failed') + } + } + + const total = Math.max(1, questions.length) + const clampedStep = Math.min(Math.max(step, 0), total - 1) + + const mode: 'single' | 'multi' = questions[clampedStep]?.multiSelect ? 'multi' : 'single' + + const validateQuestion = (idx: number): string[] | null => { + if (questions.length === 0) { + const text = fallbackText.trim() + return text.length > 0 ? [text] : null + } + + const question = questions[idx] + if (!question) return null + const answers = computeAnswersForQuestion( + question, + selectedByQuestion[idx] ?? [], + otherSelectedByQuestion[idx] ?? false, + otherTextByQuestion[idx] ?? '' + ) + return answers.length > 0 ? answers : null + } + + const submit = async () => { + if (loading) return + + const answers: Record = {} + if (questions.length === 0) { + const a0 = validateQuestion(0) + if (!a0) { + setError('Please type an answer.') + return + } + answers['0'] = a0 + } else { + for (let i = 0; i < questions.length; i += 1) { + const a = validateQuestion(i) + if (!a) { + setError(`Please answer question ${i + 1} before submitting.`) + setStep(i) + return + } + answers[String(i)] = a + } + } + + setLoading(true) + await run(() => props.api.approvePermission(props.sessionId, permission.id, { answers }), 'success') + setLoading(false) + } + + const next = () => { + if (questions.length === 0) return + const a = validateQuestion(clampedStep) + if (!a) { + setError('Please select at least one option or type an answer.') + return + } + setError(null) + setStep((s) => Math.min(s + 1, questions.length - 1)) + } + + const prev = () => { + setError(null) + setStep((s) => Math.max(s - 1, 0)) + } + + const toggleOption = (qIdx: number, optIdx: number) => { + const q = questions[qIdx] + if (!q) return + haptic.selection() + + setSelectedByQuestion((prevSelected) => { + const nextSelected = prevSelected.slice() + const cur = new Set(nextSelected[qIdx] ?? []) + if (q.multiSelect) { + if (cur.has(optIdx)) cur.delete(optIdx) + else cur.add(optIdx) + nextSelected[qIdx] = Array.from(cur).sort((a, b) => a - b) + return nextSelected + } + + nextSelected[qIdx] = [optIdx] + return nextSelected + }) + + if (!q.multiSelect) { + setOtherSelectedByQuestion((prevOther) => { + const nextOther = prevOther.slice() + nextOther[qIdx] = false + return nextOther + }) + } + } + + const toggleOther = (qIdx: number) => { + const q = questions[qIdx] + if (!q) return + haptic.selection() + + if (!q.multiSelect) { + setSelectedByQuestion((prevSelected) => { + const nextSelected = prevSelected.slice() + nextSelected[qIdx] = [] + return nextSelected + }) + setOtherSelectedByQuestion((prevOther) => { + const nextOther = prevOther.slice() + nextOther[qIdx] = true + return nextOther + }) + return + } + + setOtherSelectedByQuestion((prevOther) => { + const nextOther = prevOther.slice() + nextOther[qIdx] = !nextOther[qIdx] + return nextOther + }) + } + + const updateOtherText = (qIdx: number, value: string) => { + setOtherTextByQuestion((prevText) => { + const nextText = prevText.slice() + nextText[qIdx] = value + return nextText + }) + if (value.trim().length > 0) { + setOtherSelectedByQuestion((prevOther) => { + const nextOther = prevOther.slice() + nextOther[qIdx] = true + return nextOther + }) + } + } + + return ( +
+
+
+
+ + Question + + + [{clampedStep + 1}/{total}] + +
+
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {questions.length === 0 ? ( +
+
+ AskUserQuestion payload is not in the expected format. Type your answer: +
+