diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index b122f3b8..ea7753e1 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -1,17 +1,20 @@ -import { createSignal, Show, For, createEffect, createMemo, onCleanup, type Accessor } from "solid-js" +import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js" import { messageStoreBus } from "../stores/message-v2/bus" -import { Markdown } from "./markdown" -import { ToolCallDiffViewer } from "./diff-viewer" import { useTheme } from "../lib/theme" import { useGlobalCache } from "../lib/hooks/use-global-cache" import { useConfig } from "../stores/preferences" -import type { DiffViewMode } from "../stores/preferences" import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances" import type { PermissionRequestLike } from "../types/permission" -import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission" +import { getPermissionSessionId } from "../types/permission" import type { QuestionRequest } from "@opencode-ai/sdk/v2" -import type { TextPart, RenderCache } from "../types/message" import { resolveToolRenderer } from "./tool-call/renderers" +import { QuestionToolBlock } from "./tool-call/question-block" +import { PermissionToolBlock } from "./tool-call/permission-block" +import { createAnsiContentRenderer } from "./tool-call/ansi-render" +import { createDiffContentRenderer } from "./tool-call/diff-render" +import { createMarkdownContentRenderer } from "./tool-call/markdown-render" +import { extractDiagnostics, diagnosticFileName } from "./tool-call/diagnostics" +import { renderDiagnosticsSection } from "./tool-call/diagnostics-section" import type { DiffPayload, DiffRenderOptions, @@ -24,38 +27,11 @@ import type { import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils" import { resolveTitleForTool } from "./tool-call/tool-title" import { getLogger } from "../lib/logger" -import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../lib/ansi" -import { escapeHtml } from "../lib/markdown" const log = getLogger("session") type ToolState = import("@opencode-ai/sdk").ToolState -type AnsiRenderCache = RenderCache & { hasAnsi: boolean } - -type QuestionOption = { label: string; description: string } - -type QuestionPrompt = { - header: string - question: string - options: QuestionOption[] - multiple?: boolean -} - -type QuestionToolBlockProps = { - toolName: Accessor - toolState: Accessor - toolCallId: Accessor - request: Accessor - active: Accessor - submitting: Accessor - error: Accessor - draftAnswers: Accessor> - setDraftAnswers: (updater: (prev: Record) => Record) => void - onSubmit: () => void | Promise - onDismiss: () => void | Promise -} - const TOOL_CALL_CACHE_SCOPE = "tool-call" const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48 const TOOL_SCROLL_INTENT_WINDOW_MS = 600 @@ -86,447 +62,7 @@ interface ToolCallProps { -interface LspRangePosition { - line?: number - character?: number -} -interface LspRange { - start?: LspRangePosition -} - -interface LspDiagnostic { - message?: string - severity?: number - range?: LspRange -} - -interface DiagnosticEntry { - id: string - severity: number - tone: "error" | "warning" | "info" - label: string - icon: string - message: string - filePath: string - displayPath: string - line: number - column: number -} - - -function normalizeDiagnosticPath(path: string) { - return path.replace(/\\/g, "/") -} - -function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] { - if (severity === 1) return "error" - if (severity === 2) return "warning" - return "info" -} - -function getSeverityMeta(tone: DiagnosticEntry["tone"]) { - if (tone === "error") return { label: "ERR", icon: "!", rank: 0 } - if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 } - return { label: "INFO", icon: "i", rank: 2 } -} - -function QuestionToolBlock(props: QuestionToolBlockProps) { - const requestId = createMemo(() => { - const state = props.toolState() - const request = props.request() - return request?.id ?? (state as any)?.input?.requestID ?? `question-${props.toolCallId()}` - }) - - const questions = createMemo(() => { - const state = props.toolState() - const request = props.request() - const isQuestionTool = props.toolName() === "question" - if (!request && !isQuestionTool) return [] as QuestionPrompt[] - - const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? [] - const list = Array.isArray(questionsSource) ? questionsSource : [] - return list as QuestionPrompt[] - }) - - const isVisible = createMemo(() => { - const request = props.request() - const isQuestionTool = props.toolName() === "question" - return Boolean(request) || isQuestionTool - }) - - const answers = createMemo(() => { - const state = props.toolState() - - const completedAnswers = - (state as any)?.status === "completed" && Array.isArray((state as any)?.metadata?.answers) - ? ((state as any).metadata.answers as string[][]) - : undefined - - if (completedAnswers) return completedAnswers - - const request = props.request() - const requestAnswers = request?.questions?.map((q) => (q as any)?.answer) // defensive (if server ever inlines) - - if (Array.isArray(requestAnswers) && requestAnswers.some((row) => Array.isArray(row) && row.length > 0)) { - return requestAnswers as string[][] - } - - const draft = props.draftAnswers()[requestId()] ?? [] - return Array.isArray(draft) ? draft : [] - }) - - const updateAnswer = (questionIndex: number, next: string[]) => { - if (!props.active()) return - props.setDraftAnswers((prev) => { - const current = prev[requestId()] ?? [] - const updated = [...current] - updated[questionIndex] = next - return { ...prev, [requestId()]: updated } - }) - } - - const toggleOption = (questionIndex: number, label: string) => { - const info = questions()[questionIndex] - const multi = info?.multiple === true - const existing = answers()[questionIndex] ?? [] - if (multi) { - const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label] - updateAnswer(questionIndex, next) - return - } - updateAnswer(questionIndex, [label]) - } - - const submitDisabled = () => { - if (!props.active()) return true - if (props.submitting()) return true - return questions().some((_, index) => (answers()[index]?.length ?? 0) === 0) - } - - const toggleFromCustomInput = (questionIndex: number, input: HTMLInputElement | null) => { - if (!props.active()) return - const rawValue = input?.value ?? "" - const value = rawValue - if (value.trim().length === 0) return - - const info = questions()[questionIndex] - const multi = info?.multiple === true - if (!multi) { - // When switching a radio to custom, clear existing selection first. - updateAnswer(questionIndex, []) - } - - toggleOption(questionIndex, value) - } - - const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => { - if (!props.active()) return - if (valuesToRemove.length === 0) return - const existing = answers()[questionIndex] ?? [] - const next = existing.filter((value) => !valuesToRemove.includes(value)) - updateAnswer(questionIndex, next) - } - - const handleCustomTyping = (questionIndex: number, input: HTMLInputElement) => { - if (!props.active()) return - - const value = input.value - const trimmed = value.trim() - const info = questions()[questionIndex] - const multi = info?.multiple === true - - if (!multi) { - updateAnswer(questionIndex, trimmed.length > 0 ? [value] : []) - return - } - - const optionLabels = new Set((info?.options ?? []).map((opt) => opt.label)) - const existing = answers()[questionIndex] ?? [] - const last = input.dataset.lastValue ?? "" - - let next = existing.filter((item) => item !== last) - - if (trimmed.length > 0) { - // Only treat it as custom if it doesn't match an existing option label. - if (!optionLabels.has(trimmed) && !next.includes(value)) { - next = [...next, value] - } else if (optionLabels.has(trimmed)) { - // If they typed an existing option label, don't treat it as custom. - } else if (!next.includes(value)) { - next = [...next, value] - } - input.dataset.lastValue = value - } else { - delete input.dataset.lastValue - } - - updateAnswer(questionIndex, next) - } - - return ( - 0}> -
-
- - {props.active() ? "Question Required" : props.request() ? "Question Queued" : "Questions"} - - {questions().length === 1 ? "Question" : "Questions"} -
- -
-
- - {(q, index) => { - const i = () => index() - const multi = () => q?.multiple === true - const selected = () => answers()[i()] ?? [] - const inputType = () => (multi() ? "checkbox" : "radio") - const groupName = () => `question-${requestId()}-${i()}` - const optionLabels = () => new Set((q?.options ?? []).map((opt) => opt.label)) - const customSelected = () => selected().filter((value) => !optionLabels().has(value)) - const customValue = () => customSelected()[0] ?? "" - const customChecked = () => customValue().length > 0 - - return ( -
-
-
- Q{i() + 1}: {q?.header} -
- -
Multiple
-
-
- -
{q?.question}
- -
- - {(opt) => { - const checked = () => selected().includes(opt.label) - return ( - - ) - }} - - - -
-
- ) - }} -
- - -
-
- - -
- -
- Enter - Submit - Esc - Dismiss -
- - -
{props.error()}
-
-
-
- - -

