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 }