From f5d4cb6917c701ceb7e05524976fe650fbe2a500 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 22 Jan 2026 21:54:18 +0000 Subject: [PATCH 1/5] refactor(ui): split ToolCall into focused modules --- packages/ui/src/components/tool-call.tsx | 794 ++---------------- .../src/components/tool-call/ansi-render.tsx | 98 +++ .../tool-call/diagnostics-section.tsx | 53 ++ .../src/components/tool-call/diagnostics.ts | 106 +++ .../src/components/tool-call/diff-render.tsx | 95 +++ .../components/tool-call/markdown-render.tsx | 66 ++ .../components/tool-call/permission-block.tsx | 120 +++ .../components/tool-call/question-block.tsx | 311 +++++++ 8 files changed, 899 insertions(+), 744 deletions(-) create mode 100644 packages/ui/src/components/tool-call/ansi-render.tsx create mode 100644 packages/ui/src/components/tool-call/diagnostics-section.tsx create mode 100644 packages/ui/src/components/tool-call/diagnostics.ts create mode 100644 packages/ui/src/components/tool-call/diff-render.tsx create mode 100644 packages/ui/src/components/tool-call/markdown-render.tsx create mode 100644 packages/ui/src/components/tool-call/permission-block.tsx create mode 100644 packages/ui/src/components/tool-call/question-block.tsx 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.

