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<string, { answers: string[] }>)
- Backward compatibility with flat answer format (Record<string, string[]>)
- 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
This commit is contained in:
weishu
2026-01-26 11:58:36 +08:00
parent 8137ce4f23
commit ae9650ca7c
18 changed files with 754 additions and 27 deletions
+70 -11
View File
@@ -28,7 +28,7 @@ interface PermissionResponse {
reason?: string;
mode?: PermissionMode;
allowTools?: string[];
answers?: Record<string, string[]>;
answers?: Record<string, string[]> | Record<string, { answers: string[] }>;
receivedAt?: number;
}
@@ -38,7 +38,25 @@ function isAskUserQuestionToolName(toolName: string): boolean {
return toolName === 'AskUserQuestion' || toolName === 'ask_user_question';
}
function formatAskUserQuestionAnswers(answers: Record<string, string[]>, 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<string, string[]> | Record<string, { answers: string[] }>, input: unknown): string {
// Normalize nested format to flat format for display
const flatAnswers: Record<string, string[]> = {};
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<string, string[]>, 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<string, string[]>, 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<string, string[]>, input:
: `User answered:\n${body}`;
}
function buildAskUserQuestionUpdatedInput(input: unknown, answers: Record<string, string[]>): Record<string, unknown> {
function buildAskUserQuestionUpdatedInput(input: unknown, answers: Record<string, string[]> | Record<string, { answers: string[] }>): Record<string, unknown> {
// Normalize to flat format for AskUserQuestion
const flatAnswers: Record<string, string[]> = {};
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<string, unknown> {
if (!isObject(input)) {
return { answers };
}
@@ -138,7 +181,7 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
// Update allowed tools
if (response.allowTools && response.allowTools.length > 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<PermissionResponse,
return completion;
}
// Handle request_user_input
if (isRequestUserInputToolName(pending.toolName)) {
const answers = response.answers ?? {};
if (Object.keys(answers).length === 0) {
pending.resolve({ behavior: 'deny', message: 'No answers were provided.' });
completion.status = 'denied';
completion.reason = completion.reason ?? 'No answers were provided.';
} else {
pending.resolve({
behavior: 'allow',
updatedInput: buildRequestUserInputUpdatedInput(pending.input, answers)
});
}
return completion;
}
if (pending.toolName === 'exit_plan_mode' || pending.toolName === 'ExitPlanMode') {
// Handle exit_plan_mode specially
logger.debug('Plan mode result received', response);
@@ -202,10 +261,10 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
* Creates the canCallTool callback for the SDK
*/
handleToolCall = async (toolName: string, input: unknown, mode: EnhancedMode, options: { signal: AbortSignal }): Promise<PermissionResult> => {
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<PermissionResponse,
}
}
}
} else if (!isAskUserQuestion && this.allowedTools.has(toolName)) {
} else if (!isQuestionTool && this.allowedTools.has(toolName)) {
return { behavior: 'allow', updatedInput: input as Record<string, unknown> };
}
@@ -230,11 +289,11 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
// Handle special cases
//
if (!isAskUserQuestion && this.permissionMode === 'bypassPermissions') {
if (!isQuestionTool && this.permissionMode === 'bypassPermissions') {
return { behavior: 'allow', updatedInput: input as Record<string, unknown> };
}
if (!isAskUserQuestion && this.permissionMode === 'acceptEdits' && descriptor.edit) {
if (!isQuestionTool && this.permissionMode === 'acceptEdits' && descriptor.edit) {
return { behavior: 'allow', updatedInput: input as Record<string, unknown> };
}
@@ -45,7 +45,7 @@ export type PermissionCompletion = {
mode?: string;
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort';
allowTools?: string[];
answers?: Record<string, string[]>;
answers?: Record<string, string[]> | Record<string, { answers: string[] }>;
};
export type CancelPendingRequestOptions = {
+1 -1
View File
@@ -44,7 +44,7 @@ export class RpcGateway {
mode?: PermissionMode,
allowTools?: string[],
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort',
answers?: Record<string, string[]>
answers?: Record<string, string[]> | Record<string, { answers: string[] }>
): Promise<void> {
await this.sessionRpc(sessionId, 'permission', {
id: requestId,
+1 -1
View File
@@ -212,7 +212,7 @@ export class SyncEngine {
mode?: PermissionMode,
allowTools?: string[],
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort',
answers?: Record<string, string[]>
answers?: Record<string, string[]> | Record<string, { answers: string[] }>
): Promise<void> {
await this.rpcGateway.approvePermission(sessionId, requestId, mode, allowTools, decision, answers)
}
+8 -1
View File
@@ -8,11 +8,18 @@ import { requireSessionFromParam, requireSyncEngine } from './guards'
const decisionSchema = z.enum(['approved', 'approved_for_session', 'denied', 'abort'])
// Flat format: Record<string, string[]> (AskUserQuestion)
// Nested format: Record<string, { answers: string[] }> (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({
+6 -1
View File
@@ -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<string, string[]> (AskUserQuestion)
// Nested format: Record<string, { answers: string[] }> (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<typeof AgentStateCompletedRequestSchema>
+1 -1
View File
@@ -306,7 +306,7 @@ export class ApiClient {
mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
allowTools?: string[]
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
answers?: Record<string, string[]>
answers?: Record<string, string[]> | Record<string, { answers: string[] }>
}
): Promise<void> {
const body = typeof modeOrOptions === 'string' || modeOrOptions === undefined
+14 -3
View File
@@ -31,9 +31,18 @@ function areStringArraysEqual(left?: string[] | null, right?: string[] | null):
return true
}
type AnswersFormat = Record<string, string[]> | Record<string, { answers: string[] }>
function normalizeAnswerEntry(entry: string[] | { answers: string[] }): string[] {
if (Array.isArray(entry)) {
return entry
}
return entry.answers ?? []
}
function areAnswersEqual(
left?: Record<string, string[]> | null,
right?: Record<string, string[]> | 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<string, string[] | { answers: string[] }>)[leftKey]
const rightEntry = (right as Record<string, string[] | { answers: string[] }>)[leftKey]
if (!areStringArraysEqual(normalizeAnswerEntry(leftEntry), normalizeAnswerEntry(rightEntry))) return false
}
return true
}
+1 -1
View File
@@ -92,7 +92,7 @@ export type ToolPermission = {
mode?: string
allowedTools?: string[]
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
answers?: Record<string, string[]>
answers?: Record<string, string[]> | Record<string, { answers: string[] }>
date?: number
createdAt?: number | null
completedAt?: number | null
@@ -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 (
<span className="mt-0.5 w-4 shrink-0 text-center text-[var(--app-hint)]">
{mark}
</span>
)
}
function OptionRow(props: {
checked: boolean
disabled: boolean
title: string
description?: string | null
onClick: () => void
}) {
return (
<button
type="button"
className={cn(
'flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-[var(--app-subtle-bg)] disabled:pointer-events-none disabled:opacity-50',
props.checked ? 'bg-[var(--app-subtle-bg)]' : null
)}
disabled={props.disabled}
onClick={props.onClick}
>
<SelectionMark checked={props.checked} />
<span className="min-w-0 flex-1">
<div className="font-medium text-[var(--app-fg)] break-words">{props.title}</div>
{props.description ? (
<div className="mt-0.5 text-xs text-[var(--app-hint)] break-words">
{props.description}
</div>
) : null}
</span>
</button>
)
}
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<Record<string, QuestionState>>({})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setStep(0)
const initial: Record<string, QuestionState> = {}
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<void>, 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 (
<div className="mt-3 rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Badge variant="default">
{t('tool.question')}
</Badge>
<span className="font-mono text-xs text-[var(--app-hint)]">
[{clampedStep + 1}/{total}]
</span>
</div>
</div>
</div>
{error ? (
<div className="mt-2 text-xs text-red-600">
{error}
</div>
) : null}
{currentQuestion ? (
<div className="mt-3">
{currentQuestion.question ? (
<div className="text-sm text-[var(--app-fg)] break-words">
{currentQuestion.question}
</div>
) : null}
{isPureTextQuestion ? (
// Pure text question - show only textarea
<textarea
value={currentState?.userNote ?? ''}
onChange={(e) => updateUserNote(currentQuestion.id, e.target.value)}
disabled={props.disabled || loading}
placeholder={t('tool.requestUserInput.textPlaceholder')}
className="mt-3 w-full min-h-[88px] resize-y rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 text-sm text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] focus:border-transparent disabled:opacity-50"
/>
) : (
// Question with options
<>
<div className="mt-3 flex flex-col gap-1">
{currentQuestion.options.map((opt, optIdx) => {
const isSelected = currentState?.selected === opt.label
return (
<OptionRow
key={optIdx}
checked={isSelected}
disabled={props.disabled || loading}
title={opt.label}
description={opt.description}
onClick={() => selectOption(currentQuestion.id, opt.label)}
/>
)
})}
</div>
{/* User note input - always shown for questions with options */}
<div className="mt-3">
<div className="text-xs text-[var(--app-hint)] mb-1">
{t('tool.requestUserInput.noteLabel')}
</div>
<textarea
value={currentState?.userNote ?? ''}
onChange={(e) => updateUserNote(currentQuestion.id, e.target.value)}
disabled={props.disabled || loading}
placeholder={t('tool.requestUserInput.notePlaceholder')}
className="w-full min-h-[60px] resize-y rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 text-sm text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] focus:border-transparent disabled:opacity-50"
/>
</div>
</>
)}
</div>
) : null}
<div className="mt-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{questions.length > 1 ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={props.disabled || loading || clampedStep === 0}
onClick={prev}
>
{t('tool.prev')}
</Button>
) : null}
</div>
<div className="flex items-center gap-2">
{questions.length > 1 && clampedStep < questions.length - 1 ? (
<Button
type="button"
variant="default"
size="sm"
disabled={props.disabled || loading}
onClick={next}
>
{t('tool.next')}
</Button>
) : (
<Button
type="button"
variant="default"
size="sm"
disabled={props.disabled || loading}
onClick={submit}
aria-busy={loading}
className="gap-2"
>
{loading ? (
<>
<Spinner size="sm" label={null} className="text-[var(--app-button-text)]" />
{t('tool.submitting')}
</>
) : (
t('tool.submit')
)}
</Button>
)}
</div>
</div>
</div>
)
}
+15 -3
View File
@@ -10,7 +10,9 @@ import { DiffView } from '@/components/DiffView'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { PermissionFooter } from '@/components/ToolCard/PermissionFooter'
import { AskUserQuestionFooter } from '@/components/ToolCard/AskUserQuestionFooter'
import { RequestUserInputFooter } from '@/components/ToolCard/RequestUserInputFooter'
import { isAskUserQuestionToolName } from '@/components/ToolCard/askUserQuestion'
import { isRequestUserInputToolName } from '@/components/ToolCard/requestUserInput'
import { getToolPresentation } from '@/components/ToolCard/knownTools'
import { getToolFullViewComponent, getToolViewComponent } from '@/components/ToolCard/views/_all'
import { getToolResultViewComponent } from '@/components/ToolCard/views/_results'
@@ -328,6 +330,8 @@ function ToolCardInner(props: ToolCardProps) {
const ResultToolView = getToolResultViewComponent(toolName)
const permission = props.block.tool.permission
const isAskUserQuestion = isAskUserQuestionToolName(toolName)
const isRequestUserInput = isRequestUserInputToolName(toolName)
const isQuestionTool = isAskUserQuestion || isRequestUserInput
const showsPermissionFooter = Boolean(permission && (
permission.status === 'pending'
|| ((permission.status === 'denied' || permission.status === 'canceled') && Boolean(permission.reason))
@@ -390,7 +394,7 @@ function ToolCardInner(props: ToolCardProps) {
<DialogTitle>{toolTitle}</DialogTitle>
</DialogHeader>
{(() => {
const isAskUserQuestionWithAnswers = isAskUserQuestion
const isQuestionToolWithAnswers = isQuestionTool
&& permission?.answers
&& Object.keys(permission.answers).length > 0
@@ -398,7 +402,7 @@ function ToolCardInner(props: ToolCardProps) {
<div className="mt-3 flex max-h-[75vh] flex-col gap-4 overflow-auto">
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">
{isAskUserQuestionWithAnswers ? t('tool.questionsAnswers') : t('tool.input')}
{isQuestionToolWithAnswers ? t('tool.questionsAnswers') : t('tool.input')}
</div>
{FullToolView ? (
<FullToolView block={props.block} metadata={props.metadata} />
@@ -406,7 +410,7 @@ function ToolCardInner(props: ToolCardProps) {
renderToolInput(props.block)
)}
</div>
{!isAskUserQuestionWithAnswers && (
{!isQuestionToolWithAnswers && (
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">{t('tool.result')}</div>
<ResultToolView block={props.block} metadata={props.metadata} />
@@ -454,6 +458,14 @@ function ToolCardInner(props: ToolCardProps) {
disabled={props.disabled}
onDone={props.onDone}
/>
) : isRequestUserInput && permission?.status === 'pending' ? (
<RequestUserInputFooter
api={props.api}
sessionId={props.sessionId}
tool={props.block.tool}
disabled={props.disabled}
onDone={props.onDone}
/>
) : (
<PermissionFooter
api={props.api}
@@ -367,6 +367,36 @@ export const knownTools: Record<string, {
return question.length > 0 ? truncate(question, 120) : null
},
minimal: true
},
request_user_input: {
icon: () => <QuestionIcon className={DEFAULT_ICON_CLASS} />,
title: (opts) => {
const questions = isObject(opts.input) && Array.isArray(opts.input.questions)
? opts.input.questions : []
const count = questions.length
const first = questions[0] ?? null
const id = isObject(first) && typeof first.id === 'string'
? first.id.trim() : ''
if (count > 1) {
return `${count} Questions`
}
return id.length > 0 ? id : 'Question'
},
subtitle: (opts) => {
const questions = isObject(opts.input) && Array.isArray(opts.input.questions)
? opts.input.questions : []
const count = questions.length
const first = questions[0] ?? null
const question = isObject(first) && typeof first.question === 'string'
? first.question.trim() : ''
if (count > 1 && question.length > 0) {
return truncate(question, 100) + ` (+${count - 1} more)`
}
return question.length > 0 ? truncate(question, 120) : null
},
minimal: true
}
}
@@ -0,0 +1,145 @@
import { isObject } from '@hapi/protocol'
export type RequestUserInputOption = {
label: string
description: string | null
}
export type RequestUserInputQuestion = {
id: string
question: string
options: RequestUserInputOption[]
}
export type RequestUserInputQuestionInfo = {
id: string
question: string | null
}
// Nested answer format: { answers: { [id]: { answers: string[] } } }
export type RequestUserInputAnswers = Record<string, { answers: string[] }>
export function isRequestUserInputToolName(toolName: string): boolean {
return toolName === 'request_user_input'
}
export function parseRequestUserInputInput(input: unknown): { questions: RequestUserInputQuestion[] } {
if (!isObject(input)) return { questions: [] }
const rawQuestions = input.questions
if (!Array.isArray(rawQuestions)) return { questions: [] }
const questions: RequestUserInputQuestion[] = []
for (const raw of rawQuestions) {
if (!isObject(raw)) continue
const id = typeof raw.id === 'string' ? raw.id.trim() : ''
const question = typeof raw.question === 'string' ? raw.question.trim() : ''
// Skip questions without id
if (!id) continue
const rawOptions = Array.isArray(raw.options) ? raw.options : []
const options: RequestUserInputOption[] = []
for (const opt of rawOptions) {
if (!isObject(opt)) continue
const label = typeof opt.label === 'string' ? opt.label.trim() : ''
if (!label) continue
const description = typeof opt.description === 'string' ? opt.description.trim() : null
options.push({ label, description })
}
questions.push({
id,
question,
options
})
}
return { questions }
}
export function extractRequestUserInputQuestionsInfo(input: unknown): RequestUserInputQuestionInfo[] | null {
if (!isObject(input)) return null
const raw = input.questions
if (!Array.isArray(raw)) return null
const questions: RequestUserInputQuestionInfo[] = []
for (const q of raw) {
if (!isObject(q)) continue
const id = typeof q.id === 'string' ? q.id.trim() : ''
const question = typeof q.question === 'string' ? q.question.trim() : null
if (!id) continue
questions.push({
id,
question: question && question.length > 0 ? question : null
})
}
return questions
}
/**
* Format answers for submission in the nested format expected by request_user_input
* Format: { answers: { [id]: { answers: ["option", "user_note: note text"] } } }
*/
export function formatRequestUserInputAnswers(
answersByQuestion: Record<string, { selected: string | null; userNote: string }>
): { answers: RequestUserInputAnswers } {
const answers: RequestUserInputAnswers = {}
for (const [id, answer] of Object.entries(answersByQuestion)) {
const answerArray: string[] = []
if (answer.selected) {
answerArray.push(answer.selected)
}
const note = answer.userNote.trim()
if (note.length > 0) {
answerArray.push(`user_note: ${note}`)
}
answers[id] = { answers: answerArray }
}
return { answers }
}
/**
* Parse answers from the nested format for display
*/
export function parseRequestUserInputAnswers(
answers: unknown
): Record<string, { selected: string | null; userNote: string | null }> | null {
if (!isObject(answers)) return null
// Handle nested format: { answers: { [id]: { answers: string[] } } }
const answersObj = isObject(answers.answers) ? answers.answers : answers
const parsed: Record<string, { selected: string | null; userNote: string | null }> = {}
for (const [id, value] of Object.entries(answersObj)) {
let answerArray: string[] = []
if (isObject(value) && Array.isArray(value.answers)) {
answerArray = value.answers.filter((a): a is string => typeof a === 'string')
} else if (Array.isArray(value)) {
answerArray = value.filter((a): a is string => typeof a === 'string')
}
let selected: string | null = null
let userNote: string | null = null
for (const item of answerArray) {
if (item.startsWith('user_note: ')) {
userNote = item.slice('user_note: '.length).trim()
} else if (!selected) {
selected = item
}
}
parsed[id] = { selected, userNote }
}
return parsed
}
@@ -3,6 +3,24 @@ import type { ToolViewProps } from '@/components/ToolCard/views/_all'
import { parseAskUserQuestionInput } from '@/components/ToolCard/askUserQuestion'
import { cn } from '@/lib/utils'
type AnswersFormat = Record<string, string[]> | Record<string, { answers: string[] }>
/**
* Normalize answers to flat format: Record<string, string[]>
*/
function normalizeAnswers(answers: AnswersFormat | undefined): Record<string, string[]> | undefined {
if (!answers) return undefined
const result: Record<string, string[]> = {}
for (const [key, value] of Object.entries(answers)) {
if (Array.isArray(value)) {
result[key] = value
} else if (value && typeof value === 'object' && 'answers' in value) {
result[key] = value.answers
}
}
return result
}
function isAnswerSelected(
answers: Record<string, string[]> | undefined,
questionIdx: number,
@@ -95,7 +113,8 @@ function renderFreeformAnswers(
export function AskUserQuestionView(props: ToolViewProps) {
const parsed = parseAskUserQuestionInput(props.block.tool.input)
const questions = parsed.questions
const answers = props.block.tool.permission?.answers ?? undefined
const rawAnswers = props.block.tool.permission?.answers ?? undefined
const answers = normalizeAnswers(rawAnswers)
const hasAnswers = answers && Object.keys(answers).length > 0
// When questions array is empty but answers exist (fallback path),
@@ -0,0 +1,121 @@
import type { ToolViewProps } from '@/components/ToolCard/views/_all'
import {
parseRequestUserInputInput,
parseRequestUserInputAnswers
} from '@/components/ToolCard/requestUserInput'
import { cn } from '@/lib/utils'
function getSelectionMark(isSelected: boolean): string {
return isSelected ? '●' : '○'
}
export function RequestUserInputView(props: ToolViewProps) {
const parsed = parseRequestUserInputInput(props.block.tool.input)
const questions = parsed.questions
const rawAnswers = props.block.tool.permission?.answers ?? undefined
const parsedAnswers = rawAnswers ? parseRequestUserInputAnswers(rawAnswers) : null
const hasAnswers = parsedAnswers && Object.keys(parsedAnswers).length > 0
if (questions.length === 0) {
return null
}
return (
<div className="flex flex-col gap-3">
{questions.map((q) => {
const answer = parsedAnswers?.[q.id]
const isPureTextQuestion = q.options.length === 0
return (
<div key={q.id} className="rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-3">
{q.question ? (
<div className="text-sm text-[var(--app-fg)] break-words">
{q.question}
</div>
) : null}
{isPureTextQuestion ? (
// Pure text question - show the answer directly
hasAnswers && answer?.userNote ? (
<div className="mt-3">
<div className="rounded-md border border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 px-2 py-2">
<div className="flex items-start gap-2">
<span className="shrink-0 text-sm text-emerald-600"></span>
<div className="min-w-0 flex-1">
<div className="text-sm text-emerald-700 dark:text-emerald-300 font-medium break-words">
{answer.userNote}
</div>
</div>
</div>
</div>
</div>
) : null
) : (
// Question with options
<div className="mt-3 flex flex-col gap-1">
{q.options.map((opt, optIdx) => {
const isSelected = answer?.selected === opt.label
return (
<div
key={optIdx}
className={cn(
"rounded-md border px-2 py-2",
isSelected
? "border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30"
: "border-[var(--app-border)]"
)}
>
<div className="flex items-start gap-2">
{hasAnswers && (
<span className={cn(
"shrink-0 text-sm",
isSelected
? "text-emerald-600"
: "text-[var(--app-hint)]"
)}>
{getSelectionMark(isSelected)}
</span>
)}
<div className="min-w-0 flex-1">
<div className={cn(
"text-sm break-words",
isSelected
? "text-emerald-700 dark:text-emerald-300 font-medium"
: "text-[var(--app-fg)]"
)}>
{opt.label}
</div>
{opt.description ? (
<div className="mt-0.5 text-xs text-[var(--app-hint)] break-words">
{opt.description}
</div>
) : null}
</div>
</div>
</div>
)
})}
{/* Show user note if present */}
{hasAnswers && answer?.userNote ? (
<div className="mt-2 rounded-md border border-blue-300 bg-blue-50 dark:bg-blue-950/30 px-2 py-2">
<div className="flex items-start gap-2">
<span className="shrink-0 text-xs text-blue-500">📝</span>
<div className="min-w-0 flex-1">
<div className="text-xs text-[var(--app-hint)]">Note:</div>
<div className="text-sm text-blue-700 dark:text-blue-300 break-words">
{answer.userNote}
</div>
</div>
</div>
</div>
) : null}
</div>
)}
</div>
)
})}
</div>
)
}
+5 -2
View File
@@ -5,6 +5,7 @@ import { CodexDiffCompactView, CodexDiffFullView } from '@/components/ToolCard/v
import { CodexPatchView } from '@/components/ToolCard/views/CodexPatchView'
import { EditView } from '@/components/ToolCard/views/EditView'
import { AskUserQuestionView } from '@/components/ToolCard/views/AskUserQuestionView'
import { RequestUserInputView } from '@/components/ToolCard/views/RequestUserInputView'
import { ExitPlanModeView } from '@/components/ToolCard/views/ExitPlanModeView'
import { MultiEditFullView, MultiEditView } from '@/components/ToolCard/views/MultiEditView'
import { TodoWriteView } from '@/components/ToolCard/views/TodoWriteView'
@@ -26,7 +27,8 @@ export const toolViewRegistry: Record<string, ToolViewComponent> = {
AskUserQuestion: AskUserQuestionView,
ExitPlanMode: ExitPlanModeView,
ask_user_question: AskUserQuestionView,
exit_plan_mode: ExitPlanModeView
exit_plan_mode: ExitPlanModeView,
request_user_input: RequestUserInputView
}
export const toolFullViewRegistry: Record<string, ToolViewComponent> = {
@@ -38,7 +40,8 @@ export const toolFullViewRegistry: Record<string, ToolViewComponent> = {
AskUserQuestion: AskUserQuestionView,
ExitPlanMode: ExitPlanModeView,
ask_user_question: AskUserQuestionView,
exit_plan_mode: ExitPlanModeView
exit_plan_mode: ExitPlanModeView,
request_user_input: RequestUserInputView
}
export function getToolViewComponent(toolName: string): ToolViewComponent | null {
+3
View File
@@ -184,6 +184,9 @@ export default {
'tool.askUserQuestion.fallback': 'AskUserQuestion payload is not in the expected format. Type your answer:',
'tool.askUserQuestion.placeholder': 'Type your answer…',
'tool.askUserQuestion.otherPlaceholder': 'Or type your own answer…',
'tool.requestUserInput.textPlaceholder': 'Type your answer…',
'tool.requestUserInput.noteLabel': 'Additional note (optional)',
'tool.requestUserInput.notePlaceholder': 'Add a note…',
// Composer buttons
'composer.settings': 'Settings',
+3
View File
@@ -186,6 +186,9 @@ export default {
'tool.askUserQuestion.fallback': 'AskUserQuestion 格式不正确。请输入您的答案:',
'tool.askUserQuestion.placeholder': '输入您的答案…',
'tool.askUserQuestion.otherPlaceholder': '或输入您自己的答案…',
'tool.requestUserInput.textPlaceholder': '输入您的答案…',
'tool.requestUserInput.noteLabel': '补充说明(可选)',
'tool.requestUserInput.notePlaceholder': '添加备注…',
// Composer buttons
'composer.settings': '设置',