Files
hapi/web/src/components/ToolCard/ToolCard.tsx
T
weishu ae9650ca7c 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
2026-01-26 11:58:36 +08:00

486 lines
20 KiB
TypeScript

import type { ToolCallBlock } from '@/chat/types'
import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
import { isObject, safeStringify } from '@hapi/protocol'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { CodeBlock } from '@/components/CodeBlock'
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 { 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'
import { usePointerFocusRing } from '@/hooks/usePointerFocusRing'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation'
function getInputString(input: unknown, key: string): string | null {
if (!isObject(input)) return null
const value = input[key]
return typeof value === 'string' ? value : null
}
function getInputStringAny(input: unknown, keys: string[]): string | null {
for (const key of keys) {
const value = getInputString(input, key)
if (value) return value
}
return null
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text
return text.slice(0, maxLen - 3) + '...'
}
const ELAPSED_INTERVAL_MS = 1000
function ElapsedView(props: { from: number; active: boolean }) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!props.active) return
const id = setInterval(() => setNow(Date.now()), ELAPSED_INTERVAL_MS)
return () => clearInterval(id)
}, [props.active])
if (!props.active) return null
const elapsed = (now - props.from) / 1000
if (!Number.isFinite(elapsed)) return null
return (
<span className="font-mono text-xs text-[var(--app-hint)]">
{elapsed.toFixed(1)}s
</span>
)
}
function formatTaskChildLabel(child: ToolCallBlock, metadata: SessionMetadataSummary | null): string {
const presentation = getToolPresentation({
toolName: child.tool.name,
input: child.tool.input,
result: child.tool.result,
childrenCount: child.children.length,
description: child.tool.description,
metadata
})
if (presentation.subtitle) {
return truncate(`${presentation.title}: ${presentation.subtitle}`, 140)
}
return presentation.title
}
function TaskStateIcon(props: { state: ToolCallBlock['tool']['state'] }) {
if (props.state === 'completed') {
return <span className="text-emerald-600"></span>
}
if (props.state === 'error') {
return <span className="text-red-600"></span>
}
if (props.state === 'pending') {
return <span className="text-amber-600">🔐</span>
}
return <span className="text-amber-600 animate-pulse"></span>
}
function getTaskSummaryChildren(block: ToolCallBlock): { visible: ToolCallBlock[]; remaining: number } | null {
if (block.tool.name !== 'Task') return null
const children = block.children
.filter((child): child is ToolCallBlock => child.kind === 'tool-call')
.filter((child) => child.tool.state === 'pending' || child.tool.state === 'running' || child.tool.state === 'completed' || child.tool.state === 'error')
if (children.length === 0) return null
const visible = children.slice(-3)
return { visible, remaining: children.length - visible.length }
}
function renderTaskSummary(block: ToolCallBlock, metadata: SessionMetadataSummary | null): ReactNode | null {
const summary = getTaskSummaryChildren(block)
if (!summary) return null
const visible = summary.visible
const remaining = summary.remaining
return (
<div className="flex flex-col gap-1 px-1">
<div className="flex flex-col gap-1">
{visible.map((child) => (
<div key={child.id} className="flex items-center gap-2">
<div className="min-w-0 flex-1 font-mono text-xs text-[var(--app-hint)]">
<span className="mr-2 inline-block w-4 text-center align-middle">
<TaskStateIcon state={child.tool.state} />
</span>
<span className="align-middle break-all">
{formatTaskChildLabel(child, metadata)}
</span>
</div>
</div>
))}
{remaining > 0 ? (
<div className="text-xs text-[var(--app-hint)] italic">
(+{remaining} more)
</div>
) : null}
</div>
</div>
)
}
function renderEditInput(input: unknown): ReactNode | null {
if (!isObject(input)) return null
const filePath = getInputStringAny(input, ['file_path', 'path']) ?? undefined
const oldString = getInputString(input, 'old_string')
const newString = getInputString(input, 'new_string')
if (oldString === null || newString === null) return null
return (
<DiffView
oldString={oldString}
newString={newString}
filePath={filePath}
/>
)
}
function renderExitPlanModeInput(input: unknown): ReactNode | null {
if (!isObject(input)) return null
const plan = getInputString(input, 'plan')
if (!plan) return null
return <MarkdownRenderer content={plan} />
}
function renderToolInput(block: ToolCallBlock): ReactNode {
const toolName = block.tool.name
const input = block.tool.input
if (toolName === 'Task' && isObject(input) && typeof input.prompt === 'string') {
return <MarkdownRenderer content={input.prompt} />
}
if (toolName === 'Edit') {
const diff = renderEditInput(input)
if (diff) return diff
}
if (toolName === 'MultiEdit' && isObject(input)) {
const filePath = getInputStringAny(input, ['file_path', 'path']) ?? undefined
const edits = Array.isArray(input.edits) ? input.edits : null
if (edits && edits.length > 0) {
const rendered = edits
.slice(0, 3)
.map((edit, idx) => {
if (!isObject(edit)) return null
const oldString = getInputString(edit, 'old_string')
const newString = getInputString(edit, 'new_string')
if (oldString === null || newString === null) return null
return (
<div key={idx}>
<DiffView oldString={oldString} newString={newString} filePath={filePath} />
</div>
)
})
.filter(Boolean)
if (rendered.length > 0) {
return (
<div className="flex flex-col gap-2">
{rendered}
{edits.length > 3 ? (
<div className="text-xs text-[var(--app-hint)]">
(+{edits.length - 3} more edits)
</div>
) : null}
</div>
)
}
}
}
if (toolName === 'Write' && isObject(input)) {
const filePath = getInputStringAny(input, ['file_path', 'path'])
const content = getInputStringAny(input, ['content', 'text'])
if (filePath && content !== null) {
return (
<div className="flex flex-col gap-2">
<div className="text-xs text-[var(--app-hint)] font-mono break-all">
{filePath}
</div>
<CodeBlock code={content} language="text" />
</div>
)
}
}
if (toolName === 'CodexDiff' && isObject(input) && typeof input.unified_diff === 'string') {
return <CodeBlock code={input.unified_diff} language="diff" />
}
if (toolName === 'ExitPlanMode' || toolName === 'exit_plan_mode') {
const plan = renderExitPlanModeInput(input)
if (plan) return plan
}
const commandArray = isObject(input) && Array.isArray(input.command) ? input.command : null
if ((toolName === 'CodexBash' || toolName === 'Bash') && (typeof commandArray?.[0] === 'string' || typeof input === 'object')) {
const cmd = Array.isArray(commandArray)
? commandArray.filter((part) => typeof part === 'string').join(' ')
: getInputStringAny(input, ['command', 'cmd'])
if (cmd) {
return <CodeBlock code={cmd} language="bash" />
}
}
return <CodeBlock code={safeStringify(input)} language="json" />
}
function StatusIcon(props: { state: ToolCallBlock['tool']['state'] }) {
if (props.state === 'completed') {
return (
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
<path d="M5.2 8.3l1.8 1.8 3.8-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
if (props.state === 'error') {
return (
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
<path d="M5.6 5.6l4.8 4.8M10.4 5.6l-4.8 4.8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
if (props.state === 'pending') {
return (
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none">
<rect x="4.5" y="7" width="7" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M6 7V5.8a2 2 0 0 1 4 0V7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
return (
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" opacity="0.25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" opacity="0.75" />
</svg>
)
}
function statusColorClass(state: ToolCallBlock['tool']['state']): string {
if (state === 'completed') return 'text-emerald-600'
if (state === 'error') return 'text-red-600'
if (state === 'pending') return 'text-amber-600'
return 'text-[var(--app-hint)]'
}
function DetailsIcon() {
return (
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none">
<path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
type ToolCardProps = {
api: ApiClient
sessionId: string
metadata: SessionMetadataSummary | null
disabled: boolean
onDone: () => void
block: ToolCallBlock
}
function ToolCardInner(props: ToolCardProps) {
const { t } = useTranslation()
const presentation = useMemo(() => getToolPresentation({
toolName: props.block.tool.name,
input: props.block.tool.input,
result: props.block.tool.result,
childrenCount: props.block.children.length,
description: props.block.tool.description,
metadata: props.metadata
}), [
props.block.tool.name,
props.block.tool.input,
props.block.tool.result,
props.block.children.length,
props.block.tool.description,
props.metadata
])
const toolName = props.block.tool.name
const toolTitle = presentation.title
const subtitle = presentation.subtitle ?? props.block.tool.description
const taskSummary = renderTaskSummary(props.block, props.metadata)
const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt
const showInline = !presentation.minimal && toolName !== 'Task'
const CompactToolView = showInline ? getToolViewComponent(toolName) : null
const FullToolView = getToolFullViewComponent(toolName)
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))
))
const hasBody = showInline || taskSummary !== null || showsPermissionFooter
const stateColor = statusColorClass(props.block.tool.state)
const { suppressFocusRing, onTriggerPointerDown, onTriggerKeyDown, onTriggerBlur } = usePointerFocusRing()
const header = (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex items-center gap-2">
<div className="shrink-0 flex h-3.5 w-3.5 items-center justify-center text-[var(--app-hint)] leading-none">
{presentation.icon}
</div>
<CardTitle className="min-w-0 text-sm font-medium leading-tight break-words">
{toolTitle}
</CardTitle>
</div>
<div className="flex items-center gap-2 shrink-0">
<ElapsedView from={runningFrom} active={props.block.tool.state === 'running'} />
<span className={stateColor}>
<StatusIcon state={props.block.tool.state} />
</span>
<span className="text-[var(--app-hint)]">
<DetailsIcon />
</span>
</div>
</div>
{subtitle ? (
<CardDescription className="font-mono text-xs break-all opacity-80">
{truncate(subtitle, 160)}
</CardDescription>
) : null}
</div>
)
return (
<Card className="overflow-hidden shadow-sm">
<CardHeader className="p-3 space-y-0">
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className={cn(
'w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]',
suppressFocusRing && 'focus-visible:ring-0'
)}
onPointerDown={onTriggerPointerDown}
onKeyDown={onTriggerKeyDown}
onBlur={onTriggerBlur}
>
{header}
</button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{toolTitle}</DialogTitle>
</DialogHeader>
{(() => {
const isQuestionToolWithAnswers = isQuestionTool
&& permission?.answers
&& Object.keys(permission.answers).length > 0
return (
<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)]">
{isQuestionToolWithAnswers ? t('tool.questionsAnswers') : t('tool.input')}
</div>
{FullToolView ? (
<FullToolView block={props.block} metadata={props.metadata} />
) : (
renderToolInput(props.block)
)}
</div>
{!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} />
</div>
)}
</div>
)
})()}
</DialogContent>
</Dialog>
</CardHeader>
{hasBody ? (
<CardContent className="px-3 pb-3 pt-0">
{taskSummary ? (
<div className="mt-2">
{taskSummary}
</div>
) : null}
{showInline ? (
CompactToolView ? (
<div className="mt-3">
<CompactToolView block={props.block} metadata={props.metadata} />
</div>
) : (
<div className="mt-3 flex flex-col gap-3">
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">{t('tool.input')}</div>
{renderToolInput(props.block)}
</div>
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">{t('tool.result')}</div>
<ResultToolView block={props.block} metadata={props.metadata} />
</div>
</div>
)
) : null}
{isAskUserQuestion && permission?.status === 'pending' ? (
<AskUserQuestionFooter
api={props.api}
sessionId={props.sessionId}
tool={props.block.tool}
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}
sessionId={props.sessionId}
metadata={props.metadata}
tool={props.block.tool}
disabled={props.disabled}
onDone={props.onDone}
/>
)}
</CardContent>
) : null}
</Card>
)
}
export const ToolCard = memo(ToolCardInner)