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
@@ -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 {