mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
Reference in New Issue
Block a user