modularize tool-call rendering and styles
This commit is contained in:
@@ -3,38 +3,18 @@ import { messageStoreBus } from "../stores/message-v2/bus"
|
|||||||
import { Markdown } from "./markdown"
|
import { Markdown } from "./markdown"
|
||||||
import { ToolCallDiffViewer } from "./diff-viewer"
|
import { ToolCallDiffViewer } from "./diff-viewer"
|
||||||
import { useTheme } from "../lib/theme"
|
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 { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import type { DiffViewMode } from "../stores/preferences"
|
import type { DiffViewMode } from "../stores/preferences"
|
||||||
import { sendPermissionResponse } from "../stores/instances"
|
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<ClientPart, { type: "tool" }>
|
|
||||||
|
|
||||||
// Import ToolState types from SDK
|
|
||||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
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 TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||||
const taskSummaryCache = new Map<string, { signature: string; items: TaskSummaryItem[] }>()
|
|
||||||
|
|
||||||
function makeRenderCacheKey(
|
function makeRenderCacheKey(
|
||||||
toolCallId?: string | null,
|
toolCallId?: string | null,
|
||||||
@@ -49,7 +29,7 @@ function makeRenderCacheKey(
|
|||||||
|
|
||||||
|
|
||||||
interface ToolCallProps {
|
interface ToolCallProps {
|
||||||
toolCall: Extract<ClientPart, { type: "tool" }>
|
toolCall: ToolCallPart
|
||||||
toolCallId?: string
|
toolCallId?: string
|
||||||
messageId?: string
|
messageId?: string
|
||||||
messageVersion?: number
|
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 {
|
interface LspRangePosition {
|
||||||
line?: number
|
line?: number
|
||||||
@@ -143,44 +69,6 @@ interface DiagnosticEntry {
|
|||||||
column: number
|
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<string, unknown>
|
|
||||||
: {}
|
|
||||||
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) {
|
function normalizeDiagnosticPath(path: string) {
|
||||||
return path.replace(/\\/g, "/")
|
return path.replace(/\\/g, "/")
|
||||||
@@ -198,7 +86,7 @@ function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
|||||||
return { label: "INFO", icon: "i", rank: 2 }
|
return { label: "INFO", icon: "i", rank: 2 }
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractDiagnostics(toolName: string, state: ToolState | undefined): DiagnosticEntry[] {
|
function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
|
||||||
if (!state) return []
|
if (!state) return []
|
||||||
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
|
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
|
||||||
if (!supportsMetadata) return []
|
if (!supportsMetadata) return []
|
||||||
@@ -270,7 +158,6 @@ function renderDiagnosticsSection(
|
|||||||
entries: DiagnosticEntry[],
|
entries: DiagnosticEntry[],
|
||||||
expanded: boolean,
|
expanded: boolean,
|
||||||
toggle: () => void,
|
toggle: () => void,
|
||||||
toolIcon: string,
|
|
||||||
fileLabel: string,
|
fileLabel: string,
|
||||||
) {
|
) {
|
||||||
if (entries.length === 0) return null
|
if (entries.length === 0) return null
|
||||||
@@ -320,6 +207,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
const { preferences, setDiffViewMode } = useConfig()
|
const { preferences, setDiffViewMode } = useConfig()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const toolCallMemo = createMemo(() => props.toolCall)
|
const toolCallMemo = createMemo(() => props.toolCall)
|
||||||
|
const toolName = createMemo(() => toolCallMemo()?.tool || "")
|
||||||
const toolCallId = () => props.toolCallId || toolCallMemo()?.id || ""
|
const toolCallId = () => props.toolCallId || toolCallMemo()?.id || ""
|
||||||
const toolState = createMemo(() => toolCallMemo()?.state)
|
const toolState = createMemo(() => toolCallMemo()?.state)
|
||||||
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
@@ -393,10 +281,9 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const diagnosticsEntries = createMemo(() => {
|
const diagnosticsEntries = createMemo(() => {
|
||||||
const tool = toolCallMemo()?.tool || ""
|
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return []
|
if (!state) return []
|
||||||
return extractDiagnostics(tool, state)
|
return extractDiagnostics(state)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -494,224 +381,9 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderToolAction = () => {
|
const renderer = createMemo(() => resolveToolRenderer(toolName()))
|
||||||
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..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePermissionResponse(response: "once" | "always" | "reject") {
|
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions) {
|
||||||
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<TodoViewStatus | "total", number>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, unknown>)
|
|
||||||
: {} as Record<string, unknown>
|
|
||||||
|
|
||||||
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 }) {
|
|
||||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
||||||
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
||||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "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 diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
|
||||||
const themeKey = isDark() ? "dark" : "light"
|
const themeKey = isDark() ? "dark" : "light"
|
||||||
|
|
||||||
// Check if we have valid cache
|
|
||||||
let cachedHtml: string | undefined
|
let cachedHtml: string | undefined
|
||||||
const cached = cacheHandle.get<RenderCache>()
|
const cached = cacheHandle.get<RenderCache>()
|
||||||
const currentMode = diffMode()
|
const currentMode = diffMode()
|
||||||
@@ -738,7 +409,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
props.onContentRendered?.()
|
props.onContentRendered?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||||
@@ -748,7 +418,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}}
|
}}
|
||||||
onScroll={options?.disableScrollTracking ? undefined : (event) => persistScrollSnapshot(event.currentTarget)}
|
onScroll={options?.disableScrollTracking ? undefined : (event) => persistScrollSnapshot(event.currentTarget)}
|
||||||
>
|
>
|
||||||
|
|
||||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||||
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||||
<div class="tool-call-diff-toggle">
|
<div class="tool-call-diff-toggle">
|
||||||
@@ -783,17 +452,16 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkdownTool(toolName: string, state: ToolState) {
|
function renderMarkdownContent(options: MarkdownRenderOptions) {
|
||||||
const content = getMarkdownContent(toolName, state)
|
if (!options.content) {
|
||||||
if (!content) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLarge = toolName === "edit" || toolName === "write" || toolName === "patch"
|
const size = options.size || "default"
|
||||||
const messageClass = `message-text tool-call-markdown${isLarge ? " tool-call-markdown-large" : ""}`
|
const disableHighlight = options.disableHighlight || false
|
||||||
const disableHighlight = state?.status === "running"
|
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<RenderCache>()
|
const cached = markdownCache.get<RenderCache>()
|
||||||
if (cached) {
|
if (cached) {
|
||||||
markdownPart.renderCache = cached
|
markdownPart.renderCache = cached
|
||||||
@@ -805,7 +473,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
props.onContentRendered?.()
|
props.onContentRendered?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={messageClass}
|
class={messageClass}
|
||||||
@@ -822,257 +489,55 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMarkdownContent(toolName: string, state: ToolState): string | null {
|
const rendererContext: ToolRendererContext = {
|
||||||
if (!state) return null
|
toolCall: toolCallMemo,
|
||||||
|
toolState,
|
||||||
const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
|
toolName,
|
||||||
? state.input as Record<string, unknown>
|
renderMarkdown: renderMarkdownContent,
|
||||||
: {}
|
renderDiff: renderDiffContent,
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureMarkdownContent(
|
const getRendererAction = () => renderer().getAction?.(rendererContext) ?? getDefaultToolAction(toolName())
|
||||||
value: string | null,
|
|
||||||
language?: string,
|
|
||||||
forceFence = false,
|
|
||||||
): string | null {
|
|
||||||
if (!value) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = value.replace(/\s+$/, "")
|
const renderToolTitle = () => {
|
||||||
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 state = toolState()
|
const state = toolState()
|
||||||
if (!state) return null
|
if (!state) return getRendererAction()
|
||||||
|
if (state.status === "pending") return getRendererAction()
|
||||||
|
|
||||||
const todos = extractTodosFromState(state)
|
if (isToolStateRunning(state) && state.title) {
|
||||||
const counts = summarizeTodos(todos)
|
return state.title
|
||||||
|
|
||||||
if (counts.total === 0) {
|
|
||||||
return <div class="tool-call-todo-empty">No plan items yet.</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (isToolStateCompleted(state) && state.title) {
|
||||||
<div class="tool-call-todo-region">
|
return state.title
|
||||||
<div class="tool-call-todos" role="list">
|
}
|
||||||
<For each={todos}>
|
|
||||||
{(todo) => {
|
|
||||||
const label = getTodoStatusLabel(todo.status)
|
|
||||||
|
|
||||||
return (
|
const customTitle = renderer().getTitle?.(rendererContext)
|
||||||
<div
|
if (customTitle) return customTitle
|
||||||
class="tool-call-todo-item"
|
|
||||||
classList={{
|
return getToolName(toolName())
|
||||||
"tool-call-todo-item-completed": todo.status === "completed",
|
|
||||||
"tool-call-todo-item-cancelled": todo.status === "cancelled",
|
|
||||||
"tool-call-todo-item-active": todo.status === "in_progress",
|
|
||||||
}}
|
|
||||||
role="listitem"
|
|
||||||
>
|
|
||||||
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
|
|
||||||
<div class="tool-call-todo-body">
|
|
||||||
<div class="tool-call-todo-heading">
|
|
||||||
<span class="tool-call-todo-text">{todo.content}</span>
|
|
||||||
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskSummaryItem = {
|
const renderToolBody = () => {
|
||||||
id: string
|
return renderer().renderBody(rendererContext)
|
||||||
tool: string
|
|
||||||
input: Record<string, any>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskSummary = createMemo(() => {
|
async function handlePermissionResponse(response: "once" | "always" | "reject") {
|
||||||
const state = toolState()
|
const permission = permissionDetails()
|
||||||
if (!state) return []
|
if (!permission || !isPermissionActive()) {
|
||||||
const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
|
return
|
||||||
? (state.metadata || {}) as Record<string, unknown>
|
|
||||||
: ({} as Record<string, unknown>)
|
|
||||||
const rawSummary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : []
|
|
||||||
if (rawSummary.length === 0) {
|
|
||||||
taskSummaryCache.delete(toolCallId())
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
const signature = JSON.stringify(rawSummary)
|
setPermissionSubmitting(true)
|
||||||
const cacheKey = toolCallId() || "__unknown__"
|
setPermissionError(null)
|
||||||
const cached = taskSummaryCache.get(cacheKey)
|
try {
|
||||||
if (cached && cached.signature === signature) {
|
const sessionId = permission.sessionID || props.sessionId
|
||||||
return cached.items
|
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 (
|
|
||||||
<div class="message-text tool-call-markdown tool-call-task-container">
|
|
||||||
<div class="tool-call-task-summary">
|
|
||||||
<For each={items}>
|
|
||||||
{(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 (
|
|
||||||
<div class="tool-call-task-item" data-task-id={item.id}>
|
|
||||||
<span class="tool-call-task-icon">{icon}</span>
|
|
||||||
<span class="tool-call-task-text">{description}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1088,6 +553,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const renderPermissionBlock = () => {
|
const renderPermissionBlock = () => {
|
||||||
const permission = permissionDetails()
|
const permission = permissionDetails()
|
||||||
if (!permission) return null
|
if (!permission) return null
|
||||||
@@ -1118,7 +584,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
<Show when={diffPayload}>
|
<Show when={diffPayload}>
|
||||||
{(payload) => (
|
{(payload) => (
|
||||||
<div class="tool-call-permission-diff">
|
<div class="tool-call-permission-diff">
|
||||||
{renderDiffTool(payload(), {
|
{renderDiffContent(payload(), {
|
||||||
variant: "permission-diff",
|
variant: "permission-diff",
|
||||||
disableScrollTracking: true,
|
disableScrollTracking: true,
|
||||||
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
|
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 || ""
|
const status = () => toolState()?.status || ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1222,10 +687,39 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
const current = prev === undefined ? diagnosticsDefaultExpanded() : prev
|
const current = prev === undefined ? diagnosticsDefaultExpanded() : prev
|
||||||
return !current
|
return !current
|
||||||
}),
|
}),
|
||||||
getToolIcon(toolName()),
|
|
||||||
diagnosticFileName(diagnosticsEntries()),
|
diagnosticFileName(diagnosticsEntries()),
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
38
packages/ui/src/components/tool-call/renderers/bash.tsx
Normal file
38
packages/ui/src/components/tool-call/renderers/bash.tsx
Normal file
@@ -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" })
|
||||||
|
},
|
||||||
|
}
|
||||||
25
packages/ui/src/components/tool-call/renderers/default.tsx
Normal file
25
packages/ui/src/components/tool-call/renderers/default.tsx
Normal file
@@ -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" })
|
||||||
|
},
|
||||||
|
}
|
||||||
32
packages/ui/src/components/tool-call/renderers/edit.tsx
Normal file
32
packages/ui/src/components/tool-call/renderers/edit.tsx
Normal file
@@ -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" })
|
||||||
|
},
|
||||||
|
}
|
||||||
36
packages/ui/src/components/tool-call/renderers/index.ts
Normal file
36
packages/ui/src/components/tool-call/renderers/index.ts
Normal file
@@ -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<Record<string, ToolRenderer>>((acc, renderer) => {
|
||||||
|
renderer.tools.forEach((tool) => {
|
||||||
|
acc[tool] = renderer
|
||||||
|
})
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
export function resolveToolRenderer(toolName: string): ToolRenderer {
|
||||||
|
return rendererMap[toolName] ?? defaultRenderer
|
||||||
|
}
|
||||||
|
|
||||||
|
export { defaultRenderer }
|
||||||
19
packages/ui/src/components/tool-call/renderers/invalid.tsx
Normal file
19
packages/ui/src/components/tool-call/renderers/invalid.tsx
Normal file
@@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
32
packages/ui/src/components/tool-call/renderers/patch.tsx
Normal file
32
packages/ui/src/components/tool-call/renderers/patch.tsx
Normal file
@@ -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" })
|
||||||
|
},
|
||||||
|
}
|
||||||
25
packages/ui/src/components/tool-call/renderers/read.tsx
Normal file
25
packages/ui/src/components/tool-call/renderers/read.tsx
Normal file
@@ -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" })
|
||||||
|
},
|
||||||
|
}
|
||||||
103
packages/ui/src/components/tool-call/renderers/task.tsx
Normal file
103
packages/ui/src/components/tool-call/renderers/task.tsx
Normal file
@@ -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<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskSummaryCache = new Map<string, { signature: string; items: TaskSummaryItem[] }>()
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div class="message-text tool-call-markdown tool-call-task-container">
|
||||||
|
<div class="tool-call-task-summary">
|
||||||
|
<For each={items}>
|
||||||
|
{(item) => {
|
||||||
|
const icon = getToolIcon(item.tool)
|
||||||
|
const description = describeTaskItem(item)
|
||||||
|
return (
|
||||||
|
<div class="tool-call-task-item" data-task-id={item.id}>
|
||||||
|
<span class="tool-call-task-icon">{icon}</span>
|
||||||
|
<span class="tool-call-task-text">{description}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
121
packages/ui/src/components/tool-call/renderers/todo.tsx
Normal file
121
packages/ui/src/components/tool-call/renderers/todo.tsx
Normal file
@@ -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<TodoViewStatus | "total", number>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div class="tool-call-todo-empty">No plan items yet.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="tool-call-todo-region">
|
||||||
|
<div class="tool-call-todos" role="list">
|
||||||
|
<For each={todos}>
|
||||||
|
{(todo) => {
|
||||||
|
const label = getTodoStatusLabel(todo.status)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="tool-call-todo-item"
|
||||||
|
classList={{
|
||||||
|
"tool-call-todo-item-completed": todo.status === "completed",
|
||||||
|
"tool-call-todo-item-cancelled": todo.status === "cancelled",
|
||||||
|
"tool-call-todo-item-active": todo.status === "in_progress",
|
||||||
|
}}
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
|
||||||
|
<div class="tool-call-todo-body">
|
||||||
|
<div class="tool-call-todo-heading">
|
||||||
|
<span class="tool-call-todo-text">{todo.content}</span>
|
||||||
|
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
33
packages/ui/src/components/tool-call/renderers/webfetch.tsx
Normal file
33
packages/ui/src/components/tool-call/renderers/webfetch.tsx
Normal file
@@ -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" })
|
||||||
|
},
|
||||||
|
}
|
||||||
25
packages/ui/src/components/tool-call/renderers/write.tsx
Normal file
25
packages/ui/src/components/tool-call/renderers/write.tsx
Normal file
@@ -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" })
|
||||||
|
},
|
||||||
|
}
|
||||||
39
packages/ui/src/components/tool-call/types.ts
Normal file
39
packages/ui/src/components/tool-call/types.ts
Normal file
@@ -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<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
|
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<ToolCallPart>
|
||||||
|
toolState: Accessor<ToolState | undefined>
|
||||||
|
toolName: Accessor<string>
|
||||||
|
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<string, ToolRenderer>
|
||||||
191
packages/ui/src/components/tool-call/utils.ts
Normal file
191
packages/ui/src/components/tool-call/utils.ts
Normal file
@@ -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<string, any>
|
||||||
|
metadata: Record<string, any>
|
||||||
|
output: unknown
|
||||||
|
} {
|
||||||
|
if (!state) {
|
||||||
|
return { input: {}, metadata: {}, output: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
|
||||||
|
return {
|
||||||
|
input: supportsMetadata ? ((state.input || {}) as Record<string, any>) : {},
|
||||||
|
metadata: supportsMetadata ? ((state.metadata || {}) as Record<string, any>) : {},
|
||||||
|
output: isToolStateCompleted(state) ? state.output : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
/* Tool call rendering */
|
/* Tool call rendering */
|
||||||
|
@import "./tool-call/todo.css";
|
||||||
|
@import "./tool-call/task.css";
|
||||||
|
|
||||||
.tool-call-message {
|
.tool-call-message {
|
||||||
@apply flex flex-col gap-2 p-3 w-full;
|
@apply flex flex-col gap-2 p-3 w-full;
|
||||||
background-color: var(--message-tool-bg);
|
background-color: var(--message-tool-bg);
|
||||||
@@ -671,164 +674,6 @@
|
|||||||
font-size: inherit;
|
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 {
|
.tool-call-error-content {
|
||||||
background-color: var(--message-error-bg);
|
background-color: var(--message-error-bg);
|
||||||
|
|||||||
19
packages/ui/src/styles/messaging/tool-call/task.css
Normal file
19
packages/ui/src/styles/messaging/tool-call/task.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
138
packages/ui/src/styles/messaging/tool-call/todo.css
Normal file
138
packages/ui/src/styles/messaging/tool-call/todo.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user