diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 49cfdf23..740981bf 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -17,7 +17,8 @@ import type { ToolRendererContext, ToolScrollHelpers, } from "./tool-call/types" -import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./tool-call/utils" +import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils" +import { resolveTitleForTool } from "./tool-call/tool-title" import { getLogger } from "../lib/logger" const log = getLogger("session") @@ -710,6 +711,12 @@ export default function ToolCall(props: ToolCallProps) { const renderToolTitle = () => { const state = toolState() + const currentTool = toolName() + + if (currentTool !== "task") { + return resolveTitleForTool({ toolName: currentTool, state }) + } + if (!state) return getRendererAction() if (state.status === "pending") return getRendererAction() @@ -724,7 +731,7 @@ export default function ToolCall(props: ToolCallProps) { return state.title } - return getToolName(toolName()) + return getToolName(currentTool) } const renderToolBody = () => { @@ -918,33 +925,3 @@ export default function ToolCall(props: ToolCallProps) { ) } - -function getDefaultToolAction(toolName: string) { - switch (toolName) { - case "task": - return "Delegating..." - case "bash": - return "Writing command..." - case "edit": - return "Preparing edit..." - case "webfetch": - return "Fetching from the web..." - case "glob": - return "Finding files..." - case "grep": - return "Searching content..." - case "list": - return "Listing directory..." - case "read": - return "Reading file..." - case "write": - return "Preparing write..." - case "todowrite": - case "todoread": - return "Planning..." - case "patch": - return "Preparing patch..." - default: - return "Working..." - } -} diff --git a/packages/ui/src/components/tool-call/renderers/task.tsx b/packages/ui/src/components/tool-call/renderers/task.tsx index 5c146196..0ac0ecdd 100644 --- a/packages/ui/src/components/tool-call/renderers/task.tsx +++ b/packages/ui/src/components/tool-call/renderers/task.tsx @@ -1,25 +1,84 @@ -import { For, createMemo } from "solid-js" +import { For, Show, createMemo } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" import type { ToolRenderer } from "../types" -import { getRelativePath, getToolIcon, getToolName, readToolStatePayload } from "../utils" +import { getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" +import { getTodoTitle } from "./todo" +import { resolveTitleForTool } from "../tool-title" interface TaskSummaryItem { id: string tool: string input: Record + metadata: Record + state?: ToolState + status?: ToolState["status"] + title?: string } -function describeTaskItem(item: TaskSummaryItem): string { - const input = item.input || {} - switch (item.tool) { - case "bash": - return typeof input.description === "string" ? input.description : input.command || "bash" - case "edit": - case "read": - case "write": - return `${item.tool} ${getRelativePath(typeof input.filePath === "string" ? input.filePath : "")}`.trim() - default: - return item.tool +function normalizeStatus(status?: string | null): ToolState["status"] | undefined { + if (status === "pending" || status === "running" || status === "completed" || status === "error") { + return status } + return undefined +} + +function summarizeStatusIcon(status?: ToolState["status"]) { + switch (status) { + case "pending": + return "⏸" + case "running": + return "⏳" + case "completed": + return "✓" + case "error": + return "✗" + default: + return "" + } +} + +function summarizeStatusLabel(status?: ToolState["status"]) { + switch (status) { + case "pending": + return "Pending" + case "running": + return "Running" + case "completed": + return "Completed" + case "error": + return "Error" + default: + return "Unknown" + } +} + +function describeTaskTitle(input: Record) { + const description = typeof input.description === "string" ? input.description : undefined + const subagent = typeof input.subagent_type === "string" ? input.subagent_type : undefined + const base = getToolName("task") + if (description && subagent) { + return `${base}[${subagent}] ${description}` + } + if (description) { + return `${base} ${description}` + } + return base +} + +function describeToolTitle(item: TaskSummaryItem): string { + if (item.title && item.title.length > 0) { + return item.title + } + + if (item.tool === "task") { + return describeTaskTitle({ ...item.metadata, ...item.input }) + } + + if (item.state) { + return resolveTitleForTool({ toolName: item.tool, state: item.state }) + } + + return getDefaultToolAction(item.tool) } export const taskRenderer: ToolRenderer = { @@ -29,18 +88,9 @@ export const taskRenderer: ToolRenderer = { const state = toolState() if (!state) return undefined const { input } = readToolStatePayload(state) - const description = input.description - const subagent = input.subagent_type - const base = getToolName("task") - if (description && subagent) { - return `${base}[${subagent}] ${description}` - } - if (description) { - return `${base} ${description}` - } - return base + return describeTaskTitle(input) }, - renderBody({ toolState, toolCall, messageVersion, partVersion, scrollHelpers }) { + renderBody({ toolState, messageVersion, partVersion, scrollHelpers }) { const items = createMemo(() => { // Track the reactive change points so we only recompute when the part/message changes messageVersion?.() @@ -54,9 +104,13 @@ export const taskRenderer: ToolRenderer = { return summary.map((entry, index) => { const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown" - const input = typeof (entry as any)?.state?.input === "object" && entry.state?.input ? entry.state.input : {} + const stateValue = typeof entry?.state === "object" ? (entry.state as ToolState) : undefined + const metadataFromEntry = typeof entry?.metadata === "object" && entry.metadata ? entry.metadata : {} + const fallbackInput = typeof entry?.input === "object" && entry.input ? entry.input : {} const id = typeof entry?.id === "string" && entry.id.length > 0 ? entry.id : `${tool}-${index}` - return { id, tool, input } + const statusValue = normalizeStatus((entry?.status as string | undefined) ?? stateValue?.status) + const title = typeof entry?.title === "string" ? entry.title : undefined + return { id, tool, input: fallbackInput, metadata: metadataFromEntry, state: stateValue, status: statusValue, title } }) }) @@ -72,11 +126,23 @@ export const taskRenderer: ToolRenderer = { {(item) => { const icon = getToolIcon(item.tool) - const description = describeTaskItem(item) + const description = describeToolTitle(item) + const toolLabel = getToolName(item.tool) + const status = normalizeStatus(item.status ?? item.state?.status) + const statusIcon = summarizeStatusIcon(status) + const statusLabel = summarizeStatusLabel(status) + const statusAttr = status ?? "pending" return ( -
+
{icon} + {toolLabel} + {description} + + + {statusIcon} + +
) }} @@ -87,4 +153,3 @@ export const taskRenderer: ToolRenderer = { ) }, } - diff --git a/packages/ui/src/components/tool-call/renderers/todo.tsx b/packages/ui/src/components/tool-call/renderers/todo.tsx index 54b0ceb6..8f507fee 100644 --- a/packages/ui/src/components/tool-call/renderers/todo.tsx +++ b/packages/ui/src/components/tool-call/renderers/todo.tsx @@ -58,7 +58,7 @@ function getTodoStatusLabel(status: TodoViewStatus): string { } } -function getTodoTitle(state?: ToolState): string { +export function getTodoTitle(state?: ToolState): string { if (!state) return "Plan" const todos = extractTodosFromState(state) diff --git a/packages/ui/src/components/tool-call/tool-title.ts b/packages/ui/src/components/tool-call/tool-title.ts new file mode 100644 index 00000000..2e8496be --- /dev/null +++ b/packages/ui/src/components/tool-call/tool-title.ts @@ -0,0 +1,86 @@ +import type { ToolState } from "@opencode-ai/sdk" +import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types" +import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils" +import { defaultRenderer } from "./renderers/default" +import { bashRenderer } from "./renderers/bash" +import { readRenderer } from "./renderers/read" +import { writeRenderer } from "./renderers/write" +import { editRenderer } from "./renderers/edit" +import { patchRenderer } from "./renderers/patch" +import { webfetchRenderer } from "./renderers/webfetch" +import { todoRenderer } from "./renderers/todo" +import { invalidRenderer } from "./renderers/invalid" + +const TITLE_RENDERERS: Record = { + bash: bashRenderer, + read: readRenderer, + write: writeRenderer, + edit: editRenderer, + patch: patchRenderer, + webfetch: webfetchRenderer, + todowrite: todoRenderer, + todoread: todoRenderer, + invalid: invalidRenderer, +} + +interface TitleSnapshot { + toolName: string + state?: ToolState +} + +function lookupRenderer(toolName: string): ToolRenderer { + return TITLE_RENDERERS[toolName] ?? defaultRenderer +} + +function createStaticToolPart(snapshot: TitleSnapshot): ToolCallPart { + return { + id: "", + type: "tool", + tool: snapshot.toolName, + state: snapshot.state, + } as ToolCallPart +} + +function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext { + const toolStateAccessor = () => snapshot.state + const toolNameAccessor = () => snapshot.toolName + const toolCallAccessor = () => createStaticToolPart(snapshot) + const messageVersionAccessor = () => undefined + const partVersionAccessor = () => undefined + const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null + const renderDiff: ToolRendererContext["renderDiff"] = () => null + + return { + toolCall: toolCallAccessor, + toolState: toolStateAccessor, + toolName: toolNameAccessor, + messageVersion: messageVersionAccessor, + partVersion: partVersionAccessor, + renderMarkdown, + renderDiff, + scrollHelpers: undefined, + } +} + +export function resolveTitleForTool(snapshot: TitleSnapshot): string { + const renderer = lookupRenderer(snapshot.toolName) + const context = createStaticContext(snapshot) + const state = snapshot.state + const defaultAction = renderer.getAction?.(context) ?? getDefaultToolAction(snapshot.toolName) + + if (!state || state.status === "pending") { + return defaultAction + } + + const stateTitle = typeof (state as { title?: string }).title === "string" ? (state as { title?: string }).title : undefined + if (stateTitle && stateTitle.length > 0) { + return stateTitle + } + + const customTitle = renderer.getTitle?.(context) + if (customTitle) { + return customTitle + } + + return getToolName(snapshot.toolName) +} diff --git a/packages/ui/src/components/tool-call/utils.ts b/packages/ui/src/components/tool-call/utils.ts index 8155f3e7..229bd0f4 100644 --- a/packages/ui/src/components/tool-call/utils.ts +++ b/packages/ui/src/components/tool-call/utils.ts @@ -192,3 +192,33 @@ export function readToolStatePayload(state?: ToolState): { output: isToolStateCompleted(state) ? state.output : undefined, } } + +export function getDefaultToolAction(toolName: string) { + switch (toolName) { + case "task": + return "Delegating..." + case "bash": + return "Writing command..." + case "edit": + return "Preparing edit..." + case "webfetch": + return "Fetching from the web..." + case "glob": + return "Finding files..." + case "grep": + return "Searching content..." + case "list": + return "Listing directory..." + case "read": + return "Reading file..." + case "write": + return "Preparing write..." + case "todowrite": + case "todoread": + return "Planning..." + case "patch": + return "Preparing patch..." + default: + return "Working..." + } +} diff --git a/packages/ui/src/styles/messaging/tool-call/task.css b/packages/ui/src/styles/messaging/tool-call/task.css index b2d95c9b..914690c1 100644 --- a/packages/ui/src/styles/messaging/tool-call/task.css +++ b/packages/ui/src/styles/messaging/tool-call/task.css @@ -3,17 +3,77 @@ } .tool-call-task-summary { - @apply my-2 flex flex-col gap-1.5; + display: flex; + flex-direction: column; + gap: 0.1rem; + margin: 0; } .tool-call-task-item { - font-size: var(--font-size-xs); - line-height: var(--line-height-normal); - padding-left: 8px; + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.5rem 0.35rem 0.75rem; border-left: 2px solid var(--border-base); + font-size: var(--font-size-sm); + font-family: var(--font-family-mono); + line-height: 1.35; + background-color: var(--surface-code); + transition: background-color 0.2s ease, border-color 0.2s ease; } -.tool-call-task-item::before { - content: "∟ "; +.tool-call-task-item + .tool-call-task-item { + margin-top: 0.1rem; +} + +.tool-call-task-item:hover { + background-color: var(--surface-hover); +} + +.tool-call-task-item[data-task-status="completed"] { + border-left-color: var(--status-success); +} + +.tool-call-task-item[data-task-status="running"] { + border-left-color: var(--status-warning); +} + +.tool-call-task-item[data-task-status="pending"] { + border-left-color: var(--accent-primary); +} + +.tool-call-task-item[data-task-status="error"] { + border-left-color: var(--status-error); +} + +.tool-call-task-icon { + font-size: 0.9rem; + line-height: 1; color: var(--text-muted); } + +.tool-call-task-label { + font-weight: var(--font-weight-semibold); + color: var(--text-secondary); + font-size: inherit; +} + +.tool-call-task-separator { + color: var(--text-muted); + font-size: inherit; +} + +.tool-call-task-text { + flex: 1; + min-width: 0; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tool-call-task-status { + font-weight: var(--font-weight-semibold); + color: var(--text-muted); + font-size: 0.9rem; +}