feat: implement AskUserQuestion tool with interactive UI and question flow

Add comprehensive support for the AskUserQuestion tool including:
- New footer component for handling user responses with multi-step question flow
- Type definitions and parsing utilities for question and answer formats
- View component for displaying questions and options
- Integration with permission system to capture user answers
- Support for single and multi-select questions with optional text input
- Haptic feedback and error handling for better UX
This commit is contained in:
weishu
2025-12-18 23:24:41 +08:00
parent 7042596c90
commit ac67718ec0
16 changed files with 758 additions and 32 deletions
+3 -1
View File
@@ -80,6 +80,7 @@ export type AgentState = {
mode?: string
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
allowTools?: string[]
answers?: Record<string, string[]>
}
}
}
@@ -100,7 +101,8 @@ export const AgentStateSchema = z.object({
reason: z.string().optional(),
mode: z.string().optional(),
decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(),
allowTools: z.array(z.string()).optional()
allowTools: z.array(z.string()).optional(),
answers: z.record(z.string(), z.array(z.string())).optional()
})).optional()
}).passthrough()
+87 -5
View File
@@ -22,9 +22,72 @@ interface PermissionResponse {
reason?: string;
mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
allowTools?: string[];
answers?: Record<string, string[]>;
receivedAt?: number;
}
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object';
}
function isAskUserQuestionToolName(toolName: string): boolean {
return toolName === 'AskUserQuestion' || toolName === 'ask_user_question';
}
function formatAskUserQuestionAnswers(answers: Record<string, string[]>, input: unknown): string {
const questions = (() => {
if (!isObject(input)) return null;
const raw = input.questions;
if (!Array.isArray(raw)) return null;
return raw.filter((q) => isObject(q));
})();
const keys = Object.keys(answers).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;
if (Number.isFinite(aNum)) return -1;
if (Number.isFinite(bNum)) return 1;
return a.localeCompare(b);
});
const lines = keys.map((key) => {
const idx = Number.parseInt(key, 10);
const q = questions && Number.isFinite(idx) ? questions[idx] : null;
const header = q && typeof q.header === 'string' && q.header.trim().length > 0
? q.header.trim()
: Number.isFinite(idx)
? `Question ${idx + 1}`
: `Question ${key}`;
const value = answers[key] ?? [];
const joined = value.map((v) => String(v)).filter((v) => v.trim().length > 0).join(', ');
return `${header}: ${joined || '(no answer)'}`;
});
const rawJson = (() => {
try {
return JSON.stringify(answers);
} catch {
return null;
}
})();
const body = lines.length > 0 ? lines.join('\n') : '(no answers)';
return rawJson
? `User answered:\n${body}\n\nRaw answers JSON:\n${rawJson}`
: `User answered:\n${body}`;
}
function buildAskUserQuestionUpdatedInput(input: unknown, answers: Record<string, string[]>): Record<string, unknown> {
if (!isObject(input)) {
return { answers };
}
return {
...input,
answers
};
}
interface PendingRequest {
resolve: (value: PermissionResult) => void;
@@ -71,6 +134,9 @@ export class PermissionHandler {
// Update allowed tools
if (response.allowTools && response.allowTools.length > 0) {
response.allowTools.forEach(tool => {
if (isAskUserQuestionToolName(tool)) {
return;
}
if (tool.startsWith('Bash(') || tool === 'Bash') {
this.parseBashPermission(tool);
} else {
@@ -85,6 +151,20 @@ export class PermissionHandler {
}
// Handle
if (isAskUserQuestionToolName(pending.toolName)) {
const answers = response.answers ?? {};
if (Object.keys(answers).length === 0) {
pending.resolve({ behavior: 'deny', message: 'No answers were provided.' });
return;
}
pending.resolve({
behavior: 'allow',
updatedInput: buildAskUserQuestionUpdatedInput(pending.input, answers)
});
return;
}
if (pending.toolName === 'exit_plan_mode' || pending.toolName === 'ExitPlanMode') {
// Handle exit_plan_mode specially
logger.debug('Plan mode result received', response);
@@ -114,9 +194,10 @@ export class PermissionHandler {
* Creates the canCallTool callback for the SDK
*/
handleToolCall = async (toolName: string, input: unknown, mode: EnhancedMode, options: { signal: AbortSignal }): Promise<PermissionResult> => {
const isAskUserQuestion = isAskUserQuestionToolName(toolName);
// Check if tool is explicitly allowed
if (toolName === 'Bash') {
if (!isAskUserQuestion && toolName === 'Bash') {
const inputObj = input as { command?: string };
if (inputObj?.command) {
// Check literal matches
@@ -130,7 +211,7 @@ export class PermissionHandler {
}
}
}
} else if (this.allowedTools.has(toolName)) {
} else if (!isAskUserQuestion && this.allowedTools.has(toolName)) {
return { behavior: 'allow', updatedInput: input as Record<string, unknown> };
}
@@ -141,11 +222,11 @@ export class PermissionHandler {
// Handle special cases
//
if (this.permissionMode === 'bypassPermissions') {
if (!isAskUserQuestion && this.permissionMode === 'bypassPermissions') {
return { behavior: 'allow', updatedInput: input as Record<string, unknown> };
}
if (this.permissionMode === 'acceptEdits' && descriptor.edit) {
if (!isAskUserQuestion && this.permissionMode === 'acceptEdits' && descriptor.edit) {
return { behavior: 'allow', updatedInput: input as Record<string, unknown> };
}
@@ -399,7 +480,8 @@ export class PermissionHandler {
status: message.approved ? 'approved' : 'denied',
reason: message.reason,
mode: message.mode,
allowTools: message.allowTools
allowTools: message.allowTools,
answers: message.answers
}
}
};
+6 -3
View File
@@ -48,7 +48,8 @@ export const AgentStateSchema = z.object({
reason: z.string().optional(),
mode: z.string().optional(),
decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(),
allowTools: z.array(z.string()).optional()
allowTools: z.array(z.string()).optional(),
answers: z.record(z.string(), z.array(z.string())).optional()
}).passthrough()).nullish()
}).passthrough()
@@ -591,14 +592,16 @@ export class SyncEngine {
requestId: string,
mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan',
allowTools?: string[],
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort',
answers?: Record<string, string[]>
): Promise<void> {
await this.sessionRpc(sessionId, 'permission', {
id: requestId,
approved: true,
mode,
allowTools,
decision
decision,
answers
})
}
+4 -2
View File
@@ -9,7 +9,8 @@ const decisionSchema = z.enum(['approved', 'approved_for_session', 'denied', 'ab
const approveBodySchema = z.object({
mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional(),
allowTools: z.array(z.string()).optional(),
decision: decisionSchema.optional()
decision: decisionSchema.optional(),
answers: z.record(z.string(), z.array(z.string())).optional()
})
const denyBodySchema = z.object({
@@ -47,7 +48,8 @@ export function createPermissionsRoutes(getSyncEngine: () => SyncEngine | null):
const mode = parsed.data.mode
const allowTools = parsed.data.allowTools
const decision = parsed.data.decision
await engine.approvePermission(sessionId, requestId, mode, allowTools, decision)
const answers = parsed.data.answers
await engine.approvePermission(sessionId, requestId, mode, allowTools, decision, answers)
return c.json({ ok: true })
})
+1
View File
@@ -106,6 +106,7 @@ export class ApiClient {
mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
allowTools?: string[]
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
answers?: Record<string, string[]>
}
): Promise<void> {
const body = typeof modeOrOptions === 'string' || modeOrOptions === undefined
+13 -12
View File
@@ -127,18 +127,19 @@ function getPermissions(agentState: AgentState | null | undefined): Map<string,
map.set(id, {
toolName: entry.tool,
input: entry.arguments,
permission: {
id,
status: entry.status,
reason: entry.reason ?? undefined,
mode: entry.mode ?? undefined,
decision: entry.decision ?? undefined,
allowedTools: entry.allowTools,
createdAt: entry.createdAt ?? null,
completedAt: entry.completedAt ?? null
}
})
}
permission: {
id,
status: entry.status,
reason: entry.reason ?? undefined,
mode: entry.mode ?? undefined,
decision: entry.decision ?? undefined,
allowedTools: entry.allowTools,
answers: entry.answers,
createdAt: entry.createdAt ?? null,
completedAt: entry.completedAt ?? null
}
})
}
}
const requests = agentState?.requests ?? null
+1
View File
@@ -83,6 +83,7 @@ export type ToolPermission = {
mode?: string
allowedTools?: string[]
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
answers?: Record<string, string[]>
date?: number
createdAt?: number | null
completedAt?: number | null
@@ -0,0 +1,392 @@
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 { isAskUserQuestionToolName, parseAskUserQuestionInput, type AskUserQuestionQuestion } from '@/components/ToolCard/askUserQuestion'
import { cn } from '@/lib/utils'
import { usePlatform } from '@/hooks/usePlatform'
function SelectionMark(props: { checked: boolean; mode: 'single' | 'multi' }) {
const mark = props.mode === 'multi'
? (props.checked ? '☑' : '☐')
: (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
mode: 'single' | 'multi'
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} mode={props.mode} />
<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>
)
}
function computeAnswersForQuestion(
question: AskUserQuestionQuestion,
selectedOptionIndices: number[],
otherSelected: boolean,
otherText: string
): string[] {
const answers: string[] = []
for (const idx of selectedOptionIndices) {
const opt = question.options[idx]
if (!opt) continue
const label = opt.label.trim()
if (label.length > 0) answers.push(label)
}
const other = otherText.trim()
if (otherSelected && other.length > 0) {
answers.push(other)
}
return answers
}
export function AskUserQuestionFooter(props: {
api: ApiClient
sessionId: string
tool: ChatToolCall
disabled: boolean
onDone: () => void
}) {
const { haptic } = usePlatform()
const permission = props.tool.permission
const parsed = useMemo(() => parseAskUserQuestionInput(props.tool.input), [props.tool.input])
const questions = parsed.questions
const [step, setStep] = useState(0)
const [selectedByQuestion, setSelectedByQuestion] = useState<number[][]>([])
const [otherSelectedByQuestion, setOtherSelectedByQuestion] = useState<boolean[]>([])
const [otherTextByQuestion, setOtherTextByQuestion] = useState<string[]>([])
const [fallbackText, setFallbackText] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setStep(0)
setSelectedByQuestion(questions.map(() => []))
setOtherSelectedByQuestion(questions.map(() => false))
setOtherTextByQuestion(questions.map(() => ''))
setFallbackText('')
setLoading(false)
setError(null)
}, [props.tool.id])
if (!permission || permission.status !== 'pending') return null
if (!isAskUserQuestionToolName(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 : 'Request failed')
}
}
const total = Math.max(1, questions.length)
const clampedStep = Math.min(Math.max(step, 0), total - 1)
const mode: 'single' | 'multi' = questions[clampedStep]?.multiSelect ? 'multi' : 'single'
const validateQuestion = (idx: number): string[] | null => {
if (questions.length === 0) {
const text = fallbackText.trim()
return text.length > 0 ? [text] : null
}
const question = questions[idx]
if (!question) return null
const answers = computeAnswersForQuestion(
question,
selectedByQuestion[idx] ?? [],
otherSelectedByQuestion[idx] ?? false,
otherTextByQuestion[idx] ?? ''
)
return answers.length > 0 ? answers : null
}
const submit = async () => {
if (loading) return
const answers: Record<string, string[]> = {}
if (questions.length === 0) {
const a0 = validateQuestion(0)
if (!a0) {
setError('Please type an answer.')
return
}
answers['0'] = a0
} else {
for (let i = 0; i < questions.length; i += 1) {
const a = validateQuestion(i)
if (!a) {
setError(`Please answer question ${i + 1} before submitting.`)
setStep(i)
return
}
answers[String(i)] = a
}
}
setLoading(true)
await run(() => props.api.approvePermission(props.sessionId, permission.id, { answers }), 'success')
setLoading(false)
}
const next = () => {
if (questions.length === 0) return
const a = validateQuestion(clampedStep)
if (!a) {
setError('Please select at least one option or type an answer.')
return
}
setError(null)
setStep((s) => Math.min(s + 1, questions.length - 1))
}
const prev = () => {
setError(null)
setStep((s) => Math.max(s - 1, 0))
}
const toggleOption = (qIdx: number, optIdx: number) => {
const q = questions[qIdx]
if (!q) return
haptic.selection()
setSelectedByQuestion((prevSelected) => {
const nextSelected = prevSelected.slice()
const cur = new Set(nextSelected[qIdx] ?? [])
if (q.multiSelect) {
if (cur.has(optIdx)) cur.delete(optIdx)
else cur.add(optIdx)
nextSelected[qIdx] = Array.from(cur).sort((a, b) => a - b)
return nextSelected
}
nextSelected[qIdx] = [optIdx]
return nextSelected
})
if (!q.multiSelect) {
setOtherSelectedByQuestion((prevOther) => {
const nextOther = prevOther.slice()
nextOther[qIdx] = false
return nextOther
})
}
}
const toggleOther = (qIdx: number) => {
const q = questions[qIdx]
if (!q) return
haptic.selection()
if (!q.multiSelect) {
setSelectedByQuestion((prevSelected) => {
const nextSelected = prevSelected.slice()
nextSelected[qIdx] = []
return nextSelected
})
setOtherSelectedByQuestion((prevOther) => {
const nextOther = prevOther.slice()
nextOther[qIdx] = true
return nextOther
})
return
}
setOtherSelectedByQuestion((prevOther) => {
const nextOther = prevOther.slice()
nextOther[qIdx] = !nextOther[qIdx]
return nextOther
})
}
const updateOtherText = (qIdx: number, value: string) => {
setOtherTextByQuestion((prevText) => {
const nextText = prevText.slice()
nextText[qIdx] = value
return nextText
})
if (value.trim().length > 0) {
setOtherSelectedByQuestion((prevOther) => {
const nextOther = prevOther.slice()
nextOther[qIdx] = true
return nextOther
})
}
}
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">
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}
{questions.length === 0 ? (
<div className="mt-3">
<div className="text-sm text-[var(--app-hint)]">
AskUserQuestion payload is not in the expected format. Type your answer:
</div>
<textarea
value={fallbackText}
onChange={(e) => setFallbackText(e.target.value)}
disabled={props.disabled || loading}
placeholder="Type your answer…"
className="mt-2 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"
/>
</div>
) : (
<div className="mt-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{questions[clampedStep]?.header ? (
<Badge variant="default">
{questions[clampedStep].header}
</Badge>
) : null}
<Badge variant="default">
{mode === 'multi' ? 'Multi' : 'Single'}
</Badge>
</div>
{questions[clampedStep]?.question ? (
<div className="mt-2 text-sm text-[var(--app-fg)] break-words">
{questions[clampedStep].question}
</div>
) : null}
</div>
</div>
<div className="mt-3 flex flex-col gap-1">
{questions[clampedStep].options.map((opt, optIdx) => {
const selected = (selectedByQuestion[clampedStep] ?? []).includes(optIdx)
return (
<OptionRow
key={optIdx}
checked={selected}
mode={mode}
disabled={props.disabled || loading}
title={opt.label}
description={opt.description}
onClick={() => toggleOption(clampedStep, optIdx)}
/>
)
})}
<OptionRow
checked={otherSelectedByQuestion[clampedStep] ?? false}
mode={mode}
disabled={props.disabled || loading}
title="Other"
description="Type your own answer"
onClick={() => toggleOther(clampedStep)}
/>
{(otherSelectedByQuestion[clampedStep] ?? false) ? (
<textarea
value={otherTextByQuestion[clampedStep] ?? ''}
onChange={(e) => updateOtherText(clampedStep, e.target.value)}
disabled={props.disabled || loading}
placeholder="Or type your own answer…"
className="mt-2 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"
/>
) : null}
</div>
</div>
)}
<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}
>
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}
>
Next
</Button>
) : (
<Button
type="button"
variant="default"
size="sm"
disabled={props.disabled || loading}
onClick={submit}
>
{loading ? 'Submitting…' : 'Submit'}
</Button>
)}
</div>
</div>
</div>
)
}
+21 -8
View File
@@ -8,6 +8,8 @@ import { MarkdownRenderer } from '@/components/MarkdownRenderer'
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 { isAskUserQuestionToolName } from '@/components/ToolCard/askUserQuestion'
import { getToolPresentation } from '@/components/ToolCard/knownTools'
import { getToolFullViewComponent, getToolViewComponent } from '@/components/ToolCard/views/_all'
import { getToolResultViewComponent } from '@/components/ToolCard/views/_results'
@@ -313,6 +315,7 @@ export function ToolCard(props: {
const FullToolView = getToolFullViewComponent(toolName)
const ResultToolView = getToolResultViewComponent(toolName)
const permission = props.block.tool.permission
const isAskUserQuestion = isAskUserQuestionToolName(toolName)
const showsPermissionFooter = Boolean(permission && (
permission.status === 'pending'
|| ((permission.status === 'denied' || permission.status === 'canceled') && Boolean(permission.reason))
@@ -409,14 +412,24 @@ export function ToolCard(props: {
)
) : null}
<PermissionFooter
api={props.api}
sessionId={props.sessionId}
metadata={props.metadata}
tool={props.block.tool}
disabled={props.disabled}
onDone={props.onDone}
/>
{isAskUserQuestion && permission?.status === 'pending' ? (
<AskUserQuestionFooter
api={props.api}
sessionId={props.sessionId}
tool={props.block.tool}
disabled={props.disabled}
onDone={props.onDone}
/>
) : (
<PermissionFooter
api={props.api}
sessionId={props.sessionId}
metadata={props.metadata}
tool={props.block.tool}
disabled={props.disabled}
onDone={props.onDone}
/>
)}
</CardContent>
) : null}
</Card>
@@ -0,0 +1,80 @@
export type AskUserQuestionOption = {
label: string
description: string | null
}
export type AskUserQuestionQuestion = {
header: string | null
question: string
options: AskUserQuestionOption[]
multiSelect: boolean
}
export type AskUserQuestionQuestionInfo = {
header: string | null
question: string | null
}
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object'
}
export function isAskUserQuestionToolName(toolName: string): boolean {
return toolName === 'AskUserQuestion' || toolName === 'ask_user_question'
}
export function parseAskUserQuestionInput(input: unknown): { questions: AskUserQuestionQuestion[] } {
if (!isObject(input)) return { questions: [] }
const rawQuestions = input.questions
if (!Array.isArray(rawQuestions)) return { questions: [] }
const questions: AskUserQuestionQuestion[] = []
for (const raw of rawQuestions) {
if (!isObject(raw)) continue
const question = typeof raw.question === 'string' ? raw.question.trim() : ''
const header = typeof raw.header === 'string' ? raw.header.trim() : ''
const multiSelect = typeof raw.multiSelect === 'boolean' ? raw.multiSelect : false
const rawOptions = Array.isArray(raw.options) ? raw.options : []
const options: AskUserQuestionOption[] = []
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 })
}
if (!question && options.length === 0) continue
questions.push({
header: header.length > 0 ? header : null,
question,
options,
multiSelect
})
}
return { questions }
}
export function extractAskUserQuestionQuestionsInfo(input: unknown): AskUserQuestionQuestionInfo[] | null {
if (!isObject(input)) return null
const raw = input.questions
if (!Array.isArray(raw)) return null
const questions: AskUserQuestionQuestionInfo[] = []
for (const q of raw) {
if (!isObject(q)) continue
const header = typeof q.header === 'string' ? q.header.trim() : null
const question = typeof q.question === 'string' ? q.question.trim() : null
questions.push({
header: header && header.length > 0 ? header : null,
question: question && question.length > 0 ? question : null
})
}
return questions
}
+10
View File
@@ -127,3 +127,13 @@ export function WrenchIcon(props: IconProps) {
)
}
export function QuestionIcon(props: IconProps) {
return createIcon(
<>
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9a2.5 2.5 0 1 1 4.1 1.9c-.9.7-1.6 1.3-1.6 2.6" />
<path d="M12 17h.01" />
</>,
props
)
}
+29 -1
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import type { SessionMetadataSummary } from '@/types/api'
import { BulbIcon, ClipboardIcon, EyeIcon, FileDiffIcon, GlobeIcon, PuzzleIcon, RocketIcon, SearchIcon, TerminalIcon, WrenchIcon } from '@/components/ToolCard/icons'
import { BulbIcon, ClipboardIcon, EyeIcon, FileDiffIcon, GlobeIcon, PuzzleIcon, QuestionIcon, RocketIcon, SearchIcon, TerminalIcon, WrenchIcon } from '@/components/ToolCard/icons'
import { basename, resolveDisplayPath } from '@/components/ToolCard/path'
const DEFAULT_ICON_CLASS = 'h-3.5 w-3.5'
@@ -295,6 +295,34 @@ export const knownTools: Record<string, {
icon: () => <ClipboardIcon className={DEFAULT_ICON_CLASS} />,
title: () => 'Plan proposal',
minimal: false
},
AskUserQuestion: {
icon: () => <QuestionIcon className={DEFAULT_ICON_CLASS} />,
title: (opts) => {
const first = isObject(opts.input) && Array.isArray(opts.input.questions) ? opts.input.questions[0] : null
const header = isObject(first) && typeof first.header === 'string' ? first.header.trim() : ''
return header.length > 0 ? header : 'Question'
},
subtitle: (opts) => {
const first = isObject(opts.input) && Array.isArray(opts.input.questions) ? opts.input.questions[0] : null
const question = isObject(first) && typeof first.question === 'string' ? first.question.trim() : ''
return question.length > 0 ? truncate(question, 120) : null
},
minimal: true
},
ask_user_question: {
icon: () => <QuestionIcon className={DEFAULT_ICON_CLASS} />,
title: (opts) => {
const first = isObject(opts.input) && Array.isArray(opts.input.questions) ? opts.input.questions[0] : null
const header = isObject(first) && typeof first.header === 'string' ? first.header.trim() : ''
return header.length > 0 ? header : 'Question'
},
subtitle: (opts) => {
const first = isObject(opts.input) && Array.isArray(opts.input.questions) ? opts.input.questions[0] : null
const question = isObject(first) && typeof first.question === 'string' ? first.question.trim() : ''
return question.length > 0 ? truncate(question, 120) : null
},
minimal: true
}
}
@@ -0,0 +1,49 @@
import type { ToolViewProps } from '@/components/ToolCard/views/_all'
import { Badge } from '@/components/ui/badge'
import { parseAskUserQuestionInput } from '@/components/ToolCard/askUserQuestion'
export function AskUserQuestionView(props: ToolViewProps) {
const parsed = parseAskUserQuestionInput(props.block.tool.input)
const questions = parsed.questions
if (questions.length === 0) return null
return (
<div className="flex flex-col gap-3">
{questions.map((q, idx) => (
<div key={idx} className="rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-3">
<div className="flex items-center gap-2">
<Badge variant="default">
{q.header ?? `Question ${idx + 1}`}
</Badge>
<Badge variant="default">
{q.multiSelect ? 'Multi' : 'Single'}
</Badge>
</div>
{q.question ? (
<div className="mt-2 text-sm text-[var(--app-fg)] break-words">
{q.question}
</div>
) : null}
{q.options.length > 0 ? (
<div className="mt-3 flex flex-col gap-1">
{q.options.map((opt, optIdx) => (
<div key={optIdx} className="rounded-md border border-[var(--app-border)] px-2 py-2">
<div className="text-sm text-[var(--app-fg)] break-words">
{opt.label}
</div>
{opt.description ? (
<div className="mt-0.5 text-xs text-[var(--app-hint)] break-words">
{opt.description}
</div>
) : null}
</div>
))}
</div>
) : null}
</div>
))}
</div>
)
}
@@ -4,6 +4,7 @@ import type { SessionMetadataSummary } from '@/types/api'
import { CodexDiffCompactView, CodexDiffFullView } from '@/components/ToolCard/views/CodexDiffView'
import { CodexPatchView } from '@/components/ToolCard/views/CodexPatchView'
import { EditView } from '@/components/ToolCard/views/EditView'
import { AskUserQuestionView } from '@/components/ToolCard/views/AskUserQuestionView'
import { ExitPlanModeView } from '@/components/ToolCard/views/ExitPlanModeView'
import { MultiEditFullView, MultiEditView } from '@/components/ToolCard/views/MultiEditView'
import { TodoWriteView } from '@/components/ToolCard/views/TodoWriteView'
@@ -22,7 +23,9 @@ export const toolViewRegistry: Record<string, ToolViewComponent> = {
Write: WriteView,
TodoWrite: TodoWriteView,
CodexDiff: CodexDiffCompactView,
AskUserQuestion: AskUserQuestionView,
ExitPlanMode: ExitPlanModeView,
ask_user_question: AskUserQuestionView,
exit_plan_mode: ExitPlanModeView
}
@@ -32,7 +35,9 @@ export const toolFullViewRegistry: Record<string, ToolViewComponent> = {
Write: WriteView,
CodexDiff: CodexDiffFullView,
CodexPatch: CodexPatchView,
AskUserQuestion: AskUserQuestionView,
ExitPlanMode: ExitPlanModeView,
ask_user_question: AskUserQuestionView,
exit_plan_mode: ExitPlanModeView
}
@@ -1,6 +1,7 @@
import type { ToolViewComponent, ToolViewProps } from '@/components/ToolCard/views/_all'
import { CodeBlock } from '@/components/CodeBlock'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
import { extractAskUserQuestionQuestionsInfo } from '@/components/ToolCard/askUserQuestion'
import { basename, resolveDisplayPath } from '@/components/ToolCard/path'
function isObject(value: unknown): value is Record<string, unknown> {
@@ -193,6 +194,59 @@ function isProbablyMarkdownList(text: string): boolean {
return trimmed.startsWith('- ') || trimmed.startsWith('* ') || trimmed.startsWith('1. ')
}
const AskUserQuestionResultView: ToolViewComponent = (props: ToolViewProps) => {
const answers = props.block.tool.permission?.answers ?? null
if (!answers || Object.keys(answers).length === 0) {
return <MarkdownResultView {...props} />
}
const questions = extractAskUserQuestionQuestionsInfo(props.block.tool.input)
const keys = Object.keys(answers).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
if (Number.isFinite(aNum)) return -1
if (Number.isFinite(bNum)) return 1
return a.localeCompare(b)
})
return (
<div className="flex flex-col gap-2">
{keys.map((key) => {
const idx = Number.parseInt(key, 10)
const q = questions && Number.isFinite(idx) ? questions[idx] : null
const header = q?.header ?? (Number.isFinite(idx) ? `Question ${idx + 1}` : `Question ${key}`)
const values = answers[key] ?? []
const cleaned = values.map((v) => String(v)).map((v) => v.trim()).filter((v) => v.length > 0)
return (
<div key={key} className="rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2">
<div className="text-xs font-medium text-[var(--app-hint)] break-words">
{header}
</div>
{q?.question ? (
<div className="mt-1 text-xs text-[var(--app-hint)] break-words">
{q.question}
</div>
) : null}
{cleaned.length > 0 ? (
<ul className="mt-2 list-disc pl-5 text-sm text-[var(--app-fg)]">
{cleaned.map((v, i) => (
<li key={i} className="break-words">{v}</li>
))}
</ul>
) : (
<div className="mt-2 text-sm text-[var(--app-hint)]">
(no answer)
</div>
)}
</div>
)
})}
</div>
)
}
const BashResultView: ToolViewComponent = (props: ToolViewProps) => {
const result = props.block.tool.result
@@ -572,7 +626,9 @@ export const toolResultViewRegistry: Record<string, ToolViewComponent> = {
CodexReasoning: CodexReasoningResultView,
CodexPatch: CodexPatchResultView,
CodexDiff: CodexDiffResultView,
AskUserQuestion: AskUserQuestionResultView,
ExitPlanMode: MarkdownResultView,
ask_user_question: AskUserQuestionResultView,
exit_plan_mode: MarkdownResultView
}
+1
View File
@@ -29,6 +29,7 @@ export type AgentStateCompletedRequest = {
mode?: string
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
allowTools?: string[]
answers?: Record<string, string[]>
}
export type AgentState = {