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