From ae9650ca7c679d75d3211995e8e59b4884717eee Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 26 Jan 2026 11:58:36 +0800 Subject: [PATCH] feat: add support for Codex's request_user_input tool Implement full support for the request_user_input tool with both Web UI and CLI components. This includes: - New view and footer components for request_user_input tool UI - Tool registration in knownTools and view registries - Nested answer format support (Record) - Backward compatibility with flat answer format (Record) - Permission handler updates for CLI request_user_input acceptance - Type definitions and schema updates across shared/cli/server/web packages - Translation strings for request_user_input UI elements - Conditional footer rendering in ToolCard for question tools --- cli/src/claude/utils/permissionHandler.ts | 81 ++++- .../permission/BasePermissionHandler.ts | 2 +- server/src/sync/rpcGateway.ts | 2 +- server/src/sync/syncEngine.ts | 2 +- server/src/web/routes/permissions.ts | 9 +- shared/src/schemas.ts | 7 +- web/src/api/client.ts | 2 +- web/src/chat/reconcile.ts | 17 +- web/src/chat/types.ts | 2 +- .../ToolCard/RequestUserInputFooter.tsx | 309 ++++++++++++++++++ web/src/components/ToolCard/ToolCard.tsx | 18 +- web/src/components/ToolCard/knownTools.tsx | 30 ++ .../components/ToolCard/requestUserInput.ts | 145 ++++++++ .../ToolCard/views/AskUserQuestionView.tsx | 21 +- .../ToolCard/views/RequestUserInputView.tsx | 121 +++++++ web/src/components/ToolCard/views/_all.tsx | 7 +- web/src/lib/locales/en.ts | 3 + web/src/lib/locales/zh-CN.ts | 3 + 18 files changed, 754 insertions(+), 27 deletions(-) create mode 100644 web/src/components/ToolCard/RequestUserInputFooter.tsx create mode 100644 web/src/components/ToolCard/requestUserInput.ts create mode 100644 web/src/components/ToolCard/views/RequestUserInputView.tsx diff --git a/cli/src/claude/utils/permissionHandler.ts b/cli/src/claude/utils/permissionHandler.ts index abdd0a9f..444bf7af 100644 --- a/cli/src/claude/utils/permissionHandler.ts +++ b/cli/src/claude/utils/permissionHandler.ts @@ -28,7 +28,7 @@ interface PermissionResponse { reason?: string; mode?: PermissionMode; allowTools?: string[]; - answers?: Record; + answers?: Record | Record; receivedAt?: number; } @@ -38,7 +38,25 @@ function isAskUserQuestionToolName(toolName: string): boolean { return toolName === 'AskUserQuestion' || toolName === 'ask_user_question'; } -function formatAskUserQuestionAnswers(answers: Record, input: unknown): string { +function isRequestUserInputToolName(toolName: string): boolean { + return toolName === 'request_user_input'; +} + +function isQuestionToolName(toolName: string): boolean { + return isAskUserQuestionToolName(toolName) || isRequestUserInputToolName(toolName); +} + +function formatAskUserQuestionAnswers(answers: Record | Record, input: unknown): string { + // Normalize nested format to flat format for display + const flatAnswers: Record = {}; + for (const [key, value] of Object.entries(answers)) { + if (Array.isArray(value)) { + flatAnswers[key] = value; + } else if (value && typeof value === 'object' && 'answers' in value) { + flatAnswers[key] = value.answers; + } + } + const questions = (() => { if (!isObject(input)) return null; const raw = input.questions; @@ -46,7 +64,7 @@ function formatAskUserQuestionAnswers(answers: Record, input: return raw.filter((q) => isObject(q)); })(); - const keys = Object.keys(answers).sort((a, b) => { + const keys = Object.keys(flatAnswers).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; @@ -63,7 +81,7 @@ function formatAskUserQuestionAnswers(answers: Record, input: : Number.isFinite(idx) ? `Question ${idx + 1}` : `Question ${key}`; - const value = answers[key] ?? []; + const value = flatAnswers[key] ?? []; const joined = value.map((v) => String(v)).filter((v) => v.trim().length > 0).join(', '); return `${header}: ${joined || '(no answer)'}`; }); @@ -82,7 +100,32 @@ function formatAskUserQuestionAnswers(answers: Record, input: : `User answered:\n${body}`; } -function buildAskUserQuestionUpdatedInput(input: unknown, answers: Record): Record { +function buildAskUserQuestionUpdatedInput(input: unknown, answers: Record | Record): Record { + // Normalize to flat format for AskUserQuestion + const flatAnswers: Record = {}; + for (const [key, value] of Object.entries(answers)) { + if (Array.isArray(value)) { + flatAnswers[key] = value; + } else if (value && typeof value === 'object' && 'answers' in value) { + flatAnswers[key] = value.answers; + } + } + + if (!isObject(input)) { + return { answers: flatAnswers }; + } + + return { + ...input, + answers: flatAnswers + }; +} + +/** + * Build updated input for request_user_input tool + * The answers format is nested: { answers: { [id]: { answers: string[] } } } + */ +function buildRequestUserInputUpdatedInput(input: unknown, answers: unknown): Record { if (!isObject(input)) { return { answers }; } @@ -138,7 +181,7 @@ export class PermissionHandler extends BasePermissionHandler 0) { response.allowTools.forEach(tool => { - if (isAskUserQuestionToolName(tool)) { + if (isQuestionToolName(tool)) { return; } if (tool.startsWith('Bash(') || tool === 'Bash') { @@ -171,6 +214,22 @@ export class PermissionHandler extends BasePermissionHandler => { - const isAskUserQuestion = isAskUserQuestionToolName(toolName); + const isQuestionTool = isQuestionToolName(toolName); // Check if tool is explicitly allowed - if (!isAskUserQuestion && toolName === 'Bash') { + if (!isQuestionTool && toolName === 'Bash') { const inputObj = input as { command?: string }; if (inputObj?.command) { // Check literal matches @@ -219,7 +278,7 @@ export class PermissionHandler extends BasePermissionHandler }; } @@ -230,11 +289,11 @@ export class PermissionHandler extends BasePermissionHandler }; } - if (!isAskUserQuestion && this.permissionMode === 'acceptEdits' && descriptor.edit) { + if (!isQuestionTool && this.permissionMode === 'acceptEdits' && descriptor.edit) { return { behavior: 'allow', updatedInput: input as Record }; } diff --git a/cli/src/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index defb12b8..edb486cc 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -45,7 +45,7 @@ export type PermissionCompletion = { mode?: string; decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; allowTools?: string[]; - answers?: Record; + answers?: Record | Record; }; export type CancelPendingRequestOptions = { diff --git a/server/src/sync/rpcGateway.ts b/server/src/sync/rpcGateway.ts index 08edbc09..d943c298 100644 --- a/server/src/sync/rpcGateway.ts +++ b/server/src/sync/rpcGateway.ts @@ -44,7 +44,7 @@ export class RpcGateway { mode?: PermissionMode, allowTools?: string[], decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort', - answers?: Record + answers?: Record | Record ): Promise { await this.sessionRpc(sessionId, 'permission', { id: requestId, diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index e9d25763..1ad6b0b1 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -212,7 +212,7 @@ export class SyncEngine { mode?: PermissionMode, allowTools?: string[], decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort', - answers?: Record + answers?: Record | Record ): Promise { await this.rpcGateway.approvePermission(sessionId, requestId, mode, allowTools, decision, answers) } diff --git a/server/src/web/routes/permissions.ts b/server/src/web/routes/permissions.ts index 47a041e7..3173ae84 100644 --- a/server/src/web/routes/permissions.ts +++ b/server/src/web/routes/permissions.ts @@ -8,11 +8,18 @@ import { requireSessionFromParam, requireSyncEngine } from './guards' const decisionSchema = z.enum(['approved', 'approved_for_session', 'denied', 'abort']) +// Flat format: Record (AskUserQuestion) +// Nested format: Record (request_user_input) +const answersSchema = z.union([ + z.record(z.string(), z.array(z.string())), + z.record(z.string(), z.object({ answers: z.array(z.string()) })) +]) + const approveBodySchema = z.object({ mode: PermissionModeSchema.optional(), allowTools: z.array(z.string()).optional(), decision: decisionSchema.optional(), - answers: z.record(z.string(), z.array(z.string())).optional() + answers: answersSchema.optional() }) const denyBodySchema = z.object({ diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index da081cfd..bae6bba1 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -67,7 +67,12 @@ export const AgentStateCompletedRequestSchema = z.object({ mode: z.string().optional(), decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(), allowTools: z.array(z.string()).optional(), - answers: z.record(z.string(), z.array(z.string())).optional() + // Flat format: Record (AskUserQuestion) + // Nested format: Record (request_user_input) + answers: z.union([ + z.record(z.string(), z.array(z.string())), + z.record(z.string(), z.object({ answers: z.array(z.string()) })) + ]).optional() }) export type AgentStateCompletedRequest = z.infer diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f5c78242..9bcbb65d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -306,7 +306,7 @@ export class ApiClient { mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' allowTools?: string[] decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' - answers?: Record + answers?: Record | Record } ): Promise { const body = typeof modeOrOptions === 'string' || modeOrOptions === undefined diff --git a/web/src/chat/reconcile.ts b/web/src/chat/reconcile.ts index bab31926..4be71206 100644 --- a/web/src/chat/reconcile.ts +++ b/web/src/chat/reconcile.ts @@ -31,9 +31,18 @@ function areStringArraysEqual(left?: string[] | null, right?: string[] | null): return true } +type AnswersFormat = Record | Record + +function normalizeAnswerEntry(entry: string[] | { answers: string[] }): string[] { + if (Array.isArray(entry)) { + return entry + } + return entry.answers ?? [] +} + function areAnswersEqual( - left?: Record | null, - right?: Record | null + left?: AnswersFormat | null, + right?: AnswersFormat | null ): boolean { if (left === right) return true if (!left || !right) return false @@ -45,7 +54,9 @@ function areAnswersEqual( for (let i = 0; i < leftKeys.length; i += 1) { const leftKey = leftKeys[i] if (leftKey !== rightKeys[i]) return false - if (!areStringArraysEqual(left[leftKey], right[leftKey])) return false + const leftEntry = (left as Record)[leftKey] + const rightEntry = (right as Record)[leftKey] + if (!areStringArraysEqual(normalizeAnswerEntry(leftEntry), normalizeAnswerEntry(rightEntry))) return false } return true } diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index a171e670..4a60ee53 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -92,7 +92,7 @@ export type ToolPermission = { mode?: string allowedTools?: string[] decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' - answers?: Record + answers?: Record | Record date?: number createdAt?: number | null completedAt?: number | null diff --git a/web/src/components/ToolCard/RequestUserInputFooter.tsx b/web/src/components/ToolCard/RequestUserInputFooter.tsx new file mode 100644 index 00000000..8498de27 --- /dev/null +++ b/web/src/components/ToolCard/RequestUserInputFooter.tsx @@ -0,0 +1,309 @@ +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 { + isRequestUserInputToolName, + parseRequestUserInputInput, + formatRequestUserInputAnswers, + type RequestUserInputQuestion +} from '@/components/ToolCard/requestUserInput' +import { cn } from '@/lib/utils' +import { usePlatform } from '@/hooks/usePlatform' +import { Spinner } from '@/components/Spinner' +import { useTranslation } from '@/lib/use-translation' + +function SelectionMark(props: { checked: boolean }) { + const mark = props.checked ? '●' : '○' + return ( + + {mark} + + ) +} + +function OptionRow(props: { + checked: boolean + disabled: boolean + title: string + description?: string | null + onClick: () => void +}) { + return ( + + ) +} + +type QuestionState = { + selected: string | null + userNote: string +} + +export function RequestUserInputFooter(props: { + api: ApiClient + sessionId: string + tool: ChatToolCall + disabled: boolean + onDone: () => void +}) { + const { t } = useTranslation() + const { haptic } = usePlatform() + const permission = props.tool.permission + const parsed = useMemo(() => parseRequestUserInputInput(props.tool.input), [props.tool.input]) + const questions = parsed.questions + + const [step, setStep] = useState(0) + const [stateByQuestion, setStateByQuestion] = useState>({}) + + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + setStep(0) + const initial: Record = {} + for (const q of questions) { + initial[q.id] = { selected: null, userNote: '' } + } + setStateByQuestion(initial) + setLoading(false) + setError(null) + }, [props.tool.id]) + + if (!permission || permission.status !== 'pending') return null + if (!isRequestUserInputToolName(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 : t('dialog.error.default')) + } + } + + const total = Math.max(1, questions.length) + const clampedStep = Math.min(Math.max(step, 0), total - 1) + const currentQuestion = questions[clampedStep] as RequestUserInputQuestion | undefined + + const validateQuestion = (question: RequestUserInputQuestion): boolean => { + const state = stateByQuestion[question.id] + if (!state) return false + + // For questions with options, require a selection OR user note + if (question.options.length > 0) { + return state.selected !== null || state.userNote.trim().length > 0 + } + + // For pure text questions (no options), require user note + return state.userNote.trim().length > 0 + } + + const submit = async () => { + if (loading) return + + // Validate all questions + for (let i = 0; i < questions.length; i += 1) { + const q = questions[i] + if (!validateQuestion(q)) { + setError(t('tool.selectOption')) + setStep(i) + return + } + } + + // Format answers for submission + const formattedAnswers = formatRequestUserInputAnswers(stateByQuestion) + + setLoading(true) + await run(() => props.api.approvePermission(props.sessionId, permission.id, formattedAnswers), 'success') + setLoading(false) + } + + const next = () => { + if (!currentQuestion) return + if (!validateQuestion(currentQuestion)) { + setError(t('tool.selectOption')) + return + } + setError(null) + setStep((s) => Math.min(s + 1, questions.length - 1)) + } + + const prev = () => { + setError(null) + setStep((s) => Math.max(s - 1, 0)) + } + + const selectOption = (questionId: string, optionLabel: string) => { + haptic.selection() + setStateByQuestion((prev) => ({ + ...prev, + [questionId]: { + ...prev[questionId], + selected: optionLabel + } + })) + } + + const updateUserNote = (questionId: string, value: string) => { + setStateByQuestion((prev) => ({ + ...prev, + [questionId]: { + ...prev[questionId], + userNote: value + } + })) + } + + const currentState = currentQuestion ? stateByQuestion[currentQuestion.id] : null + const isPureTextQuestion = currentQuestion && currentQuestion.options.length === 0 + + return ( +
+
+
+
+ + {t('tool.question')} + + + [{clampedStep + 1}/{total}] + +
+
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {currentQuestion ? ( +
+ {currentQuestion.question ? ( +
+ {currentQuestion.question} +
+ ) : null} + + {isPureTextQuestion ? ( + // Pure text question - show only textarea +