198 lines
6.9 KiB
TypeScript
198 lines
6.9 KiB
TypeScript
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<string, LspDiagnostic[] | undefined>,
|
|
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<string, LspDiagnostic[] | undefined>,
|
|
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 (
|
|
<Show when={props.entries.length > 0}>
|
|
<div class="tool-call-diagnostics-wrapper">
|
|
<div class="tool-call-diagnostics" role="region" aria-label={`Diagnostics ${props.label}`}
|
|
>
|
|
<div class="tool-call-diagnostics-body" role="list">
|
|
<For each={props.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>
|
|
</div>
|
|
</Show>
|
|
)
|
|
}
|
|
|
|
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<string, LspDiagnostic[] | undefined>) : {}
|
|
})
|
|
|
|
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 (
|
|
<div class="tool-call-apply-patch">
|
|
<For each={files()}>
|
|
{(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 (
|
|
<div class="tool-call-apply-patch-file">
|
|
<Show when={diffText.trim().length > 0}>
|
|
{renderDiff(
|
|
{ diffText, filePath },
|
|
{
|
|
label: `Diff · ${getRelativePath(labelBase)}`,
|
|
cacheKey: `apply_patch:${labelBase}:${index()}`,
|
|
},
|
|
)}
|
|
</Show>
|
|
<DiagnosticsInline entries={entries()} label={labelBase} />
|
|
</div>
|
|
)
|
|
}}
|
|
</For>
|
|
</div>
|
|
)
|
|
},
|
|
}
|