Waiting for earlier responses.

-
-
-
-
-
- ) -} - -function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] { - if (!state) return [] - const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state) - if (!supportsMetadata) return [] - - const metadata = (state.metadata || {}) as Record - const input = (state.input || {}) as Record - const diagnosticsMap = metadata?.diagnostics as Record | undefined - if (!diagnosticsMap) return [] - - const preferredPath = [ - input.filePath, - metadata.filePath, - metadata.filepath, - input.path, - ].find((value) => typeof value === "string" && value.length > 0) as string | undefined - - const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined - if (!normalizedPreferred) return [] - const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0) - if (candidateEntries.length === 0) return [] - - const prioritizedEntries = candidateEntries.filter(([path]) => { - const normalized = normalizeDiagnosticPath(path) - return normalized === normalizedPreferred - }) - - if (prioritizedEntries.length === 0) return [] - - const entries: DiagnosticEntry[] = [] - for (const [pathKey, list] of prioritizedEntries) { - if (!Array.isArray(list)) continue - const normalizedPath = normalizeDiagnosticPath(pathKey) - for (let index = 0; index < list.length; index++) { - const diagnostic = list[index] - if (!diagnostic || typeof diagnostic.message !== "string") continue - const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined) - const severityMeta = getSeverityMeta(tone) - const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0 - const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0 - entries.push({ - id: `${normalizedPath}-${index}-${diagnostic.message}`, - severity: severityMeta.rank, - tone, - label: severityMeta.label, - icon: severityMeta.icon, - message: diagnostic.message, - filePath: normalizedPath, - displayPath: getRelativePath(normalizedPath), - line, - column, - }) - } - } - - return entries.sort((a, b) => a.severity - b.severity) -} - -function diagnosticFileName(entries: DiagnosticEntry[]) { - const first = entries[0] - return first ? first.displayPath : "" -} - -function renderDiagnosticsSection( - entries: DiagnosticEntry[], - expanded: boolean, - toggle: () => void, - fileLabel: string, -) { - if (entries.length === 0) return null - return ( -
- - -
-
- - {(entry) => ( -
- - {entry.icon} - {entry.label} - - - {entry.displayPath} - - :L{entry.line || "-"}:C{entry.column || "-"} - - - {entry.message} -
- )} -
-
-
-
-
- ) -} export default function ToolCall(props: ToolCallProps) { const { preferences, setDiffViewMode } = useConfig() @@ -561,6 +97,9 @@ export default function ToolCall(props: ToolCallProps) { return "noversion" }) + const messageVersionAccessor = createMemo(() => props.messageVersion) + const partVersionAccessor = createMemo(() => props.partVersion) + const createVariantCache = (variant: string | (() => string), version?: () => string) => useGlobalCache({ instanceId: () => props.instanceId, @@ -578,8 +117,6 @@ export default function ToolCall(props: ToolCallProps) { const permissionDiffCache = createVariantCache("permission-diff") const ansiRunningCache = createVariantCache("ansi-running", () => "running") const ansiFinalCache = createVariantCache("ansi-final") - const runningAnsiRenderer = createAnsiStreamRenderer() - let runningAnsiSource = "" const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier())) const pendingPermission = createMemo(() => { @@ -997,191 +534,35 @@ export default function ToolCall(props: ToolCallProps) { const renderer = createMemo(() => resolveToolRenderer(toolName())) - 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" - const cacheHandle = selectedVariant === "permission-diff" ? permissionDiffCache : diffCache - const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode - const themeKey = isDark() ? "dark" : "light" + const { renderAnsiContent } = createAnsiContentRenderer({ + ansiRunningCache, + ansiFinalCache, + scrollHelpers, + partVersion: partVersionAccessor, + }) - let cachedHtml: string | undefined - const cached = cacheHandle.get() - const currentMode = diffMode() - if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) { - cachedHtml = cached.html - } + const { renderDiffContent } = createDiffContentRenderer({ + preferences, + setDiffViewMode, + isDark, + diffCache, + permissionDiffCache, + scrollHelpers, + handleScrollRendered, + onContentRendered: props.onContentRendered, + }) - const handleModeChange = (mode: DiffViewMode) => { - setDiffViewMode(mode) - } - - const handleDiffRendered = () => { - if (!options?.disableScrollTracking) { - handleScrollRendered() - } - props.onContentRendered?.() - } - - return ( -
scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })} - onScroll={options?.disableScrollTracking ? undefined : scrollHelpers.handleScroll} - > -
- {toolbarLabel} -
- - -
-
- - {scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })} -
- ) - } - - function renderAnsiContent(options: AnsiRenderOptions) { - if (!options.content) { - return null - } - - const size = options.size || "default" - const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}` - const cacheHandle = options.variant === "running" ? ansiRunningCache : ansiFinalCache - const cached = cacheHandle.get() - const mode = typeof props.partVersion === "number" ? String(props.partVersion) : undefined - const isRunningVariant = options.variant === "running" - - let nextCache: AnsiRenderCache - - if (isRunningVariant) { - const content = options.content - const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource - - if (resetStreaming) { - const detectedAnsi = hasAnsi(content) - if (detectedAnsi) { - runningAnsiRenderer.reset() - const html = runningAnsiRenderer.render(content) - nextCache = { text: content, html, mode, hasAnsi: true } - } else { - runningAnsiRenderer.reset() - nextCache = { text: content, html: escapeHtml(content), mode, hasAnsi: false } - } - } else { - const delta = content.slice(cached.text.length) - if (delta.length === 0) { - nextCache = { ...cached, mode } - } else if (!cached.hasAnsi && hasAnsi(delta)) { - runningAnsiRenderer.reset() - const html = runningAnsiRenderer.render(content) - nextCache = { text: content, html, mode, hasAnsi: true } - } else if (cached.hasAnsi) { - const htmlChunk = runningAnsiRenderer.render(delta) - nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true } - } else { - nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false } - } - } - - runningAnsiSource = nextCache.text - cacheHandle.set(nextCache) - } else { - if (cached && cached.text === options.content) { - nextCache = { ...cached, mode } - } else { - const detectedAnsi = hasAnsi(options.content) - const html = detectedAnsi ? ansiToHtml(options.content) : escapeHtml(options.content) - nextCache = { text: options.content, html, mode, hasAnsi: detectedAnsi } - cacheHandle.set(nextCache) - } - } - - if (options.requireAnsi && !nextCache.hasAnsi) { - return null - } - - return ( -
scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}> -
-        {scrollHelpers.renderSentinel()}
-      
- ) - } - - function renderMarkdownContent(options: MarkdownRenderOptions) { - if (!options.content) { - return null - } - - const size = options.size || "default" - const disableHighlight = options.disableHighlight || false - const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}` - - const state = toolState() - const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight) - if (shouldDeferMarkdown) { - return ( -
scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}> -
{options.content}
- {scrollHelpers.renderSentinel()} -
- ) - } - - const partId = toolCallMemo()?.id - if (!partId) { - throw new Error("Tool call markdown requires a part id") - } - const markdownPart: TextPart = { id: partId, type: "text", text: options.content, version: props.partVersion } - - const handleMarkdownRendered = () => { - handleScrollRendered() - props.onContentRendered?.() - } - - return ( -
scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}> - - {scrollHelpers.renderSentinel()} -
- ) - } - - - const messageVersionAccessor = createMemo(() => props.messageVersion) - const partVersionAccessor = createMemo(() => props.partVersion) + const { renderMarkdownContent } = createMarkdownContentRenderer({ + toolState, + partId: toolCallIdentifier, + partVersion: partVersionAccessor, + instanceId: props.instanceId, + sessionId: props.sessionId, + isDark, + scrollHelpers, + handleScrollRendered, + onContentRendered: props.onContentRendered, + }) const rendererContext: ToolRendererContext = { toolCall: toolCallMemo, @@ -1278,92 +659,17 @@ export default function ToolCall(props: ToolCallProps) { } - const renderPermissionBlock = () => { - const permission = permissionDetails() - if (!permission) return null - const active = isPermissionActive() - const metadata = (permission.metadata ?? {}) as Record - const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null - const diffPathRaw = (() => { - if (typeof metadata.filePath === "string") { - return metadata.filePath as string - } - if (typeof metadata.path === "string") { - return metadata.path as string - } - return undefined - })() - const diffPayload = diffValue && diffValue.trim().length > 0 ? { diffText: diffValue, filePath: diffPathRaw } : null - - return ( -
-
- {active ? "Permission Required" : "Permission Queued"} - {getPermissionKind(permission)} -
-
-
- {getPermissionDisplayTitle(permission)} -
- - {(payload) => ( -
- {renderDiffContent(payload(), { - variant: "permission-diff", - disableScrollTracking: true, - label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff", - })} -
- )} -
- -

Waiting for earlier permission responses.

-
-
-
- - - -
- -
- Enter - Allow once - A - Always allow - D - Deny -
-
-
- -
{permissionError()}
-
-
-
- ) - } + const renderPermissionBlock = () => ( + props.sessionId} + onRespond={(permission, sessionId, response) => void handlePermissionResponse(permission, response)} + /> + ) const renderQuestionBlock = () => ( (): T | undefined + set(value: unknown): void +} + +export function createAnsiContentRenderer(params: { + ansiRunningCache: CacheHandle + ansiFinalCache: CacheHandle + scrollHelpers: ToolScrollHelpers + partVersion?: Accessor +}) { + const runningAnsiRenderer = createAnsiStreamRenderer() + let runningAnsiSource = "" + + const getMode = () => { + const version = params.partVersion?.() + return typeof version === "number" ? String(version) : undefined + } + + function renderAnsiContent(options: AnsiRenderOptions): JSXElement | null { + if (!options.content) { + return null + } + + const size = options.size || "default" + const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}` + const cacheHandle = options.variant === "running" ? params.ansiRunningCache : params.ansiFinalCache + const cached = cacheHandle.get() + const mode = getMode() + const isRunningVariant = options.variant === "running" + + let nextCache: AnsiRenderCache + + if (isRunningVariant) { + const content = options.content + const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource + + if (resetStreaming) { + const detectedAnsi = hasAnsi(content) + if (detectedAnsi) { + runningAnsiRenderer.reset() + const html = runningAnsiRenderer.render(content) + nextCache = { text: content, html, mode, hasAnsi: true } + } else { + runningAnsiRenderer.reset() + nextCache = { text: content, html: escapeHtml(content), mode, hasAnsi: false } + } + } else { + const delta = content.slice(cached.text.length) + if (delta.length === 0) { + nextCache = { ...cached, mode } + } else if (!cached.hasAnsi && hasAnsi(delta)) { + runningAnsiRenderer.reset() + const html = runningAnsiRenderer.render(content) + nextCache = { text: content, html, mode, hasAnsi: true } + } else if (cached.hasAnsi) { + const htmlChunk = runningAnsiRenderer.render(delta) + nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true } + } else { + nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false } + } + } + + runningAnsiSource = nextCache.text + cacheHandle.set(nextCache) + } else { + if (cached && cached.text === options.content) { + nextCache = { ...cached, mode } + } else { + const detectedAnsi = hasAnsi(options.content) + const html = detectedAnsi ? ansiToHtml(options.content) : escapeHtml(options.content) + nextCache = { text: options.content, html, mode, hasAnsi: detectedAnsi } + cacheHandle.set(nextCache) + } + } + + if (options.requireAnsi && !nextCache.hasAnsi) { + return null + } + + return ( +
params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}> +
+        {params.scrollHelpers.renderSentinel()}
+      
+ ) + } + + return { renderAnsiContent } +} diff --git a/packages/ui/src/components/tool-call/diagnostics-section.tsx b/packages/ui/src/components/tool-call/diagnostics-section.tsx new file mode 100644 index 00000000..1c5e659c --- /dev/null +++ b/packages/ui/src/components/tool-call/diagnostics-section.tsx @@ -0,0 +1,53 @@ +import { For, Show } from "solid-js" +import type { DiagnosticEntry } from "./diagnostics" + +export function renderDiagnosticsSection( + entries: DiagnosticEntry[], + expanded: boolean, + toggle: () => void, + fileLabel: string, +) { + if (entries.length === 0) return null + return ( +
+ + +
+
+ + {(entry) => ( +
+ + {entry.icon} + {entry.label} + + + {entry.displayPath} + :L{entry.line || "-"}:C{entry.column || "-"} + + {entry.message} +
+ )} +
+
+
+
+
+ ) +} diff --git a/packages/ui/src/components/tool-call/diagnostics.ts b/packages/ui/src/components/tool-call/diagnostics.ts new file mode 100644 index 00000000..415fbc7f --- /dev/null +++ b/packages/ui/src/components/tool-call/diagnostics.ts @@ -0,0 +1,106 @@ +import type { ToolState } from "@opencode-ai/sdk" +import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils" + +interface LspRangePosition { + line?: number + character?: number +} + +interface LspRange { + start?: LspRangePosition +} + +interface LspDiagnostic { + message?: string + severity?: number + range?: LspRange +} + +export interface DiagnosticEntry { + id: string + severity: number + tone: "error" | "warning" | "info" + label: string + icon: string + message: string + filePath: string + displayPath: string + line: number + column: number +} + +function normalizeDiagnosticPath(path: string) { + return path.replace(/\\/g, "/") +} + +function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] { + if (severity === 1) return "error" + if (severity === 2) return "warning" + return "info" +} + +function getSeverityMeta(tone: DiagnosticEntry["tone"]) { + if (tone === "error") return { label: "ERR", icon: "!", rank: 0 } + if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 } + return { label: "INFO", icon: "i", rank: 2 } +} + +export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] { + if (!state) return [] + const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state) + if (!supportsMetadata) return [] + + const metadata = (state.metadata || {}) as Record + const input = (state.input || {}) as Record + const diagnosticsMap = metadata?.diagnostics as Record | undefined + if (!diagnosticsMap) return [] + + const preferredPath = [input.filePath, metadata.filePath, metadata.filepath, input.path].find( + (value) => typeof value === "string" && value.length > 0, + ) as string | undefined + + const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined + if (!normalizedPreferred) return [] + const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0) + if (candidateEntries.length === 0) return [] + + const prioritizedEntries = candidateEntries.filter(([path]) => { + const normalized = normalizeDiagnosticPath(path) + return normalized === normalizedPreferred + }) + + if (prioritizedEntries.length === 0) return [] + + const entries: DiagnosticEntry[] = [] + for (const [pathKey, list] of prioritizedEntries) { + if (!Array.isArray(list)) continue + const normalizedPath = normalizeDiagnosticPath(pathKey) + for (let index = 0; index < list.length; index++) { + const diagnostic = list[index] + if (!diagnostic || typeof diagnostic.message !== "string") continue + const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined) + const severityMeta = getSeverityMeta(tone) + const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0 + const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0 + entries.push({ + id: `${normalizedPath}-${index}-${diagnostic.message}`, + severity: severityMeta.rank, + tone, + label: severityMeta.label, + icon: severityMeta.icon, + message: diagnostic.message, + filePath: normalizedPath, + displayPath: getRelativePath(normalizedPath), + line, + column, + }) + } + } + + return entries.sort((a, b) => a.severity - b.severity) +} + +export function diagnosticFileName(entries: DiagnosticEntry[]) { + const first = entries[0] + return first ? first.displayPath : "" +} diff --git a/packages/ui/src/components/tool-call/diff-render.tsx b/packages/ui/src/components/tool-call/diff-render.tsx new file mode 100644 index 00000000..22679733 --- /dev/null +++ b/packages/ui/src/components/tool-call/diff-render.tsx @@ -0,0 +1,95 @@ +import type { Accessor, JSXElement } from "solid-js" +import type { RenderCache } from "../../types/message" +import type { DiffViewMode } from "../../stores/preferences" +import { ToolCallDiffViewer } from "../diff-viewer" +import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types" +import { getRelativePath } from "./utils" + +type CacheHandle = { + get(): T | undefined + params(): unknown +} + +type DiffPrefs = { + diffViewMode?: DiffViewMode +} + +export function createDiffContentRenderer(params: { + preferences: Accessor + setDiffViewMode: (mode: DiffViewMode) => void + isDark: Accessor + diffCache: CacheHandle + permissionDiffCache: CacheHandle + scrollHelpers: ToolScrollHelpers + handleScrollRendered: () => void + onContentRendered?: () => void +}) { + function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null { + const relativePath = payload.filePath ? getRelativePath(payload.filePath) : "" + const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff") + const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff" + const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache + const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode + const themeKey = params.isDark() ? "dark" : "light" + + let cachedHtml: string | undefined + const cached = cacheHandle.get() + const currentMode = diffMode() + if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) { + cachedHtml = cached.html + } + + const handleModeChange = (mode: DiffViewMode) => { + params.setDiffViewMode(mode) + } + + const handleDiffRendered = () => { + if (!options?.disableScrollTracking) { + params.handleScrollRendered() + } + params.onContentRendered?.() + } + + return ( +
params.scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })} + onScroll={options?.disableScrollTracking ? undefined : params.scrollHelpers.handleScroll} + > +
+ {toolbarLabel} +
+ + +
+
+ + {params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })} +
+ ) + } + + return { renderDiffContent } +} diff --git a/packages/ui/src/components/tool-call/markdown-render.tsx b/packages/ui/src/components/tool-call/markdown-render.tsx new file mode 100644 index 00000000..58e356e5 --- /dev/null +++ b/packages/ui/src/components/tool-call/markdown-render.tsx @@ -0,0 +1,66 @@ +import type { Accessor, JSXElement } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" +import type { TextPart } from "../../types/message" +import { Markdown } from "../markdown" +import type { MarkdownRenderOptions, ToolScrollHelpers } from "./types" + +export function createMarkdownContentRenderer(params: { + toolState: Accessor + partId: Accessor + partVersion?: Accessor + instanceId: string + sessionId: string + isDark: Accessor + scrollHelpers: ToolScrollHelpers + handleScrollRendered: () => void + onContentRendered?: () => void +}) { + function renderMarkdownContent(options: MarkdownRenderOptions): JSXElement | null { + if (!options.content) { + return null + } + + const size = options.size || "default" + const disableHighlight = options.disableHighlight || false + const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}` + + const state = params.toolState() + const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight) + if (shouldDeferMarkdown) { + return ( +
params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}> +
{options.content}
+ {params.scrollHelpers.renderSentinel()} +
+ ) + } + + const markdownPart: TextPart = { + id: params.partId(), + type: "text", + text: options.content, + version: params.partVersion?.(), + } + + const handleMarkdownRendered = () => { + params.handleScrollRendered() + params.onContentRendered?.() + } + + return ( +
params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}> + + {params.scrollHelpers.renderSentinel()} +
+ ) + } + + return { renderMarkdownContent } +} diff --git a/packages/ui/src/components/tool-call/permission-block.tsx b/packages/ui/src/components/tool-call/permission-block.tsx new file mode 100644 index 00000000..b8a2f5ae --- /dev/null +++ b/packages/ui/src/components/tool-call/permission-block.tsx @@ -0,0 +1,120 @@ +import { Show, type Accessor, type JSXElement } from "solid-js" +import type { PermissionRequestLike } from "../../types/permission" +import { getPermissionDisplayTitle, getPermissionKind } from "../../types/permission" +import { getPermissionSessionId } from "../../types/permission" +import type { DiffPayload, DiffRenderOptions } from "./types" +import { getRelativePath } from "./utils" + +type PermissionResponse = "once" | "always" | "reject" + +export type PermissionToolBlockProps = { + permission: Accessor + active: Accessor + submitting: Accessor + error: Accessor + onRespond: (permission: PermissionRequestLike, sessionId: string, response: PermissionResponse) => void | Promise + renderDiff: (payload: DiffPayload, options?: DiffRenderOptions) => JSXElement | null + fallbackSessionId: Accessor +} + +export function PermissionToolBlock(props: PermissionToolBlockProps) { + const diffPayload = () => { + const permission = props.permission() + if (!permission) return null + const metadata = (permission.metadata ?? {}) as Record + const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null + const diffPathRaw = (() => { + if (typeof metadata.filePath === "string") { + return metadata.filePath as string + } + if (typeof metadata.path === "string") { + return metadata.path as string + } + return undefined + })() + if (!diffValue || diffValue.trim().length === 0) return null + return { diffText: diffValue, filePath: diffPathRaw } satisfies DiffPayload + } + + const respond = (response: PermissionResponse) => { + const permission = props.permission() + if (!permission) return + const sessionId = getPermissionSessionId(permission) || props.fallbackSessionId() + props.onRespond(permission, sessionId, response) + } + + return ( + + {(permission) => ( +
+
+ {props.active() ? "Permission Required" : "Permission Queued"} + {getPermissionKind(permission())} +
+
+
+ {getPermissionDisplayTitle(permission())} +
+ + {(payload) => ( +
+ {props.renderDiff(payload(), { + variant: "permission-diff", + disableScrollTracking: true, + label: payload().filePath + ? `Requested diff · ${getRelativePath(payload().filePath || "")}` + : "Requested diff", + })} +
+ )} +
+ +

Waiting for earlier permission responses.

+
+
+
+ + + +
+ +
+ Enter + Allow once + A + Always allow + D + Deny +
+
+
+ +
{props.error()}
+
+
+
+ )} +
+ ) +} diff --git a/packages/ui/src/components/tool-call/question-block.tsx b/packages/ui/src/components/tool-call/question-block.tsx new file mode 100644 index 00000000..8f261f5b --- /dev/null +++ b/packages/ui/src/components/tool-call/question-block.tsx @@ -0,0 +1,311 @@ +import { createMemo, Show, For, type Accessor } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" +import type { QuestionRequest } from "@opencode-ai/sdk/v2" + +type QuestionOption = { label: string; description: string } + +type QuestionPrompt = { + header: string + question: string + options: QuestionOption[] + multiple?: boolean +} + +export type QuestionToolBlockProps = { + toolName: Accessor + toolState: Accessor + toolCallId: Accessor + request: Accessor + active: Accessor + submitting: Accessor + error: Accessor + draftAnswers: Accessor> + setDraftAnswers: (updater: (prev: Record) => Record) => void + onSubmit: () => void | Promise + onDismiss: () => void | Promise +} + +export function QuestionToolBlock(props: QuestionToolBlockProps) { + const requestId = createMemo(() => { + const state = props.toolState() + const request = props.request() + return request?.id ?? (state as any)?.input?.requestID ?? `question-${props.toolCallId()}` + }) + + const questions = createMemo(() => { + const state = props.toolState() + const request = props.request() + const isQuestionTool = props.toolName() === "question" + if (!request && !isQuestionTool) return [] as QuestionPrompt[] + + const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? [] + const list = Array.isArray(questionsSource) ? questionsSource : [] + return list as QuestionPrompt[] + }) + + const isVisible = createMemo(() => { + const request = props.request() + const isQuestionTool = props.toolName() === "question" + return Boolean(request) || isQuestionTool + }) + + const answers = createMemo(() => { + const state = props.toolState() + + const completedAnswers = + (state as any)?.status === "completed" && Array.isArray((state as any)?.metadata?.answers) + ? ((state as any).metadata.answers as string[][]) + : undefined + + if (completedAnswers) return completedAnswers + + const request = props.request() + const requestAnswers = request?.questions?.map((q) => (q as any)?.answer) // defensive (if server ever inlines) + + if (Array.isArray(requestAnswers) && requestAnswers.some((row) => Array.isArray(row) && row.length > 0)) { + return requestAnswers as string[][] + } + + const draft = props.draftAnswers()[requestId()] ?? [] + return Array.isArray(draft) ? draft : [] + }) + + const updateAnswer = (questionIndex: number, next: string[]) => { + if (!props.active()) return + props.setDraftAnswers((prev) => { + const current = prev[requestId()] ?? [] + const updated = [...current] + updated[questionIndex] = next + return { ...prev, [requestId()]: updated } + }) + } + + const toggleOption = (questionIndex: number, label: string) => { + const info = questions()[questionIndex] + const multi = info?.multiple === true + const existing = answers()[questionIndex] ?? [] + if (multi) { + const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label] + updateAnswer(questionIndex, next) + return + } + updateAnswer(questionIndex, [label]) + } + + const submitDisabled = () => { + if (!props.active()) return true + if (props.submitting()) return true + return questions().some((_, index) => (answers()[index]?.length ?? 0) === 0) + } + + const toggleFromCustomInput = (questionIndex: number, input: HTMLInputElement | null) => { + if (!props.active()) return + const rawValue = input?.value ?? "" + const value = rawValue + if (value.trim().length === 0) return + + const info = questions()[questionIndex] + const multi = info?.multiple === true + if (!multi) { + // When switching a radio to custom, clear existing selection first. + updateAnswer(questionIndex, []) + } + + toggleOption(questionIndex, value) + } + + const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => { + if (!props.active()) return + if (valuesToRemove.length === 0) return + const existing = answers()[questionIndex] ?? [] + const next = existing.filter((value) => !valuesToRemove.includes(value)) + updateAnswer(questionIndex, next) + } + + const handleCustomTyping = (questionIndex: number, input: HTMLInputElement) => { + if (!props.active()) return + + const value = input.value + const trimmed = value.trim() + const info = questions()[questionIndex] + const multi = info?.multiple === true + + if (!multi) { + updateAnswer(questionIndex, trimmed.length > 0 ? [value] : []) + return + } + + const optionLabels = new Set((info?.options ?? []).map((opt) => opt.label)) + const existing = answers()[questionIndex] ?? [] + const last = input.dataset.lastValue ?? "" + + let next = existing.filter((item) => item !== last) + + if (trimmed.length > 0) { + // Only treat it as custom if it doesn't match an existing option label. + if (!optionLabels.has(trimmed) && !next.includes(value)) { + next = [...next, value] + } else if (optionLabels.has(trimmed)) { + // If they typed an existing option label, don't treat it as custom. + } else if (!next.includes(value)) { + next = [...next, value] + } + input.dataset.lastValue = value + } else { + delete input.dataset.lastValue + } + + updateAnswer(questionIndex, next) + } + + return ( + 0}> +
+
+ + {props.active() ? "Question Required" : props.request() ? "Question Queued" : "Questions"} + + {questions().length === 1 ? "Question" : "Questions"} +
+ +
+
+ + {(q, index) => { + const i = () => index() + const multi = () => q?.multiple === true + const selected = () => answers()[i()] ?? [] + const inputType = () => (multi() ? "checkbox" : "radio") + const groupName = () => `question-${requestId()}-${i()}` + const optionLabels = () => new Set((q?.options ?? []).map((opt) => opt.label)) + const customSelected = () => selected().filter((value) => !optionLabels().has(value)) + const customValue = () => customSelected()[0] ?? "" + const customChecked = () => customValue().length > 0 + + return ( +
+
+
+ Q{i() + 1}: {q?.header} +
+ +
Multiple
+
+
+ +
{q?.question}
+ +
+ + {(opt) => { + const checked = () => selected().includes(opt.label) + return ( + + ) + }} + + + +
+
+ ) + }} +
+ + +
+
+ + +
+ +
+ Enter + Submit + Esc + Dismiss +
+ + +
{props.error()}
+
+
+
+ + +

Waiting for earlier responses.

+
+
+
+
+
+ ) +}