From 4f06cdfd7c27084e41f5bad77aa308863c18919e Mon Sep 17 00:00:00 2001 From: weishu Date: Sun, 8 Mar 2026 12:11:19 +0800 Subject: [PATCH] Support codex plan tool --- .../components/ToolCard/checklist.test.tsx | 178 ++++++++++++++++++ web/src/components/ToolCard/checklist.tsx | 110 +++++++++++ web/src/components/ToolCard/knownTools.tsx | 29 +-- .../ToolCard/views/TodoWriteView.tsx | 61 +----- .../ToolCard/views/UpdatePlanView.tsx | 7 + web/src/components/ToolCard/views/_all.tsx | 2 + .../components/ToolCard/views/_results.tsx | 58 +----- 7 files changed, 318 insertions(+), 127 deletions(-) create mode 100644 web/src/components/ToolCard/checklist.test.tsx create mode 100644 web/src/components/ToolCard/checklist.tsx create mode 100644 web/src/components/ToolCard/views/UpdatePlanView.tsx diff --git a/web/src/components/ToolCard/checklist.test.tsx b/web/src/components/ToolCard/checklist.test.tsx new file mode 100644 index 00000000..73898ba8 --- /dev/null +++ b/web/src/components/ToolCard/checklist.test.tsx @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import type { ToolCallBlock } from '@/chat/types' +import { ChecklistList, extractTodoChecklist, extractUpdatePlanChecklist } from '@/components/ToolCard/checklist' +import { getToolPresentation } from '@/components/ToolCard/knownTools' +import { getToolViewComponent } from '@/components/ToolCard/views/_all' +import { UpdatePlanView } from '@/components/ToolCard/views/UpdatePlanView' + +function makeUpdatePlanBlock(input: unknown, result?: unknown): ToolCallBlock { + return { + kind: 'tool-call', + id: 'tool-1', + localId: null, + createdAt: 0, + tool: { + id: 'tool-1', + name: 'update_plan', + state: 'completed', + input, + createdAt: 0, + startedAt: 0, + completedAt: 0, + description: null, + result + }, + children: [] + } +} + +describe('extractUpdatePlanChecklist', () => { + it('prefers input.plan over result.plan', () => { + const items = extractUpdatePlanChecklist( + { + plan: [ + { step: 'Patch root cause', status: 'completed' } + ] + }, + { + plan: [ + { step: 'Result fallback', status: 'pending' } + ] + } + ) + + expect(items).toEqual([ + { text: 'Patch root cause', status: 'completed', id: undefined } + ]) + }) + + it('falls back to result.plan when input.plan is absent', () => { + const items = extractUpdatePlanChecklist( + {}, + { + plan: [ + { step: 'Re-run build validation', status: 'in_progress' } + ] + } + ) + + expect(items).toEqual([ + { text: 'Re-run build validation', status: 'in_progress', id: undefined } + ]) + }) + + it('keeps valid steps and normalizes unknown status to pending', () => { + const items = extractUpdatePlanChecklist( + { + plan: [ + { step: 'Summarize fix', status: 'unknown_status' }, + { step: 123, status: 'completed' }, + { status: 'pending' } + ] + }, + null + ) + + expect(items).toEqual([ + { text: 'Summarize fix', status: 'pending', id: undefined } + ]) + }) +}) + +describe('extractTodoChecklist', () => { + it('uses result.newTodos when input.todos is unavailable', () => { + const items = extractTodoChecklist( + null, + { + newTodos: [ + { id: 'todo-1', content: 'Ship it', status: 'completed' } + ] + } + ) + + expect(items).toEqual([ + { id: 'todo-1', text: 'Ship it', status: 'completed' } + ]) + }) +}) + +describe('update_plan tool presentation', () => { + it('shows plan title, step count, and expanded body when steps exist', () => { + const presentation = getToolPresentation({ + toolName: 'update_plan', + input: { + plan: [ + { step: 'Reproduce web build failure', status: 'completed' }, + { step: 'Trace broken build path', status: 'completed' } + ] + }, + result: undefined, + childrenCount: 0, + description: null, + metadata: null + }) + + expect(presentation.title).toBe('Plan') + expect(presentation.subtitle).toBe('2 steps') + expect(presentation.minimal).toBe(false) + }) + + it('stays minimal when there are no valid steps', () => { + const presentation = getToolPresentation({ + toolName: 'update_plan', + input: { plan: [{ status: 'completed' }] }, + result: undefined, + childrenCount: 0, + description: null, + metadata: null + }) + + expect(presentation.subtitle).toBeNull() + expect(presentation.minimal).toBe(true) + }) +}) + +describe('UpdatePlanView', () => { + it('renders checklist rows with status styling', () => { + render( + + ) + + const completed = screen.getByText(/Reproduce web build failure/) + const inProgress = screen.getByText(/Trace broken build path/) + const pending = screen.getByText(/Summarize fix/) + + expect(completed).toBeInTheDocument() + expect(completed.className).toContain('line-through') + expect(inProgress.className).toContain('text-[var(--app-link)]') + expect(pending.className).toContain('text-[var(--app-hint)]') + }) + + it('is registered as the compact tool view', () => { + expect(getToolViewComponent('update_plan')).toBe(UpdatePlanView) + }) +}) + +describe('ChecklistList', () => { + it('renders blank steps as empty placeholders', () => { + render( + + ) + + expect(screen.getByText(/\(empty\)/)).toBeInTheDocument() + }) +}) diff --git a/web/src/components/ToolCard/checklist.tsx b/web/src/components/ToolCard/checklist.tsx new file mode 100644 index 00000000..6d4e8f8a --- /dev/null +++ b/web/src/components/ToolCard/checklist.tsx @@ -0,0 +1,110 @@ +import type { ReactNode } from 'react' +import { isObject } from '@hapi/protocol' + +export type ChecklistStatus = 'pending' | 'in_progress' | 'completed' + +export type ChecklistItem = { + id?: string + text: string + status: ChecklistStatus +} + +function normalizeChecklistStatus(value: unknown): ChecklistStatus { + if (value === 'completed') return 'completed' + if (value === 'in_progress') return 'in_progress' + return 'pending' +} + +function parseChecklistEntries( + entries: unknown, + opts: { + textKey: 'content' | 'step' + idKey?: string + } +): ChecklistItem[] { + if (!Array.isArray(entries)) return [] + + const items: ChecklistItem[] = [] + for (const entry of entries) { + if (!isObject(entry)) continue + + const text = entry[opts.textKey] + if (typeof text !== 'string') continue + + const idValue = opts.idKey ? entry[opts.idKey] : undefined + items.push({ + id: typeof idValue === 'string' ? idValue : undefined, + text, + status: normalizeChecklistStatus(entry.status) + }) + } + + return items +} + +export function extractTodoChecklist(input: unknown, result: unknown): ChecklistItem[] { + if (isObject(input) && Array.isArray(input.todos)) { + const items = parseChecklistEntries(input.todos, { + textKey: 'content', + idKey: 'id' + }) + if (items.length > 0) return items + } + + if (isObject(result) && Array.isArray(result.newTodos)) { + return parseChecklistEntries(result.newTodos, { + textKey: 'content', + idKey: 'id' + }) + } + + return [] +} + +export function extractUpdatePlanChecklist(input: unknown, result: unknown): ChecklistItem[] { + if (isObject(input) && Object.prototype.hasOwnProperty.call(input, 'plan')) { + return parseChecklistEntries(input.plan, { + textKey: 'step' + }) + } + + if (isObject(result)) { + return parseChecklistEntries(result.plan, { + textKey: 'step' + }) + } + + return [] +} + +function checklistTone(item: ChecklistItem): string { + if (item.status === 'completed') return 'text-emerald-600 line-through' + if (item.status === 'in_progress') return 'text-[var(--app-link)]' + return 'text-[var(--app-hint)]' +} + +function checklistIcon(item: ChecklistItem): ReactNode { + if (item.status === 'completed') return '☑' + return '☐' +} + +export function ChecklistList(props: { items: ChecklistItem[]; emptyLabel?: string | null }) { + if (props.items.length === 0) { + return props.emptyLabel ? ( +
{props.emptyLabel}
+ ) : null + } + + return ( +
+ {props.items.map((item, idx) => { + const text = item.text.trim().length > 0 ? item.text.trim() : '(empty)' + return ( +
+ {checklistIcon(item)} {text} +
+ ) + })} +
+ ) +} diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 9bf6eeb1..7289ec18 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -2,6 +2,8 @@ import type { ReactNode } from 'react' import type { SessionMetadataSummary } from '@/types/api' import { isObject } from '@hapi/protocol' import { BulbIcon, ClipboardIcon, EyeIcon, FileDiffIcon, GlobeIcon, MessageSquareIcon, PuzzleIcon, QuestionIcon, RocketIcon, SearchIcon, TerminalIcon, UsersIcon, WrenchIcon } from '@/components/ToolCard/icons' +import type { ChecklistItem } from '@/components/ToolCard/checklist' +import { extractTodoChecklist, extractUpdatePlanChecklist } from '@/components/ToolCard/checklist' import { basename, resolveDisplayPath } from '@/utils/path' import { getInputStringAny, truncate } from '@/lib/toolInputUtils' @@ -19,6 +21,11 @@ function countLines(text: string): number { return text.split('\n').length } +function formatChecklistCount(items: ChecklistItem[], noun: string): string | null { + if (items.length === 0) return null + return `${items.length} ${noun}${items.length === 1 ? '' : 's'}` +} + function snakeToTitleWithSpaces(value: string): string { return value .split('_') @@ -261,20 +268,14 @@ export const knownTools: Record , title: () => 'Todo list', - subtitle: (opts) => { - const todos = isObject(opts.input) && Array.isArray(opts.input.todos) ? opts.input.todos : null - if (todos && todos.length > 0) return `${todos.length} items` - const newTodos = isObject(opts.result) && Array.isArray(opts.result.newTodos) ? opts.result.newTodos : null - if (newTodos && newTodos.length > 0) return `${newTodos.length} items` - return null - }, - minimal: (opts) => { - const todos = isObject(opts.input) && Array.isArray(opts.input.todos) ? opts.input.todos : null - if (todos && todos.length > 0) return false - const newTodos = isObject(opts.result) && Array.isArray(opts.result.newTodos) ? opts.result.newTodos : null - if (newTodos && newTodos.length > 0) return false - return true - } + subtitle: (opts) => formatChecklistCount(extractTodoChecklist(opts.input, opts.result), 'item'), + minimal: (opts) => extractTodoChecklist(opts.input, opts.result).length === 0 + }, + update_plan: { + icon: () => , + title: () => 'Plan', + subtitle: (opts) => formatChecklistCount(extractUpdatePlanChecklist(opts.input, opts.result), 'step'), + minimal: (opts) => extractUpdatePlanChecklist(opts.input, opts.result).length === 0 }, CodexReasoning: { icon: () => , diff --git a/web/src/components/ToolCard/views/TodoWriteView.tsx b/web/src/components/ToolCard/views/TodoWriteView.tsx index 4367eba9..d601ef5e 100644 --- a/web/src/components/ToolCard/views/TodoWriteView.tsx +++ b/web/src/components/ToolCard/views/TodoWriteView.tsx @@ -1,62 +1,7 @@ import type { ToolViewProps } from '@/components/ToolCard/views/_all' -import { isObject } from '@hapi/protocol' - -type TodoItem = { - id?: string - content?: string - status?: 'pending' | 'in_progress' | 'completed' - priority?: 'high' | 'medium' | 'low' -} - -function extractTodos(input: unknown, result: unknown): TodoItem[] { - const todosFromInput = isObject(input) && Array.isArray(input.todos) - ? input.todos.filter(isObject) - : [] - if (todosFromInput.length > 0) { - return todosFromInput.map((t) => ({ - id: typeof t.id === 'string' ? t.id : undefined, - content: typeof t.content === 'string' ? t.content : undefined, - status: t.status === 'pending' || t.status === 'in_progress' || t.status === 'completed' ? t.status : undefined, - priority: t.priority === 'high' || t.priority === 'medium' || t.priority === 'low' ? t.priority : undefined - })) - } - - const newTodos = isObject(result) && Array.isArray(result.newTodos) - ? result.newTodos.filter(isObject) - : [] - return newTodos.map((t) => ({ - id: typeof t.id === 'string' ? t.id : undefined, - content: typeof t.content === 'string' ? t.content : undefined, - status: t.status === 'pending' || t.status === 'in_progress' || t.status === 'completed' ? t.status : undefined, - priority: t.priority === 'high' || t.priority === 'medium' || t.priority === 'low' ? t.priority : undefined - })) -} - -function todoTone(todo: TodoItem): string { - if (todo.status === 'completed') return 'text-emerald-600 line-through' - if (todo.status === 'in_progress') return 'text-[var(--app-link)]' - return 'text-[var(--app-hint)]' -} - -function todoIcon(todo: TodoItem): string { - if (todo.status === 'completed') return '☑' - return '☐' -} +import { ChecklistList, extractTodoChecklist } from '@/components/ToolCard/checklist' export function TodoWriteView(props: ToolViewProps) { - const todos = extractTodos(props.block.tool.input, props.block.tool.result) - if (todos.length === 0) return null - - return ( -
- {todos.map((todo, idx) => { - const text = todo.content?.trim() ? todo.content.trim() : '(empty)' - return ( -
- {todoIcon(todo)} {text} -
- ) - })} -
- ) + const todos = extractTodoChecklist(props.block.tool.input, props.block.tool.result) + return } diff --git a/web/src/components/ToolCard/views/UpdatePlanView.tsx b/web/src/components/ToolCard/views/UpdatePlanView.tsx new file mode 100644 index 00000000..93ff2243 --- /dev/null +++ b/web/src/components/ToolCard/views/UpdatePlanView.tsx @@ -0,0 +1,7 @@ +import type { ToolViewProps } from '@/components/ToolCard/views/_all' +import { ChecklistList, extractUpdatePlanChecklist } from '@/components/ToolCard/checklist' + +export function UpdatePlanView(props: ToolViewProps) { + const steps = extractUpdatePlanChecklist(props.block.tool.input, props.block.tool.result) + return +} diff --git a/web/src/components/ToolCard/views/_all.tsx b/web/src/components/ToolCard/views/_all.tsx index cee5738b..e1b9c7c8 100644 --- a/web/src/components/ToolCard/views/_all.tsx +++ b/web/src/components/ToolCard/views/_all.tsx @@ -9,6 +9,7 @@ import { RequestUserInputView } from '@/components/ToolCard/views/RequestUserInp import { ExitPlanModeView } from '@/components/ToolCard/views/ExitPlanModeView' import { MultiEditFullView, MultiEditView } from '@/components/ToolCard/views/MultiEditView' import { TodoWriteView } from '@/components/ToolCard/views/TodoWriteView' +import { UpdatePlanView } from '@/components/ToolCard/views/UpdatePlanView' import { WriteView } from '@/components/ToolCard/views/WriteView' export type ToolViewProps = { @@ -23,6 +24,7 @@ export const toolViewRegistry: Record = { MultiEdit: MultiEditView, Write: WriteView, TodoWrite: TodoWriteView, + update_plan: UpdatePlanView, CodexDiff: CodexDiffCompactView, AskUserQuestion: AskUserQuestionView, ExitPlanMode: ExitPlanModeView, diff --git a/web/src/components/ToolCard/views/_results.tsx b/web/src/components/ToolCard/views/_results.tsx index fe24212d..b441ddbb 100644 --- a/web/src/components/ToolCard/views/_results.tsx +++ b/web/src/components/ToolCard/views/_results.tsx @@ -2,6 +2,7 @@ import type { ToolViewComponent, ToolViewProps } from '@/components/ToolCard/vie import { isObject, safeStringify } from '@hapi/protocol' import { CodeBlock } from '@/components/CodeBlock' import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { ChecklistList, extractTodoChecklist } from '@/components/ToolCard/checklist' import { basename, resolveDisplayPath } from '@/utils/path' function parseToolUseError(message: string): { isToolUseError: boolean; errorMessage: string | null } { @@ -489,66 +490,13 @@ const CodexDiffResultView: ToolViewComponent = (props: ToolViewProps) => { ) } -type TodoItem = { - id?: string - content?: string - status?: 'pending' | 'in_progress' | 'completed' - priority?: 'high' | 'medium' | 'low' -} - -function extractTodos(input: unknown, result: unknown): TodoItem[] { - const todosFromInput = isObject(input) && Array.isArray(input.todos) - ? input.todos.filter(isObject) - : [] - if (todosFromInput.length > 0) { - return todosFromInput.map((t) => ({ - id: typeof t.id === 'string' ? t.id : undefined, - content: typeof t.content === 'string' ? t.content : undefined, - status: t.status === 'pending' || t.status === 'in_progress' || t.status === 'completed' ? t.status : undefined, - priority: t.priority === 'high' || t.priority === 'medium' || t.priority === 'low' ? t.priority : undefined - })) - } - - const newTodos = isObject(result) && Array.isArray(result.newTodos) - ? result.newTodos.filter(isObject) - : [] - return newTodos.map((t) => ({ - id: typeof t.id === 'string' ? t.id : undefined, - content: typeof t.content === 'string' ? t.content : undefined, - status: t.status === 'pending' || t.status === 'in_progress' || t.status === 'completed' ? t.status : undefined, - priority: t.priority === 'high' || t.priority === 'medium' || t.priority === 'low' ? t.priority : undefined - })) -} - -function todoTone(todo: TodoItem): string { - if (todo.status === 'completed') return 'text-emerald-600 line-through' - if (todo.status === 'in_progress') return 'text-[var(--app-link)]' - return 'text-[var(--app-hint)]' -} - -function todoIcon(todo: TodoItem): string { - if (todo.status === 'completed') return '☑' - return '☐' -} - const TodoWriteResultView: ToolViewComponent = (props: ToolViewProps) => { - const todos = extractTodos(props.block.tool.input, props.block.tool.result) + const todos = extractTodoChecklist(props.block.tool.input, props.block.tool.result) if (todos.length === 0) { return
{placeholderForState(props.block.tool.state)}
} - return ( -
- {todos.map((todo, idx) => { - const text = todo.content?.trim() ? todo.content.trim() : '(empty)' - return ( -
- {todoIcon(todo)} {text} -
- ) - })} -
- ) + return } const GenericResultView: ToolViewComponent = (props: ToolViewProps) => {