diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 64419a2e..c274df79 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -3,38 +3,18 @@ import { messageStoreBus } from "../stores/message-v2/bus" import { Markdown } from "./markdown" import { ToolCallDiffViewer } from "./diff-viewer" import { useTheme } from "../lib/theme" -import { getLanguageFromPath } from "../lib/markdown" -import { isRenderableDiffText } from "../lib/diff-utils" import { useGlobalCache } from "../lib/hooks/use-global-cache" import { useConfig } from "../stores/preferences" import type { DiffViewMode } from "../stores/preferences" import { sendPermissionResponse } from "../stores/instances" -import type { TextPart, SDKPart, ClientPart, RenderCache } from "../types/message" +import type { TextPart, RenderCache } from "../types/message" +import { resolveToolRenderer } from "./tool-call/renderers" +import type { DiffPayload, DiffRenderOptions, MarkdownRenderOptions, ToolCallPart, ToolRendererContext } from "./tool-call/types" +import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./tool-call/utils" -type ToolCallPart = Extract - -// Import ToolState types from SDK type ToolState = import("@opencode-ai/sdk").ToolState -type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning -type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted -type ToolStateError = import("@opencode-ai/sdk").ToolStateError - -// Type guards -function isToolStateRunning(state: ToolState): state is ToolStateRunning { - return state.status === "running" -} - -function isToolStateCompleted(state: ToolState): state is ToolStateCompleted { - return state.status === "completed" -} - -function isToolStateError(state: ToolState): state is ToolStateError { - return state.status === "error" -} - const TOOL_CALL_CACHE_SCOPE = "tool-call" -const taskSummaryCache = new Map() function makeRenderCacheKey( toolCallId?: string | null, @@ -49,7 +29,7 @@ function makeRenderCacheKey( interface ToolCallProps { - toolCall: Extract + toolCall: ToolCallPart toolCallId?: string messageId?: string messageVersion?: number @@ -60,60 +40,6 @@ interface ToolCallProps { } -function getToolIcon(tool: string): string { - switch (tool) { - case "bash": - return "โšก" - case "edit": - return "โœ๏ธ" - case "read": - return "๐Ÿ“–" - case "write": - return "๐Ÿ“" - case "glob": - return "๐Ÿ”" - case "grep": - return "๐Ÿ”Ž" - case "webfetch": - return "๐ŸŒ" - case "task": - return "๐ŸŽฏ" - case "todowrite": - case "todoread": - return "๐Ÿ“‹" - case "list": - return "๐Ÿ“" - case "patch": - return "๐Ÿ”ง" - default: - return "๐Ÿ”ง" - } -} - -function getToolName(tool: string): string { - switch (tool) { - case "bash": - return "Shell" - case "webfetch": - return "Fetch" - case "invalid": - return "Invalid" - case "todowrite": - case "todoread": - return "Plan" - default: - const normalized = tool.replace(/^opencode_/, "") - return normalized.charAt(0).toUpperCase() + normalized.slice(1) - } -} - -function getRelativePath(path: string): string { - if (!path) return "" - const parts = path.split("/") - return parts.slice(-1)[0] || path -} - -const diffCapableTools = new Set(["edit", "patch"]) interface LspRangePosition { line?: number @@ -143,44 +69,6 @@ interface DiagnosticEntry { column: number } -interface DiffPayload { - diffText: string - filePath?: string -} - -function extractDiffPayload(toolName: string, state: ToolState): DiffPayload | null { - - if (!diffCapableTools.has(toolName)) return null - if (!state) return null - - const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) - ? state.metadata || {} - : {} - - const output = isToolStateCompleted(state) ? state.output : undefined - const candidates = [metadata.diff, output, metadata.output] - let diffText: string | null = null - - for (const candidate of candidates) { - if (typeof candidate === "string" && isRenderableDiffText(candidate)) { - diffText = candidate - break - } - } - - if (!diffText) { - return null - } - - const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) - ? state.input as Record - : {} - const filePath = (typeof input.filePath === "string" ? input.filePath : undefined) || - (typeof metadata.filePath === "string" ? metadata.filePath : undefined) || - (typeof input.path === "string" ? input.path : undefined) - - return { diffText, filePath } -} function normalizeDiagnosticPath(path: string) { return path.replace(/\\/g, "/") @@ -198,7 +86,7 @@ function getSeverityMeta(tone: DiagnosticEntry["tone"]) { return { label: "INFO", icon: "i", rank: 2 } } -function extractDiagnostics(toolName: string, state: ToolState | undefined): DiagnosticEntry[] { +function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] { if (!state) return [] const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state) if (!supportsMetadata) return [] @@ -270,7 +158,6 @@ function renderDiagnosticsSection( entries: DiagnosticEntry[], expanded: boolean, toggle: () => void, - toolIcon: string, fileLabel: string, ) { if (entries.length === 0) return null @@ -320,6 +207,7 @@ export default function ToolCall(props: ToolCallProps) { const { preferences, setDiffViewMode } = useConfig() const { isDark } = useTheme() const toolCallMemo = createMemo(() => props.toolCall) + const toolName = createMemo(() => toolCallMemo()?.tool || "") const toolCallId = () => props.toolCallId || toolCallMemo()?.id || "" const toolState = createMemo(() => toolCallMemo()?.state) const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) @@ -393,10 +281,9 @@ export default function ToolCall(props: ToolCallProps) { } const diagnosticsEntries = createMemo(() => { - const tool = toolCallMemo()?.tool || "" const state = toolState() if (!state) return [] - return extractDiagnostics(tool, state) + return extractDiagnostics(state) }) @@ -494,224 +381,9 @@ export default function ToolCall(props: ToolCallProps) { }) } - const renderToolAction = () => { - const toolName = toolCallMemo()?.tool || "" - 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..." - } - } + const renderer = createMemo(() => resolveToolRenderer(toolName())) - async function handlePermissionResponse(response: "once" | "always" | "reject") { - const permission = permissionDetails() - if (!permission || !isPermissionActive()) { - return - } - setPermissionSubmitting(true) - setPermissionError(null) - try { - const sessionId = permission.sessionID || props.sessionId - await sendPermissionResponse(props.instanceId, sessionId, permission.id, response) - } catch (error) { - console.error("Failed to send permission response:", error) - setPermissionError(error instanceof Error ? error.message : "Unable to update permission") - } finally { - setPermissionSubmitting(false) - } - } - - type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled" - - interface TodoViewItem { - id: string - content: string - status: TodoViewStatus - } - - function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus { - if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus - return "pending" - } - - function extractTodosFromState(state: ToolState | undefined): TodoViewItem[] { - if (!state) return [] - const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) - ? state.metadata || {} - : {} - const todos = Array.isArray((metadata as any).todos) ? (metadata as any).todos : [] - const items: TodoViewItem[] = [] - - for (let index = 0; index < todos.length; index++) { - const todo = todos[index] - const content = typeof todo?.content === "string" ? todo.content.trim() : "" - if (!content) continue - const status = normalizeTodoStatus((todo as any).status) - const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : `${index}-${content}` - items.push({ id, content, status }) - } - - return items - } - - function summarizeTodos(todos: TodoViewItem[]) { - return todos.reduce( - (acc, todo) => { - acc.total += 1 - acc[todo.status] = (acc[todo.status] || 0) + 1 - return acc - }, - { total: 0, pending: 0, in_progress: 0, completed: 0, cancelled: 0 } as Record, - ) - } - - function getTodoStatusLabel(status: TodoViewStatus): string { - switch (status) { - case "completed": - return "Completed" - case "in_progress": - return "In progress" - case "cancelled": - return "Cancelled" - default: - return "Pending" - } - } - - const getTodoTitle = () => { - const state = toolState() - if (!state) return "Plan" - - const todos = extractTodosFromState(state) - if (state.status !== "completed" || todos.length === 0) return "Plan" - - const counts = summarizeTodos(todos) - if (counts.pending === counts.total) return "Creating plan" - if (counts.completed === counts.total) return "Completing plan" - return "Updating plan" - } - - const renderToolTitle = () => { - const toolName = toolCallMemo()?.tool || "" - const state = toolState() - - if (!state) return renderToolAction() - if (state.status === "pending") return renderToolAction() - - const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) - ? (state.input as Record) - : {} as Record - - if (isToolStateRunning(state) && state.title) { - return state.title - } - - if (isToolStateCompleted(state)) { - return state.title - } - - const name = getToolName(toolName) - - switch (toolName) { - case "read": - if (typeof input.filePath === "string") { - return `${name} ${getRelativePath(input.filePath)}` - } - return name - - case "edit": - case "write": - if (typeof input.filePath === "string") { - return `${name} ${getRelativePath(input.filePath)}` - } - return name - - case "bash": - if (typeof input.description === "string") { - return `${name} ${input.description}` - } - return name - - case "task": - const description = input.description - const subagent = input.subagent_type - if (description && subagent) { - return `${name}[${subagent}] ${description}` - } else if (description) { - return `${name} ${description}` - } - return name - - case "webfetch": - if (input.url) { - return `${name} ${input.url}` - } - return name - - case "todowrite": - return getTodoTitle() - - case "todoread": - return getTodoTitle() - - case "invalid": - if (typeof input.tool === "string") { - return getToolName(input.tool) - } - return name - - default: - return name - } - } - - function renderToolBody() { - const toolName = toolCallMemo()?.tool || "" - const state = toolState() || {} - - if (toolName === "todoread" || toolName === "todowrite") { - return renderTodoTool() - } - - if (state.status === "pending") { - return null - } - - if (toolName === "task") { - return renderTaskTool() - } - - const diffPayload = extractDiffPayload(toolName, state) - if (diffPayload) { - return renderDiffTool(diffPayload) - } - - return renderMarkdownTool(toolName, state) - } - - function renderDiffTool(payload: DiffPayload, options?: { variant?: string; disableScrollTracking?: boolean; label?: string }) { + function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions) { const relativePath = payload.filePath ? getRelativePath(payload.filePath) : "" const toolbarLabel = options?.label || (relativePath ? `Diff ยท ${relativePath}` : "Diff") const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff" @@ -719,7 +391,6 @@ export default function ToolCall(props: ToolCallProps) { const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode const themeKey = isDark() ? "dark" : "light" - // Check if we have valid cache let cachedHtml: string | undefined const cached = cacheHandle.get() const currentMode = diffMode() @@ -738,7 +409,6 @@ export default function ToolCall(props: ToolCallProps) { props.onContentRendered?.() } - return (
persistScrollSnapshot(event.currentTarget)} > -
{toolbarLabel}
@@ -783,17 +452,16 @@ export default function ToolCall(props: ToolCallProps) { ) } - function renderMarkdownTool(toolName: string, state: ToolState) { - const content = getMarkdownContent(toolName, state) - if (!content) { + function renderMarkdownContent(options: MarkdownRenderOptions) { + if (!options.content) { return null } - const isLarge = toolName === "edit" || toolName === "write" || toolName === "patch" - const messageClass = `message-text tool-call-markdown${isLarge ? " tool-call-markdown-large" : ""}` - const disableHighlight = state?.status === "running" + const size = options.size || "default" + const disableHighlight = options.disableHighlight || false + const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}` - const markdownPart: TextPart = { type: "text", text: content } + const markdownPart: TextPart = { type: "text", text: options.content } const cached = markdownCache.get() if (cached) { markdownPart.renderCache = cached @@ -805,7 +473,6 @@ export default function ToolCall(props: ToolCallProps) { props.onContentRendered?.() } - return (
- : {} - const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) - ? state.metadata || {} - : {} - - switch (toolName) { - case "read": { - const preview = typeof metadata.preview === "string" ? metadata.preview : null - const language = getLanguageFromPath(typeof input.filePath === "string" ? input.filePath : "") - return ensureMarkdownContent(preview, language, true) - } - - case "edit": { - const diffText = typeof metadata.diff === "string" ? metadata.diff : null - const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - return ensureMarkdownContent(diffText || fallback, "diff", true) - } - - case "write": { - const content = typeof input.content === "string" ? input.content : null - const metadataContent = typeof metadata.content === "string" ? metadata.content : null - const language = getLanguageFromPath(typeof input.filePath === "string" ? input.filePath : "") - return ensureMarkdownContent(content || metadataContent, language, true) - } - - case "patch": { - const patchContent = typeof metadata.diff === "string" ? metadata.diff : null - const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - return ensureMarkdownContent(patchContent || fallback, "diff", true) - } - - case "bash": { - const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" - const outputResult = formatUnknown( - isToolStateCompleted(state) ? state.output : - (isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output : - undefined - ) - const parts = [command, outputResult?.text].filter(Boolean) - const combined = parts.join("\n") - return ensureMarkdownContent(combined, "bash", true) - } - - case "webfetch": { - const result = formatUnknown( - isToolStateCompleted(state) ? state.output : - (isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output : - undefined - ) - if (!result) return null - return ensureMarkdownContent(result.text, result.language, true) - } - - default: { - const result = formatUnknown( - isToolStateCompleted(state) ? state.output : - (isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output : - metadata.diff ?? metadata.preview ?? input.content, - ) - if (!result) return null - return ensureMarkdownContent(result.text, result.language, true) - } - } + const rendererContext: ToolRendererContext = { + toolCall: toolCallMemo, + toolState, + toolName, + renderMarkdown: renderMarkdownContent, + renderDiff: renderDiffContent, } - function ensureMarkdownContent( - value: string | null, - language?: string, - forceFence = false, - ): string | null { - if (!value) { - return null - } + const getRendererAction = () => renderer().getAction?.(rendererContext) ?? getDefaultToolAction(toolName()) - const trimmed = value.replace(/\s+$/, "") - if (!trimmed) { - return null - } - - const startsWithFence = trimmed.trimStart().startsWith("```") - if (startsWithFence && !forceFence) { - return trimmed - } - - const langSuffix = language ? language : "" - if (language || forceFence) { - return `\u0060\u0060\u0060${langSuffix}\n${trimmed}\n\u0060\u0060\u0060` - } - - return trimmed - } - - function formatUnknown(value: unknown): { text: string; language?: string } | null { - if (value === null || value === undefined) { - return null - } - - if (typeof value === "string") { - return { text: value } - } - - if (typeof value === "number" || typeof value === "boolean") { - return { text: String(value) } - } - - if (Array.isArray(value)) { - const parts = value - .map((item) => { - const formatted = formatUnknown(item) - return formatted?.text ?? "" - }) - .filter(Boolean) - - if (parts.length === 0) { - return null - } - - return { text: parts.join("\n") } - } - - if (typeof value === "object") { - try { - return { text: JSON.stringify(value, null, 2), language: "json" } - } catch (error) { - console.error("Failed to stringify tool call output", error) - return { text: String(value) } - } - } - - return null - } - - const renderTodoTool = () => { + const renderToolTitle = () => { const state = toolState() - if (!state) return null + if (!state) return getRendererAction() + if (state.status === "pending") return getRendererAction() - const todos = extractTodosFromState(state) - const counts = summarizeTodos(todos) - - if (counts.total === 0) { - return
No plan items yet.
+ if (isToolStateRunning(state) && state.title) { + return state.title } - return ( -
-
- - {(todo) => { - const label = getTodoStatusLabel(todo.status) + if (isToolStateCompleted(state) && state.title) { + return state.title + } - return ( -
- -
-
- {todo.content} - {label} -
-
-
- ) - }} -
-
-
- ) + const customTitle = renderer().getTitle?.(rendererContext) + if (customTitle) return customTitle + + return getToolName(toolName()) } - type TaskSummaryItem = { - id: string - tool: string - input: Record + const renderToolBody = () => { + return renderer().renderBody(rendererContext) } - const taskSummary = createMemo(() => { - const state = toolState() - if (!state) return [] - const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) - ? (state.metadata || {}) as Record - : ({} as Record) - const rawSummary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : [] - if (rawSummary.length === 0) { - taskSummaryCache.delete(toolCallId()) - return [] + async function handlePermissionResponse(response: "once" | "always" | "reject") { + const permission = permissionDetails() + if (!permission || !isPermissionActive()) { + return } - const signature = JSON.stringify(rawSummary) - const cacheKey = toolCallId() || "__unknown__" - const cached = taskSummaryCache.get(cacheKey) - if (cached && cached.signature === signature) { - return cached.items + setPermissionSubmitting(true) + setPermissionError(null) + try { + const sessionId = permission.sessionID || props.sessionId + await sendPermissionResponse(props.instanceId, sessionId, permission.id, response) + } catch (error) { + console.error("Failed to send permission response:", error) + setPermissionError(error instanceof Error ? error.message : "Unable to update permission") + } finally { + setPermissionSubmitting(false) } - const normalized: TaskSummaryItem[] = rawSummary.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 id = typeof entry?.id === "string" && entry.id.length > 0 ? entry.id : `${tool}-${index}` - return { id, tool, input } - }) - taskSummaryCache.set(cacheKey, { signature, items: normalized }) - return normalized - }) - - const renderTaskTool = () => { - const items = taskSummary() - if (items.length === 0) return null - - return ( -
-
- - {(item) => { - const icon = getToolIcon(item.tool) - const input = item.input || {} - - let description = "" - switch (item.tool) { - case "bash": - description = input.description || input.command || "" - break - case "edit": - case "read": - case "write": - description = `${item.tool} ${getRelativePath(input.filePath || "")}` - break - default: - description = item.tool - } - - return ( -
- {icon} - {description} -
- ) - }} -
-
-
- ) } @@ -1088,6 +553,7 @@ export default function ToolCall(props: ToolCallProps) { return null } + const renderPermissionBlock = () => { const permission = permissionDetails() if (!permission) return null @@ -1118,7 +584,7 @@ export default function ToolCall(props: ToolCallProps) { {(payload) => (
- {renderDiffTool(payload(), { + {renderDiffContent(payload(), { variant: "permission-diff", disableScrollTracking: true, label: payload().filePath ? `Requested diff ยท ${getRelativePath(payload().filePath || "")}` : "Requested diff", @@ -1175,7 +641,6 @@ export default function ToolCall(props: ToolCallProps) { ) } - const toolName = () => toolCallMemo()?.tool || "" const status = () => toolState()?.status || "" return ( @@ -1222,10 +687,39 @@ export default function ToolCall(props: ToolCallProps) { const current = prev === undefined ? diagnosticsDefaultExpanded() : prev return !current }), - getToolIcon(toolName()), diagnosticFileName(diagnosticsEntries()), )}
) } + +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/bash.tsx b/packages/ui/src/components/tool-call/renderers/bash.tsx new file mode 100644 index 00000000..33cc1793 --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/bash.tsx @@ -0,0 +1,38 @@ +import type { ToolRenderer } from "../types" +import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" + +export const bashRenderer: ToolRenderer = { + tools: ["bash"], + getAction: () => "Writing command...", + getTitle({ toolState }) { + const state = toolState() + if (!state) return undefined + const { input } = readToolStatePayload(state) + const name = getToolName("bash") + if (typeof input.description === "string" && input.description.length > 0) { + return `${name} ${input.description}` + } + return name + }, + renderBody({ toolState, renderMarkdown }) { + const state = toolState() + if (!state || state.status === "pending") return null + + const { input, metadata } = readToolStatePayload(state) + const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" + const outputResult = formatUnknown( + isToolStateCompleted(state) + ? state.output + : (isToolStateRunning(state) || isToolStateError(state)) && metadata.output + ? metadata.output + : undefined, + ) + const parts = [command, outputResult?.text].filter(Boolean) + if (parts.length === 0) return null + + const content = ensureMarkdownContent(parts.join("\n"), "bash", true) + if (!content) return null + + return renderMarkdown({ content, disableHighlight: state.status === "running" }) + }, +} diff --git a/packages/ui/src/components/tool-call/renderers/default.tsx b/packages/ui/src/components/tool-call/renderers/default.tsx new file mode 100644 index 00000000..181a84f6 --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/default.tsx @@ -0,0 +1,25 @@ +import type { ToolRenderer } from "../types" +import { ensureMarkdownContent, formatUnknown, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" + +export const defaultRenderer: ToolRenderer = { + tools: ["*"], + renderBody({ toolState, renderMarkdown }) { + const state = toolState() + if (!state || state.status === "pending") return null + + const { metadata, input } = readToolStatePayload(state) + const primaryOutput = isToolStateCompleted(state) + ? state.output + : (isToolStateRunning(state) || isToolStateError(state)) && metadata.output + ? metadata.output + : metadata.diff ?? metadata.preview ?? input.content + + const result = formatUnknown(primaryOutput) + if (!result) return null + + const content = ensureMarkdownContent(result.text, result.language, true) + if (!content) return null + + return renderMarkdown({ content, disableHighlight: state.status === "running" }) + }, +} diff --git a/packages/ui/src/components/tool-call/renderers/edit.tsx b/packages/ui/src/components/tool-call/renderers/edit.tsx new file mode 100644 index 00000000..57fea584 --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/edit.tsx @@ -0,0 +1,32 @@ +import type { ToolRenderer } from "../types" +import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" + +export const editRenderer: ToolRenderer = { + tools: ["edit"], + getAction: () => "Preparing edit...", + getTitle({ toolState }) { + const state = toolState() + if (!state) return undefined + const { input } = readToolStatePayload(state) + const filePath = typeof input.filePath === "string" ? input.filePath : "" + if (!filePath) return getToolName("edit") + return `${getToolName("edit")} ${getRelativePath(filePath)}` + }, + renderBody({ toolState, toolName, renderDiff, renderMarkdown }) { + const state = toolState() + if (!state || state.status === "pending") return null + + const diffPayload = extractDiffPayload(toolName(), state) + if (diffPayload) { + return renderDiff(diffPayload) + } + + const { metadata } = readToolStatePayload(state) + const diffText = typeof metadata.diff === "string" ? metadata.diff : null + const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null + const content = ensureMarkdownContent(diffText || fallback, "diff", true) + if (!content) return null + + return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) + }, +} diff --git a/packages/ui/src/components/tool-call/renderers/index.ts b/packages/ui/src/components/tool-call/renderers/index.ts new file mode 100644 index 00000000..3bc838ad --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/index.ts @@ -0,0 +1,36 @@ +import type { ToolRenderer } from "../types" +import { bashRenderer } from "./bash" +import { defaultRenderer } from "./default" +import { editRenderer } from "./edit" +import { patchRenderer } from "./patch" +import { readRenderer } from "./read" +import { taskRenderer } from "./task" +import { todoRenderer } from "./todo" +import { webfetchRenderer } from "./webfetch" +import { writeRenderer } from "./write" +import { invalidRenderer } from "./invalid" + +const TOOL_RENDERERS: ToolRenderer[] = [ + bashRenderer, + readRenderer, + writeRenderer, + editRenderer, + patchRenderer, + webfetchRenderer, + todoRenderer, + taskRenderer, + invalidRenderer, +] + +const rendererMap = TOOL_RENDERERS.reduce>((acc, renderer) => { + renderer.tools.forEach((tool) => { + acc[tool] = renderer + }) + return acc +}, {}) + +export function resolveToolRenderer(toolName: string): ToolRenderer { + return rendererMap[toolName] ?? defaultRenderer +} + +export { defaultRenderer } diff --git a/packages/ui/src/components/tool-call/renderers/invalid.tsx b/packages/ui/src/components/tool-call/renderers/invalid.tsx new file mode 100644 index 00000000..2064b12a --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/invalid.tsx @@ -0,0 +1,19 @@ +import type { ToolRenderer } from "../types" +import { defaultRenderer } from "./default" +import { getToolName, readToolStatePayload } from "../utils" + +export const invalidRenderer: ToolRenderer = { + tools: ["invalid"], + getTitle({ toolState }) { + const state = toolState() + if (!state) return getToolName("invalid") + const { input } = readToolStatePayload(state) + if (typeof input.tool === "string") { + return getToolName(input.tool) + } + return getToolName("invalid") + }, + renderBody(context) { + return defaultRenderer.renderBody(context) + }, +} diff --git a/packages/ui/src/components/tool-call/renderers/patch.tsx b/packages/ui/src/components/tool-call/renderers/patch.tsx new file mode 100644 index 00000000..2de9bc07 --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/patch.tsx @@ -0,0 +1,32 @@ +import type { ToolRenderer } from "../types" +import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" + +export const patchRenderer: ToolRenderer = { + tools: ["patch"], + getAction: () => "Preparing patch...", + getTitle({ toolState }) { + const state = toolState() + if (!state) return undefined + const { input } = readToolStatePayload(state) + const filePath = typeof input.filePath === "string" ? input.filePath : "" + if (!filePath) return getToolName("patch") + return `${getToolName("patch")} ${getRelativePath(filePath)}` + }, + renderBody({ toolState, toolName, renderDiff, renderMarkdown }) { + const state = toolState() + if (!state || state.status === "pending") return null + + const diffPayload = extractDiffPayload(toolName(), state) + if (diffPayload) { + return renderDiff(diffPayload) + } + + const { metadata } = readToolStatePayload(state) + const diffText = typeof metadata.diff === "string" ? metadata.diff : null + const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null + const content = ensureMarkdownContent(diffText || fallback, "diff", true) + if (!content) return null + + return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) + }, +} diff --git a/packages/ui/src/components/tool-call/renderers/read.tsx b/packages/ui/src/components/tool-call/renderers/read.tsx new file mode 100644 index 00000000..1c0913e9 --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/read.tsx @@ -0,0 +1,25 @@ +import type { ToolRenderer } from "../types" +import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils" + +export const readRenderer: ToolRenderer = { + tools: ["read"], + getAction: () => "Reading file...", + getTitle({ toolState }) { + const state = toolState() + if (!state) return undefined + const { input } = readToolStatePayload(state) + const filePath = typeof input.filePath === "string" ? input.filePath : "" + if (!filePath) return getToolName("read") + return `${getToolName("read")} ${getRelativePath(filePath)}` + }, + renderBody({ toolState, renderMarkdown }) { + const state = toolState() + if (!state || state.status === "pending") return null + const { metadata, input } = readToolStatePayload(state) + const preview = typeof metadata.preview === "string" ? metadata.preview : null + const language = inferLanguageFromPath(typeof input.filePath === "string" ? input.filePath : undefined) + const content = ensureMarkdownContent(preview, language, true) + if (!content) return null + return renderMarkdown({ content, disableHighlight: state.status === "running" }) + }, +} diff --git a/packages/ui/src/components/tool-call/renderers/task.tsx b/packages/ui/src/components/tool-call/renderers/task.tsx new file mode 100644 index 00000000..25f92fbf --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/task.tsx @@ -0,0 +1,103 @@ +import { For } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" +import type { ToolRenderer } from "../types" +import { getRelativePath, getToolIcon, getToolName, readToolStatePayload } from "../utils" + +interface TaskSummaryItem { + id: string + tool: string + input: Record +} + +const taskSummaryCache = new Map() + +function normalizeTaskSummary(state?: ToolState, toolCallId?: string): TaskSummaryItem[] { + if (!state) return [] + const { metadata } = readToolStatePayload(state) + const rawSummary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : [] + if (rawSummary.length === 0) { + if (toolCallId) taskSummaryCache.delete(toolCallId) + return [] + } + + const signature = JSON.stringify(rawSummary) + if (toolCallId) { + const cached = taskSummaryCache.get(toolCallId) + if (cached && cached.signature === signature) { + return cached.items + } + } + + const normalized: TaskSummaryItem[] = rawSummary.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 id = typeof entry?.id === "string" && entry.id.length > 0 ? entry.id : `${tool}-${index}` + return { id, tool, input } + }) + + if (toolCallId) { + taskSummaryCache.set(toolCallId, { signature, items: normalized }) + } + + return normalized +} + +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 + } +} + +export const taskRenderer: ToolRenderer = { + tools: ["task"], + getAction: () => "Delegating...", + getTitle({ toolState }) { + 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 + }, + renderBody({ toolState, toolCall }) { + const state = toolState() + if (!state) return null + + const items = normalizeTaskSummary(state, toolCall().id || "__unknown__") + if (items.length === 0) return null + + return ( +
+
+ + {(item) => { + const icon = getToolIcon(item.tool) + const description = describeTaskItem(item) + return ( +
+ {icon} + {description} +
+ ) + }} +
+
+
+ ) + }, +} diff --git a/packages/ui/src/components/tool-call/renderers/todo.tsx b/packages/ui/src/components/tool-call/renderers/todo.tsx new file mode 100644 index 00000000..54b0ceb6 --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/todo.tsx @@ -0,0 +1,121 @@ +import { For } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" +import type { ToolRenderer } from "../types" +import { readToolStatePayload } from "../utils" + +export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled" + +interface TodoViewItem { + id: string + content: string + status: TodoViewStatus +} + +function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus { + if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus + return "pending" +} + +function extractTodosFromState(state?: ToolState): TodoViewItem[] { + if (!state) return [] + const { metadata } = readToolStatePayload(state) + const todos = Array.isArray((metadata as any).todos) ? (metadata as any).todos : [] + const items: TodoViewItem[] = [] + + for (let index = 0; index < todos.length; index++) { + const todo = todos[index] + const content = typeof todo?.content === "string" ? todo.content.trim() : "" + if (!content) continue + const status = normalizeTodoStatus((todo as any).status) + const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : `${index}-${content}` + items.push({ id, content, status }) + } + + return items +} + +function summarizeTodos(todos: TodoViewItem[]) { + return todos.reduce( + (acc, todo) => { + acc.total += 1 + acc[todo.status] = (acc[todo.status] || 0) + 1 + return acc + }, + { total: 0, pending: 0, in_progress: 0, completed: 0, cancelled: 0 } as Record, + ) +} + +function getTodoStatusLabel(status: TodoViewStatus): string { + switch (status) { + case "completed": + return "Completed" + case "in_progress": + return "In progress" + case "cancelled": + return "Cancelled" + default: + return "Pending" + } +} + +function getTodoTitle(state?: ToolState): string { + if (!state) return "Plan" + + const todos = extractTodosFromState(state) + if (state.status !== "completed" || todos.length === 0) return "Plan" + + const counts = summarizeTodos(todos) + if (counts.pending === counts.total) return "Creating plan" + if (counts.completed === counts.total) return "Completing plan" + return "Updating plan" +} + +export const todoRenderer: ToolRenderer = { + tools: ["todowrite", "todoread"], + getAction: () => "Planning...", + getTitle({ toolState }) { + return getTodoTitle(toolState()) + }, + renderBody({ toolState }) { + const state = toolState() + if (!state) return null + + const todos = extractTodosFromState(state) + const counts = summarizeTodos(todos) + + if (counts.total === 0) { + return
No plan items yet.
+ } + + return ( +
+
+ + {(todo) => { + const label = getTodoStatusLabel(todo.status) + return ( +
+ +
+
+ {todo.content} + {label} +
+
+
+ ) + }} +
+
+
+ ) + }, +} diff --git a/packages/ui/src/components/tool-call/renderers/webfetch.tsx b/packages/ui/src/components/tool-call/renderers/webfetch.tsx new file mode 100644 index 00000000..42109d40 --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/webfetch.tsx @@ -0,0 +1,33 @@ +import type { ToolRenderer } from "../types" +import { ensureMarkdownContent, formatUnknown, getToolName, readToolStatePayload } from "../utils" + +export const webfetchRenderer: ToolRenderer = { + tools: ["webfetch"], + getAction: () => "Fetching from the web...", + getTitle({ toolState }) { + const state = toolState() + if (!state) return undefined + const { input } = readToolStatePayload(state) + if (typeof input.url === "string" && input.url.length > 0) { + return `${getToolName("webfetch")} ${input.url}` + } + return getToolName("webfetch") + }, + renderBody({ toolState, renderMarkdown }) { + const state = toolState() + if (!state || state.status === "pending") return null + + const { metadata } = readToolStatePayload(state) + const result = formatUnknown( + state.status === "completed" + ? state.output + : metadata.output, + ) + if (!result) return null + + const content = ensureMarkdownContent(result.text, result.language, true) + if (!content) return null + + return renderMarkdown({ content, disableHighlight: state.status === "running" }) + }, +} diff --git a/packages/ui/src/components/tool-call/renderers/write.tsx b/packages/ui/src/components/tool-call/renderers/write.tsx new file mode 100644 index 00000000..d0fcfc8e --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/write.tsx @@ -0,0 +1,25 @@ +import type { ToolRenderer } from "../types" +import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils" + +export const writeRenderer: ToolRenderer = { + tools: ["write"], + getAction: () => "Preparing write...", + getTitle({ toolState }) { + const state = toolState() + if (!state) return undefined + const { input } = readToolStatePayload(state) + const filePath = typeof input.filePath === "string" ? input.filePath : "" + if (!filePath) return getToolName("write") + return `${getToolName("write")} ${getRelativePath(filePath)}` + }, + renderBody({ toolState, renderMarkdown }) { + const state = toolState() + if (!state || state.status === "pending") return null + const { metadata, input } = readToolStatePayload(state) + const contentValue = typeof input.content === "string" ? input.content : metadata.content + const filePath = typeof input.filePath === "string" ? input.filePath : undefined + const content = ensureMarkdownContent(contentValue ?? null, inferLanguageFromPath(filePath), true) + if (!content) return null + return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) + }, +} diff --git a/packages/ui/src/components/tool-call/types.ts b/packages/ui/src/components/tool-call/types.ts new file mode 100644 index 00000000..45de9bd0 --- /dev/null +++ b/packages/ui/src/components/tool-call/types.ts @@ -0,0 +1,39 @@ +import type { Accessor, JSXElement } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" +import type { ClientPart } from "../../types/message" + +export type ToolCallPart = Extract + +export interface DiffPayload { + diffText: string + filePath?: string +} + +export interface MarkdownRenderOptions { + content: string + size?: "default" | "large" + disableHighlight?: boolean +} + +export interface DiffRenderOptions { + variant?: string + disableScrollTracking?: boolean + label?: string +} + +export interface ToolRendererContext { + toolCall: Accessor + toolState: Accessor + toolName: Accessor + renderMarkdown(options: MarkdownRenderOptions): JSXElement | null + renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null +} + +export interface ToolRenderer { + tools: string[] + getTitle?(context: ToolRendererContext): string | undefined + getAction?(context: ToolRendererContext): string | undefined + renderBody(context: ToolRendererContext): JSXElement | null +} + +export type ToolRendererMap = Record diff --git a/packages/ui/src/components/tool-call/utils.ts b/packages/ui/src/components/tool-call/utils.ts new file mode 100644 index 00000000..f25d5271 --- /dev/null +++ b/packages/ui/src/components/tool-call/utils.ts @@ -0,0 +1,191 @@ +import { isRenderableDiffText } from "../../lib/diff-utils" +import { getLanguageFromPath } from "../../lib/markdown" +import type { ToolState } from "@opencode-ai/sdk" +import type { DiffPayload } from "./types" + +export type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning +export type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted +export type ToolStateError = import("@opencode-ai/sdk").ToolStateError + +export const diffCapableTools = new Set(["edit", "patch"]) + +export function isToolStateRunning(state: ToolState): state is ToolStateRunning { + return state.status === "running" +} + +export function isToolStateCompleted(state: ToolState): state is ToolStateCompleted { + return state.status === "completed" +} + +export function isToolStateError(state: ToolState): state is ToolStateError { + return state.status === "error" +} + +export function getToolIcon(tool: string): string { + switch (tool) { + case "bash": + return "โšก" + case "edit": + return "โœ๏ธ" + case "read": + return "๐Ÿ“–" + case "write": + return "๐Ÿ“" + case "glob": + return "๐Ÿ”" + case "grep": + return "๐Ÿ”Ž" + case "webfetch": + return "๐ŸŒ" + case "task": + return "๐ŸŽฏ" + case "todowrite": + case "todoread": + return "๐Ÿ“‹" + case "list": + return "๐Ÿ“" + case "patch": + return "๐Ÿ”ง" + default: + return "๐Ÿ”ง" + } +} + +export function getToolName(tool: string): string { + switch (tool) { + case "bash": + return "Shell" + case "webfetch": + return "Fetch" + case "invalid": + return "Invalid" + case "todowrite": + case "todoread": + return "Plan" + default: { + const normalized = tool.replace(/^opencode_/, "") + return normalized.charAt(0).toUpperCase() + normalized.slice(1) + } + } +} + +export function getRelativePath(path: string): string { + if (!path) return "" + const parts = path.split("/") + return parts.slice(-1)[0] || path +} + +export function ensureMarkdownContent( + value: string | null, + language?: string, + forceFence = false, +): string | null { + if (!value) { + return null + } + + const trimmed = value.replace(/\s+$/, "") + if (!trimmed) { + return null + } + + const startsWithFence = trimmed.trimStart().startsWith("```") + if (startsWithFence && !forceFence) { + return trimmed + } + + const langSuffix = language ? language : "" + if (language || forceFence) { + return `\u0060\u0060\u0060${langSuffix}\n${trimmed}\n\u0060\u0060\u0060` + } + + return trimmed +} + +export function formatUnknown(value: unknown): { text: string; language?: string } | null { + if (value === null || value === undefined) { + return null + } + + if (typeof value === "string") { + return { text: value } + } + + if (typeof value === "number" || typeof value === "boolean") { + return { text: String(value) } + } + + if (Array.isArray(value)) { + const parts = value + .map((item) => { + const formatted = formatUnknown(item) + return formatted?.text ?? "" + }) + .filter(Boolean) + + if (parts.length === 0) { + return null + } + + return { text: parts.join("\n") } + } + + if (typeof value === "object") { + try { + return { text: JSON.stringify(value, null, 2), language: "json" } + } catch (error) { + console.error("Failed to stringify tool call output", error) + return { text: String(value) } + } + } + + return null +} + +export function inferLanguageFromPath(path?: string): string | undefined { + return getLanguageFromPath(path || "") +} + +export function extractDiffPayload(toolName: string, state?: ToolState): DiffPayload | null { + if (!state) return null + if (!diffCapableTools.has(toolName)) return null + + const { metadata, input, output } = readToolStatePayload(state) + const candidates = [metadata.diff, output, metadata.output] + let diffText: string | null = null + + for (const candidate of candidates) { + if (typeof candidate === "string" && isRenderableDiffText(candidate)) { + diffText = candidate + break + } + } + + if (!diffText) { + return null + } + + const filePath = + (typeof input.filePath === "string" ? input.filePath : undefined) || + (typeof metadata.filePath === "string" ? metadata.filePath : undefined) || + (typeof input.path === "string" ? input.path : undefined) + + return { diffText, filePath } +} + +export function readToolStatePayload(state?: ToolState): { + input: Record + metadata: Record + output: unknown +} { + if (!state) { + return { input: {}, metadata: {}, output: undefined } + } + + const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state) + return { + input: supportsMetadata ? ((state.input || {}) as Record) : {}, + metadata: supportsMetadata ? ((state.metadata || {}) as Record) : {}, + output: isToolStateCompleted(state) ? state.output : undefined, + } +} diff --git a/packages/ui/src/styles/messaging/tool-call.css b/packages/ui/src/styles/messaging/tool-call.css index 63e5c63a..a88b1f0f 100644 --- a/packages/ui/src/styles/messaging/tool-call.css +++ b/packages/ui/src/styles/messaging/tool-call.css @@ -1,4 +1,7 @@ /* Tool call rendering */ +@import "./tool-call/todo.css"; +@import "./tool-call/task.css"; + .tool-call-message { @apply flex flex-col gap-2 p-3 w-full; background-color: var(--message-tool-bg); @@ -671,164 +674,6 @@ font-size: inherit; } -.tool-call-todo-region { - @apply flex flex-col; -} - -.tool-call-todo-empty { - @apply text-sm text-muted; - padding: 0.75rem 0; -} - -.tool-call-todos { - @apply flex flex-col gap-0; - list-style: none; - padding: 0; - margin: 0; -} - -.tool-call-todo-item { - @apply flex items-start gap-3; - border: 1px solid var(--border-base); - border-radius: 0; - padding: 10px 12px; - background-color: var(--surface-secondary); -} - -.tool-call-todo-item-completed { - background-color: var(--surface-code); -} - -.tool-call-todo-item-active { - border-color: var(--accent-primary); - background-color: var(--surface-hover); -} - -.tool-call-todo-item-cancelled { - opacity: 0.75; -} - -.tool-call-todo-checkbox { - width: 1.1rem; - height: 1.1rem; - border-radius: 9999px; - border: 2px solid var(--border-base); - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 0.75rem; - font-weight: var(--font-weight-semibold); - color: var(--text-muted); - background-color: transparent; -} - -.tool-call-todo-checkbox::after { - content: ""; - line-height: 1; -} - -.tool-call-todo-checkbox[data-status="completed"] { - background-color: var(--accent-primary); - border-color: var(--accent-primary); - color: var(--text-inverted); -} - -.tool-call-todo-checkbox[data-status="completed"]::after { - content: "โœ“"; -} - -.tool-call-todo-checkbox[data-status="in_progress"]::after { - content: "โ€ฆ"; - color: var(--accent-primary); -} - -.tool-call-todo-checkbox[data-status="cancelled"]::after { - content: "ร—"; - color: var(--status-error); -} - -.tool-call-todo-body { - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; -} - -.tool-call-todo-heading { - @apply flex items-start justify-between gap-3; -} - -.tool-call-todo-status { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.08em; - border-radius: 9999px; - padding: 2px 8px; - background-color: var(--surface-hover); - color: var(--text-muted); - white-space: nowrap; -} - -.tool-call-todo-status-completed { - background-color: var(--badge-success-bg); - color: var(--status-success); -} - -.tool-call-todo-status-in_progress { - background-color: var(--badge-neutral-bg); - color: var(--text-primary); -} - -.tool-call-todo-status-cancelled { - background-color: var(--status-error-bg); - color: var(--status-error); -} - -.tool-call-todo-text { - font-size: var(--font-size-sm); - line-height: var(--line-height-tight); - color: var(--text-primary); -} - -.tool-call-todo-item-cancelled .tool-call-todo-text { - text-decoration: line-through; - color: var(--text-muted); -} - -.tool-call-todo-tag { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.08em; - border-radius: 9999px; - padding: 2px 8px; - background-color: var(--surface-hover); - color: var(--text-muted); -} - -.tool-call-todo-item-active .tool-call-todo-tag { - background-color: var(--accent-primary); - color: var(--text-inverted); -} - -.tool-call-task-container { - padding: 12px; -} - -.tool-call-task-summary { - @apply my-2 flex flex-col gap-1.5; -} - -.tool-call-task-item { - font-size: var(--font-size-xs); - line-height: var(--line-height-normal); - padding-left: 8px; - border-left: 2px solid var(--border-base); -} - -.tool-call-task-item::before { - content: "โˆŸ "; - color: var(--text-muted); -} .tool-call-error-content { background-color: var(--message-error-bg); diff --git a/packages/ui/src/styles/messaging/tool-call/task.css b/packages/ui/src/styles/messaging/tool-call/task.css new file mode 100644 index 00000000..b2d95c9b --- /dev/null +++ b/packages/ui/src/styles/messaging/tool-call/task.css @@ -0,0 +1,19 @@ +.tool-call-task-container { + padding: 12px; +} + +.tool-call-task-summary { + @apply my-2 flex flex-col gap-1.5; +} + +.tool-call-task-item { + font-size: var(--font-size-xs); + line-height: var(--line-height-normal); + padding-left: 8px; + border-left: 2px solid var(--border-base); +} + +.tool-call-task-item::before { + content: "โˆŸ "; + color: var(--text-muted); +} diff --git a/packages/ui/src/styles/messaging/tool-call/todo.css b/packages/ui/src/styles/messaging/tool-call/todo.css new file mode 100644 index 00000000..03667d8b --- /dev/null +++ b/packages/ui/src/styles/messaging/tool-call/todo.css @@ -0,0 +1,138 @@ +.tool-call-todo-region { + @apply flex flex-col; +} + +.tool-call-todo-empty { + @apply text-sm text-muted; + padding: 0.75rem 0; +} + +.tool-call-todos { + @apply flex flex-col gap-0; + list-style: none; + padding: 0; + margin: 0; +} + +.tool-call-todo-item { + @apply flex items-start gap-3; + border: 1px solid var(--border-base); + border-radius: 0; + padding: 10px 12px; + background-color: var(--surface-secondary); +} + +.tool-call-todo-item-completed { + background-color: var(--surface-code); +} + +.tool-call-todo-item-active { + border-color: var(--accent-primary); + background-color: var(--surface-hover); +} + +.tool-call-todo-item-cancelled { + opacity: 0.75; +} + +.tool-call-todo-checkbox { + width: 1.1rem; + height: 1.1rem; + border-radius: 9999px; + border: 2px solid var(--border-base); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: var(--font-weight-semibold); + color: var(--text-muted); + background-color: transparent; +} + +.tool-call-todo-checkbox::after { + content: ""; + line-height: 1; +} + +.tool-call-todo-checkbox[data-status="completed"] { + background-color: var(--accent-primary); + border-color: var(--accent-primary); + color: var(--text-inverted); +} + +.tool-call-todo-checkbox[data-status="completed"]::after { + content: "โœ“"; +} + +.tool-call-todo-checkbox[data-status="in_progress"]::after { + content: "โ€ฆ"; + color: var(--accent-primary); +} + +.tool-call-todo-checkbox[data-status="cancelled"]::after { + content: "ร—"; + color: var(--status-error); +} + +.tool-call-todo-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +} + +.tool-call-todo-heading { + @apply flex items-start justify-between gap-3; +} + +.tool-call-todo-status { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + border-radius: 9999px; + padding: 2px 8px; + background-color: var(--surface-hover); + color: var(--text-muted); + white-space: nowrap; +} + +.tool-call-todo-status-completed { + background-color: var(--badge-success-bg); + color: var(--status-success); +} + +.tool-call-todo-status-in_progress { + background-color: var(--badge-neutral-bg); + color: var(--text-primary); +} + +.tool-call-todo-status-cancelled { + background-color: var(--status-error-bg); + color: var(--status-error); +} + +.tool-call-todo-text { + font-size: var(--font-size-sm); + line-height: var(--line-height-tight); + color: var(--text-primary); +} + +.tool-call-todo-item-cancelled .tool-call-todo-text { + text-decoration: line-through; + color: var(--text-muted); +} + +.tool-call-todo-tag { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + border-radius: 9999px; + padding: 2px 8px; + background-color: var(--surface-hover); + color: var(--text-muted); +} + +.tool-call-todo-item-active .tool-call-todo-tag { + background-color: var(--accent-primary); + color: var(--text-inverted); +}