From 9cc13b51befe6e3033040d6497042f660a406d1a Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 17 Dec 2025 11:45:17 +0800 Subject: [PATCH] feat: add todo tracking and custom tool views Adds todo tracking infrastructure with TodoWrite tool integration to extract and persist todos in sessions. Refactors ToolCard component to support custom tool view rendering and displays todo progress in SessionList. --- server/src/socket/handlers/cli.ts | 9 + server/src/store/index.ts | 43 +++ server/src/sync/syncEngine.ts | 28 +- server/src/sync/todos.ts | 101 ++++++ server/src/web/routes/sessions.ts | 2 + web/src/chat/reducer.ts | 15 +- web/src/components/SessionList.tsx | 57 +++- web/src/components/ToolCard/ToolCard.tsx | 102 +++--- web/src/components/ToolCard/icons.tsx | 129 +++++++ web/src/components/ToolCard/knownTools.ts | 219 ------------ web/src/components/ToolCard/knownTools.tsx | 322 ++++++++++++++++++ web/src/components/ToolCard/path.ts | 26 ++ .../ToolCard/views/CodexDiffView.tsx | 81 +++++ .../ToolCard/views/CodexPatchView.tsx | 28 ++ .../components/ToolCard/views/EditView.tsx | 23 ++ .../ToolCard/views/ExitPlanModeView.tsx | 15 + .../ToolCard/views/MultiEditView.tsx | 37 ++ .../ToolCard/views/TodoWriteView.tsx | 66 ++++ .../components/ToolCard/views/WriteView.tsx | 22 ++ web/src/components/ToolCard/views/_all.tsx | 46 +++ web/src/types/api.ts | 9 + 21 files changed, 1103 insertions(+), 277 deletions(-) create mode 100644 server/src/sync/todos.ts create mode 100644 web/src/components/ToolCard/icons.tsx delete mode 100644 web/src/components/ToolCard/knownTools.ts create mode 100644 web/src/components/ToolCard/knownTools.tsx create mode 100644 web/src/components/ToolCard/path.ts create mode 100644 web/src/components/ToolCard/views/CodexDiffView.tsx create mode 100644 web/src/components/ToolCard/views/CodexPatchView.tsx create mode 100644 web/src/components/ToolCard/views/EditView.tsx create mode 100644 web/src/components/ToolCard/views/ExitPlanModeView.tsx create mode 100644 web/src/components/ToolCard/views/MultiEditView.tsx create mode 100644 web/src/components/ToolCard/views/TodoWriteView.tsx create mode 100644 web/src/components/ToolCard/views/WriteView.tsx create mode 100644 web/src/components/ToolCard/views/_all.tsx diff --git a/server/src/socket/handlers/cli.ts b/server/src/socket/handlers/cli.ts index e6b1fd36..60226d40 100644 --- a/server/src/socket/handlers/cli.ts +++ b/server/src/socket/handlers/cli.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto' import type { Store } from '../../store' import { RpcRegistry } from '../rpcRegistry' import type { SyncEvent } from '../../sync/syncEngine' +import { extractTodoWriteTodosFromMessageContent } from '../../sync/todos' type SessionAlivePayload = { sid: string @@ -125,6 +126,14 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void const msg = store.addMessage(sid, content, localId) + const todos = extractTodoWriteTodosFromMessageContent(content) + if (todos) { + const updated = store.setSessionTodos(sid, todos, msg.createdAt) + if (updated) { + onWebappEvent?.({ type: 'session-updated', sessionId: sid, data: { sid } }) + } + } + // Broadcast to other CLI sockets interested in this session (skip sender). const update = { id: randomUUID(), diff --git a/server/src/store/index.ts b/server/src/store/index.ts index c9db9bd3..88c5e949 100644 --- a/server/src/store/index.ts +++ b/server/src/store/index.ts @@ -13,6 +13,8 @@ export type StoredSession = { metadataVersion: number agentState: unknown | null agentStateVersion: number + todos: unknown | null + todosUpdatedAt: number | null active: boolean activeAt: number | null seq: number @@ -55,6 +57,8 @@ type DbSessionRow = { metadata_version: number agent_state: string | null agent_state_version: number + todos: string | null + todos_updated_at: number | null active: number active_at: number | null seq: number @@ -102,6 +106,8 @@ function toStoredSession(row: DbSessionRow): StoredSession { metadataVersion: row.metadata_version, agentState: safeJsonParse(row.agent_state), agentStateVersion: row.agent_state_version, + todos: safeJsonParse(row.todos), + todosUpdatedAt: row.todos_updated_at, active: row.active === 1, activeAt: row.active_at, seq: row.seq @@ -184,6 +190,8 @@ export class Store { metadata_version INTEGER DEFAULT 1, agent_state TEXT, agent_state_version INTEGER DEFAULT 1, + todos TEXT, + todos_updated_at INTEGER, active INTEGER DEFAULT 0, active_at INTEGER, seq INTEGER DEFAULT 0 @@ -215,6 +223,16 @@ export class Store { CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq); CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_local_id ON messages(session_id, local_id) WHERE local_id IS NOT NULL; `) + + const sessionColumns = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> + const sessionColumnNames = new Set(sessionColumns.map((c) => c.name)) + + if (!sessionColumnNames.has('todos')) { + this.db.exec('ALTER TABLE sessions ADD COLUMN todos TEXT') + } + if (!sessionColumnNames.has('todos_updated_at')) { + this.db.exec('ALTER TABLE sessions ADD COLUMN todos_updated_at INTEGER') + } } getOrCreateSession(tag: string, metadata: unknown, agentState: unknown): StoredSession { @@ -237,11 +255,13 @@ export class Store { id, tag, machine_id, created_at, updated_at, metadata, metadata_version, agent_state, agent_state_version, + todos, todos_updated_at, active, active_at, seq ) VALUES ( @id, @tag, NULL, @created_at, @updated_at, @metadata, 1, @agent_state, 1, + NULL, NULL, 0, NULL, 0 ) `).run({ @@ -326,6 +346,29 @@ export class Store { } } + setSessionTodos(id: string, todos: unknown, todosUpdatedAt: number): boolean { + try { + const json = todos === null || todos === undefined ? null : JSON.stringify(todos) + const result = this.db.prepare(` + UPDATE sessions + SET todos = @todos, + todos_updated_at = @todos_updated_at, + updated_at = CASE WHEN updated_at > @updated_at THEN updated_at ELSE @updated_at END, + seq = seq + 1 + WHERE id = @id AND (todos_updated_at IS NULL OR todos_updated_at < @todos_updated_at) + `).run({ + id, + todos: json, + todos_updated_at: todosUpdatedAt, + updated_at: todosUpdatedAt + }) + + return result.changes === 1 + } catch { + return false + } + } + getSession(id: string): StoredSession | null { const row = this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as DbSessionRow | undefined return row ? toStoredSession(row) : null diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 24ff080f..4c074a90 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -11,6 +11,7 @@ import { z } from 'zod' import type { Server } from 'socket.io' import type { Store } from '../store' import type { RpcRegistry } from '../socket/rpcRegistry' +import { extractTodoWriteTodosFromMessageContent, TodosSchema, type TodoItem } from './todos' export type ConnectionStatus = 'disconnected' | 'connected' @@ -73,6 +74,7 @@ export interface Session { agentStateVersion: number thinking: boolean thinkingAt: number + todos?: TodoItem[] permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | null modelMode?: 'default' | 'sonnet' | 'opus' | null } @@ -143,6 +145,7 @@ export class SyncEngine { private readonly lastBroadcastAtBySessionId: Map = new Map() private readonly lastBroadcastAtByMachineId: Map = new Map() + private readonly todoBackfillAttemptedSessionIds: Set = new Set() private inactivityTimer: NodeJS.Timeout | null = null constructor( @@ -382,7 +385,7 @@ export class SyncEngine { } private refreshSession(sessionId: string): Session | null { - const stored = this.store.getSession(sessionId) + let stored = this.store.getSession(sessionId) if (!stored) { const existed = this.sessions.delete(sessionId) if (existed) { @@ -393,6 +396,22 @@ export class SyncEngine { const existing = this.sessions.get(sessionId) + if (stored.todos === null && !this.todoBackfillAttemptedSessionIds.has(sessionId)) { + this.todoBackfillAttemptedSessionIds.add(sessionId) + const messages = this.store.getMessages(sessionId, 200) + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] + const todos = extractTodoWriteTodosFromMessageContent(message.content) + if (todos) { + const updated = this.store.setSessionTodos(sessionId, todos, message.createdAt) + if (updated) { + stored = this.store.getSession(sessionId) ?? stored + } + break + } + } + } + const metadata = (() => { const parsed = MetadataSchema.safeParse(stored.metadata) return parsed.success ? parsed.data : null @@ -403,6 +422,12 @@ export class SyncEngine { return parsed.success ? parsed.data : null })() + const todos = (() => { + if (stored.todos === null) return undefined + const parsed = TodosSchema.safeParse(stored.todos) + return parsed.success ? parsed.data : undefined + })() + const session: Session = { id: stored.id, seq: stored.seq, @@ -416,6 +441,7 @@ export class SyncEngine { agentStateVersion: stored.agentStateVersion, thinking: existing?.thinking ?? false, thinkingAt: existing?.thinkingAt ?? 0, + todos, permissionMode: existing?.permissionMode ?? null, modelMode: existing?.modelMode ?? null } diff --git a/server/src/sync/todos.ts b/server/src/sync/todos.ts new file mode 100644 index 00000000..11e1905c --- /dev/null +++ b/server/src/sync/todos.ts @@ -0,0 +1,101 @@ +import { z } from 'zod' + +export const TodoItemSchema = z.object({ + content: z.string(), + status: z.enum(['pending', 'in_progress', 'completed']), + priority: z.enum(['high', 'medium', 'low']), + id: z.string() +}).passthrough() + +export type TodoItem = z.infer + +export const TodosSchema = z.array(TodoItemSchema) + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +type RoleWrappedRecord = { + role: string + content: unknown + meta?: unknown +} + +function isRoleWrappedRecord(value: unknown): value is RoleWrappedRecord { + if (!isObject(value)) return false + return typeof value.role === 'string' && 'content' in value +} + +function unwrapRoleWrappedRecordEnvelope(value: unknown): RoleWrappedRecord | null { + if (isRoleWrappedRecord(value)) return value + if (!isObject(value)) return null + + const direct = value.message + if (isRoleWrappedRecord(direct)) return direct + + const data = value.data + if (isObject(data) && isRoleWrappedRecord(data.message)) return data.message as RoleWrappedRecord + + const payload = value.payload + if (isObject(payload) && isRoleWrappedRecord(payload.message)) return payload.message as RoleWrappedRecord + + return null +} + +function extractTodosFromClaudeOutput(content: Record): TodoItem[] | null { + if (content.type !== 'output') return null + + const data = isObject(content.data) ? content.data : null + if (!data || data.type !== 'assistant') return null + + const message = isObject(data.message) ? data.message : null + if (!message) return null + + const modelContent = message.content + if (!Array.isArray(modelContent)) return null + + for (const block of modelContent) { + if (!isObject(block) || block.type !== 'tool_use') continue + const name = typeof block.name === 'string' ? block.name : null + if (name !== 'TodoWrite') continue + const input = 'input' in block ? (block as Record).input : null + if (!isObject(input)) continue + + const todosCandidate = input.todos + const parsed = TodosSchema.safeParse(todosCandidate) + if (parsed.success) { + return parsed.data + } + } + + return null +} + +function extractTodosFromCodexMessage(content: Record): TodoItem[] | null { + if (content.type !== 'codex') return null + + const data = isObject(content.data) ? content.data : null + if (!data || data.type !== 'tool-call') return null + + const name = typeof data.name === 'string' ? data.name : null + if (name !== 'TodoWrite') return null + + const input = 'input' in data ? (data as Record).input : null + if (!isObject(input)) return null + + const todosCandidate = input.todos + const parsed = TodosSchema.safeParse(todosCandidate) + return parsed.success ? parsed.data : null +} + +export function extractTodoWriteTodosFromMessageContent(messageContent: unknown): TodoItem[] | null { + const record = unwrapRoleWrappedRecordEnvelope(messageContent) + if (!record) return null + + if (record.role !== 'agent' && record.role !== 'assistant') return null + + if (!isObject(record.content) || typeof record.content.type !== 'string') return null + + return extractTodosFromClaudeOutput(record.content) ?? extractTodosFromCodexMessage(record.content) +} + diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index a28820de..63e3ce0b 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -13,6 +13,7 @@ type SessionSummary = { permissionMode: Session['permissionMode'] modelMode: Session['modelMode'] metadata: Session['metadata'] + todos?: Session['todos'] pendingRequestsCount: number } @@ -27,6 +28,7 @@ function toSessionSummary(session: Session): SessionSummary { permissionMode: session.permissionMode, modelMode: session.modelMode, metadata: session.metadata, + todos: session.todos, pendingRequestsCount } } diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 9d6f8a27..7777db98 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -176,6 +176,11 @@ function ensureToolBlock( ): ToolCallBlock { const existing = toolBlocksById.get(id) if (existing) { + const isPlaceholderToolName = (name: string): boolean => { + const normalized = name.trim().toLowerCase() + return normalized === '' || normalized === 'tool' || normalized === 'unknown' + } + // Preserve earliest createdAt for stable ordering. if (seed.createdAt < existing.createdAt) { existing.createdAt = seed.createdAt @@ -187,11 +192,15 @@ function ensureToolBlock( existing.tool.state = 'pending' } } - if (seed.name) { + if (seed.name && (!isPlaceholderToolName(seed.name) || isPlaceholderToolName(existing.tool.name))) { existing.tool.name = seed.name } - existing.tool.input = seed.input - existing.tool.description = seed.description + if (seed.input !== null && seed.input !== undefined) { + existing.tool.input = seed.input + } + if (seed.description !== null) { + existing.tool.description = seed.description + } return existing } diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index a4f294d5..e46f93fa 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -22,6 +22,27 @@ function PlusIcon(props: { className?: string }) { ) } +function BulbIcon(props: { className?: string }) { + return ( + + + + + + ) +} + function getSessionTitle(session: SessionSummary): string { if (session.metadata?.summary?.text) { return session.metadata.summary.text @@ -33,6 +54,14 @@ function getSessionTitle(session: SessionSummary): string { return session.id.slice(0, 8) } +function getTodoProgress(session: SessionSummary): { completed: number; total: number } | null { + if (!session.todos || session.todos.length === 0) return null + const total = session.todos.length + const completed = session.todos.filter(t => t.status === 'completed').length + if (completed === total) return null + return { completed, total } +} + export function SessionList(props: { sessions: SessionSummary[] onSelect: (sessionId: string) => void @@ -62,15 +91,27 @@ export function SessionList(props: {
{getSessionTitle(s)} - {s.active ? ( - s.pendingRequestsCount > 0 ? ( - {s.pendingRequestsCount} pending +
+ {(() => { + const progress = getTodoProgress(s) + if (!progress) return null + return ( + + + {progress.completed}/{progress.total} + + ) + })()} + {s.active ? ( + s.pendingRequestsCount > 0 ? ( + {s.pendingRequestsCount} pending + ) : ( + active + ) ) : ( - active - ) - ) : ( - inactive - )} + inactive + )} +
{s.metadata?.host ? `Host: ${s.metadata.host}` : 'Host: (unknown)'} diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index 4fb0bb58..284a107c 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -9,6 +9,7 @@ import { DiffView } from '@/components/DiffView' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { PermissionFooter } from '@/components/ToolCard/PermissionFooter' import { getToolPresentation } from '@/components/ToolCard/knownTools' +import { getToolFullViewComponent, getToolViewComponent } from '@/components/ToolCard/views/_all' function isObject(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' @@ -76,12 +77,14 @@ function ElapsedView(props: { from: number; active: boolean }) { ) } -function formatTaskChildLabel(child: ToolCallBlock): string { +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 + description: child.tool.description, + metadata }) if (presentation.subtitle) { @@ -104,7 +107,7 @@ function TaskStateIcon(props: { state: ToolCallBlock['tool']['state'] }) { return โ— } -function renderTaskSummary(block: ToolCallBlock): ReactNode | null { +function renderTaskSummary(block: ToolCallBlock, metadata: SessionMetadataSummary | null): ReactNode | null { if (block.tool.name !== 'Task') return null const children = block.children @@ -126,7 +129,7 @@ function renderTaskSummary(block: ToolCallBlock): ReactNode | null { - {formatTaskChildLabel(child)} + {formatTaskChildLabel(child, metadata)} @@ -341,17 +344,20 @@ export function ToolCard(props: { const presentation = 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 + description: props.block.tool.description, + metadata: 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) + const taskSummary = renderTaskSummary(props.block, props.metadata) const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt - const showDialog = presentation.minimal || toolName === 'Task' const showInline = !presentation.minimal && toolName !== 'Task' + const CompactToolView = showInline ? getToolViewComponent(toolName) : null + const FullToolView = getToolFullViewComponent(toolName) const permission = props.block.tool.permission const showsPermissionFooter = Boolean(permission && ( permission.status === 'pending' @@ -364,7 +370,7 @@ export function ToolCard(props: {
-
+
{presentation.icon}
@@ -377,11 +383,9 @@ export function ToolCard(props: { - {showDialog ? ( - - - - ) : null} + + +
@@ -396,32 +400,32 @@ export function ToolCard(props: { return ( - {showDialog ? ( - - - - - - - {toolTitle} - -
-
-
Input
- {renderToolInput(props.block)} -
-
-
Result
- {renderToolResult(props.block)} -
+ + + + + + + {toolTitle} + +
+
+
Input
+ {FullToolView ? ( + + ) : ( + renderToolInput(props.block) + )}
- -
- ) : ( - header - )} +
+
Result
+ {renderToolResult(props.block)} +
+
+
+
{hasBody ? ( @@ -433,16 +437,22 @@ export function ToolCard(props: { ) : null} {showInline ? ( -
-
-
Input
- {renderToolInput(props.block)} + CompactToolView ? ( +
+
-
-
Result
- {renderToolResult(props.block)} + ) : ( +
+
+
Input
+ {renderToolInput(props.block)} +
+
+
Result
+ {renderToolResult(props.block)} +
-
+ ) ) : null} + {paths} + + ) +} + +export function TerminalIcon(props: IconProps) { + return createIcon( + <> + + + + , + props + ) +} + +export function SearchIcon(props: IconProps) { + return createIcon( + <> + + + , + props + ) +} + +export function EyeIcon(props: IconProps) { + return createIcon( + <> + + + , + props + ) +} + +export function FileDiffIcon(props: IconProps) { + return createIcon( + <> + + + + + + , + props + ) +} + +export function GlobeIcon(props: IconProps) { + return createIcon( + <> + + + + + , + props + ) +} + +export function ClipboardIcon(props: IconProps) { + return createIcon( + <> + + + , + props + ) +} + +export function BulbIcon(props: IconProps) { + return createIcon( + <> + + + + , + props + ) +} + +export function PuzzleIcon(props: IconProps) { + return createIcon( + <> + + , + props + ) +} + +export function RocketIcon(props: IconProps) { + return createIcon( + <> + + + + + , + props + ) +} + +export function WrenchIcon(props: IconProps) { + return createIcon( + <> + + , + props + ) +} + diff --git a/web/src/components/ToolCard/knownTools.ts b/web/src/components/ToolCard/knownTools.ts deleted file mode 100644 index 65a29d08..00000000 --- a/web/src/components/ToolCard/knownTools.ts +++ /dev/null @@ -1,219 +0,0 @@ -export type ToolPresentation = { - icon: string - title: string - subtitle: string | null - minimal: boolean -} - -function isObject(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' -} - -function getInputStringAny(input: unknown, keys: string[]): string | null { - if (!isObject(input)) return null - for (const key of keys) { - const value = input[key] - if (typeof value === 'string' && value.length > 0) return value - } - return null -} - -function truncate(text: string, maxLen: number): string { - if (text.length <= maxLen) return text - return text.slice(0, maxLen - 3) + '...' -} - -function snakeToTitleWithSpaces(value: string): string { - return value - .split('_') - .filter((part) => part.length > 0) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) - .join(' ') -} - -function formatMCPTitle(toolName: string): string { - const withoutPrefix = toolName.replace(/^mcp__/, '') - const parts = withoutPrefix.split('__') - if (parts.length >= 2) { - const serverName = snakeToTitleWithSpaces(parts[0]) - const toolPart = snakeToTitleWithSpaces(parts.slice(1).join('_')) - return `MCP: ${serverName} ${toolPart}` - } - return `MCP: ${snakeToTitleWithSpaces(withoutPrefix)}` -} - -type ToolOpts = { - toolName: string - input: unknown - childrenCount: number - description: string | null -} - -export const knownTools: Record string - subtitle?: (opts: ToolOpts) => string | null - minimal?: boolean | ((opts: ToolOpts) => boolean) -}> = { - Task: { - icon: '๐Ÿš€', - title: (opts) => { - const description = getInputStringAny(opts.input, ['description']) - return description ?? 'Task' - }, - subtitle: (opts) => { - const prompt = getInputStringAny(opts.input, ['prompt']) - return prompt ? truncate(prompt, 120) : null - }, - minimal: (opts) => opts.childrenCount === 0 - }, - Bash: { - icon: '๐Ÿ–ฅ๏ธ', - title: (opts) => opts.description ?? 'Terminal', - subtitle: (opts) => getInputStringAny(opts.input, ['command', 'cmd']), - minimal: true - }, - CodexBash: { - icon: '๐Ÿ–ฅ๏ธ', - title: (opts) => opts.description ?? 'Terminal', - subtitle: (opts) => { - const command = getInputStringAny(opts.input, ['command', 'cmd']) - if (command) return command - if (isObject(opts.input) && Array.isArray(opts.input.command)) { - return opts.input.command.filter((part) => typeof part === 'string').join(' ') - } - return null - }, - minimal: true - }, - Read: { - icon: '๐Ÿ‘๏ธ', - title: () => 'Read', - subtitle: (opts) => getInputStringAny(opts.input, ['file_path', 'path', 'file']), - minimal: true - }, - Edit: { - icon: '๐Ÿ“', - title: () => 'Edit', - subtitle: (opts) => getInputStringAny(opts.input, ['file_path', 'path']), - minimal: false - }, - MultiEdit: { - icon: '๐Ÿ“', - title: () => 'MultiEdit', - subtitle: (opts) => getInputStringAny(opts.input, ['file_path', 'path']), - minimal: false - }, - Write: { - icon: '๐Ÿ“', - title: () => 'Write', - subtitle: (opts) => getInputStringAny(opts.input, ['file_path', 'path']), - minimal: false - }, - WebFetch: { - icon: '๐ŸŒ', - title: (opts) => { - const url = getInputStringAny(opts.input, ['url']) - if (!url) return 'Web fetch' - try { - return new URL(url).hostname - } catch { - return url - } - }, - subtitle: (opts) => { - const url = getInputStringAny(opts.input, ['url']) - if (!url) return null - return url - }, - minimal: true - }, - WebSearch: { - icon: '๐ŸŒ', - title: (opts) => getInputStringAny(opts.input, ['query']) ?? 'Web search', - subtitle: (opts) => { - const query = getInputStringAny(opts.input, ['query']) - return query ? truncate(query, 80) : null - }, - minimal: true - }, - CodexPatch: { - icon: '๐Ÿฉน', - title: () => 'Apply changes', - subtitle: (opts) => { - if (isObject(opts.input) && isObject(opts.input.changes)) { - const files = Object.keys(opts.input.changes) - if (files.length === 0) return null - const first = files[0] - const basename = first.split('/').pop() ?? first - return files.length > 1 ? `${basename} (+${files.length - 1})` : basename - } - return null - }, - minimal: true - }, - CodexDiff: { - icon: '๐Ÿงพ', - title: () => 'Diff', - subtitle: (opts) => { - const unified = getInputStringAny(opts.input, ['unified_diff']) - if (!unified) return null - const lines = unified.split('\n') - for (const line of lines) { - if (line.startsWith('+++ b/') || line.startsWith('+++ ')) { - const fileName = line.replace(/^\+\+\+ (b\/)?/, '') - return fileName.split('/').pop() ?? fileName - } - } - return null - }, - minimal: false - }, - ExitPlanMode: { - icon: '๐Ÿ“‹', - title: () => 'Plan proposal', - minimal: false - }, - exit_plan_mode: { - icon: '๐Ÿ“‹', - title: () => 'Plan proposal', - minimal: false - } -} - -export function getToolPresentation(opts: ToolOpts): ToolPresentation { - if (opts.toolName.startsWith('mcp__')) { - return { - icon: '๐Ÿ”Œ', - title: formatMCPTitle(opts.toolName), - subtitle: null, - minimal: true - } - } - - const known = knownTools[opts.toolName] - if (known) { - const minimal = typeof known.minimal === 'function' ? known.minimal(opts) : (known.minimal ?? false) - return { - icon: known.icon, - title: known.title(opts), - subtitle: known.subtitle ? known.subtitle(opts) : null, - minimal - } - } - - const filePath = getInputStringAny(opts.input, ['file_path', 'path', 'filePath', 'file']) - const command = getInputStringAny(opts.input, ['command', 'cmd']) - const pattern = getInputStringAny(opts.input, ['pattern']) - const url = getInputStringAny(opts.input, ['url']) - const query = getInputStringAny(opts.input, ['query']) - - const subtitle = filePath ?? command ?? pattern ?? url ?? query - - return { - icon: '๐Ÿ”ง', - title: opts.toolName, - subtitle: subtitle ? truncate(subtitle, 80) : null, - minimal: true - } -} diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx new file mode 100644 index 00000000..8da45780 --- /dev/null +++ b/web/src/components/ToolCard/knownTools.tsx @@ -0,0 +1,322 @@ +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 { basename, resolveDisplayPath } from '@/components/ToolCard/path' + +const DEFAULT_ICON_CLASS = 'h-3.5 w-3.5' +// Tool presentation registry for `hapi/web` (aligned with `happy-app`). + +export type ToolPresentation = { + icon: ReactNode + title: string + subtitle: string | null + minimal: boolean +} + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +function getInputStringAny(input: unknown, keys: string[]): string | null { + if (!isObject(input)) return null + for (const key of keys) { + const value = input[key] + if (typeof value === 'string' && value.length > 0) return value + } + return null +} + +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text + return text.slice(0, maxLen - 3) + '...' +} + +function snakeToTitleWithSpaces(value: string): string { + return value + .split('_') + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(' ') +} + +function formatMCPTitle(toolName: string): string { + const withoutPrefix = toolName.replace(/^mcp__/, '') + const parts = withoutPrefix.split('__') + if (parts.length >= 2) { + const serverName = snakeToTitleWithSpaces(parts[0]) + const toolPart = snakeToTitleWithSpaces(parts.slice(1).join('_')) + return `MCP: ${serverName} ${toolPart}` + } + return `MCP: ${snakeToTitleWithSpaces(withoutPrefix)}` +} + +type ToolOpts = { + toolName: string + input: unknown + result: unknown + childrenCount: number + description: string | null + metadata: SessionMetadataSummary | null +} + +export const knownTools: Record ReactNode + title: (opts: ToolOpts) => string + subtitle?: (opts: ToolOpts) => string | null + minimal?: boolean | ((opts: ToolOpts) => boolean) +}> = { + Task: { + icon: () => , + title: (opts) => { + const description = getInputStringAny(opts.input, ['description']) + return description ?? 'Task' + }, + subtitle: (opts) => { + const prompt = getInputStringAny(opts.input, ['prompt']) + return prompt ? truncate(prompt, 120) : null + }, + minimal: (opts) => opts.childrenCount === 0 + }, + Bash: { + icon: () => , + title: (opts) => opts.description ?? 'Terminal', + subtitle: (opts) => getInputStringAny(opts.input, ['command', 'cmd']), + minimal: true + }, + Glob: { + icon: () => , + title: (opts) => getInputStringAny(opts.input, ['pattern']) ?? 'Search files', + minimal: true + }, + Grep: { + icon: () => , + title: (opts) => { + const pattern = getInputStringAny(opts.input, ['pattern']) + return pattern ? `grep(pattern: ${pattern})` : 'Search content' + }, + minimal: true + }, + LS: { + icon: () => , + title: (opts) => { + const path = getInputStringAny(opts.input, ['path']) + return path ? resolveDisplayPath(path, opts.metadata) : 'List files' + }, + minimal: true + }, + CodexBash: { + icon: (opts) => { + if (isObject(opts.input) && Array.isArray(opts.input.parsed_cmd) && opts.input.parsed_cmd.length > 0) { + const first = opts.input.parsed_cmd[0] + const type = isObject(first) ? first.type : null + if (type === 'read') return + if (type === 'write') return + } + return + }, + title: (opts) => { + if (isObject(opts.input) && Array.isArray(opts.input.parsed_cmd) && opts.input.parsed_cmd.length === 1) { + const parsed = opts.input.parsed_cmd[0] + if (isObject(parsed) && parsed.type === 'read' && typeof parsed.name === 'string') { + return resolveDisplayPath(parsed.name, opts.metadata) + } + } + return opts.description ?? 'Terminal' + }, + subtitle: (opts) => { + const command = getInputStringAny(opts.input, ['command', 'cmd']) + if (command) return command + if (isObject(opts.input) && Array.isArray(opts.input.command)) { + return opts.input.command.filter((part) => typeof part === 'string').join(' ') + } + return null + }, + minimal: true + }, + Read: { + icon: () => , + title: (opts) => { + const file = getInputStringAny(opts.input, ['file_path', 'path', 'file']) + return file ? resolveDisplayPath(file, opts.metadata) : 'Read file' + }, + minimal: true + }, + Edit: { + icon: () => , + title: (opts) => { + const file = getInputStringAny(opts.input, ['file_path', 'path']) + return file ? resolveDisplayPath(file, opts.metadata) : 'Edit file' + }, + minimal: false + }, + MultiEdit: { + icon: () => , + title: (opts) => { + const file = getInputStringAny(opts.input, ['file_path', 'path']) + if (!file) return 'Edit file' + const edits = isObject(opts.input) && Array.isArray(opts.input.edits) ? opts.input.edits : null + const count = edits ? edits.length : 0 + const path = resolveDisplayPath(file, opts.metadata) + return count > 1 ? `${path} (${count} edits)` : path + }, + minimal: false + }, + Write: { + icon: () => , + title: (opts) => { + const file = getInputStringAny(opts.input, ['file_path', 'path']) + return file ? resolveDisplayPath(file, opts.metadata) : 'Write file' + }, + minimal: false + }, + WebFetch: { + icon: () => , + title: (opts) => { + const url = getInputStringAny(opts.input, ['url']) + if (!url) return 'Web fetch' + try { + return new URL(url).hostname + } catch { + return url + } + }, + subtitle: (opts) => { + const url = getInputStringAny(opts.input, ['url']) + if (!url) return null + return url + }, + minimal: true + }, + WebSearch: { + icon: () => , + title: (opts) => getInputStringAny(opts.input, ['query']) ?? 'Web search', + subtitle: (opts) => { + const query = getInputStringAny(opts.input, ['query']) + return query ? truncate(query, 80) : null + }, + minimal: true + }, + NotebookRead: { + icon: () => , + title: (opts) => { + const path = getInputStringAny(opts.input, ['notebook_path']) + return path ? resolveDisplayPath(path, opts.metadata) : 'Read notebook' + }, + minimal: true + }, + NotebookEdit: { + icon: () => , + title: (opts) => { + const path = getInputStringAny(opts.input, ['notebook_path']) + return path ? resolveDisplayPath(path, opts.metadata) : 'Edit notebook' + }, + subtitle: (opts) => { + const mode = getInputStringAny(opts.input, ['edit_mode']) + return mode ? `mode: ${mode}` : null + }, + minimal: false + }, + TodoWrite: { + icon: () => , + 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 + } + }, + CodexReasoning: { + icon: () => , + title: (opts) => getInputStringAny(opts.input, ['title']) ?? 'Reasoning', + minimal: true + }, + CodexPatch: { + icon: () => , + title: () => 'Apply changes', + subtitle: (opts) => { + if (isObject(opts.input) && isObject(opts.input.changes)) { + const files = Object.keys(opts.input.changes) + if (files.length === 0) return null + const first = files[0] + const display = resolveDisplayPath(first, opts.metadata) + const name = basename(display) + return files.length > 1 ? `${name} (+${files.length - 1})` : name + } + return null + }, + minimal: true + }, + CodexDiff: { + icon: () => , + title: () => 'Diff', + subtitle: (opts) => { + const unified = getInputStringAny(opts.input, ['unified_diff']) + if (!unified) return null + const lines = unified.split('\n') + for (const line of lines) { + if (line.startsWith('+++ b/') || line.startsWith('+++ ')) { + const fileName = line.replace(/^\+\+\+ (b\/)?/, '') + return fileName.split('/').pop() ?? fileName + } + } + return null + }, + minimal: false + }, + ExitPlanMode: { + icon: () => , + title: () => 'Plan proposal', + minimal: false + }, + exit_plan_mode: { + icon: () => , + title: () => 'Plan proposal', + minimal: false + } +} + +export function getToolPresentation(opts: Omit & { metadata: SessionMetadataSummary | null }): ToolPresentation { + if (opts.toolName.startsWith('mcp__')) { + return { + icon: , + title: formatMCPTitle(opts.toolName), + subtitle: null, + minimal: true + } + } + + const known = knownTools[opts.toolName] + if (known) { + const minimal = typeof known.minimal === 'function' ? known.minimal(opts) : (known.minimal ?? false) + return { + icon: known.icon(opts), + title: known.title(opts), + subtitle: known.subtitle ? known.subtitle(opts) : null, + minimal + } + } + + const filePath = getInputStringAny(opts.input, ['file_path', 'path', 'filePath', 'file']) + const command = getInputStringAny(opts.input, ['command', 'cmd']) + const pattern = getInputStringAny(opts.input, ['pattern']) + const url = getInputStringAny(opts.input, ['url']) + const query = getInputStringAny(opts.input, ['query']) + + const subtitle = filePath ?? command ?? pattern ?? url ?? query + + return { + icon: , + title: opts.toolName, + subtitle: subtitle ? truncate(subtitle, 80) : null, + minimal: true + } +} diff --git a/web/src/components/ToolCard/path.ts b/web/src/components/ToolCard/path.ts new file mode 100644 index 00000000..2e201d63 --- /dev/null +++ b/web/src/components/ToolCard/path.ts @@ -0,0 +1,26 @@ +import type { SessionMetadataSummary } from '@/types/api' + +export function resolveDisplayPath(path: string, metadata: SessionMetadataSummary | null): string { + if (!metadata?.path) return path + + const root = metadata.path + const lowerPath = path.toLowerCase() + const lowerRoot = root.toLowerCase() + if (!lowerPath.startsWith(lowerRoot)) return path + + const remainder = path.slice(root.length) + if (remainder !== '' && !remainder.startsWith('/') && !remainder.startsWith('\\')) return path + + let out = remainder + if (out.startsWith('/') || out.startsWith('\\')) { + out = out.slice(1) + } + return out.length === 0 ? '' : out +} + +export function basename(path: string): string { + const normalized = path.replace(/\\/g, '/') + const parts = normalized.split('/').filter(Boolean) + return parts.length > 0 ? parts[parts.length - 1] : path +} + diff --git a/web/src/components/ToolCard/views/CodexDiffView.tsx b/web/src/components/ToolCard/views/CodexDiffView.tsx new file mode 100644 index 00000000..659e85e6 --- /dev/null +++ b/web/src/components/ToolCard/views/CodexDiffView.tsx @@ -0,0 +1,81 @@ +import type { ToolViewProps } from '@/components/ToolCard/views/_all' +import { DiffView } from '@/components/DiffView' + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +function parseUnifiedDiff(unifiedDiff: string): { oldText: string; newText: string; fileName?: string } { + const lines = unifiedDiff.split('\n') + const oldLines: string[] = [] + const newLines: string[] = [] + let fileName: string | undefined + let inHunk = false + + for (const line of lines) { + if (line.startsWith('+++ b/') || line.startsWith('+++ ')) { + fileName = line.replace(/^\+\+\+ (b\/)?/, '') + continue + } + + if ( + line.startsWith('diff --git') + || line.startsWith('index ') + || line.startsWith('---') + || line.startsWith('new file mode') + || line.startsWith('deleted file mode') + ) { + continue + } + + if (line.startsWith('@@')) { + inHunk = true + continue + } + + if (!inHunk) continue + + if (line.startsWith('+')) { + newLines.push(line.substring(1)) + } else if (line.startsWith('-')) { + oldLines.push(line.substring(1)) + } else if (line.startsWith(' ')) { + oldLines.push(line.substring(1)) + newLines.push(line.substring(1)) + } else if (line === '\\ No newline at end of file') { + continue + } else if (line === '') { + oldLines.push('') + newLines.push('') + } + } + + return { + oldText: oldLines.join('\n'), + newText: newLines.join('\n'), + fileName + } +} + +function renderDiff(block: ToolViewProps['block'], showFileHeader: boolean) { + const input = block.tool.input + if (!isObject(input) || typeof input.unified_diff !== 'string') return null + + const parsed = parseUnifiedDiff(input.unified_diff) + return ( + + ) +} + +export function CodexDiffCompactView(props: ToolViewProps) { + return renderDiff(props.block, false) +} + +export function CodexDiffFullView(props: ToolViewProps) { + return renderDiff(props.block, true) +} + diff --git a/web/src/components/ToolCard/views/CodexPatchView.tsx b/web/src/components/ToolCard/views/CodexPatchView.tsx new file mode 100644 index 00000000..08e1351f --- /dev/null +++ b/web/src/components/ToolCard/views/CodexPatchView.tsx @@ -0,0 +1,28 @@ +import type { ToolViewProps } from '@/components/ToolCard/views/_all' +import { basename, resolveDisplayPath } from '@/components/ToolCard/path' + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +export function CodexPatchView(props: ToolViewProps) { + const input = props.block.tool.input + if (!isObject(input) || !isObject(input.changes)) return null + + const files = Object.keys(input.changes) + if (files.length === 0) return null + + return ( +
+ {files.map((file) => { + const display = resolveDisplayPath(file, props.metadata) + return ( +
+ {basename(display)} +
+ ) + })} +
+ ) +} + diff --git a/web/src/components/ToolCard/views/EditView.tsx b/web/src/components/ToolCard/views/EditView.tsx new file mode 100644 index 00000000..f63a4879 --- /dev/null +++ b/web/src/components/ToolCard/views/EditView.tsx @@ -0,0 +1,23 @@ +import type { ToolViewProps } from '@/components/ToolCard/views/_all' +import { DiffView } from '@/components/DiffView' + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +export function EditView(props: ToolViewProps) { + const input = props.block.tool.input + if (!isObject(input)) return null + + const oldString = typeof input.old_string === 'string' ? input.old_string : null + const newString = typeof input.new_string === 'string' ? input.new_string : null + if (oldString === null || newString === null) return null + + return ( + + ) +} + diff --git a/web/src/components/ToolCard/views/ExitPlanModeView.tsx b/web/src/components/ToolCard/views/ExitPlanModeView.tsx new file mode 100644 index 00000000..4e82f1aa --- /dev/null +++ b/web/src/components/ToolCard/views/ExitPlanModeView.tsx @@ -0,0 +1,15 @@ +import type { ToolViewProps } from '@/components/ToolCard/views/_all' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +export function ExitPlanModeView(props: ToolViewProps) { + const input = props.block.tool.input + if (!isObject(input)) return null + const plan = typeof input.plan === 'string' ? input.plan : null + if (!plan) return null + return +} + diff --git a/web/src/components/ToolCard/views/MultiEditView.tsx b/web/src/components/ToolCard/views/MultiEditView.tsx new file mode 100644 index 00000000..789a1bfc --- /dev/null +++ b/web/src/components/ToolCard/views/MultiEditView.tsx @@ -0,0 +1,37 @@ +import type { ToolViewProps } from '@/components/ToolCard/views/_all' +import { DiffView } from '@/components/DiffView' + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +type Edit = { old_string: string; new_string: string } + +function extractEdits(input: unknown): Edit[] { + if (!isObject(input) || !Array.isArray(input.edits)) return [] + return input.edits + .filter(isObject) + .map((edit) => ({ + old_string: typeof edit.old_string === 'string' ? edit.old_string : '', + new_string: typeof edit.new_string === 'string' ? edit.new_string : '' + })) + .filter((edit) => edit.old_string.length > 0 || edit.new_string.length > 0) +} + +export function MultiEditView(props: ToolViewProps) { + const edits = extractEdits(props.block.tool.input) + if (edits.length === 0) return null + + return ( +
+ {edits.map((edit, idx) => ( + + ))} +
+ ) +} + diff --git a/web/src/components/ToolCard/views/TodoWriteView.tsx b/web/src/components/ToolCard/views/TodoWriteView.tsx new file mode 100644 index 00000000..987f66a7 --- /dev/null +++ b/web/src/components/ToolCard/views/TodoWriteView.tsx @@ -0,0 +1,66 @@ +import type { ToolViewProps } from '@/components/ToolCard/views/_all' + +type TodoItem = { + id?: string + content?: string + status?: 'pending' | 'in_progress' | 'completed' + priority?: 'high' | 'medium' | 'low' +} + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +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 'โ˜' +} + +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} +
+ ) + })} +
+ ) +} + diff --git a/web/src/components/ToolCard/views/WriteView.tsx b/web/src/components/ToolCard/views/WriteView.tsx new file mode 100644 index 00000000..23351c08 --- /dev/null +++ b/web/src/components/ToolCard/views/WriteView.tsx @@ -0,0 +1,22 @@ +import type { ToolViewProps } from '@/components/ToolCard/views/_all' +import { DiffView } from '@/components/DiffView' + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +export function WriteView(props: ToolViewProps) { + const input = props.block.tool.input + if (!isObject(input)) return null + + const content = typeof input.content === 'string' ? input.content : typeof input.text === 'string' ? input.text : null + if (content === null) return null + + return ( + + ) +} + diff --git a/web/src/components/ToolCard/views/_all.tsx b/web/src/components/ToolCard/views/_all.tsx new file mode 100644 index 00000000..47d87a5b --- /dev/null +++ b/web/src/components/ToolCard/views/_all.tsx @@ -0,0 +1,46 @@ +import type { ComponentType } from 'react' +import type { ToolCallBlock } from '@/chat/types' +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 { ExitPlanModeView } from '@/components/ToolCard/views/ExitPlanModeView' +import { MultiEditView } from '@/components/ToolCard/views/MultiEditView' +import { TodoWriteView } from '@/components/ToolCard/views/TodoWriteView' +import { WriteView } from '@/components/ToolCard/views/WriteView' + +export type ToolViewProps = { + block: ToolCallBlock + metadata: SessionMetadataSummary | null +} + +export type ToolViewComponent = ComponentType + +export const toolViewRegistry: Record = { + Edit: EditView, + MultiEdit: MultiEditView, + Write: WriteView, + TodoWrite: TodoWriteView, + CodexDiff: CodexDiffCompactView, + ExitPlanMode: ExitPlanModeView, + exit_plan_mode: ExitPlanModeView +} + +export const toolFullViewRegistry: Record = { + Edit: EditView, + MultiEdit: MultiEditView, + Write: WriteView, + CodexDiff: CodexDiffFullView, + CodexPatch: CodexPatchView, + TodoWrite: TodoWriteView, + ExitPlanMode: ExitPlanModeView, + exit_plan_mode: ExitPlanModeView +} + +export function getToolViewComponent(toolName: string): ToolViewComponent | null { + return toolViewRegistry[toolName] ?? null +} + +export function getToolFullViewComponent(toolName: string): ToolViewComponent | null { + return toolFullViewRegistry[toolName] ?? null +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 3e7bb562..293b370e 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -37,6 +37,13 @@ export type AgentState = { completedRequests?: Record | null } +export type TodoItem = { + content: string + status: 'pending' | 'in_progress' | 'completed' + priority: 'high' | 'medium' | 'low' + id: string +} + export type Session = { id: string createdAt: number @@ -45,6 +52,7 @@ export type Session = { thinking: boolean metadata: SessionMetadataSummary | null agentState: AgentState | null + todos?: TodoItem[] permissionMode?: PermissionMode modelMode?: ModelMode } @@ -58,6 +66,7 @@ export type SessionSummary = { permissionMode: PermissionMode modelMode: ModelMode metadata: SessionMetadataSummary | null + todos?: TodoItem[] pendingRequestsCount: number }