refactor(ui): split ToolCall into focused modules
This commit is contained in:
@@ -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 { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { Markdown } from "./markdown"
|
|
||||||
import { ToolCallDiffViewer } from "./diff-viewer"
|
|
||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import type { DiffViewMode } from "../stores/preferences"
|
|
||||||
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
||||||
import type { PermissionRequestLike } from "../types/permission"
|
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 { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import type { TextPart, RenderCache } from "../types/message"
|
|
||||||
import { resolveToolRenderer } from "./tool-call/renderers"
|
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 {
|
import type {
|
||||||
DiffPayload,
|
DiffPayload,
|
||||||
DiffRenderOptions,
|
DiffRenderOptions,
|
||||||
@@ -24,38 +27,11 @@ import type {
|
|||||||
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
|
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
|
||||||
import { resolveTitleForTool } from "./tool-call/tool-title"
|
import { resolveTitleForTool } from "./tool-call/tool-title"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
|
|
||||||
import { escapeHtml } from "../lib/markdown"
|
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
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<string>
|
|
||||||
toolState: Accessor<ToolState | undefined>
|
|
||||||
toolCallId: Accessor<string>
|
|
||||||
request: Accessor<QuestionRequest | undefined>
|
|
||||||
active: Accessor<boolean>
|
|
||||||
submitting: Accessor<boolean>
|
|
||||||
error: Accessor<string | null>
|
|
||||||
draftAnswers: Accessor<Record<string, string[][]>>
|
|
||||||
setDraftAnswers: (updater: (prev: Record<string, string[][]>) => Record<string, string[][]>) => void
|
|
||||||
onSubmit: () => void | Promise<void>
|
|
||||||
onDismiss: () => void | Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
|
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 (
|
|
||||||
<Show when={isVisible() && questions().length > 0}>
|
|
||||||
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
|
||||||
<div class="tool-call-permission-header">
|
|
||||||
<span class="tool-call-permission-label">
|
|
||||||
{props.active() ? "Question Required" : props.request() ? "Question Queued" : "Questions"}
|
|
||||||
</span>
|
|
||||||
<span class="tool-call-permission-type">{questions().length === 1 ? "Question" : "Questions"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tool-call-permission-body">
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<For each={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 (
|
|
||||||
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
|
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
|
||||||
<div class="text-xs">
|
|
||||||
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={multi()}>
|
|
||||||
<div class="text-xs text-muted">Multiple</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-1 text-sm font-medium">{q?.question}</div>
|
|
||||||
|
|
||||||
<div class="mt-3 flex flex-col gap-1">
|
|
||||||
<For each={q?.options ?? []}>
|
|
||||||
{(opt) => {
|
|
||||||
const checked = () => selected().includes(opt.label)
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
class={`flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
|
||||||
title={opt.description}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type={inputType()}
|
|
||||||
name={groupName()}
|
|
||||||
checked={checked()}
|
|
||||||
disabled={!props.active() || props.submitting()}
|
|
||||||
onChange={() => toggleOption(i(), opt.label)}
|
|
||||||
/>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<div class="text-sm leading-tight">{opt.label}</div>
|
|
||||||
<div class="text-xs text-muted leading-tight">{opt.description}</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
|
|
||||||
<label
|
|
||||||
class={`mt-2 flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
|
||||||
title="Type a custom answer"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type={inputType()}
|
|
||||||
name={groupName()}
|
|
||||||
checked={customChecked()}
|
|
||||||
disabled={!props.active() || props.submitting()}
|
|
||||||
onChange={(e) => {
|
|
||||||
const container = e.currentTarget.closest("label")
|
|
||||||
const input = container?.querySelector("input[type='text']") as HTMLInputElement | null
|
|
||||||
if (!props.active()) return
|
|
||||||
if (customChecked()) {
|
|
||||||
clearCustomAnswer(i(), customSelected())
|
|
||||||
if (input) {
|
|
||||||
delete input.dataset.lastValue
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toggleFromCustomInput(i(), input)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div class="flex flex-1 flex-col gap-2">
|
|
||||||
<div class="text-sm leading-tight">Custom answer</div>
|
|
||||||
<input
|
|
||||||
class="w-full rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
|
|
||||||
type="text"
|
|
||||||
placeholder="Type your own answer"
|
|
||||||
disabled={!props.active() || props.submitting()}
|
|
||||||
value={customValue()}
|
|
||||||
onFocus={(e) => {
|
|
||||||
if (!props.active()) return
|
|
||||||
// Keep the radio/checkbox selected while editing.
|
|
||||||
toggleFromCustomInput(i(), e.currentTarget)
|
|
||||||
}}
|
|
||||||
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
|
|
||||||
<Show when={props.active()}>
|
|
||||||
<div class="tool-call-permission-actions">
|
|
||||||
<div class="tool-call-permission-buttons">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="tool-call-permission-button"
|
|
||||||
disabled={submitDisabled()}
|
|
||||||
onClick={() => props.onSubmit()}
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="tool-call-permission-button"
|
|
||||||
disabled={props.submitting()}
|
|
||||||
onClick={() => props.onDismiss()}
|
|
||||||
>
|
|
||||||
Dismiss
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tool-call-permission-shortcuts">
|
|
||||||
<kbd class="kbd">Enter</kbd>
|
|
||||||
<span>Submit</span>
|
|
||||||
<kbd class="kbd">Esc</kbd>
|
|
||||||
<span>Dismiss</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={props.error()}>
|
|
||||||
<div class="tool-call-permission-error">{props.error()}</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!props.active() && props.request()}>
|
|
||||||
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, unknown>
|
|
||||||
const input = (state.input || {}) as Record<string, unknown>
|
|
||||||
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | 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 (
|
|
||||||
<div class="tool-call-diagnostics-wrapper">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="tool-call-diagnostics-heading"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
onClick={toggle}
|
|
||||||
>
|
|
||||||
<span class="tool-call-icon" aria-hidden="true">
|
|
||||||
{expanded ? "▼" : "▶"}
|
|
||||||
</span>
|
|
||||||
<span class="tool-call-emoji" aria-hidden="true">🛠</span>
|
|
||||||
<span class="tool-call-summary">Diagnostics</span>
|
|
||||||
<span class="tool-call-diagnostics-file" title={fileLabel}>{fileLabel}</span>
|
|
||||||
</button>
|
|
||||||
<Show when={expanded}>
|
|
||||||
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
|
|
||||||
<div class="tool-call-diagnostics-body" role="list">
|
|
||||||
<For each={entries}>
|
|
||||||
{(entry) => (
|
|
||||||
<div class="tool-call-diagnostic-row" role="listitem">
|
|
||||||
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
|
|
||||||
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
|
|
||||||
<span>{entry.label}</span>
|
|
||||||
</span>
|
|
||||||
<span class="tool-call-diagnostic-path" title={entry.filePath}>
|
|
||||||
{entry.displayPath}
|
|
||||||
<span class="tool-call-diagnostic-coords">
|
|
||||||
:L{entry.line || "-"}:C{entry.column || "-"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span class="tool-call-diagnostic-message">{entry.message}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ToolCall(props: ToolCallProps) {
|
export default function ToolCall(props: ToolCallProps) {
|
||||||
const { preferences, setDiffViewMode } = useConfig()
|
const { preferences, setDiffViewMode } = useConfig()
|
||||||
@@ -561,6 +97,9 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return "noversion"
|
return "noversion"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const messageVersionAccessor = createMemo(() => props.messageVersion)
|
||||||
|
const partVersionAccessor = createMemo(() => props.partVersion)
|
||||||
|
|
||||||
const createVariantCache = (variant: string | (() => string), version?: () => string) =>
|
const createVariantCache = (variant: string | (() => string), version?: () => string) =>
|
||||||
useGlobalCache({
|
useGlobalCache({
|
||||||
instanceId: () => props.instanceId,
|
instanceId: () => props.instanceId,
|
||||||
@@ -578,8 +117,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
const permissionDiffCache = createVariantCache("permission-diff")
|
const permissionDiffCache = createVariantCache("permission-diff")
|
||||||
const ansiRunningCache = createVariantCache("ansi-running", () => "running")
|
const ansiRunningCache = createVariantCache("ansi-running", () => "running")
|
||||||
const ansiFinalCache = createVariantCache("ansi-final")
|
const ansiFinalCache = createVariantCache("ansi-final")
|
||||||
const runningAnsiRenderer = createAnsiStreamRenderer()
|
|
||||||
let runningAnsiSource = ""
|
|
||||||
|
|
||||||
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier()))
|
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier()))
|
||||||
const pendingPermission = createMemo(() => {
|
const pendingPermission = createMemo(() => {
|
||||||
@@ -997,191 +534,35 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
|
|
||||||
const renderer = createMemo(() => resolveToolRenderer(toolName()))
|
const renderer = createMemo(() => resolveToolRenderer(toolName()))
|
||||||
|
|
||||||
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions) {
|
const { renderAnsiContent } = createAnsiContentRenderer({
|
||||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
ansiRunningCache,
|
||||||
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
ansiFinalCache,
|
||||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
scrollHelpers,
|
||||||
const cacheHandle = selectedVariant === "permission-diff" ? permissionDiffCache : diffCache
|
partVersion: partVersionAccessor,
|
||||||
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
|
})
|
||||||
const themeKey = isDark() ? "dark" : "light"
|
|
||||||
|
|
||||||
let cachedHtml: string | undefined
|
const { renderDiffContent } = createDiffContentRenderer({
|
||||||
const cached = cacheHandle.get<RenderCache>()
|
preferences,
|
||||||
const currentMode = diffMode()
|
setDiffViewMode,
|
||||||
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
isDark,
|
||||||
cachedHtml = cached.html
|
diffCache,
|
||||||
}
|
permissionDiffCache,
|
||||||
|
scrollHelpers,
|
||||||
|
handleScrollRendered,
|
||||||
|
onContentRendered: props.onContentRendered,
|
||||||
|
})
|
||||||
|
|
||||||
const handleModeChange = (mode: DiffViewMode) => {
|
const { renderMarkdownContent } = createMarkdownContentRenderer({
|
||||||
setDiffViewMode(mode)
|
toolState,
|
||||||
}
|
partId: toolCallIdentifier,
|
||||||
|
partVersion: partVersionAccessor,
|
||||||
const handleDiffRendered = () => {
|
instanceId: props.instanceId,
|
||||||
if (!options?.disableScrollTracking) {
|
sessionId: props.sessionId,
|
||||||
handleScrollRendered()
|
isDark,
|
||||||
}
|
scrollHelpers,
|
||||||
props.onContentRendered?.()
|
handleScrollRendered,
|
||||||
}
|
onContentRendered: props.onContentRendered,
|
||||||
|
})
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
|
||||||
ref={(element) => scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
|
|
||||||
onScroll={options?.disableScrollTracking ? undefined : scrollHelpers.handleScroll}
|
|
||||||
>
|
|
||||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
|
||||||
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
|
||||||
<div class="tool-call-diff-toggle">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
|
|
||||||
aria-pressed={diffMode() === "split"}
|
|
||||||
onClick={() => handleModeChange("split")}
|
|
||||||
>
|
|
||||||
Split
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
|
|
||||||
aria-pressed={diffMode() === "unified"}
|
|
||||||
onClick={() => handleModeChange("unified")}
|
|
||||||
>
|
|
||||||
Unified
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ToolCallDiffViewer
|
|
||||||
diffText={payload.diffText}
|
|
||||||
filePath={payload.filePath}
|
|
||||||
theme={themeKey}
|
|
||||||
mode={diffMode()}
|
|
||||||
cachedHtml={cachedHtml}
|
|
||||||
cacheEntryParams={cacheHandle.params()}
|
|
||||||
onRendered={handleDiffRendered}
|
|
||||||
/>
|
|
||||||
{scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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<AnsiRenderCache>()
|
|
||||||
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 (
|
|
||||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
|
||||||
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
|
|
||||||
{scrollHelpers.renderSentinel()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
|
||||||
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
|
||||||
{scrollHelpers.renderSentinel()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
|
||||||
<Markdown
|
|
||||||
part={markdownPart}
|
|
||||||
instanceId={props.instanceId}
|
|
||||||
sessionId={props.sessionId}
|
|
||||||
isDark={isDark()}
|
|
||||||
disableHighlight={disableHighlight}
|
|
||||||
onRendered={handleMarkdownRendered}
|
|
||||||
/>
|
|
||||||
{scrollHelpers.renderSentinel()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const messageVersionAccessor = createMemo(() => props.messageVersion)
|
|
||||||
const partVersionAccessor = createMemo(() => props.partVersion)
|
|
||||||
|
|
||||||
const rendererContext: ToolRendererContext = {
|
const rendererContext: ToolRendererContext = {
|
||||||
toolCall: toolCallMemo,
|
toolCall: toolCallMemo,
|
||||||
@@ -1278,92 +659,17 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const renderPermissionBlock = () => {
|
const renderPermissionBlock = () => (
|
||||||
const permission = permissionDetails()
|
<PermissionToolBlock
|
||||||
if (!permission) return null
|
permission={permissionDetails}
|
||||||
const active = isPermissionActive()
|
active={isPermissionActive}
|
||||||
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
|
submitting={permissionSubmitting}
|
||||||
const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null
|
error={permissionError}
|
||||||
const diffPathRaw = (() => {
|
renderDiff={renderDiffContent}
|
||||||
if (typeof metadata.filePath === "string") {
|
fallbackSessionId={() => props.sessionId}
|
||||||
return metadata.filePath as string
|
onRespond={(permission, sessionId, response) => void handlePermissionResponse(permission, response)}
|
||||||
}
|
/>
|
||||||
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 (
|
|
||||||
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
|
||||||
<div class="tool-call-permission-header">
|
|
||||||
<span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span>
|
|
||||||
<span class="tool-call-permission-type">{getPermissionKind(permission)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="tool-call-permission-body">
|
|
||||||
<div class="tool-call-permission-title">
|
|
||||||
<code>{getPermissionDisplayTitle(permission)}</code>
|
|
||||||
</div>
|
|
||||||
<Show when={diffPayload}>
|
|
||||||
{(payload) => (
|
|
||||||
<div class="tool-call-permission-diff">
|
|
||||||
{renderDiffContent(payload(), {
|
|
||||||
variant: "permission-diff",
|
|
||||||
disableScrollTracking: true,
|
|
||||||
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={!active}>
|
|
||||||
<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>
|
|
||||||
</Show>
|
|
||||||
<div class="tool-call-permission-actions">
|
|
||||||
<div class="tool-call-permission-buttons">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="tool-call-permission-button"
|
|
||||||
disabled={permissionSubmitting()}
|
|
||||||
onClick={() => void handlePermissionResponse(permission, "once")}
|
|
||||||
>
|
|
||||||
Allow Once
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="tool-call-permission-button"
|
|
||||||
disabled={permissionSubmitting()}
|
|
||||||
onClick={() => void handlePermissionResponse(permission, "always")}
|
|
||||||
>
|
|
||||||
Always Allow
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="tool-call-permission-button"
|
|
||||||
disabled={permissionSubmitting()}
|
|
||||||
onClick={() => void handlePermissionResponse(permission, "reject")}
|
|
||||||
>
|
|
||||||
Deny
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Show when={active}>
|
|
||||||
<div class="tool-call-permission-shortcuts">
|
|
||||||
<kbd class="kbd">Enter</kbd>
|
|
||||||
<span>Allow once</span>
|
|
||||||
<kbd class="kbd">A</kbd>
|
|
||||||
<span>Always allow</span>
|
|
||||||
<kbd class="kbd">D</kbd>
|
|
||||||
<span>Deny</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<Show when={permissionError()}>
|
|
||||||
<div class="tool-call-permission-error">{permissionError()}</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
const renderQuestionBlock = () => (
|
const renderQuestionBlock = () => (
|
||||||
<QuestionToolBlock
|
<QuestionToolBlock
|
||||||
|
|||||||
98
packages/ui/src/components/tool-call/ansi-render.tsx
Normal file
98
packages/ui/src/components/tool-call/ansi-render.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { Accessor, JSXElement } from "solid-js"
|
||||||
|
import type { RenderCache } from "../../types/message"
|
||||||
|
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||||
|
import { escapeHtml } from "../../lib/markdown"
|
||||||
|
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
|
|
||||||
|
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
||||||
|
|
||||||
|
type CacheHandle = {
|
||||||
|
get<T>(): T | undefined
|
||||||
|
set(value: unknown): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAnsiContentRenderer(params: {
|
||||||
|
ansiRunningCache: CacheHandle
|
||||||
|
ansiFinalCache: CacheHandle
|
||||||
|
scrollHelpers: ToolScrollHelpers
|
||||||
|
partVersion?: Accessor<number | undefined>
|
||||||
|
}) {
|
||||||
|
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<AnsiRenderCache>()
|
||||||
|
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 (
|
||||||
|
<div class={messageClass} ref={(element) => params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}>
|
||||||
|
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
|
||||||
|
{params.scrollHelpers.renderSentinel()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { renderAnsiContent }
|
||||||
|
}
|
||||||
53
packages/ui/src/components/tool-call/diagnostics-section.tsx
Normal file
53
packages/ui/src/components/tool-call/diagnostics-section.tsx
Normal file
@@ -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 (
|
||||||
|
<div class="tool-call-diagnostics-wrapper">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-diagnostics-heading"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<span class="tool-call-icon" aria-hidden="true">
|
||||||
|
{expanded ? "▼" : "▶"}
|
||||||
|
</span>
|
||||||
|
<span class="tool-call-emoji" aria-hidden="true">
|
||||||
|
🛠
|
||||||
|
</span>
|
||||||
|
<span class="tool-call-summary">Diagnostics</span>
|
||||||
|
<span class="tool-call-diagnostics-file" title={fileLabel}>
|
||||||
|
{fileLabel}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<Show when={expanded}>
|
||||||
|
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
|
||||||
|
<div class="tool-call-diagnostics-body" role="list">
|
||||||
|
<For each={entries}>
|
||||||
|
{(entry) => (
|
||||||
|
<div class="tool-call-diagnostic-row" role="listitem">
|
||||||
|
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
|
||||||
|
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
|
||||||
|
<span>{entry.label}</span>
|
||||||
|
</span>
|
||||||
|
<span class="tool-call-diagnostic-path" title={entry.filePath}>
|
||||||
|
{entry.displayPath}
|
||||||
|
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
|
||||||
|
</span>
|
||||||
|
<span class="tool-call-diagnostic-message">{entry.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
packages/ui/src/components/tool-call/diagnostics.ts
Normal file
106
packages/ui/src/components/tool-call/diagnostics.ts
Normal file
@@ -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<string, unknown>
|
||||||
|
const input = (state.input || {}) as Record<string, unknown>
|
||||||
|
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | 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 : ""
|
||||||
|
}
|
||||||
95
packages/ui/src/components/tool-call/diff-render.tsx
Normal file
95
packages/ui/src/components/tool-call/diff-render.tsx
Normal file
@@ -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>(): T | undefined
|
||||||
|
params(): unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiffPrefs = {
|
||||||
|
diffViewMode?: DiffViewMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDiffContentRenderer(params: {
|
||||||
|
preferences: Accessor<DiffPrefs>
|
||||||
|
setDiffViewMode: (mode: DiffViewMode) => void
|
||||||
|
isDark: Accessor<boolean>
|
||||||
|
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<RenderCache>()
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||||
|
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
|
||||||
|
onScroll={options?.disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||||
|
>
|
||||||
|
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||||
|
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||||
|
<div class="tool-call-diff-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
|
||||||
|
aria-pressed={diffMode() === "split"}
|
||||||
|
onClick={() => handleModeChange("split")}
|
||||||
|
>
|
||||||
|
Split
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
|
||||||
|
aria-pressed={diffMode() === "unified"}
|
||||||
|
onClick={() => handleModeChange("unified")}
|
||||||
|
>
|
||||||
|
Unified
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ToolCallDiffViewer
|
||||||
|
diffText={payload.diffText}
|
||||||
|
filePath={payload.filePath}
|
||||||
|
theme={themeKey}
|
||||||
|
mode={diffMode()}
|
||||||
|
cachedHtml={cachedHtml}
|
||||||
|
cacheEntryParams={cacheHandle.params() as any}
|
||||||
|
onRendered={handleDiffRendered}
|
||||||
|
/>
|
||||||
|
{params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { renderDiffContent }
|
||||||
|
}
|
||||||
66
packages/ui/src/components/tool-call/markdown-render.tsx
Normal file
66
packages/ui/src/components/tool-call/markdown-render.tsx
Normal file
@@ -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<ToolState | undefined>
|
||||||
|
partId: Accessor<string>
|
||||||
|
partVersion?: Accessor<number | undefined>
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
isDark: Accessor<boolean>
|
||||||
|
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 (
|
||||||
|
<div class={messageClass} ref={(element) => params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}>
|
||||||
|
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
||||||
|
{params.scrollHelpers.renderSentinel()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownPart: TextPart = {
|
||||||
|
id: params.partId(),
|
||||||
|
type: "text",
|
||||||
|
text: options.content,
|
||||||
|
version: params.partVersion?.(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMarkdownRendered = () => {
|
||||||
|
params.handleScrollRendered()
|
||||||
|
params.onContentRendered?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={messageClass} ref={(element) => params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}>
|
||||||
|
<Markdown
|
||||||
|
part={markdownPart}
|
||||||
|
instanceId={params.instanceId}
|
||||||
|
sessionId={params.sessionId}
|
||||||
|
isDark={params.isDark()}
|
||||||
|
disableHighlight={disableHighlight}
|
||||||
|
onRendered={handleMarkdownRendered}
|
||||||
|
/>
|
||||||
|
{params.scrollHelpers.renderSentinel()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { renderMarkdownContent }
|
||||||
|
}
|
||||||
120
packages/ui/src/components/tool-call/permission-block.tsx
Normal file
120
packages/ui/src/components/tool-call/permission-block.tsx
Normal file
@@ -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<PermissionRequestLike | undefined>
|
||||||
|
active: Accessor<boolean>
|
||||||
|
submitting: Accessor<boolean>
|
||||||
|
error: Accessor<string | null>
|
||||||
|
onRespond: (permission: PermissionRequestLike, sessionId: string, response: PermissionResponse) => void | Promise<void>
|
||||||
|
renderDiff: (payload: DiffPayload, options?: DiffRenderOptions) => JSXElement | null
|
||||||
|
fallbackSessionId: Accessor<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
||||||
|
const diffPayload = () => {
|
||||||
|
const permission = props.permission()
|
||||||
|
if (!permission) return null
|
||||||
|
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
|
||||||
|
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 (
|
||||||
|
<Show when={props.permission()}>
|
||||||
|
{(permission) => (
|
||||||
|
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||||
|
<div class="tool-call-permission-header">
|
||||||
|
<span class="tool-call-permission-label">{props.active() ? "Permission Required" : "Permission Queued"}</span>
|
||||||
|
<span class="tool-call-permission-type">{getPermissionKind(permission())}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tool-call-permission-body">
|
||||||
|
<div class="tool-call-permission-title">
|
||||||
|
<code>{getPermissionDisplayTitle(permission())}</code>
|
||||||
|
</div>
|
||||||
|
<Show when={diffPayload()}>
|
||||||
|
{(payload) => (
|
||||||
|
<div class="tool-call-permission-diff">
|
||||||
|
{props.renderDiff(payload(), {
|
||||||
|
variant: "permission-diff",
|
||||||
|
disableScrollTracking: true,
|
||||||
|
label: payload().filePath
|
||||||
|
? `Requested diff · ${getRelativePath(payload().filePath || "")}`
|
||||||
|
: "Requested diff",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={!props.active()}>
|
||||||
|
<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>
|
||||||
|
</Show>
|
||||||
|
<div class="tool-call-permission-actions">
|
||||||
|
<div class="tool-call-permission-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={props.submitting()}
|
||||||
|
onClick={() => respond("once")}
|
||||||
|
>
|
||||||
|
Allow Once
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={props.submitting()}
|
||||||
|
onClick={() => respond("always")}
|
||||||
|
>
|
||||||
|
Always Allow
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={props.submitting()}
|
||||||
|
onClick={() => respond("reject")}
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Show when={props.active()}>
|
||||||
|
<div class="tool-call-permission-shortcuts">
|
||||||
|
<kbd class="kbd">Enter</kbd>
|
||||||
|
<span>Allow once</span>
|
||||||
|
<kbd class="kbd">A</kbd>
|
||||||
|
<span>Always allow</span>
|
||||||
|
<kbd class="kbd">D</kbd>
|
||||||
|
<span>Deny</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={props.error()}>
|
||||||
|
<div class="tool-call-permission-error">{props.error()}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
311
packages/ui/src/components/tool-call/question-block.tsx
Normal file
311
packages/ui/src/components/tool-call/question-block.tsx
Normal file
@@ -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<string>
|
||||||
|
toolState: Accessor<ToolState | undefined>
|
||||||
|
toolCallId: Accessor<string>
|
||||||
|
request: Accessor<QuestionRequest | undefined>
|
||||||
|
active: Accessor<boolean>
|
||||||
|
submitting: Accessor<boolean>
|
||||||
|
error: Accessor<string | null>
|
||||||
|
draftAnswers: Accessor<Record<string, string[][]>>
|
||||||
|
setDraftAnswers: (updater: (prev: Record<string, string[][]>) => Record<string, string[][]>) => void
|
||||||
|
onSubmit: () => void | Promise<void>
|
||||||
|
onDismiss: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Show when={isVisible() && questions().length > 0}>
|
||||||
|
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||||
|
<div class="tool-call-permission-header">
|
||||||
|
<span class="tool-call-permission-label">
|
||||||
|
{props.active() ? "Question Required" : props.request() ? "Question Queued" : "Questions"}
|
||||||
|
</span>
|
||||||
|
<span class="tool-call-permission-type">{questions().length === 1 ? "Question" : "Questions"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-call-permission-body">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<For each={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 (
|
||||||
|
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<div class="text-xs">
|
||||||
|
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
|
||||||
|
</div>
|
||||||
|
<Show when={multi()}>
|
||||||
|
<div class="text-xs text-muted">Multiple</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 text-sm font-medium">{q?.question}</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-col gap-1">
|
||||||
|
<For each={q?.options ?? []}>
|
||||||
|
{(opt) => {
|
||||||
|
const checked = () => selected().includes(opt.label)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
class={`flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
||||||
|
title={opt.description}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type={inputType()}
|
||||||
|
name={groupName()}
|
||||||
|
checked={checked()}
|
||||||
|
disabled={!props.active() || props.submitting()}
|
||||||
|
onChange={() => toggleOption(i(), opt.label)}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="text-sm leading-tight">{opt.label}</div>
|
||||||
|
<div class="text-xs text-muted leading-tight">{opt.description}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class={`mt-2 flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
||||||
|
title="Type a custom answer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type={inputType()}
|
||||||
|
name={groupName()}
|
||||||
|
checked={customChecked()}
|
||||||
|
disabled={!props.active() || props.submitting()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const container = e.currentTarget.closest("label")
|
||||||
|
const input = container?.querySelector("input[type='text']") as HTMLInputElement | null
|
||||||
|
if (!props.active()) return
|
||||||
|
if (customChecked()) {
|
||||||
|
clearCustomAnswer(i(), customSelected())
|
||||||
|
if (input) {
|
||||||
|
delete input.dataset.lastValue
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toggleFromCustomInput(i(), input)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-1 flex-col gap-2">
|
||||||
|
<div class="text-sm leading-tight">Custom answer</div>
|
||||||
|
<input
|
||||||
|
class="w-full rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
|
||||||
|
type="text"
|
||||||
|
placeholder="Type your own answer"
|
||||||
|
disabled={!props.active() || props.submitting()}
|
||||||
|
value={customValue()}
|
||||||
|
onFocus={(e) => {
|
||||||
|
if (!props.active()) return
|
||||||
|
// Keep the radio/checkbox selected while editing.
|
||||||
|
toggleFromCustomInput(i(), e.currentTarget)
|
||||||
|
}}
|
||||||
|
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<Show when={props.active()}>
|
||||||
|
<div class="tool-call-permission-actions">
|
||||||
|
<div class="tool-call-permission-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={submitDisabled()}
|
||||||
|
onClick={() => props.onSubmit()}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={props.submitting()}
|
||||||
|
onClick={() => props.onDismiss()}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-call-permission-shortcuts">
|
||||||
|
<kbd class="kbd">Enter</kbd>
|
||||||
|
<span>Submit</span>
|
||||||
|
<kbd class="kbd">Esc</kbd>
|
||||||
|
<span>Dismiss</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={props.error()}>
|
||||||
|
<div class="tool-call-permission-error">{props.error()}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!props.active() && props.request()}>
|
||||||
|
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user