mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +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:
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '设置',
|
||||
|
||||
Reference in New Issue
Block a user