opencode/packages/web/src/components/Share.tsx
2025-06-24 19:28:51 -04:00

1980 lines
76 KiB
TypeScript

import { type JSX } from "solid-js"
import {
For,
Show,
Match,
Switch,
onMount,
onCleanup,
splitProps,
createMemo,
createEffect,
createSignal,
} from "solid-js"
import map from "lang-map"
import { DateTime } from "luxon"
import { createStore, reconcile } from "solid-js/store"
import type { Diagnostic } from "vscode-languageserver-types"
import {
IconOpenAI,
IconGemini,
IconOpencode,
IconAnthropic,
} from "./icons/custom"
import {
IconFolder,
IconCpuChip,
IconHashtag,
IconSparkles,
IconGlobeAlt,
IconDocument,
IconQueueList,
IconUserCircle,
IconCheckCircle,
IconChevronDown,
IconCommandLine,
IconChevronRight,
IconDocumentPlus,
IconPencilSquare,
IconRectangleStack,
IconMagnifyingGlass,
IconWrenchScrewdriver,
IconDocumentMagnifyingGlass,
} from "./icons"
import DiffView from "./DiffView"
import CodeBlock from "./CodeBlock"
import MarkdownView from "./MarkdownView"
import styles from "./share.module.css"
import type { Message } from "opencode/session/message"
import type { Session } from "opencode/session/index"
const MIN_DURATION = 2
type Status =
| "disconnected"
| "connecting"
| "connected"
| "error"
| "reconnecting"
type TodoStatus = "pending" | "in_progress" | "completed"
interface Todo {
id: string
content: string
status: TodoStatus
priority: "low" | "medium" | "high"
}
function sortTodosByStatus(todos: Todo[]) {
const statusPriority: Record<TodoStatus, number> = {
in_progress: 0,
pending: 1,
completed: 2,
}
return todos
.slice()
.sort((a, b) => statusPriority[a.status] - statusPriority[b.status])
}
function scrollToAnchor(id: string) {
const el = document.getElementById(id)
if (!el) return
el.scrollIntoView({ behavior: "smooth" })
}
function stripWorkingDirectory(filePath: string, workingDir?: string) {
if (filePath === undefined || workingDir === undefined) return filePath
const prefix = workingDir.endsWith("/") ? workingDir : workingDir + "/"
if (filePath === workingDir) {
return ""
}
if (filePath.startsWith(prefix)) {
return filePath.slice(prefix.length)
}
return filePath
}
function getShikiLang(filename: string) {
const ext = filename
.split('.')
.pop()
?.toLowerCase() ?? ''
// map.languages(ext) returns an array of matching Linguist language names (e.g. ['TypeScript'])
const langs = map.languages(ext)
const type = langs?.[0]?.toLowerCase()
// Overrride any specific language mappings
const overrides: Record<string, string> = {
"conf": "shellscript"
}
return type
? overrides[type] ?? type
: 'plaintext'
}
function formatDuration(ms: number): string {
const ONE_SECOND = 1000
const ONE_MINUTE = 60 * ONE_SECOND
if (ms >= ONE_MINUTE) {
const minutes = Math.floor(ms / ONE_MINUTE)
return minutes === 1 ? `1min` : `${minutes}mins`
}
if (ms >= ONE_SECOND) {
const seconds = Math.floor(ms / ONE_SECOND)
return `${seconds}s`
}
return `${ms}ms`
}
// Converts nested objects/arrays into [path, value] pairs.
// E.g. {a:{b:{c:1}}, d:[{e:2}, 3]} => [["a.b.c",1], ["d[0].e",2], ["d[1]",3]]
function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> {
const entries: Array<[string, any]> = []
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key
if (value !== null && typeof value === "object") {
if (Array.isArray(value)) {
value.forEach((item, index) => {
const arrayPath = `${path}[${index}]`
if (item !== null && typeof item === "object") {
entries.push(...flattenToolArgs(item, arrayPath))
} else {
entries.push([arrayPath, item])
}
})
} else {
entries.push(...flattenToolArgs(value, path))
}
} else {
entries.push([path, value])
}
}
return entries
}
function formatErrorString(error: string): JSX.Element {
const errorMarker = "Error: "
const startsWithError = error.startsWith(errorMarker)
return startsWithError ? (
<pre>
<span data-color="red" data-marker="label" data-separator>
Error
</span>
<span>{error.slice(errorMarker.length)}</span>
</pre>
) : (
<pre>
<span data-color="dimmed">{error}</span>
</pre>
)
}
function getDiagnostics(
diagnosticsByFile: Record<string, Diagnostic[]>,
currentFile: string,
): JSX.Element[] {
// Return a flat array of error diagnostics, in the format:
// "Error [65:20] Property 'x' does not exist on type 'Y'"
const result: JSX.Element[] = []
if (
diagnosticsByFile === undefined ||
diagnosticsByFile[currentFile] === undefined
)
return result
for (const diags of Object.values(diagnosticsByFile)) {
for (const d of diags) {
// Only keep diagnostics explicitly marked as Error (severity === 1)
if (d.severity !== 1) continue
const line = d.range.start.line + 1 // 1-based
const column = d.range.start.character + 1 // 1-based
result.push(
<pre>
<span data-color="red" data-marker="label">
Error
</span>
<span data-color="dimmed" data-separator>
[{line}:{column}]
</span>
<span>{d.message}</span>
</pre>,
)
}
}
return result
}
function stripEnclosingTag(text: string): string {
const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
const match = text.match(wrappedRe)
return match ? match[2] : text
}
function getStatusText(status: [Status, string?]): string {
switch (status[0]) {
case "connected":
return "Connected, waiting for messages..."
case "connecting":
return "Connecting..."
case "disconnected":
return "Disconnected"
case "reconnecting":
return "Reconnecting..."
case "error":
return status[1] || "Error"
default:
return "Unknown"
}
}
function ProviderIcon(props: { provider: string; size?: number }) {
const size = props.size || 16
return (
<Switch fallback={<IconSparkles width={size} height={size} />}>
<Match when={props.provider === "openai"}>
<IconOpenAI width={size} height={size} />
</Match>
<Match when={props.provider === "anthropic"}>
<IconAnthropic width={size} height={size} />
</Match>
<Match when={props.provider === "gemini"}>
<IconGemini width={size} height={size} />
</Match>
</Switch>
)
}
interface ResultsButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> {
showCopy?: string
hideCopy?: string
results: boolean
}
function ResultsButton(props: ResultsButtonProps) {
const [local, rest] = splitProps(props, ["results", "showCopy", "hideCopy"])
return (
<button
type="button"
data-element-button-text
data-element-button-more
{...rest}
>
<span>
{local.results
? local.hideCopy || "Hide results"
: local.showCopy || "Show results"}
</span>
<span data-button-icon>
<Show
when={local.results}
fallback={<IconChevronRight width={11} height={11} />}
>
<IconChevronDown width={11} height={11} />
</Show>
</span>
</button>
)
}
interface TextPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
text: string
expand?: boolean
invert?: boolean
highlight?: boolean
}
function TextPart(props: TextPartProps) {
const [local, rest] = splitProps(props, [
"text",
"expand",
"invert",
"highlight",
])
const [expanded, setExpanded] = createSignal(false)
const [overflowed, setOverflowed] = createSignal(false)
let preEl: HTMLPreElement | undefined
function checkOverflow() {
if (preEl && !local.expand) {
setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1)
}
}
onMount(() => {
checkOverflow()
window.addEventListener("resize", checkOverflow)
})
createEffect(() => {
local.text
setTimeout(checkOverflow, 0)
})
onCleanup(() => {
window.removeEventListener("resize", checkOverflow)
})
return (
<div
class={styles["message-text"]}
data-invert={local.invert}
data-highlight={local.highlight}
data-expanded={expanded() || local.expand === true}
{...rest}
>
<pre ref={(el) => (preEl = el)}>{local.text}</pre>
{((!local.expand && overflowed()) || expanded()) && (
<button
type="button"
data-element-button-text
onClick={() => setExpanded((e) => !e)}
>
{expanded() ? "Show less" : "Show more"}
</button>
)}
</div>
)
}
interface ErrorPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
expand?: boolean
}
function ErrorPart(props: ErrorPartProps) {
const [local, rest] = splitProps(props, ["expand", "children"])
const [expanded, setExpanded] = createSignal(false)
const [overflowed, setOverflowed] = createSignal(false)
let preEl: HTMLElement | undefined
function checkOverflow() {
if (preEl && !local.expand) {
setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1)
}
}
onMount(() => {
checkOverflow()
window.addEventListener("resize", checkOverflow)
})
createEffect(() => {
local.children
setTimeout(checkOverflow, 0)
})
onCleanup(() => {
window.removeEventListener("resize", checkOverflow)
})
return (
<div
class={styles["message-error"]}
data-expanded={expanded() || local.expand === true}
{...rest}
>
<div data-section="content" ref={(el) => (preEl = el)}>
{local.children}
</div>
{((!local.expand && overflowed()) || expanded()) && (
<button
type="button"
data-element-button-text
onClick={() => setExpanded((e) => !e)}
>
{expanded() ? "Show less" : "Show more"}
</button>
)}
</div>
)
}
interface MarkdownPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
text: string
expand?: boolean
highlight?: boolean
}
function MarkdownPart(props: MarkdownPartProps) {
const [local, rest] = splitProps(props, ["text", "expand", "highlight"])
const [expanded, setExpanded] = createSignal(false)
const [overflowed, setOverflowed] = createSignal(false)
let divEl: HTMLDivElement | undefined
function checkOverflow() {
if (divEl && !local.expand) {
setOverflowed(divEl.scrollHeight > divEl.clientHeight + 1)
}
}
onMount(() => {
checkOverflow()
window.addEventListener("resize", checkOverflow)
})
createEffect(() => {
local.text
setTimeout(checkOverflow, 0)
})
onCleanup(() => {
window.removeEventListener("resize", checkOverflow)
})
return (
<div
class={styles["message-markdown"]}
data-highlight={local.highlight}
data-expanded={expanded() || local.expand === true}
{...rest}
>
<MarkdownView
data-element-markdown
markdown={local.text}
ref={(el) => (divEl = el)}
/>
{((!local.expand && overflowed()) || expanded()) && (
<button
type="button"
data-element-button-text
onClick={() => setExpanded((e) => !e)}
>
{expanded() ? "Show less" : "Show more"}
</button>
)}
</div>
)
}
interface TerminalPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
command: string
error?: string
result?: string
desc?: string
expand?: boolean
}
function TerminalPart(props: TerminalPartProps) {
const [local, rest] = splitProps(props, [
"command",
"error",
"result",
"desc",
"expand",
])
const [expanded, setExpanded] = createSignal(false)
const [overflowed, setOverflowed] = createSignal(false)
let preEl: HTMLElement | undefined
function checkOverflow() {
if (!preEl) return
const code = preEl.getElementsByTagName("code")[0]
if (code && !local.expand) {
setOverflowed(preEl.clientHeight < code.offsetHeight)
}
}
onMount(() => {
window.addEventListener("resize", checkOverflow)
})
onCleanup(() => {
window.removeEventListener("resize", checkOverflow)
})
return (
<div
class={styles["message-terminal"]}
data-expanded={expanded() || local.expand === true}
{...rest}
>
<div data-section="body">
<div data-section="header">
<span>{local.desc}</span>
</div>
<div data-section="content">
<CodeBlock lang="bash" code={local.command} />
<Switch>
<Match when={local.error}>
<CodeBlock
data-section="error"
lang="text"
onRendered={checkOverflow}
ref={(el) => (preEl = el)}
code={local.error || ""}
/>
</Match>
<Match when={local.result}>
<CodeBlock
lang="console"
onRendered={checkOverflow}
ref={(el) => (preEl = el)}
code={local.result || ""}
/>
</Match>
</Switch>
</div>
</div>
{((!local.expand && overflowed()) || expanded()) && (
<button
type="button"
data-element-button-text
onClick={() => setExpanded((e) => !e)}
>
{expanded() ? "Show less" : "Show more"}
</button>
)}
</div>
)
}
function ToolFooter(props: { time: number }) {
return props.time > MIN_DURATION ? (
<span data-part-footer title={`${props.time}ms`}>
{formatDuration(props.time)}
</span>
) : (
<div data-part-footer="spacer"></div>
)
}
interface AnchorProps extends JSX.HTMLAttributes<HTMLDivElement> {
id: string
}
function AnchorIcon(props: AnchorProps) {
const [local, rest] = splitProps(props, ["id", "children"])
const [copied, setCopied] = createSignal(false)
return (
<div
{...rest}
data-element-anchor
title="Link to this message"
data-status={copied() ? "copied" : ""}
>
<a
href={`#${local.id}`}
onClick={(e) => {
e.preventDefault()
const anchor = e.currentTarget
const hash = anchor.getAttribute("href") || ""
const { origin, pathname, search } = window.location
navigator.clipboard
.writeText(`${origin}${pathname}${search}${hash}`)
.catch((err) => console.error("Copy failed", err))
setCopied(true)
setTimeout(() => setCopied(false), 3000)
}}
>
{local.children}
<IconHashtag width={18} height={18} />
<IconCheckCircle width={18} height={18} />
</a>
<span data-element-tooltip>Copied!</span>
</div>
)
}
export default function Share(props: {
id: string
api: string
info: Session.Info
messages: Record<string, Message.Info>
}) {
let hasScrolled = false
const id = props.id
const params = new URLSearchParams(window.location.search)
const debug = params.get("debug") === "true"
const anchorId = createMemo<string | null>(() => {
const raw = window.location.hash.slice(1)
const [id] = raw.split("-")
return id
})
const [store, setStore] = createStore<{
info?: Session.Info
messages: Record<string, Message.Info>
}>({ info: props.info, messages: props.messages })
const messages = createMemo(() =>
Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id)),
)
const [connectionStatus, setConnectionStatus] = createSignal<
[Status, string?]
>(["disconnected", "Disconnected"])
onMount(() => {
const apiUrl = props.api
if (!id) {
setConnectionStatus(["error", "id not found"])
return
}
if (!apiUrl) {
console.error("API URL not found in environment variables")
setConnectionStatus(["error", "API URL not found"])
return
}
let reconnectTimer: number | undefined
let socket: WebSocket | null = null
// Function to create and set up WebSocket with auto-reconnect
const setupWebSocket = () => {
// Close any existing connection
if (socket) {
socket.close()
}
setConnectionStatus(["connecting"])
// Always use secure WebSocket protocol (wss)
const wsBaseUrl = apiUrl.replace(/^https?:\/\//, "wss://")
const wsUrl = `${wsBaseUrl}/share_poll?id=${id}`
console.log("Connecting to WebSocket URL:", wsUrl)
// Create WebSocket connection
socket = new WebSocket(wsUrl)
// Handle connection opening
socket.onopen = () => {
setConnectionStatus(["connected"])
console.log("WebSocket connection established")
}
// Handle incoming messages
socket.onmessage = (event) => {
console.log("WebSocket message received")
try {
const d = JSON.parse(event.data)
const [root, type, ...splits] = d.key.split("/")
if (root !== "session") return
if (type === "info") {
setStore("info", reconcile(d.content))
return
}
if (type === "message") {
const [, messageID] = splits
setStore("messages", messageID, reconcile(d.content))
if (!hasScrolled && messageID === anchorId()) {
scrollToAnchor(window.location.hash.slice(1))
hasScrolled = true
}
}
} catch (error) {
console.error("Error parsing WebSocket message:", error)
}
}
// Handle errors
socket.onerror = (error) => {
console.error("WebSocket error:", error)
setConnectionStatus(["error", "Connection failed"])
}
// Handle connection close and reconnection
socket.onclose = (event) => {
console.log(`WebSocket closed: ${event.code} ${event.reason}`)
setConnectionStatus(["reconnecting"])
// Try to reconnect after 2 seconds
clearTimeout(reconnectTimer)
reconnectTimer = window.setTimeout(
setupWebSocket,
2000,
) as unknown as number
}
}
// Initial connection
setupWebSocket()
// Clean up on component unmount
onCleanup(() => {
console.log("Cleaning up WebSocket connection")
if (socket) {
socket.close()
}
clearTimeout(reconnectTimer)
})
})
const data = createMemo(() => {
const result = {
rootDir: undefined as string | undefined,
created: undefined as number | undefined,
completed: undefined as number | undefined,
messages: [] as Message.Info[],
models: {} as Record<string, string[]>,
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
},
}
result.created = props.info.time.created
for (let i = 0; i < messages().length; i++) {
const msg = messages()[i]
// TODO: Cleanup
// const system = result.messages.length === 0 && msg.role === "system"
const assistant = msg.metadata?.assistant
// if (system) {
// for (const part of msg.parts) {
// if (part.type === "text") {
// result.system.push(part.text)
// }
// }
// result.created = msg.metadata?.time.created
// continue
// }
result.messages.push(msg)
if (assistant) {
result.cost += assistant.cost
result.tokens.input += assistant.tokens.input
result.tokens.output += assistant.tokens.output
result.tokens.reasoning += assistant.tokens.reasoning
result.models[`${assistant.providerID} ${assistant.modelID}`] = [
assistant.providerID,
assistant.modelID,
]
if (assistant.path?.root) {
result.rootDir = assistant.path.root
}
if (msg.metadata?.time.completed) {
result.completed = msg.metadata?.time.completed
}
}
}
return result
})
return (
<main class={`${styles.root} not-content`}>
<div class={styles.header}>
<div data-section="title">
<h1>{store.info?.title}</h1>
</div>
<div data-section="row">
<ul data-section="stats">
<li>
<span data-element-label>Cost</span>
{data().cost !== undefined ? (
<span>${data().cost.toFixed(2)}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>Input Tokens</span>
{data().tokens.input ? (
<span>{data().tokens.input}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>Output Tokens</span>
{data().tokens.output ? (
<span>{data().tokens.output}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
<li>
<span data-element-label>Reasoning Tokens</span>
{data().tokens.reasoning ? (
<span>{data().tokens.reasoning}</span>
) : (
<span data-placeholder>&mdash;</span>
)}
</li>
</ul>
<Show when={data().rootDir}>
<ul data-section="stats" data-section-root>
<li title="Project root">
<div data-stat-icon>
<IconFolder width={16} height={16} />
</div>
<span>{data().rootDir}</span>
</li>
<li title="opencode version">
<div data-stat-icon title="opencode">
<IconOpencode width={16} height={16} />
</div>
<Show when={store.info?.version} fallback="v0.0.1">
<span>v{store.info?.version}</span>
</Show>
</li>
</ul>
</Show>
<ul data-section="stats" data-section-models>
{Object.values(data().models).length > 0 ? (
<For each={Object.values(data().models)}>
{([provider, model]) => (
<li>
<div data-stat-icon title={provider}>
<ProviderIcon provider={provider} />
</div>
<span data-stat-model>{model}</span>
</li>
)}
</For>
) : (
<li>
<span data-element-label>Models</span>
<span data-placeholder>&mdash;</span>
</li>
)}
</ul>
<div data-section="time">
{data().created ? (
<span
title={DateTime.fromMillis(data().created || 0).toLocaleString(
DateTime.DATETIME_FULL_WITH_SECONDS,
)}
>
{DateTime.fromMillis(data().created || 0).toLocaleString(
DateTime.DATETIME_MED,
)}
</span>
) : (
<span data-element-label data-placeholder>
Started at &mdash;
</span>
)}
</div>
</div>
</div>
<div>
<Show
when={data().messages.length > 0}
fallback={<p>Waiting for messages...</p>}
>
<div class={styles.parts}>
<For each={data().messages}>
{(msg, msgIndex) => (
<For each={msg.parts}>
{(part, partIndex) => {
if (
(part.type === "step-start" &&
(partIndex() > 0 || !msg.metadata?.assistant)) ||
(msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "todoread")
)
return null
const anchor = createMemo(() => `${msg.id}-${partIndex()}`)
const [showResults, setShowResults] = createSignal(false)
const isLastPart = createMemo(
() =>
data().messages.length === msgIndex() + 1 &&
msg.parts.length === partIndex() + 1,
)
const toolData = createMemo(() => {
if (
msg.role !== "assistant" ||
part.type !== "tool-invocation"
)
return {}
const metadata =
msg.metadata?.tool[part.toolInvocation.toolCallId]
const args = part.toolInvocation.args
const result =
part.toolInvocation.state === "result" &&
part.toolInvocation.result
const duration = DateTime.fromMillis(
metadata?.time.end || 0,
)
.diff(DateTime.fromMillis(metadata?.time.start || 0))
.toMillis()
return { metadata, args, result, duration }
})
return (
<Switch>
{/* User text */}
<Match
when={
msg.role === "user" && part.type === "text" && part
}
>
{(part) => (
<div
id={anchor()}
data-section="part"
data-part-type="user-text"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconUserCircle width={18} height={18} />
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<TextPart
invert
text={part().text}
expand={isLastPart()}
/>
</div>
</div>
)}
</Match>
{/* AI text */}
<Match
when={
msg.role === "assistant" &&
part.type === "text" &&
part
}
>
{(part) => (
<div
id={anchor()}
data-section="part"
data-part-type="ai-text"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconSparkles width={18} height={18} />
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<MarkdownPart
highlight
expand={isLastPart()}
text={stripEnclosingTag(part().text)}
/>
<Show when={isLastPart() && data().completed}>
<span
data-part-footer
title={DateTime.fromMillis(
data().completed || 0,
).toLocaleString(
DateTime.DATETIME_FULL_WITH_SECONDS,
)}
>
{DateTime.fromMillis(
data().completed || 0,
).toLocaleString(DateTime.DATETIME_MED)}
</span>
</Show>
</div>
</div>
)}
</Match>
{/* AI model */}
<Match
when={
msg.role === "assistant" &&
part.type === "step-start" &&
msg.metadata?.assistant
}
>
{(assistant) => {
const system = createMemo(() => {
const prompts = assistant().system || []
return prompts.filter(
(p: string) =>
!p.startsWith("You are Claude"),
)
})
return (
<div
id={anchor()}
data-section="part"
data-part-type="ai-model"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<ProviderIcon
size={18}
provider={assistant().providerID}
/>
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>
{assistant().providerID}
</span>
</div>
<span data-part-model>
{assistant().modelID}
</span>
<Show when={system().length > 0}>
<div data-part-tool-result>
<ResultsButton
showCopy="Show system prompt"
hideCopy="Hide system prompt"
results={showResults()}
onClick={() =>
setShowResults((e) => !e)
}
/>
<Show when={showResults()}>
<TextPart
expand
data-size="sm"
data-color="dimmed"
text={system().join("\n\n").trim()}
/>
</Show>
</div>
</Show>
</div>
</div>
</div>
)
}}
</Match>
{/* System text */}
<Match
when={
msg.role === "system" &&
part.type === "text" &&
part
}
>
{(part) => (
<div
id={anchor()}
data-section="part"
data-part-type="system-text"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconCpuChip width={18} height={18} />
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>System</span>
</div>
<TextPart
data-size="sm"
text={part().text}
data-color="dimmed"
/>
</div>
</div>
</div>
)}
</Match>
{/* Grep tool */}
<Match
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "grep" &&
part
}
>
{(_part) => {
const matches = () => toolData()?.metadata?.matches
const splitArgs = () => {
const { pattern, ...rest } = toolData()?.args
return { pattern, rest }
}
return (
<div
id={anchor()}
data-section="part"
data-part-type="tool-grep"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconDocumentMagnifyingGlass
width={18}
height={18}
/>
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>Grep</span>
<b>&ldquo;{splitArgs().pattern}&rdquo;</b>
</div>
<Show
when={
Object.keys(splitArgs().rest).length > 0
}
>
<div data-part-tool-args>
<For
each={flattenToolArgs(
splitArgs().rest,
)}
>
{([name, value]) => (
<>
<div></div>
<div>{name}</div>
<div>{value}</div>
</>
)}
</For>
</div>
</Show>
<Switch>
<Match when={matches() > 0}>
<div data-part-tool-result>
<ResultsButton
showCopy={
matches() === 1
? "1 match"
: `${matches()} matches`
}
hideCopy="Hide matches"
results={showResults()}
onClick={() =>
setShowResults((e) => !e)
}
/>
<Show when={showResults()}>
<TextPart
expand
data-size="sm"
data-color="dimmed"
text={toolData()?.result}
/>
</Show>
</div>
</Match>
<Match when={toolData()?.result}>
<div data-part-tool-result>
<TextPart
expand
data-size="sm"
data-color="dimmed"
text={toolData()?.result}
/>
</div>
</Match>
</Switch>
</div>
<ToolFooter
time={toolData()?.duration || 0}
/>
</div>
</div>
)
}}
</Match>
{/* Glob tool */}
<Match
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "glob" &&
part
}
>
{(_part) => {
const count = () => toolData()?.metadata?.count
const pattern = () => toolData()?.args.pattern
return (
<div
id={anchor()}
data-section="part"
data-part-type="tool-glob"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconMagnifyingGlass
width={18}
height={18}
/>
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>Glob</span>
<b>&ldquo;{pattern()}&rdquo;</b>
</div>
<Switch>
<Match when={count() > 0}>
<div data-part-tool-result>
<ResultsButton
showCopy={
count() === 1
? "1 result"
: `${count()} results`
}
results={showResults()}
onClick={() =>
setShowResults((e) => !e)
}
/>
<Show when={showResults()}>
<TextPart
expand
text={toolData()?.result}
data-size="sm"
data-color="dimmed"
/>
</Show>
</div>
</Match>
<Match when={toolData()?.result}>
<div data-part-tool-result>
<TextPart
expand
text={toolData()?.result}
data-size="sm"
data-color="dimmed"
/>
</div>
</Match>
</Switch>
</div>
<ToolFooter
time={toolData()?.duration || 0}
/>
</div>
</div>
)
}}
</Match>
{/* LS tool */}
<Match
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "list" &&
part
}
>
{(_part) => {
const path = createMemo(() =>
toolData()?.args.path !== data().rootDir
? stripWorkingDirectory(
toolData()?.args.path,
data().rootDir,
)
: toolData()?.args.path,
)
return (
<div
id={anchor()}
data-section="part"
data-part-type="tool-list"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconRectangleStack
width={18}
height={18}
/>
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>LS</span>
<b>{path()}</b>
</div>
<Switch>
<Match when={toolData()?.result}>
<div data-part-tool-result>
<ResultsButton
results={showResults()}
onClick={() =>
setShowResults((e) => !e)
}
/>
<Show when={showResults()}>
<TextPart
expand
data-size="sm"
data-color="dimmed"
text={toolData()?.result}
/>
</Show>
</div>
</Match>
</Switch>
</div>
<ToolFooter
time={toolData()?.duration || 0}
/>
</div>
</div>
)
}}
</Match>
{/* Read tool */}
<Match
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "read" &&
part
}
>
{(_part) => {
const filePath = createMemo(() =>
stripWorkingDirectory(
toolData()?.args.filePath,
data().rootDir,
),
)
const hasError = () => toolData()?.metadata?.error
const preview = () => toolData()?.metadata?.preview
return (
<div
id={anchor()}
data-section="part"
data-part-type="tool-read"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconDocument width={18} height={18} />
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>Read</span>
<b>{filePath()}</b>
</div>
<Switch>
<Match when={hasError()}>
<div data-part-tool-result>
<ErrorPart>
{formatErrorString(
toolData()?.result,
)}
</ErrorPart>
</div>
</Match>
<Match when={preview()}>
<div data-part-tool-result>
<ResultsButton
showCopy="Show preview"
hideCopy="Hide preview"
results={showResults()}
onClick={() =>
setShowResults((e) => !e)
}
/>
<Show when={showResults()}>
<div data-part-tool-code>
<CodeBlock
lang={getShikiLang(filePath())}
code={preview()}
/>
</div>
</Show>
</div>
</Match>
<Match when={toolData()?.result}>
<div data-part-tool-result>
<ResultsButton
results={showResults()}
onClick={() =>
setShowResults((e) => !e)
}
/>
<Show when={showResults()}>
<TextPart
expand
text={toolData()?.result}
data-size="sm"
data-color="dimmed"
/>
</Show>
</div>
</Match>
</Switch>
</div>
<ToolFooter
time={toolData()?.duration || 0}
/>
</div>
</div>
)
}}
</Match>
{/* Write tool */}
<Match
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "write" &&
part
}
>
{(_part) => {
const filePath = createMemo(() =>
stripWorkingDirectory(
toolData()?.args.filePath,
data().rootDir,
),
)
const hasError = () => toolData()?.metadata?.error
const content = () => toolData()?.args?.content
const diagnostics = createMemo(() =>
getDiagnostics(
toolData()?.metadata?.diagnostics,
toolData()?.args.filePath,
),
)
return (
<div
id={anchor()}
data-section="part"
data-part-type="tool-write"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconDocumentPlus width={18} height={18} />
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>Write</span>
<b>{filePath()}</b>
</div>
<Show when={diagnostics().length > 0}>
<ErrorPart>{diagnostics()}</ErrorPart>
</Show>
<Switch>
<Match when={hasError()}>
<div data-part-tool-result>
<ErrorPart>
{formatErrorString(
toolData()?.result,
)}
</ErrorPart>
</div>
</Match>
<Match when={content()}>
<div data-part-tool-result>
<ResultsButton
showCopy="Show contents"
hideCopy="Hide contents"
results={showResults()}
onClick={() =>
setShowResults((e) => !e)
}
/>
<Show when={showResults()}>
<div data-part-tool-code>
<CodeBlock
lang={getShikiLang(filePath())}
code={args.content}
/>
</div>
</Show>
</div>
</Match>
</Switch>
</div>
<ToolFooter
time={toolData()?.duration || 0}
/>
</div>
</div>
)
}}
</Match>
{/* Edit tool */}
<Match
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "edit" &&
part
}
>
{(_part) => {
const diff = () => toolData()?.metadata?.diff
const message = () => toolData()?.metadata?.message
const hasError = () => toolData()?.metadata?.error
const filePath = createMemo(() =>
stripWorkingDirectory(
toolData()?.args.filePath,
data().rootDir,
),
)
const diagnostics = createMemo(() =>
getDiagnostics(
toolData()?.metadata?.diagnostics,
toolData()?.args.filePath,
),
)
return (
<div
id={anchor()}
data-section="part"
data-part-type="tool-edit"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconPencilSquare width={18} height={18} />
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>Edit</span>
<b>{filePath()}</b>
</div>
<Switch>
<Match when={hasError()}>
<div data-part-tool-result>
<ErrorPart>
{formatErrorString(message())}
</ErrorPart>
</div>
</Match>
<Match when={diff()}>
<div data-part-tool-edit>
<DiffView
class={styles["diff-code-block"]}
diff={diff()}
lang={getShikiLang(filePath())}
/>
</div>
</Match>
</Switch>
<Show when={diagnostics().length > 0}>
<ErrorPart>{diagnostics()}</ErrorPart>
</Show>
</div>
<ToolFooter
time={toolData()?.duration || 0}
/>
</div>
</div>
)
}}
</Match>
{/* Bash tool */}
<Match
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "bash" &&
part
}
>
{(_part) => {
const command = () => toolData()?.metadata?.title
const desc = () => toolData()?.metadata?.description
const result = () => toolData()?.metadata?.stdout
const error = () => toolData()?.metadata?.stderr
return (
<div
id={anchor()}
data-section="part"
data-part-type="tool-bash"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconCommandLine width={18} height={18} />
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
{command() && (
<div data-part-tool-body>
<TerminalPart
desc={desc()}
data-size="sm"
command={command()!}
result={result()}
error={error()}
/>
</div>
)}
<ToolFooter
time={toolData()?.duration || 0}
/>
</div>
</div>
)
}}
</Match>
{/* Todo write */}
<Match
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName ===
"todowrite" &&
part
}
>
{(_part) => {
const todos = createMemo(() =>
sortTodosByStatus(toolData()?.args.todos),
)
const starting = () =>
todos().every((t) => t.status === "pending")
const finished = () =>
todos().every((t) => t.status === "completed")
return (
<div
id={anchor()}
data-section="part"
data-part-type="tool-todo"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconQueueList width={18} height={18} />
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>
<Switch fallback="Updating plan">
<Match when={starting()}>
Creating plan
</Match>
<Match when={finished()}>
Completing plan
</Match>
</Switch>
</span>
</div>
<Show when={todos().length > 0}>
<ul class={styles.todos}>
<For each={todos()}>
{(todo) => (
<li data-status={todo.status}>
<span></span>
{todo.content}
</li>
)}
</For>
</ul>
</Show>
</div>
<ToolFooter
time={toolData()?.duration || 0}
/>
</div>
</div>
)
}}
</Match>
{/* Fetch tool */}
<Match
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName ===
"webfetch" &&
part
}
>
{(_part) => {
const url = () => toolData()?.args.url
const format = () => toolData()?.args.format
const hasError = () => toolData()?.metadata?.error
return (
<div
id={anchor()}
data-section="part"
data-part-type="tool-fetch"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconGlobeAlt width={18} height={18} />
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>Fetch</span>
<b>{url()}</b>
</div>
<Switch>
<Match when={hasError()}>
<div data-part-tool-result>
<ErrorPart>
{formatErrorString(
toolData()?.result,
)}
</ErrorPart>
</div>
</Match>
<Match when={toolData()?.result}>
<div data-part-tool-result>
<ResultsButton
results={showResults()}
onClick={() =>
setShowResults((e) => !e)
}
/>
<Show when={showResults()}>
<div data-part-tool-code>
<CodeBlock
lang={format() || "text"}
code={toolData()?.result}
/>
</div>
</Show>
</div>
</Match>
</Switch>
</div>
<ToolFooter
time={toolData()?.duration || 0}
/>
</div>
</div>
)
}}
</Match>
{/* Tool call */}
<Match
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part
}
>
{(part) => {
return (
<div
id={anchor()}
data-section="part"
data-part-type="tool-fallback"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconWrenchScrewdriver
width={18}
height={18}
/>
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
{part().toolInvocation.toolName}
</div>
<div data-part-tool-args>
<For
each={flattenToolArgs(
part().toolInvocation.args,
)}
>
{(arg) => (
<>
<div></div>
<div>{arg[0]}</div>
<div>{arg[1]}</div>
</>
)}
</For>
</div>
<Switch>
<Match when={toolData()?.result}>
<div data-part-tool-result>
<ResultsButton
results={showResults()}
onClick={() =>
setShowResults((e) => !e)
}
/>
<Show when={showResults()}>
<TextPart
expand
data-size="sm"
data-color="dimmed"
text={toolData()?.result}
/>
</Show>
</div>
</Match>
<Match
when={
part().toolInvocation.state === "call"
}
>
<TextPart
data-size="sm"
data-color="dimmed"
text="Calling..."
/>
</Match>
</Switch>
</div>
<ToolFooter
time={toolData()?.duration || 0}
/>
</div>
</div>
)
}}
</Match>
{/* Fallback */}
<Match when={true}>
<div
id={anchor()}
data-section="part"
data-part-type="fallback"
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<Switch
fallback={
<IconWrenchScrewdriver
width={16}
height={16}
/>
}
>
<Match
when={
msg.role === "assistant" &&
part.type !== "tool-invocation"
}
>
<IconSparkles width={18} height={18} />
</Match>
<Match when={msg.role === "system"}>
<IconCpuChip width={18} height={18} />
</Match>
<Match when={msg.role === "user"}>
<IconUserCircle width={18} height={18} />
</Match>
</Switch>
</AnchorIcon>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>{part.type}</span>
</div>
<TextPart
text={JSON.stringify(part, null, 2)}
/>
</div>
</div>
</div>
</Match>
</Switch>
)
}}
</For>
)}
</For>
<div data-section="part" data-part-type="connection-status">
<div data-section="decoration">
<span data-status={connectionStatus()[0]}></span>
<div></div>
</div>
<div data-section="content">
<span>{getStatusText(connectionStatus())}</span>
</div>
</div>
</div>
</Show>
</div>
<Show when={debug}>
<div style={{ margin: "2rem 0" }}>
<div
style={{
border: "1px solid #ccc",
padding: "1rem",
"overflow-y": "auto",
}}
>
<Show
when={data().messages.length > 0}
fallback={<p>Waiting for messages...</p>}
>
<ul style={{ "list-style-type": "none", padding: 0 }}>
<For each={data().messages}>
{(msg) => (
<li
style={{
padding: "0.75rem",
margin: "0.75rem 0",
"box-shadow": "0 1px 3px rgba(0,0,0,0.1)",
}}
>
<div>
<strong>Key:</strong> {msg.id}
</div>
<pre>{JSON.stringify(msg, null, 2)}</pre>
</li>
)}
</For>
</ul>
</Show>
</div>
</div>
</Show>
</main>
)
}