+
+
+
+
+
+ ) +} From 4ea710c7356bc61ec010547dc3e84fb300c06462 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 22 Jan 2026 22:32:03 +0000 Subject: [PATCH 2/5] feat(ui): render apply_patch multi-file diffs --- .../src/components/tool-call/diff-render.tsx | 15 +- .../tool-call/renderers/apply-patch.tsx | 197 ++++++++++++++++++ .../components/tool-call/renderers/index.ts | 2 + .../ui/src/components/tool-call/tool-title.ts | 2 + packages/ui/src/components/tool-call/types.ts | 5 + packages/ui/src/components/tool-call/utils.ts | 6 + .../ui/src/styles/messaging/tool-call.css | 23 ++ 7 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/tool-call/renderers/apply-patch.tsx diff --git a/packages/ui/src/components/tool-call/diff-render.tsx b/packages/ui/src/components/tool-call/diff-render.tsx index 22679733..b76737a7 100644 --- a/packages/ui/src/components/tool-call/diff-render.tsx +++ b/packages/ui/src/components/tool-call/diff-render.tsx @@ -4,6 +4,7 @@ import type { DiffViewMode } from "../../stores/preferences" import { ToolCallDiffViewer } from "../diff-viewer" import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types" import { getRelativePath } from "./utils" +import { getCacheEntry } from "../../lib/global-cache" type CacheHandle = { get(): T | undefined @@ -32,8 +33,18 @@ export function createDiffContentRenderer(params: { const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode const themeKey = params.isDark() ? "dark" : "light" + const baseEntryParams = cacheHandle.params() as any + const cacheEntryParams = (() => { + const suffix = typeof options?.cacheKey === "string" ? options.cacheKey.trim() : "" + if (!suffix) return baseEntryParams + return { + ...baseEntryParams, + cacheId: `${baseEntryParams.cacheId}:${suffix}`, + } + })() + let cachedHtml: string | undefined - const cached = cacheHandle.get() + const cached = getCacheEntry(cacheEntryParams) const currentMode = diffMode() if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) { cachedHtml = cached.html @@ -83,7 +94,7 @@ export function createDiffContentRenderer(params: { theme={themeKey} mode={diffMode()} cachedHtml={cachedHtml} - cacheEntryParams={cacheHandle.params() as any} + cacheEntryParams={cacheEntryParams as any} onRendered={handleDiffRendered} /> {params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })} diff --git a/packages/ui/src/components/tool-call/renderers/apply-patch.tsx b/packages/ui/src/components/tool-call/renderers/apply-patch.tsx new file mode 100644 index 00000000..acfe349b --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/apply-patch.tsx @@ -0,0 +1,197 @@ +import { For, Show, createMemo } from "solid-js" +import type { ToolRenderer } from "../types" +import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" +import type { DiagnosticEntry } from "../diagnostics" + +type LspRangePosition = { + line?: number + character?: number +} + +type LspRange = { + start?: LspRangePosition +} + +type LspDiagnostic = { + message?: string + severity?: number + range?: LspRange +} + +type ApplyPatchFile = { + filePath?: string + relativePath?: string + type?: string + diff?: string +} + +function normalizePath(value: string): string { + return value.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 resolveDiagnosticsKey( + diagnostics: Record, + file: ApplyPatchFile, +): string | undefined { + const absolute = typeof file.filePath === "string" ? normalizePath(file.filePath) : "" + const relative = typeof file.relativePath === "string" ? normalizePath(file.relativePath) : "" + if (absolute && diagnostics[absolute]) return absolute + if (relative && diagnostics[relative]) return relative + + if (absolute) { + const direct = Object.keys(diagnostics).find((key) => normalizePath(key) === absolute) + if (direct) return direct + } + + if (relative) { + const suffixMatch = Object.keys(diagnostics).find((key) => { + const normalized = normalizePath(key) + return normalized === relative || normalized.endsWith("/" + relative) + }) + if (suffixMatch) return suffixMatch + } + + return undefined +} + +function buildDiagnostics( + diagnostics: Record, + file: ApplyPatchFile, +): DiagnosticEntry[] { + const key = resolveDiagnosticsKey(diagnostics, file) + if (!key) return [] + const list = diagnostics[key] + if (!Array.isArray(list) || list.length === 0) return [] + + const normalizedKey = normalizePath(key) + const entries: DiagnosticEntry[] = [] + 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: `${normalizedKey}-${index}-${diagnostic.message}`, + severity: severityMeta.rank, + tone, + label: severityMeta.label, + icon: severityMeta.icon, + message: diagnostic.message, + filePath: normalizedKey, + displayPath: getRelativePath(normalizedKey), + line, + column, + }) + } + + return entries.sort((a, b) => a.severity - b.severity) +} + +function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string }) { + return ( + 0}> +
+
+
+ + {(entry) => ( +
+ + {entry.icon} + {entry.label} + + + {entry.displayPath} + :L{entry.line || "-"}:C{entry.column || "-"} + + {entry.message} +
+ )} +
+
+
+
+
+ ) +} + +export const applyPatchRenderer: ToolRenderer = { + tools: ["apply_patch"], + getAction: () => "Preparing apply_patch...", + getTitle({ toolState }) { + const state = toolState() + if (!state) return undefined + if (state.status === "pending") return getToolName("apply_patch") + const { metadata } = readToolStatePayload(state) + const files = Array.isArray((metadata as any).files) ? ((metadata as any).files as ApplyPatchFile[]) : [] + if (files.length > 0) { + return `${getToolName("apply_patch")} (${files.length} file${files.length === 1 ? "" : "s"})` + } + return getToolName("apply_patch") + }, + renderBody({ toolState, renderDiff, renderMarkdown }) { + const state = toolState() + if (!state || state.status === "pending") return null + + const payload = readToolStatePayload(state) + const files = createMemo(() => { + const list = (payload.metadata as any).files + return Array.isArray(list) ? (list as ApplyPatchFile[]) : [] + }) + const diagnosticsMap = createMemo(() => { + const value = (payload.metadata as any).diagnostics + return value && typeof value === "object" ? (value as Record) : {} + }) + + if (files().length === 0) { + const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null + if (!fallback) return null + return renderMarkdown({ content: fallback, size: "large", disableHighlight: state.status === "running" }) + } + + return ( +
+ + {(file, index) => { + const labelBase = file.relativePath || file.filePath || `File ${index() + 1}` + const diffText = typeof file.diff === "string" ? file.diff : "" + const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath + const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file)) + + return ( +
+ 0}> + {renderDiff( + { diffText, filePath }, + { + label: `Diff · ${getRelativePath(labelBase)}`, + cacheKey: `apply_patch:${labelBase}:${index()}`, + }, + )} + + +
+ ) + }} +
+
+ ) + }, +} diff --git a/packages/ui/src/components/tool-call/renderers/index.ts b/packages/ui/src/components/tool-call/renderers/index.ts index c261a1bb..38807a58 100644 --- a/packages/ui/src/components/tool-call/renderers/index.ts +++ b/packages/ui/src/components/tool-call/renderers/index.ts @@ -2,6 +2,7 @@ import type { ToolRenderer } from "../types" import { bashRenderer } from "./bash" import { defaultRenderer } from "./default" import { editRenderer } from "./edit" +import { applyPatchRenderer } from "./apply-patch" import { patchRenderer } from "./patch" import { readRenderer } from "./read" import { taskRenderer } from "./task" @@ -16,6 +17,7 @@ const TOOL_RENDERERS: ToolRenderer[] = [ readRenderer, writeRenderer, editRenderer, + applyPatchRenderer, patchRenderer, webfetchRenderer, todoRenderer, diff --git a/packages/ui/src/components/tool-call/tool-title.ts b/packages/ui/src/components/tool-call/tool-title.ts index a87d22fb..be983386 100644 --- a/packages/ui/src/components/tool-call/tool-title.ts +++ b/packages/ui/src/components/tool-call/tool-title.ts @@ -6,6 +6,7 @@ import { bashRenderer } from "./renderers/bash" import { readRenderer } from "./renderers/read" import { writeRenderer } from "./renderers/write" import { editRenderer } from "./renderers/edit" +import { applyPatchRenderer } from "./renderers/apply-patch" import { patchRenderer } from "./renderers/patch" import { webfetchRenderer } from "./renderers/webfetch" import { todoRenderer } from "./renderers/todo" @@ -16,6 +17,7 @@ const TITLE_RENDERERS: Record = { read: readRenderer, write: writeRenderer, edit: editRenderer, + apply_patch: applyPatchRenderer, patch: patchRenderer, webfetch: webfetchRenderer, todowrite: todoRenderer, diff --git a/packages/ui/src/components/tool-call/types.ts b/packages/ui/src/components/tool-call/types.ts index aa2e9957..22db6903 100644 --- a/packages/ui/src/components/tool-call/types.ts +++ b/packages/ui/src/components/tool-call/types.ts @@ -26,6 +26,11 @@ export interface DiffRenderOptions { variant?: string disableScrollTracking?: boolean label?: string + /** + * Optional cache key suffix to avoid collisions when rendering multiple diffs + * within the same tool call (e.g. apply_patch). + */ + cacheKey?: string } export interface ToolScrollHelpers { diff --git a/packages/ui/src/components/tool-call/utils.ts b/packages/ui/src/components/tool-call/utils.ts index ac32ba60..6c3510e6 100644 --- a/packages/ui/src/components/tool-call/utils.ts +++ b/packages/ui/src/components/tool-call/utils.ts @@ -51,6 +51,8 @@ export function getToolIcon(tool: string): string { return "📁" case "patch": return "🔧" + case "apply_patch": + return "🔧" default: return "🔧" } @@ -67,6 +69,8 @@ export function getToolName(tool: string): string { case "todowrite": case "todoread": return "Plan" + case "apply_patch": + return "Apply patch" default: { const normalized = tool.replace(/^opencode_/, "") return normalized.charAt(0).toUpperCase() + normalized.slice(1) @@ -220,6 +224,8 @@ export function getDefaultToolAction(toolName: string) { return "Planning..." case "patch": return "Preparing patch..." + case "apply_patch": + return "Preparing apply_patch..." default: return "Working..." } diff --git a/packages/ui/src/styles/messaging/tool-call.css b/packages/ui/src/styles/messaging/tool-call.css index a88b1f0f..c0affd09 100644 --- a/packages/ui/src/styles/messaging/tool-call.css +++ b/packages/ui/src/styles/messaging/tool-call.css @@ -217,6 +217,16 @@ @apply flex items-center justify-between gap-3 px-3 py-2; background-color: var(--surface-secondary); border-bottom: 1px solid var(--border-base); + position: sticky; + top: 0; + z-index: 2; +} + +/* Diff shell already provides the scroll container. + Avoid nested scroll areas inside the diff viewer. */ +.tool-call-diff-shell .tool-call-diff-viewer { + max-height: none; + overflow: visible; } .tool-call-diff-toolbar-label { @@ -423,6 +433,19 @@ background-clip: padding-box; } +/* apply_patch multi-file layout */ +.tool-call-apply-patch { + @apply flex flex-col; +} + +.tool-call-apply-patch-file { + margin-top: 0.75rem; +} + +.tool-call-apply-patch-file:first-child { + margin-top: 0; +} + .tool-call-section h4 { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); From 292f695395861472279c227836efb08eef033cbb Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 22 Jan 2026 22:32:52 +0000 Subject: [PATCH 3/5] Bump version to 0.8.1 --- package-lock.json | 12 ++++++------ package.json | 2 +- packages/electron-app/package.json | 2 +- packages/opencode-config/package.json | 2 +- packages/server/package-lock.json | 4 ++-- packages/server/package.json | 2 +- packages/tauri-app/package.json | 2 +- packages/ui/package.json | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 21102305..25651b70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.8.0", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.8.0", + "version": "0.8.1", "dependencies": { "7zip-bin": "^5.2.0", "google-auth-library": "^10.5.0" @@ -7384,7 +7384,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.8.0", + "version": "0.8.1", "dependencies": { "@codenomad/ui": "file:../ui", "@neuralnomads/codenomad": "file:../server" @@ -7418,7 +7418,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.8.0", + "version": "0.8.1", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", @@ -7455,14 +7455,14 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.8.0", + "version": "0.8.1", "devDependencies": { "@tauri-apps/cli": "^2.9.4" } }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.8.0", + "version": "0.8.1", "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", diff --git a/package.json b/package.json index 2031d88a..64f5b1a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.8.0", + "version": "0.8.1", "private": true, "description": "CodeNomad monorepo workspace", "workspaces": { diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index b1dbcddc..38848347 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.8.0", + "version": "0.8.1", "description": "CodeNomad - AI coding assistant", "author": { "name": "Neural Nomads", diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index b89efcc0..a1771bb5 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -3,6 +3,6 @@ "version": "0.5.0", "private": true, "dependencies": { - "@opencode-ai/plugin": "1.1.16" + "@opencode-ai/plugin": "1.1.30" } } diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 07e5f492..b1aef738 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.8.0", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.8.0", + "version": "0.8.1", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", diff --git a/packages/server/package.json b/packages/server/package.json index 25108e7a..3c11a3e6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.8.0", + "version": "0.8.1", "description": "CodeNomad Server", "author": { "name": "Neural Nomads", diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index 3d056d5c..c9d02a3b 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.8.0", + "version": "0.8.1", "private": true, "scripts": { "dev": "tauri dev", diff --git a/packages/ui/package.json b/packages/ui/package.json index c603d24d..d301784f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.8.0", + "version": "0.8.1", "private": true, "type": "module", "scripts": { From 8c48455ae5a05cd325f5bf2e444d3e1fc13f2a71 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 22 Jan 2026 23:04:53 +0000 Subject: [PATCH 4/5] fix(server): prefer highest available UI version Selects the newest UI across bundled/current/previous with a tie-break for current, and only downloads remote UI when it is strictly newer. This prevents stale cached UIs from overriding a newer bundled release. --- .../server/src/ui/__tests__/remote-ui.test.ts | 58 +++++++ packages/server/src/ui/remote-ui.ts | 160 +++++++++++------- 2 files changed, 156 insertions(+), 62 deletions(-) create mode 100644 packages/server/src/ui/__tests__/remote-ui.test.ts diff --git a/packages/server/src/ui/__tests__/remote-ui.test.ts b/packages/server/src/ui/__tests__/remote-ui.test.ts new file mode 100644 index 00000000..e858498d --- /dev/null +++ b/packages/server/src/ui/__tests__/remote-ui.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { mkdir } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, beforeEach, describe, it } from "node:test" + +import type { Logger } from "../../logger" +import { resolveUi } from "../remote-ui" + +const noopLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + trace: () => {}, + child: () => noopLogger, + isLevelEnabled: () => false, +} as any + +let tempRoot: string + +beforeEach(() => { + tempRoot = mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-test-")) +}) + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }) +}) + +describe("resolveUi local version preference", () => { + it("prefers bundled when bundled version is higher", async () => { + const bundledDir = path.join(tempRoot, "bundled") + const configDir = path.join(tempRoot, "config") + const currentDir = path.join(configDir, "ui", "current") + + await mkdir(bundledDir, { recursive: true }) + await mkdir(currentDir, { recursive: true }) + + writeFileSync(path.join(bundledDir, "index.html"), "bundled") + writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" })) + + writeFileSync(path.join(currentDir, "index.html"), "current") + writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.0" })) + + const result = await resolveUi({ + serverVersion: "0.8.1", + bundledUiDir: bundledDir, + autoUpdate: false, + configDir, + logger: noopLogger, + }) + + assert.equal(result.source, "bundled") + assert.equal(result.uiStaticDir, bundledDir) + assert.equal(result.uiVersion, "0.8.1") + }) +}) diff --git a/packages/server/src/ui/remote-ui.ts b/packages/server/src/ui/remote-ui.ts index 45e0e544..1aff87df 100644 --- a/packages/server/src/ui/remote-ui.ts +++ b/packages/server/src/ui/remote-ui.ts @@ -73,23 +73,13 @@ export async function resolveUi(options: RemoteUiOptions): Promise const previousDir = path.join(uiRoot, "previous") if (!options.autoUpdate) { - const local = await resolveStaticUiDir(currentDir) - if (local) { - return { - uiStaticDir: local, - source: "downloaded", - uiVersion: await readUiVersion(local), - supported: true, - } - } - - const bundled = await resolveStaticUiDir(options.bundledUiDir) - return { - uiStaticDir: bundled ?? options.bundledUiDir, - source: bundled ? "bundled" : "missing", - uiVersion: bundled ? await readUiVersion(bundled) : undefined, + return await resolveFromCacheOrBundled({ + logger: options.logger, + bundledUiDir: options.bundledUiDir, + currentDir, + previousDir, supported: true, - } + }) } let manifest: RemoteUiManifest | null = null @@ -125,20 +115,28 @@ export async function resolveUi(options: RemoteUiOptions): Promise }) } - const currentVersion = await readUiVersion(currentDir) - if (currentVersion && currentVersion === manifest.latestUIVersion) { - const currentResolved = await resolveStaticUiDir(currentDir) - if (currentResolved) { - return { - uiStaticDir: currentResolved, - source: "downloaded", - uiVersion: currentVersion, - supported: true, - latestServerVersion: manifest.latestServerVersion, - latestServerUrl: manifest.latestServerUrl, - minServerVersion: manifest.minServerVersion, - } - } + const bestLocal = await pickBestLocalUi({ + logger: options.logger, + bundledUiDir: options.bundledUiDir, + currentDir, + previousDir, + }) + + const remoteIsNewer = + !bestLocal || + compareSemverMaybe(manifest.latestUIVersion, bestLocal.uiVersion) > 0 + + if (!remoteIsNewer) { + return await resolveFromCacheOrBundled({ + logger: options.logger, + bundledUiDir: options.bundledUiDir, + currentDir, + previousDir, + supported: true, + latestServerVersion: manifest.latestServerVersion, + latestServerUrl: manifest.latestServerUrl, + minServerVersion: manifest.minServerVersion, + }) } try { @@ -206,40 +204,18 @@ async function resolveFromCacheOrBundled(args: { latestServerUrl?: string minServerVersion?: string }): Promise { - const currentResolved = await resolveStaticUiDir(args.currentDir) - if (currentResolved) { - return { - uiStaticDir: currentResolved, - source: "downloaded", - uiVersion: await readUiVersion(currentResolved), - supported: args.supported, - message: args.message, - latestServerVersion: args.latestServerVersion, - latestServerUrl: args.latestServerUrl, - minServerVersion: args.minServerVersion, - } - } + const bestLocal = await pickBestLocalUi({ + logger: args.logger, + bundledUiDir: args.bundledUiDir, + currentDir: args.currentDir, + previousDir: args.previousDir, + }) - const previousResolved = await resolveStaticUiDir(args.previousDir) - if (previousResolved) { + if (bestLocal) { return { - uiStaticDir: previousResolved, - source: "previous", - uiVersion: await readUiVersion(previousResolved), - supported: args.supported, - message: args.message, - latestServerVersion: args.latestServerVersion, - latestServerUrl: args.latestServerUrl, - minServerVersion: args.minServerVersion, - } - } - - const bundledResolved = await resolveStaticUiDir(args.bundledUiDir) - if (bundledResolved) { - return { - uiStaticDir: bundledResolved, - source: "bundled", - uiVersion: await readUiVersion(bundledResolved), + uiStaticDir: bestLocal.uiStaticDir, + source: bestLocal.source, + uiVersion: bestLocal.uiVersion, supported: args.supported, message: args.message, latestServerVersion: args.latestServerVersion, @@ -260,6 +236,66 @@ async function resolveFromCacheOrBundled(args: { } } +async function pickBestLocalUi(args: { + logger: Logger + bundledUiDir: string + currentDir: string + previousDir: string +}): Promise<{ uiStaticDir: string; source: UiSource; uiVersion?: string } | null> { + const candidates: Array<{ uiStaticDir: string; source: UiSource; uiVersion?: string; priority: number }> = [] + + const currentResolved = await resolveStaticUiDir(args.currentDir) + if (currentResolved) { + candidates.push({ + uiStaticDir: currentResolved, + source: "downloaded", + uiVersion: await readUiVersion(currentResolved), + priority: 2, + }) + } + + const bundledResolved = await resolveStaticUiDir(args.bundledUiDir) + if (bundledResolved) { + candidates.push({ + uiStaticDir: bundledResolved, + source: "bundled", + uiVersion: await readUiVersion(bundledResolved), + priority: 1, + }) + } + + const previousResolved = await resolveStaticUiDir(args.previousDir) + if (previousResolved) { + candidates.push({ + uiStaticDir: previousResolved, + source: "previous", + uiVersion: await readUiVersion(previousResolved), + priority: 0, + }) + } + + if (candidates.length === 0) { + return null + } + + candidates.sort((a, b) => { + const versionCmp = compareSemverMaybe(a.uiVersion, b.uiVersion) + if (versionCmp !== 0) return -versionCmp + return b.priority - a.priority + }) + + const best = candidates[0] + if (!best) return null + return { uiStaticDir: best.uiStaticDir, source: best.source, uiVersion: best.uiVersion } +} + +function compareSemverMaybe(a: string | undefined, b: string | undefined): number { + if (!a && !b) return 0 + if (!a) return -1 + if (!b) return 1 + return compareSemverCore(a, b) +} + async function resolveStaticUiDir(uiDir: string): Promise { try { const indexPath = path.join(uiDir, "index.html") From b0eb9aec64c6c878d6b4b88c3de14b5ebbe6c36d Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 22 Jan 2026 23:05:49 +0000 Subject: [PATCH 5/5] Min server to 0.8.1 --- packages/cloudflare/release-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index 90c8bf10..8d1da1a7 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.8.0", + "minServerVersion": "0.8.1", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" }