import { Component, createEffect, createMemo, createSignal, For, Match, Show, Switch, onCleanup, type JSX, } from "solid-js" import stripAnsi from "strip-ansi" import { Dynamic } from "solid-js/web" import { AgentPart, AssistantMessage, FilePart, Message as MessageType, Part as PartType, ReasoningPart, TextPart, ToolPart, UserMessage, Todo, QuestionRequest, QuestionAnswer, QuestionInfo, } from "@opencode-ai/sdk/v2" import { createStore } from "solid-js/store" import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { useCodeComponent } from "../context/code" import { useDialog } from "../context/dialog" import { useI18n } from "../context/i18n" import { BasicTool } from "./basic-tool" import { GenericTool } from "./basic-tool" import { Button } from "./button" import { Card } from "./card" import { Icon } from "./icon" import { Checkbox } from "./checkbox" import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { createAutoScroll } from "../hooks" import { createResizeObserver } from "@solid-primitives/resize-observer" interface Diagnostic { range: { start: { line: number; character: number } end: { line: number; character: number } } message: string severity?: number } function getDiagnostics( diagnosticsByFile: Record | undefined, filePath: string | undefined, ): Diagnostic[] { if (!diagnosticsByFile || !filePath) return [] const diagnostics = diagnosticsByFile[filePath] ?? [] return diagnostics.filter((d) => d.severity === 1).slice(0, 3) } function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { const i18n = useI18n() return ( 0}>
{(diagnostic) => (
{i18n.t("ui.messagePart.diagnostic.error")} [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
)}
) } export interface MessageProps { message: MessageType parts: PartType[] } export interface MessagePartProps { part: PartType message: MessageType hideDetails?: boolean defaultOpen?: boolean } export type PartComponent = Component export const PART_MAPPING: Record = {} const TEXT_RENDER_THROTTLE_MS = 100 function same(a: readonly T[], b: readonly T[]) { if (a === b) return true if (a.length !== b.length) return false return a.every((x, i) => x === b[i]) } function createThrottledValue(getValue: () => string) { const [value, setValue] = createSignal(getValue()) let timeout: ReturnType | undefined let last = 0 createEffect(() => { const next = getValue() const now = Date.now() const remaining = TEXT_RENDER_THROTTLE_MS - (now - last) if (remaining <= 0) { if (timeout) { clearTimeout(timeout) timeout = undefined } last = now setValue(next) return } if (timeout) clearTimeout(timeout) timeout = setTimeout(() => { last = Date.now() setValue(next) timeout = undefined }, remaining) }) onCleanup(() => { if (timeout) clearTimeout(timeout) }) return value } function relativizeProjectPaths(text: string, directory?: string) { if (!text) return "" if (!directory) return text return text.split(directory).join("") } function getDirectory(path: string | undefined) { const data = useData() return relativizeProjectPaths(_getDirectory(path), data.directory) } export function getSessionToolParts(store: ReturnType["store"], sessionId: string): ToolPart[] { const messages = store.message[sessionId]?.filter((m) => m.role === "assistant") if (!messages) return [] const parts: ToolPart[] = [] for (const m of messages) { const msgParts = store.part[m.id] if (msgParts) { for (const p of msgParts) { if (p && p.type === "tool") parts.push(p as ToolPart) } } } return parts } import type { IconProps } from "./icon" export type ToolInfo = { icon: IconProps["name"] title: string subtitle?: string } export function getToolInfo(tool: string, input: any = {}): ToolInfo { const i18n = useI18n() switch (tool) { case "read": return { icon: "glasses", title: i18n.t("ui.tool.read"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } case "list": return { icon: "bullet-list", title: i18n.t("ui.tool.list"), subtitle: input.path ? getFilename(input.path) : undefined, } case "glob": return { icon: "magnifying-glass-menu", title: i18n.t("ui.tool.glob"), subtitle: input.pattern, } case "grep": return { icon: "magnifying-glass-menu", title: i18n.t("ui.tool.grep"), subtitle: input.pattern, } case "webfetch": return { icon: "window-cursor", title: i18n.t("ui.tool.webfetch"), subtitle: input.url, } case "task": return { icon: "task", title: i18n.t("ui.tool.agent", { type: input.subagent_type || "task" }), subtitle: input.description, } case "bash": return { icon: "console", title: i18n.t("ui.tool.shell"), subtitle: input.description, } case "edit": return { icon: "code-lines", title: i18n.t("ui.messagePart.title.edit"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } case "write": return { icon: "code-lines", title: i18n.t("ui.messagePart.title.write"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } case "apply_patch": return { icon: "code-lines", title: i18n.t("ui.tool.patch"), subtitle: input.files?.length ? `${input.files.length} ${i18n.t(input.files.length > 1 ? "ui.common.file.other" : "ui.common.file.one")}` : undefined, } case "todowrite": return { icon: "checklist", title: i18n.t("ui.tool.todos"), } case "todoread": return { icon: "checklist", title: i18n.t("ui.tool.todos.read"), } case "question": return { icon: "bubble-5", title: i18n.t("ui.tool.questions"), } default: return { icon: "mcp", title: tool, } } } export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } export function Message(props: MessageProps) { return ( {(userMessage) => } {(assistantMessage) => ( )} ) } export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) { const emptyParts: PartType[] = [] const filteredParts = createMemo( () => props.parts.filter((x) => { return x.type !== "tool" || (x as ToolPart).tool !== "todoread" }), emptyParts, { equals: same }, ) return {(part) => } } export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { const dialog = useDialog() const i18n = useI18n() const [copied, setCopied] = createSignal(false) const [expanded, setExpanded] = createSignal(false) const [canExpand, setCanExpand] = createSignal(false) let textRef: HTMLDivElement | undefined const updateCanExpand = () => { const el = textRef if (!el) return if (expanded()) return setCanExpand(el.scrollHeight > el.clientHeight + 2) } createResizeObserver( () => textRef, () => { updateCanExpand() }, ) const textPart = createMemo( () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined, ) const text = createMemo(() => textPart()?.text || "") createEffect(() => { text() updateCanExpand() }) const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? []) const attachments = createMemo(() => files()?.filter((f) => { const mime = f.mime return mime.startsWith("image/") || mime === "application/pdf" }), ) const inlineFiles = createMemo(() => files().filter((f) => { const mime = f.mime return !mime.startsWith("image/") && mime !== "application/pdf" && f.source?.text?.start !== undefined }), ) const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? []) const openImagePreview = (url: string, alt?: string) => { dialog.show(() => ) } const handleCopy = async () => { const content = text() if (!content) return await navigator.clipboard.writeText(content) setCopied(true) setTimeout(() => setCopied(false), 2000) } const toggleExpanded = () => { if (!canExpand()) return setExpanded((value) => !value) } return (
0}>
{(file) => (
{ if (file.mime.startsWith("image/") && file.url) { openImagePreview(file.url, file.filename) } }} >
} > {file.filename
)}
(textRef = el)} onClick={toggleExpanded}>
e.preventDefault()} onClick={(event) => { event.stopPropagation() handleCopy() }} aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} />
) } type HighlightSegment = { text: string; type?: "file" | "agent" } function HighlightedText(props: { text: string; references: FilePart[]; agents: AgentPart[] }) { const segments = createMemo(() => { const text = props.text const allRefs: { start: number; end: number; type: "file" | "agent" }[] = [ ...props.references .filter((r) => r.source?.text?.start !== undefined && r.source?.text?.end !== undefined) .map((r) => ({ start: r.source!.text!.start, end: r.source!.text!.end, type: "file" as const })), ...props.agents .filter((a) => a.source?.start !== undefined && a.source?.end !== undefined) .map((a) => ({ start: a.source!.start, end: a.source!.end, type: "agent" as const })), ].sort((a, b) => a.start - b.start) const result: HighlightSegment[] = [] let lastIndex = 0 for (const ref of allRefs) { if (ref.start < lastIndex) continue if (ref.start > lastIndex) { result.push({ text: text.slice(lastIndex, ref.start) }) } result.push({ text: text.slice(ref.start, ref.end), type: ref.type }) lastIndex = ref.end } if (lastIndex < text.length) { result.push({ text: text.slice(lastIndex) }) } return result }) return ( {(segment) => ( {segment.text} )} ) } export function Part(props: MessagePartProps) { const component = createMemo(() => PART_MAPPING[props.part.type]) return ( ) } export interface ToolProps { input: Record metadata: Record tool: string output?: string status?: string hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean locked?: boolean } export type ToolComponent = Component const state: Record< string, { name: string render?: ToolComponent } > = {} export function registerTool(input: { name: string; render?: ToolComponent }) { state[input.name] = input return input } export function getTool(name: string) { return state[name]?.render } export const ToolRegistry = { register: registerTool, render: getTool, } PART_MAPPING["tool"] = function ToolPartDisplay(props) { const data = useData() const i18n = useI18n() const part = props.part as ToolPart const permission = createMemo(() => { const next = data.store.permission?.[props.message.sessionID]?.[0] if (!next || !next.tool) return undefined if (next.tool!.callID !== part.callID) return undefined return next }) const questionRequest = createMemo(() => { const next = data.store.question?.[props.message.sessionID]?.[0] if (!next || !next.tool) return undefined if (next.tool!.callID !== part.callID) return undefined return next }) const [showPermission, setShowPermission] = createSignal(false) const [showQuestion, setShowQuestion] = createSignal(false) createEffect(() => { const perm = permission() if (perm) { const timeout = setTimeout(() => setShowPermission(true), 50) onCleanup(() => clearTimeout(timeout)) } else { setShowPermission(false) } }) createEffect(() => { const question = questionRequest() if (question) { const timeout = setTimeout(() => setShowQuestion(true), 50) onCleanup(() => clearTimeout(timeout)) } else { setShowQuestion(false) } }) const [forceOpen, setForceOpen] = createSignal(false) createEffect(() => { if (permission() || questionRequest()) setForceOpen(true) }) const respond = (response: "once" | "always" | "reject") => { const perm = permission() if (!perm || !data.respondToPermission) return data.respondToPermission({ sessionID: perm.sessionID, permissionID: perm.id, response, }) } const emptyInput: Record = {} const emptyMetadata: Record = {} const input = () => part.state?.input ?? emptyInput // @ts-expect-error const metadata = () => part.state?.metadata ?? emptyMetadata const render = ToolRegistry.render(part.tool) ?? GenericTool return (
{(error) => { const cleaned = error().replace("Error: ", "") const [title, ...rest] = cleaned.split(": ") return (
{title}
{rest.join(": ")}
{cleaned}
) }}
{(request) => }
) } PART_MAPPING["text"] = function TextPartDisplay(props) { const data = useData() const i18n = useI18n() const part = props.part as TextPart const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory) const throttledText = createThrottledValue(displayText) const [copied, setCopied] = createSignal(false) const handleCopy = async () => { const content = displayText() if (!content) return await navigator.clipboard.writeText(content) setCopied(true) setTimeout(() => setCopied(false), 2000) } return (
e.preventDefault()} onClick={handleCopy} aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} />
) } PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { const part = props.part as ReasoningPart const text = () => part.text.trim() const throttledText = createThrottledValue(text) return (
) } ToolRegistry.register({ name: "read", render(props) { const i18n = useI18n() const args: string[] = [] if (props.input.offset) args.push("offset=" + props.input.offset) if (props.input.limit) args.push("limit=" + props.input.limit) return ( ) }, }) ToolRegistry.register({ name: "list", render(props) { const i18n = useI18n() return ( {(output) => (
)}
) }, }) ToolRegistry.register({ name: "glob", render(props) { const i18n = useI18n() return ( )} ) }, }) ToolRegistry.register({ name: "grep", render(props) { const i18n = useI18n() const args: string[] = [] if (props.input.pattern) args.push("pattern=" + props.input.pattern) if (props.input.include) args.push("include=" + props.input.include) return ( {(output) => (
)}
) }, }) ToolRegistry.register({ name: "webfetch", render(props) { const i18n = useI18n() return ( ), }} > {(output) => (
)}
) }, }) ToolRegistry.register({ name: "task", render(props) { const data = useData() const i18n = useI18n() const summary = () => (props.metadata.summary ?? []) as { id: string; tool: string; state: { status: string; title?: string } }[] const autoScroll = createAutoScroll({ working: () => true, overflowAnchor: "auto", }) const childSessionId = () => props.metadata.sessionId as string | undefined const childPermission = createMemo(() => { const sessionId = childSessionId() if (!sessionId) return undefined const permissions = data.store.permission?.[sessionId] ?? [] return permissions[0] }) const childToolPart = createMemo(() => { const perm = childPermission() if (!perm || !perm.tool) return undefined const sessionId = childSessionId() if (!sessionId) return undefined // Find the tool part that matches the permission's callID const messages = data.store.message[sessionId] ?? [] const message = messages.findLast((m) => m.id === perm.tool!.messageID) if (!message) return undefined const parts = data.store.part[message.id] ?? [] for (const part of parts) { if (part.type === "tool" && (part as ToolPart).callID === perm.tool!.callID) { return { part: part as ToolPart, message } } } return undefined }) const respond = (response: "once" | "always" | "reject") => { const perm = childPermission() if (!perm || !data.respondToPermission) return data.respondToPermission({ sessionID: perm.sessionID, permissionID: perm.id, response, }) } const handleSubtitleClick = () => { const sessionId = childSessionId() if (sessionId && data.navigateToSession) { data.navigateToSession(sessionId) } } const renderChildToolPart = () => { const toolData = childToolPart() if (!toolData) return null const { part } = toolData const render = ToolRegistry.render(part.tool) ?? GenericTool // @ts-expect-error const metadata = part.state?.metadata ?? {} const input = part.state?.input ?? {} return ( ) } return (
<> } > {renderChildToolPart()}
{(item) => { const info = getToolInfo(item.tool) return (
{info.title} {item.state.title}
) }}
) }, }) ToolRegistry.register({ name: "bash", render(props) { const i18n = useI18n() return (
) }, }) ToolRegistry.register({ name: "edit", render(props) { const i18n = useI18n() const diffComponent = useDiffComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") return (
{i18n.t("ui.messagePart.title.edit")} {filename()}
{getDirectory(props.input.filePath!)}
} >
) }, }) ToolRegistry.register({ name: "write", render(props) { const i18n = useI18n() const codeComponent = useCodeComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") return (
{i18n.t("ui.messagePart.title.write")} {filename()}
{getDirectory(props.input.filePath!)}
{/* */}
} >
) }, }) interface ApplyPatchFile { filePath: string relativePath: string type: "add" | "update" | "delete" | "move" diff: string before: string after: string additions: number deletions: number movePath?: string } ToolRegistry.register({ name: "apply_patch", render(props) { const i18n = useI18n() const diffComponent = useDiffComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" return `${count} ${i18n.t(count > 1 ? "ui.common.file.other" : "ui.common.file.one")}` }) return ( 0}>
{(file) => (
{i18n.t("ui.patch.action.deleted")} {i18n.t("ui.patch.action.created")} {i18n.t("ui.patch.action.moved")} {i18n.t("ui.patch.action.patched")} {file.relativePath} -{file.deletions}
)}
) }, }) ToolRegistry.register({ name: "todowrite", render(props) { const i18n = useI18n() const todos = createMemo(() => { const meta = props.metadata?.todos if (Array.isArray(meta)) return meta const input = props.input.todos if (Array.isArray(input)) return input return [] }) const subtitle = createMemo(() => { const list = todos() if (list.length === 0) return "" return `${list.filter((t: Todo) => t.status === "completed").length}/${list.length}` }) return (
{(todo: Todo) => (
{todo.content}
)}
) }, }) ToolRegistry.register({ name: "question", render(props) { const i18n = useI18n() const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) const completed = createMemo(() => answers().length > 0) const subtitle = createMemo(() => { const count = questions().length if (count === 0) return "" if (completed()) return i18n.t("ui.question.subtitle.answered", { count }) return `${count} ${i18n.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` }) return (
{(q, i) => { const answer = () => answers()[i()] ?? [] return (
{q.question}
{answer().join(", ") || i18n.t("ui.question.answer.none")}
) }}
) }, }) function QuestionPrompt(props: { request: QuestionRequest }) { const data = useData() const i18n = useI18n() const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) const [store, setStore] = createStore({ tab: 0, answers: [] as QuestionAnswer[], custom: [] as string[], editing: false, }) const question = createMemo(() => questions()[store.tab]) const confirm = createMemo(() => !single() && store.tab === questions().length) const options = createMemo(() => question()?.options ?? []) const input = createMemo(() => store.custom[store.tab] ?? "") const multi = createMemo(() => question()?.multiple === true) const customPicked = createMemo(() => { const value = input() if (!value) return false return store.answers[store.tab]?.includes(value) ?? false }) function submit() { const answers = questions().map((_, i) => store.answers[i] ?? []) data.replyToQuestion?.({ requestID: props.request.id, answers, }) } function reject() { data.rejectQuestion?.({ requestID: props.request.id, }) } function pick(answer: string, custom: boolean = false) { const answers = [...store.answers] answers[store.tab] = [answer] setStore("answers", answers) if (custom) { const inputs = [...store.custom] inputs[store.tab] = answer setStore("custom", inputs) } if (single()) { data.replyToQuestion?.({ requestID: props.request.id, answers: [[answer]], }) return } setStore("tab", store.tab + 1) } function toggle(answer: string) { const existing = store.answers[store.tab] ?? [] const next = [...existing] const index = next.indexOf(answer) if (index === -1) next.push(answer) if (index !== -1) next.splice(index, 1) const answers = [...store.answers] answers[store.tab] = next setStore("answers", answers) } function selectTab(index: number) { setStore("tab", index) setStore("editing", false) } function selectOption(optIndex: number) { if (optIndex === options().length) { setStore("editing", true) return } const opt = options()[optIndex] if (!opt) return if (multi()) { toggle(opt.label) return } pick(opt.label) } function handleCustomSubmit(e: Event) { e.preventDefault() const value = input().trim() if (!value) { setStore("editing", false) return } if (multi()) { const existing = store.answers[store.tab] ?? [] const next = [...existing] if (!next.includes(value)) next.push(value) const answers = [...store.answers] answers[store.tab] = next setStore("answers", answers) setStore("editing", false) return } pick(value, true) setStore("editing", false) } return (
{(q, index) => { const active = () => index() === store.tab const answered = () => (store.answers[index()]?.length ?? 0) > 0 return ( ) }}
{question()?.question} {multi() ? " " + i18n.t("ui.question.multiHint") : ""}
{(opt, i) => { const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false return ( ) }}
setTimeout(() => el.focus(), 0)} type="text" data-slot="custom-input" placeholder={i18n.t("ui.question.custom.placeholder")} value={input()} onInput={(e) => { const inputs = [...store.custom] inputs[store.tab] = e.currentTarget.value setStore("custom", inputs) }} />
{i18n.t("ui.messagePart.review.title")}
{(q, index) => { const value = () => store.answers[index()]?.join(", ") ?? "" const answered = () => Boolean(value()) return (
{q.question} {answered() ? value() : i18n.t("ui.question.review.notAnswered")}
) }}
) }