Adam 3 месяцев назад
Родитель
Сommit
30f4c2cf4c

+ 10 - 9
bun.lock

@@ -114,7 +114,6 @@
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
-        "@pierre/precision-diffs": "catalog:",
         "@shikijs/transformers": "3.9.2",
         "@solid-primitives/active-element": "2.1.3",
         "@solid-primitives/event-bus": "1.1.2",
@@ -141,7 +140,6 @@
         "@types/luxon": "3.7.1",
         "@types/node": "catalog:",
         "@typescript/native-preview": "catalog:",
-        "opencode": "workspace:*",
         "typescript": "catalog:",
         "vite": "catalog:",
         "vite-plugin-icons-spritesheet": "3.0.1",
@@ -281,6 +279,7 @@
       "version": "0.15.29",
       "dependencies": {
         "@kobalte/core": "catalog:",
+        "@opencode-ai/sdk": "workspace:*",
         "@pierre/precision-diffs": "catalog:",
         "@solidjs/meta": "catalog:",
         "fuzzysort": "catalog:",
@@ -1080,7 +1079,7 @@
 
     "@selderee/plugin-htmlparser2": ["@selderee/[email protected]", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
 
-    "@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
+    "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
 
     "@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-kUTRVKPsB/28H5Ko6qEsyudBiWEDLst+Sfi+hwr59E0GLHV0h8RfgbQU7fdN5Lt9A8R1ulRiZyTvAizkROjwDA=="],
 
@@ -3518,6 +3517,8 @@
 
     "@parcel/watcher-wasm/napi-wasm": ["[email protected]", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="],
 
+    "@pierre/precision-diffs/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
+
     "@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/types": "3.13.0" } }, "sha512-833lcuVzcRiG+fXvgslWsM2f4gHpjEgui1ipIknSizRuTgMkNZupiXE5/TVJ6eSYfhNBFhBZKkReKWO2GgYmqA=="],
 
     "@pierre/precision-diffs/shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="],
@@ -3530,10 +3531,6 @@
 
     "@rollup/pluginutils/estree-walker": ["[email protected]", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
 
-    "@shikijs/core/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
-
-    "@shikijs/transformers/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
-
     "@slack/bolt/path-to-regexp": ["[email protected]", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
 
     "@slack/oauth/@slack/logger": ["@slack/[email protected]", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
@@ -3798,8 +3795,6 @@
 
     "send/mime": ["[email protected]", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
 
-    "shiki/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
-
     "sitemap/sax": ["[email protected]", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
 
     "source-map-support/source-map": ["[email protected]", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -3954,6 +3949,8 @@
 
     "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
 
+    "@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
+
     "@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
 
     "@expressive-code/plugin-shiki/shiki/@shikijs/engine-oniguruma": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
@@ -4088,6 +4085,8 @@
 
     "@opencode-ai/web/shiki/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="],
 
+    "@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
+
     "@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
 
     "@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
@@ -4350,6 +4349,8 @@
 
     "@actions/github/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/[email protected]", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
 
+    "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
+
     "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
 
     "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],

+ 1 - 2
packages/desktop/package.json

@@ -4,6 +4,7 @@
   "description": "",
   "type": "module",
   "scripts": {
+    "typecheck": "tsgo --noEmit",
     "start": "vite",
     "dev": "vite",
     "build": "vite build",
@@ -11,7 +12,6 @@
   },
   "license": "MIT",
   "devDependencies": {
-    "opencode": "workspace:*",
     "@tailwindcss/vite": "catalog:",
     "@tsconfig/bun": "1.0.9",
     "@types/luxon": "3.7.1",
@@ -26,7 +26,6 @@
     "@kobalte/core": "catalog:",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
-    "@pierre/precision-diffs": "catalog:",
     "@shikijs/transformers": "3.9.2",
     "@solid-primitives/active-element": "2.1.3",
     "@solid-primitives/event-bus": "1.1.2",

+ 0 - 20
packages/desktop/src/components/diff-changes.tsx

@@ -1,20 +0,0 @@
-import { FileDiff } from "@opencode-ai/sdk"
-import { createMemo, Show } from "solid-js"
-
-export function DiffChanges(props: { diff: FileDiff | FileDiff[] }) {
-  const additions = createMemo(() =>
-    Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) : props.diff.additions,
-  )
-  const deletions = createMemo(() =>
-    Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) : props.diff.deletions,
-  )
-  const total = createMemo(() => additions() + deletions())
-  return (
-    <Show when={total() > 0}>
-      <div class="flex gap-2 justify-end items-center">
-        <span class="text-12-mono text-right text-text-diff-add-base">{`+${additions()}`}</span>
-        <span class="text-12-mono text-right text-text-diff-delete-base">{`-${deletions()}`}</span>
-      </div>
-    </Show>
-  )
-}

+ 36 - 217
packages/desktop/src/components/message.tsx

@@ -1,238 +1,57 @@
-import type { Part, ReasoningPart, TextPart, ToolPart, Message, AssistantMessage, UserMessage } from "@opencode-ai/sdk"
-import { children, Component, createMemo, For, Match, Show, Switch, type JSX } from "solid-js"
+import type { Part, TextPart, ToolPart, Message } from "@opencode-ai/sdk"
+import { createMemo, For, Show } from "solid-js"
 import { Dynamic } from "solid-js/web"
 import { Markdown } from "./markdown"
-import { Checkbox, Collapsible, Diff, Icon, IconProps } from "@opencode-ai/ui"
+import { Checkbox, Diff, Icon } from "@opencode-ai/ui"
+import { Message as MessageDisplay, registerPartComponent } from "@opencode-ai/ui"
+import { BasicTool, GenericTool, ToolRegistry, DiffChanges } from "@opencode-ai/ui"
 import { getDirectory, getFilename } from "@/utils"
-import type { Tool } from "opencode/tool/tool"
-import type { ReadTool } from "opencode/tool/read"
-import type { ListTool } from "opencode/tool/ls"
-import type { GlobTool } from "opencode/tool/glob"
-import type { GrepTool } from "opencode/tool/grep"
-import type { WebFetchTool } from "opencode/tool/webfetch"
-import type { TaskTool } from "opencode/tool/task"
-import type { BashTool } from "opencode/tool/bash"
-import type { EditTool } from "opencode/tool/edit"
-import type { WriteTool } from "opencode/tool/write"
-import type { TodoWriteTool } from "opencode/tool/todo"
-import { DiffChanges } from "./diff-changes"
 
 export function Message(props: { message: Message; parts: Part[] }) {
-  return (
-    <Switch>
-      <Match when={props.message.role === "user" && props.message}>
-        {(userMessage) => <UserMessage message={userMessage()} parts={props.parts} />}
-      </Match>
-      <Match when={props.message.role === "assistant" && props.message}>
-        {(assistantMessage) => <AssistantMessage message={assistantMessage()} parts={props.parts} />}
-      </Match>
-    </Switch>
-  )
+  return <MessageDisplay message={props.message} parts={props.parts} />
 }
 
-function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) {
-  const filteredParts = createMemo(() => {
-    return props.parts?.filter((x) => {
-      if (x.type === "reasoning") return false
-      return x.type !== "tool" || x.tool !== "todoread"
-    })
-  })
+registerPartComponent("text", function TextPartDisplay(props) {
+  const part = props.part as TextPart
   return (
-    <div class="w-full flex flex-col items-start gap-4">
-      <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
-    </div>
-  )
-}
-
-function UserMessage(props: { message: UserMessage; parts: Part[] }) {
-  const text = createMemo(() =>
-    props.parts
-      ?.filter((p) => p.type === "text" && !p.synthetic)
-      ?.map((p) => (p as TextPart).text)
-      ?.join(""),
-  )
-  return <div class="text-12-regular text-text-base line-clamp-3">{text()}</div>
-}
-
-export function Part(props: { part: Part; message: Message; hideDetails?: boolean }) {
-  const component = createMemo(() => PART_MAPPING[props.part.type as keyof typeof PART_MAPPING])
-  return (
-    <Show when={component()}>
-      <Dynamic
-        component={component()}
-        part={props.part as any}
-        message={props.message}
-        hideDetails={props.hideDetails}
-      />
-    </Show>
-  )
-}
-
-const PART_MAPPING = {
-  text: TextPart,
-  tool: ToolPart,
-  reasoning: ReasoningPart,
-}
-
-function ReasoningPart(props: { part: ReasoningPart; message: Message }) {
-  return (
-    <Show when={props.part.text.trim()}>
-      <Markdown text={props.part.text.trim()} />
+    <Show when={part.text.trim()}>
+      <Markdown text={part.text.trim()} />
     </Show>
   )
-}
+})
 
-function TextPart(props: { part: TextPart; message: Message }) {
+registerPartComponent("reasoning", function ReasoningPartDisplay(props) {
+  const part = props.part as any
   return (
-    <Show when={props.part.text.trim()}>
-      <Markdown text={props.part.text.trim()} />
+    <Show when={part.text.trim()}>
+      <Markdown text={part.text.trim()} />
     </Show>
   )
-}
+})
 
-function ToolPart(props: { part: ToolPart; message: Message; hideDetails?: boolean }) {
+registerPartComponent("tool", function ToolPartDisplay(props) {
+  const part = props.part as ToolPart
   const component = createMemo(() => {
-    const render = ToolRegistry.render(props.part.tool) ?? GenericTool
-    const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
-    const input = props.part.state.status === "completed" ? props.part.state.input : {}
+    const render = ToolRegistry.render(part.tool) ?? GenericTool
+    const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {})
+    const input = part.state.status === "completed" ? part.state.input : {}
 
     return (
       <Dynamic
         component={render}
         input={input}
-        tool={props.part.tool}
+        tool={part.tool}
         metadata={metadata}
-        output={props.part.state.status === "completed" ? props.part.state.output : undefined}
+        output={part.state.status === "completed" ? part.state.output : undefined}
         hideDetails={props.hideDetails}
       />
     )
   })
 
   return <Show when={component()}>{component()}</Show>
-}
-
-type TriggerTitle = {
-  title: string
-  titleClass?: string
-  subtitle?: string
-  subtitleClass?: string
-  args?: string[]
-  argsClass?: string
-  action?: JSX.Element
-}
-
-const isTriggerTitle = (val: any): val is TriggerTitle => {
-  return typeof val === "object" && val !== null && "title" in val && !(val instanceof Node)
-}
-
-function BasicTool(props: {
-  icon: IconProps["name"]
-  trigger: TriggerTitle | JSX.Element
-  children?: JSX.Element
-  hideDetails?: boolean
-}) {
-  const resolved = children(() => props.children)
-  return (
-    <Collapsible>
-      <Collapsible.Trigger>
-        <div class="w-full flex items-center self-stretch gap-5 justify-between">
-          <div class="w-full flex items-center self-stretch gap-5">
-            <Icon name={props.icon} size="small" class="shrink-0" />
-            <div class="grow min-w-0">
-              <Switch>
-                <Match when={isTriggerTitle(props.trigger) && props.trigger}>
-                  {(trigger) => (
-                    <div class="w-full flex items-center gap-2 justify-between">
-                      <div class="flex items-center gap-2 whitespace-nowrap truncate">
-                        <span
-                          classList={{
-                            "text-12-medium text-text-base": true,
-                            [trigger().titleClass ?? ""]: !!trigger().titleClass,
-                          }}
-                        >
-                          {trigger().title}
-                        </span>
-                        <Show when={trigger().subtitle}>
-                          <span
-                            classList={{
-                              "text-12-medium text-text-weak": true,
-                              [trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
-                            }}
-                          >
-                            {trigger().subtitle}
-                          </span>
-                        </Show>
-                        <Show when={trigger().args?.length}>
-                          <For each={trigger().args}>
-                            {(arg) => (
-                              <span
-                                classList={{
-                                  "text-12-regular text-text-weak": true,
-                                  [trigger().argsClass ?? ""]: !!trigger().argsClass,
-                                }}
-                              >
-                                {arg}
-                              </span>
-                            )}
-                          </For>
-                        </Show>
-                      </div>
-                      <Show when={trigger().action}>{trigger().action}</Show>
-                    </div>
-                  )}
-                </Match>
-                <Match when={true}>{props.trigger as JSX.Element}</Match>
-              </Switch>
-            </div>
-          </div>
-          <Show when={resolved() && !props.hideDetails}>
-            <Collapsible.Arrow />
-          </Show>
-        </div>
-      </Collapsible.Trigger>
-      <Show when={resolved() && !props.hideDetails}>
-        <Collapsible.Content>{resolved()}</Collapsible.Content>
-      </Show>
-    </Collapsible>
-    // <>
-    //   <Show when={props.part.state.status === "error"}>{props.part.state.error.replace("Error: ", "")}</Show>
-    // </>
-  )
-}
-
-function GenericTool(props: ToolProps<any>) {
-  return <BasicTool icon="mcp" trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
-}
-
-type ToolProps<T extends Tool.Info> = {
-  input: Partial<Tool.InferParameters<T>>
-  metadata: Partial<Tool.InferMetadata<T>>
-  tool: string
-  output?: string
-  hideDetails?: boolean
-}
-
-const ToolRegistry = (() => {
-  const state: Record<
-    string,
-    {
-      name: string
-      render?: Component<ToolProps<any>>
-    }
-  > = {}
-  function register<T extends Tool.Info>(input: { name: string; render?: Component<ToolProps<T>> }) {
-    state[input.name] = input
-    return input
-  }
-  return {
-    register,
-    render(name: string) {
-      return state[name]?.render
-    },
-  }
-})()
+})
 
-ToolRegistry.register<typeof ReadTool>({
+ToolRegistry.register({
   name: "read",
   render(props) {
     return (
@@ -244,7 +63,7 @@ ToolRegistry.register<typeof ReadTool>({
   },
 })
 
-ToolRegistry.register<typeof ListTool>({
+ToolRegistry.register({
   name: "list",
   render(props) {
     return (
@@ -257,7 +76,7 @@ ToolRegistry.register<typeof ListTool>({
   },
 })
 
-ToolRegistry.register<typeof GlobTool>({
+ToolRegistry.register({
   name: "glob",
   render(props) {
     return (
@@ -277,7 +96,7 @@ ToolRegistry.register<typeof GlobTool>({
   },
 })
 
-ToolRegistry.register<typeof GrepTool>({
+ToolRegistry.register({
   name: "grep",
   render(props) {
     const args = []
@@ -300,7 +119,7 @@ ToolRegistry.register<typeof GrepTool>({
   },
 })
 
-ToolRegistry.register<typeof WebFetchTool>({
+ToolRegistry.register({
   name: "webfetch",
   render(props) {
     return (
@@ -325,7 +144,7 @@ ToolRegistry.register<typeof WebFetchTool>({
   },
 })
 
-ToolRegistry.register<typeof TaskTool>({
+ToolRegistry.register({
   name: "task",
   render(props) {
     return (
@@ -345,7 +164,7 @@ ToolRegistry.register<typeof TaskTool>({
   },
 })
 
-ToolRegistry.register<typeof BashTool>({
+ToolRegistry.register({
   name: "bash",
   render(props) {
     return (
@@ -364,7 +183,7 @@ ToolRegistry.register<typeof BashTool>({
   },
 })
 
-ToolRegistry.register<typeof EditTool>({
+ToolRegistry.register({
   name: "edit",
   render(props) {
     return (
@@ -402,7 +221,7 @@ ToolRegistry.register<typeof EditTool>({
   },
 })
 
-ToolRegistry.register<typeof WriteTool>({
+ToolRegistry.register({
   name: "write",
   render(props) {
     return (
@@ -431,7 +250,7 @@ ToolRegistry.register<typeof WriteTool>({
   },
 })
 
-ToolRegistry.register<typeof TodoWriteTool>({
+ToolRegistry.register({
   name: "todowrite",
   render(props) {
     return (
@@ -439,13 +258,13 @@ ToolRegistry.register<typeof TodoWriteTool>({
         icon="checklist"
         trigger={{
           title: "To-dos",
-          subtitle: `${props.input.todos?.filter((t) => t.status === "completed").length}/${props.input.todos?.length}`,
+          subtitle: `${props.input.todos?.filter((t: any) => t.status === "completed").length}/${props.input.todos?.length}`,
         }}
       >
         <Show when={props.input.todos?.length}>
           <div class="px-12 pt-2.5 pb-6 flex flex-col gap-2">
             <For each={props.input.todos}>
-              {(todo) => (
+              {(todo: any) => (
                 <Checkbox readOnly checked={todo.status === "completed"}>
                   <div classList={{ "line-through text-text-weaker": todo.status === "completed" }}>{todo.content}</div>
                 </Checkbox>

+ 0 - 536
packages/desktop/src/components/session-timeline.tsx

@@ -1,536 +0,0 @@
-import { Icon, Tooltip } from "@opencode-ai/ui"
-import { Collapsible } from "@/ui"
-import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
-import { DateTime } from "luxon"
-import {
-  createSignal,
-  For,
-  Match,
-  splitProps,
-  Switch,
-  type ComponentProps,
-  type ParentProps,
-  createEffect,
-  createMemo,
-  Show,
-} from "solid-js"
-import { getFilename } from "@/utils"
-import { Markdown } from "./markdown"
-import { Code } from "./code"
-import { createElementSize } from "@solid-primitives/resize-observer"
-import { createScrollPosition } from "@solid-primitives/scroll"
-import { ProgressCircle } from "./progress-circle"
-import { pipe, sumBy } from "remeda"
-import { useSync } from "@/context/sync"
-import { useLocal } from "@/context/local"
-
-function Part(props: ParentProps & ComponentProps<"div">) {
-  const [local, others] = splitProps(props, ["class", "classList", "children"])
-  return (
-    <div
-      classList={{
-        ...(local.classList ?? {}),
-        "h-6 flex items-center": true,
-        [local.class ?? ""]: !!local.class,
-      }}
-      {...others}
-    >
-      <p class="text-12-medium text-left">{local.children}</p>
-    </div>
-  )
-}
-
-function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps & ComponentProps<typeof Collapsible>) {
-  return (
-    <Collapsible {...props}>
-      <Collapsible.Trigger class="peer/collapsible">
-        <Part>{props.title}</Part>
-      </Collapsible.Trigger>
-      <Collapsible.Content>
-        <p class="flex-auto min-w-0 text-pretty">
-          <span class="text-12-medium text-text-weak break-words">{props.children}</span>
-        </p>
-      </Collapsible.Content>
-    </Collapsible>
-  )
-}
-
-function ReadToolPart(props: { part: ToolPart }) {
-  const sync = useSync()
-  const local = useLocal()
-  return (
-    <Switch>
-      <Match when={props.part.state.status === "pending"}>
-        <Part>Reading file...</Part>
-      </Match>
-      <Match when={props.part.state.status === "completed" && props.part.state}>
-        {(state) => {
-          const path = state().input["filePath"] as string
-          return (
-            <Part onClick={() => local.file.open(path)}>
-              <span class="">Read</span> {getFilename(path)}
-            </Part>
-          )
-        }}
-      </Match>
-      <Match when={props.part.state.status === "error" && props.part.state}>
-        {(state) => (
-          <div>
-            <Part>
-              <span class="">Read</span> {getFilename(state().input["filePath"] as string)}
-            </Part>
-            <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
-          </div>
-        )}
-      </Match>
-    </Switch>
-  )
-}
-
-function EditToolPart(props: { part: ToolPart }) {
-  const sync = useSync()
-  return (
-    <Switch>
-      <Match when={props.part.state.status === "pending"}>
-        <Part>Preparing edit...</Part>
-      </Match>
-      <Match when={props.part.state.status === "completed" && props.part.state}>
-        {(state) => (
-          <CollapsiblePart
-            title={
-              <>
-                <span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
-              </>
-            }
-          >
-            <Code path={state().input["filePath"] as string} code={state().metadata["diff"] as string} />
-          </CollapsiblePart>
-        )}
-      </Match>
-      <Match when={props.part.state.status === "error" && props.part.state}>
-        {(state) => (
-          <CollapsiblePart
-            title={
-              <>
-                <span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
-              </>
-            }
-          >
-            <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
-          </CollapsiblePart>
-        )}
-      </Match>
-    </Switch>
-  )
-}
-
-function WriteToolPart(props: { part: ToolPart }) {
-  const sync = useSync()
-  return (
-    <Switch>
-      <Match when={props.part.state.status === "pending"}>
-        <Part>Preparing write...</Part>
-      </Match>
-      <Match when={props.part.state.status === "completed" && props.part.state}>
-        {(state) => (
-          <CollapsiblePart
-            title={
-              <>
-                <span class="">Write</span> {getFilename(state().input["filePath"] as string)}
-              </>
-            }
-          >
-            <div class="p-2 bg-background-panel rounded-md border border-border-subtle"></div>
-          </CollapsiblePart>
-        )}
-      </Match>
-      <Match when={props.part.state.status === "error" && props.part.state}>
-        {(state) => (
-          <div>
-            <Part>
-              <span class="">Write</span> {getFilename(state().input["filePath"] as string)}
-            </Part>
-            <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
-          </div>
-        )}
-      </Match>
-    </Switch>
-  )
-}
-
-function BashToolPart(props: { part: ToolPart }) {
-  const sync = useSync()
-  return (
-    <Switch>
-      <Match when={props.part.state.status === "pending"}>
-        <Part>Writing shell command...</Part>
-      </Match>
-      <Match when={props.part.state.status === "completed" && props.part.state}>
-        {(state) => (
-          <CollapsiblePart
-            defaultOpen
-            title={
-              <>
-                <span class="">Run command:</span> {state().input["command"]}
-              </>
-            }
-          >
-            <Markdown text={`\`\`\`command\n${state().input["command"]}\n${state().output}\`\`\``} />
-          </CollapsiblePart>
-        )}
-      </Match>
-      <Match when={props.part.state.status === "error" && props.part.state}>
-        {(state) => (
-          <CollapsiblePart
-            title={
-              <>
-                <span class="">Shell</span> {state().input["command"]}
-              </>
-            }
-          >
-            <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
-          </CollapsiblePart>
-        )}
-      </Match>
-    </Switch>
-  )
-}
-
-function ToolPart(props: { part: ToolPart }) {
-  // read
-  // edit
-  // write
-  // bash
-  // ls
-  // glob
-  // grep
-  // todowrite
-  // todoread
-  // webfetch
-  // websearch
-  // patch
-  // task
-  return (
-    <div class="min-w-0 flex-auto text-12-medium">
-      <Switch
-        fallback={
-          <span>
-            {props.part.type}:{props.part.tool}
-          </span>
-        }
-      >
-        <Match when={props.part.tool === "read"}>
-          <ReadToolPart part={props.part} />
-        </Match>
-        <Match when={props.part.tool === "edit"}>
-          <EditToolPart part={props.part} />
-        </Match>
-        <Match when={props.part.tool === "write"}>
-          <WriteToolPart part={props.part} />
-        </Match>
-        <Match when={props.part.tool === "bash"}>
-          <BashToolPart part={props.part} />
-        </Match>
-      </Switch>
-    </div>
-  )
-}
-
-export default function SessionTimeline(props: { session: string; class?: string }) {
-  const sync = useSync()
-  const [scrollElement, setScrollElement] = createSignal<HTMLElement | undefined>(undefined)
-  const [root, setRoot] = createSignal<HTMLDivElement | undefined>(undefined)
-  const [tail, setTail] = createSignal(true)
-  const size = createElementSize(root)
-  const scroll = createScrollPosition(scrollElement)
-
-  const valid = (part: Part) => {
-    if (!part) return false
-    switch (part.type) {
-      case "step-start":
-      case "step-finish":
-      case "file":
-      case "patch":
-        return false
-      case "text":
-        return !part.synthetic && part.text.trim()
-      case "reasoning":
-        return part.text.trim()
-      case "tool":
-        switch (part.tool) {
-          case "todoread":
-          case "todowrite":
-          case "list":
-          case "grep":
-            return false
-        }
-        return true
-      default:
-        return true
-    }
-  }
-
-  const hasValidParts = (message: Message) => {
-    return sync.data.part[message.id]?.filter(valid).length > 0
-  }
-
-  const hasTextPart = (message: Message) => {
-    return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
-  }
-
-  const session = createMemo(() => sync.session.get(props.session))
-  const messages = createMemo(() => sync.data.message[props.session] ?? [])
-  const messagesWithValidParts = createMemo(() => sync.data.message[props.session]?.filter(hasValidParts) ?? [])
-  const working = createMemo(() => {
-    const last = messages()[messages().length - 1]
-    if (!last) return false
-    if (last.role === "user") return true
-    return !last.time.completed
-  })
-
-  const cost = createMemo(() => {
-    const total = pipe(
-      messages(),
-      sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
-    )
-    return new Intl.NumberFormat("en-US", {
-      style: "currency",
-      currency: "USD",
-    }).format(total)
-  })
-
-  const last = createMemo(() => {
-    return messages().findLast((x) => x.role === "assistant") as AssistantMessage
-  })
-
-  const model = createMemo(() => {
-    if (!last()) return
-    const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
-    return model
-  })
-
-  const tokens = createMemo(() => {
-    if (!last()) return
-    const tokens = last().tokens
-    const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
-    return new Intl.NumberFormat("en-US", {
-      notation: "compact",
-      compactDisplay: "short",
-    }).format(total)
-  })
-
-  const context = createMemo(() => {
-    if (!last()) return
-    if (!model()?.limit.context) return 0
-    const tokens = last().tokens
-    const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
-    return Math.round((total / model()!.limit.context) * 100)
-  })
-
-  const getScrollParent = (el: HTMLElement | null): HTMLElement | undefined => {
-    let p = el?.parentElement
-    while (p && p !== document.body) {
-      const s = getComputedStyle(p)
-      if (s.overflowY === "auto" || s.overflowY === "scroll") return p
-      p = p.parentElement
-    }
-    return undefined
-  }
-
-  createEffect(() => {
-    if (!root()) return
-    setScrollElement(getScrollParent(root()!))
-  })
-
-  const scrollToBottom = () => {
-    const element = scrollElement()
-    if (!element) return
-    element.scrollTop = element.scrollHeight
-  }
-
-  createEffect(() => {
-    size.height
-    if (tail()) scrollToBottom()
-  })
-
-  createEffect(() => {
-    if (working()) {
-      setTail(true)
-      scrollToBottom()
-    }
-  })
-
-  let lastScrollY = 0
-  createEffect(() => {
-    if (scroll.y < lastScrollY) {
-      setTail(false)
-    }
-    lastScrollY = scroll.y
-  })
-
-  const duration = (part: Part) => {
-    switch (part.type) {
-      default:
-        if (
-          "time" in part &&
-          part.time &&
-          "start" in part.time &&
-          part.time.start &&
-          "end" in part.time &&
-          part.time.end
-        ) {
-          const start = DateTime.fromMillis(part.time.start)
-          const end = DateTime.fromMillis(part.time.end)
-          return end.diff(start).toFormat("s")
-        }
-        return ""
-    }
-  }
-
-  createEffect(() => {
-    console.log("WHAT")
-    console.log(JSON.stringify(messagesWithValidParts()))
-  })
-
-  return (
-    <div
-      ref={setRoot}
-      classList={{
-        "select-text flex flex-col text-text-weak": true,
-        [props.class ?? ""]: !!props.class,
-      }}
-    >
-      <div class="flex justify-end items-center self-stretch">
-        <div class="flex items-center gap-6">
-          <Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
-            <Show when={context()}>
-              <ProgressCircle percentage={context()!} />
-            </Show>
-            <div class="text-14-regular text-text-weak text-right">{context()}%</div>
-          </Tooltip>
-          <div class="text-14-regular text-text-strong text-right">{cost()}</div>
-        </div>
-      </div>
-      <ul role="list" class="flex flex-col items-start self-stretch">
-        <For each={messagesWithValidParts()}>
-          {(message) => (
-            <div
-              classList={{
-                "flex flex-col gap-1 justify-center items-start self-stretch": true,
-                "mt-6": hasTextPart(message),
-              }}
-            >
-              <For each={sync.data.part[message.id]?.filter(valid) ?? []}>
-                {(part) => (
-                  <li class="group/li">
-                    <Switch fallback={<div class="">{part.type}</div>}>
-                      <Match when={part.type === "text" && part}>
-                        {(part) => (
-                          <Switch>
-                            <Match when={message.role === "user"}>
-                              <div class="w-fit flex items-center px-3 py-1 rounded-md bg-surface-weak">
-                                <span class="text-14-regular text-text-strong whitespace-pre-wrap break-words">
-                                  {part().text}
-                                </span>
-                              </div>
-                            </Match>
-                            <Match when={message.role === "assistant"}>
-                              <Markdown text={sync.sanitize(part().text)} />
-                            </Match>
-                          </Switch>
-                        )}
-                      </Match>
-                      <Match when={part.type === "reasoning" && part}>
-                        {(part) => (
-                          <CollapsiblePart
-                            title={
-                              <Switch fallback={<span class="text-text-weak">Thinking</span>}>
-                                <Match when={part().time.end}>
-                                  <span class="text-12-medium text-text-weak">Thought</span> for {duration(part())}s
-                                </Match>
-                              </Switch>
-                            }
-                          >
-                            <Markdown text={part().text} />
-                          </CollapsiblePart>
-                        )}
-                      </Match>
-                      <Match when={part.type === "tool" && part}>{(part) => <ToolPart part={part()} />}</Match>
-                    </Switch>
-                  </li>
-                )}
-              </For>
-            </div>
-          )}
-        </For>
-      </ul>
-      <Show when={false}>
-        <Collapsible defaultOpen={false}>
-          <Collapsible.Trigger>
-            <div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
-              <Icon name="file-code" />
-              <span>Raw Session Data</span>
-              <Collapsible.Arrow class="text-text-muted" />
-            </div>
-          </Collapsible.Trigger>
-          <Collapsible.Content class="mt-5">
-            <ul role="list" class="space-y-2">
-              <li>
-                <Collapsible>
-                  <Collapsible.Trigger>
-                    <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
-                      <Icon name="file-code" />
-                      <span>session</span>
-                      <Collapsible.Arrow class="text-text-muted" />
-                    </div>
-                  </Collapsible.Trigger>
-                  <Collapsible.Content>
-                    <Code path="session.json" code={JSON.stringify(session(), null, 2)} />
-                  </Collapsible.Content>
-                </Collapsible>
-              </li>
-              <For each={messages()}>
-                {(message) => (
-                  <>
-                    <li>
-                      <Collapsible>
-                        <Collapsible.Trigger>
-                          <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
-                            <Icon name="file-code" />
-                            <span>{message.role === "user" ? "user" : "assistant"}</span>
-                            <Collapsible.Arrow class="text-text-muted" />
-                          </div>
-                        </Collapsible.Trigger>
-                        <Collapsible.Content>
-                          <Code path={message.id + ".json"} code={JSON.stringify(message, null, 2)} />
-                        </Collapsible.Content>
-                      </Collapsible>
-                    </li>
-                    <For each={sync.data.part[message.id]}>
-                      {(part) => (
-                        <li>
-                          <Collapsible>
-                            <Collapsible.Trigger>
-                              <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
-                                <Icon name="file-code" />
-                                <span>{part.type}</span>
-                                <Collapsible.Arrow class="text-text-muted" />
-                              </div>
-                            </Collapsible.Trigger>
-                            <Collapsible.Content>
-                              <Code path={message.id + "." + part.id + ".json"} code={JSON.stringify(part, null, 2)} />
-                            </Collapsible.Content>
-                          </Collapsible>
-                        </li>
-                      )}
-                    </For>
-                  </>
-                )}
-              </For>
-            </ul>
-          </Collapsible.Content>
-        </Collapsible>
-      </Show>
-    </div>
-  )
-}

+ 5 - 4
packages/desktop/src/pages/index.tsx

@@ -9,6 +9,7 @@ import {
   Accordion,
   Diff,
   Collapsible,
+  Part,
 } from "@opencode-ai/ui"
 import { FileIcon } from "@/ui"
 import FileTree from "@/components/file-tree"
@@ -33,9 +34,9 @@ import { Code } from "@/components/code"
 import { useSync } from "@/context/sync"
 import { useSDK } from "@/context/sdk"
 import { ProgressCircle } from "@/components/progress-circle"
-import { Message, Part } from "@/components/message"
+import { Message } from "@/components/message"
 import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
-import { DiffChanges } from "@/components/diff-changes"
+import { DiffChanges } from "@opencode-ai/ui"
 import { Markdown } from "@/components/markdown"
 
 export default function Page() {
@@ -497,7 +498,7 @@ export default function Page() {
                   <Show
                     when={local.session.active()}
                     fallback={
-                      <div class="flex flex-col pb-36 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
+                      <div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
                         <div class="text-20-medium text-text-weaker">New session</div>
                         <div class="flex justify-center items-center gap-3">
                           <Icon name="folder" size="small" />
@@ -660,7 +661,7 @@ export default function Page() {
                                       class="flex flex-col items-start self-stretch gap-8 min-h-screen"
                                     >
                                       {/* Title */}
-                                      <div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger">
+                                      <div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
                                         <h1 class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
                                           {title() ?? prompt()}
                                         </h1>

+ 2 - 0
packages/ui/package.json

@@ -11,6 +11,7 @@
     "./fonts/*": "./src/assets/fonts/*"
   },
   "scripts": {
+    "typecheck": "tsgo --noEmit",
     "dev": "vite",
     "generate:tailwind": "bun run script/tailwind.ts"
   },
@@ -24,6 +25,7 @@
   },
   "dependencies": {
     "@kobalte/core": "catalog:",
+    "@opencode-ai/sdk": "workspace:*",
     "@pierre/precision-diffs": "catalog:",
     "@solidjs/meta": "catalog:",
     "fuzzysort": "catalog:",

+ 1 - 1
packages/ui/src/components/collapsible.css

@@ -11,7 +11,7 @@
   [data-slot="collapsible-trigger"] {
     width: 100%;
     display: flex;
-    height: 40px;
+    height: 32px;
     padding: 6px 8px 6px 12px;
     align-items: center;
     align-self: stretch;

+ 28 - 0
packages/ui/src/components/diff-changes.css

@@ -0,0 +1,28 @@
+[data-component="diff-changes"] {
+  display: flex;
+  gap: 8px;
+  justify-content: flex-end;
+  align-items: center;
+
+  [data-slot="additions"] {
+    font-family: var(--font-family-mono);
+    font-size: var(--font-size-small);
+    font-style: normal;
+    font-weight: var(--font-weight-regular);
+    line-height: var(--line-height-large);
+    letter-spacing: var(--letter-spacing-normal);
+    text-align: right;
+    color: var(--text-diff-add-base);
+  }
+
+  [data-slot="deletions"] {
+    font-family: var(--font-family-mono);
+    font-size: var(--font-size-small);
+    font-style: normal;
+    font-weight: var(--font-weight-regular);
+    line-height: var(--line-height-large);
+    letter-spacing: var(--letter-spacing-normal);
+    text-align: right;
+    color: var(--text-diff-delete-base);
+  }
+}

+ 24 - 0
packages/ui/src/components/diff-changes.tsx

@@ -0,0 +1,24 @@
+import type { FileDiff } from "@opencode-ai/sdk"
+import { createMemo, Show } from "solid-js"
+
+export function DiffChanges(props: { diff: FileDiff | FileDiff[] }) {
+  const additions = createMemo(() =>
+    Array.isArray(props.diff)
+      ? props.diff.reduce((acc, diff) => acc + (diff.additions ?? 0), 0)
+      : props.diff.additions,
+  )
+  const deletions = createMemo(() =>
+    Array.isArray(props.diff)
+      ? props.diff.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0)
+      : props.diff.deletions,
+  )
+  const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0))
+  return (
+    <Show when={total() > 0}>
+      <div data-component="diff-changes">
+        <span data-slot="additions">{`+${additions()}`}</span>
+        <span data-slot="deletions">{`-${deletions()}`}</span>
+      </div>
+    </Show>
+  )
+}

+ 4 - 0
packages/ui/src/components/index.ts

@@ -4,12 +4,16 @@ export * from "./checkbox"
 export * from "./collapsible"
 export * from "./dialog"
 export * from "./diff"
+export * from "./diff-changes"
 export * from "./icon"
 export * from "./icon-button"
 export * from "./input"
 export * from "./fonts"
 export * from "./list"
+export * from "./message-part"
 export * from "./select"
 export * from "./select-dialog"
 export * from "./tabs"
+export * from "./tool-display"
+export * from "./tool-registry"
 export * from "./tooltip"

+ 22 - 0
packages/ui/src/components/message-part.css

@@ -0,0 +1,22 @@
+[data-component="assistant-message"] {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 12px;
+}
+
+[data-component="user-message"] {
+  font-family: var(--font-family-sans);
+  font-size: var(--font-size-small);
+  font-style: normal;
+  font-weight: var(--font-weight-regular);
+  line-height: var(--line-height-large);
+  letter-spacing: var(--letter-spacing-normal);
+  color: var(--text-base);
+  display: -webkit-box;
+  line-clamp: 3;
+  -webkit-line-clamp: 3;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}

+ 87 - 0
packages/ui/src/components/message-part.tsx

@@ -0,0 +1,87 @@
+import { Component, createMemo, For, Match, Show, Switch } from "solid-js"
+import { Dynamic } from "solid-js/web"
+import {
+  AssistantMessage,
+  Message as MessageType,
+  Part as PartType,
+  TextPart,
+  ToolPart,
+  UserMessage,
+} from "@opencode-ai/sdk"
+
+export interface MessageProps {
+  message: MessageType
+  parts: PartType[]
+}
+
+export interface MessagePartProps {
+  part: PartType
+  message: MessageType
+  hideDetails?: boolean
+}
+
+export type PartComponent = Component<MessagePartProps>
+
+const PART_MAPPING: Record<string, PartComponent | undefined> = {}
+
+export function registerPartComponent(type: string, component: PartComponent) {
+  PART_MAPPING[type] = component
+}
+
+export function Message(props: MessageProps) {
+  return (
+    <Switch>
+      <Match when={props.message.role === "user" && props.message}>
+        {(userMessage) => (
+          <UserMessageDisplay message={userMessage() as UserMessage} parts={props.parts} />
+        )}
+      </Match>
+      <Match when={props.message.role === "assistant" && props.message}>
+        {(assistantMessage) => (
+          <AssistantMessageDisplay
+            message={assistantMessage() as AssistantMessage}
+            parts={props.parts}
+          />
+        )}
+      </Match>
+    </Switch>
+  )
+}
+
+export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
+  const filteredParts = createMemo(() => {
+    return props.parts?.filter((x) => {
+      if (x.type === "reasoning") return false
+      return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
+    })
+  })
+  return (
+    <div data-component="assistant-message">
+      <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
+    </div>
+  )
+}
+
+export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
+  const text = createMemo(() =>
+    props.parts
+      ?.filter((p) => p.type === "text" && !(p as TextPart).synthetic)
+      ?.map((p) => (p as TextPart).text)
+      ?.join(""),
+  )
+  return <div data-component="user-message">{text()}</div>
+}
+
+export function Part(props: MessagePartProps) {
+  const component = createMemo(() => PART_MAPPING[props.part.type])
+  return (
+    <Show when={component()}>
+      <Dynamic
+        component={component()}
+        part={props.part}
+        message={props.message}
+        hideDetails={props.hideDetails}
+      />
+    </Show>
+  )
+}

+ 76 - 0
packages/ui/src/components/tool-display.css

@@ -0,0 +1,76 @@
+[data-component="tool-trigger"] {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  align-self: stretch;
+  gap: 20px;
+  justify-content: space-between;
+
+  [data-slot="tool-trigger-content"] {
+    width: 100%;
+    display: flex;
+    align-items: center;
+    align-self: stretch;
+    gap: 20px;
+  }
+
+  [data-slot="tool-icon"] {
+    flex-shrink: 0;
+  }
+
+  [data-slot="tool-info"] {
+    flex-grow: 1;
+    min-width: 0;
+  }
+
+  [data-slot="tool-info-structured"] {
+    width: 100%;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    justify-content: space-between;
+  }
+
+  [data-slot="tool-info-main"] {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  [data-slot="tool-title"] {
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    font-style: normal;
+    font-weight: var(--font-weight-medium);
+    line-height: var(--line-height-large);
+    letter-spacing: var(--letter-spacing-normal);
+    color: var(--text-base);
+
+    &.capitalize {
+      text-transform: capitalize;
+    }
+  }
+
+  [data-slot="tool-subtitle"] {
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    font-style: normal;
+    font-weight: var(--font-weight-medium);
+    line-height: var(--line-height-large);
+    letter-spacing: var(--letter-spacing-normal);
+    color: var(--text-weak);
+  }
+
+  [data-slot="tool-arg"] {
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    font-style: normal;
+    font-weight: var(--font-weight-regular);
+    line-height: var(--line-height-large);
+    letter-spacing: var(--letter-spacing-normal);
+    color: var(--text-weak);
+  }
+}

+ 95 - 0
packages/ui/src/components/tool-display.tsx

@@ -0,0 +1,95 @@
+import { children, For, Match, Show, Switch, type JSX } from "solid-js"
+import { Collapsible } from "./collapsible"
+import { Icon, IconProps } from "./icon"
+
+export type TriggerTitle = {
+  title: string
+  titleClass?: string
+  subtitle?: string
+  subtitleClass?: string
+  args?: string[]
+  argsClass?: string
+  action?: JSX.Element
+}
+
+const isTriggerTitle = (val: any): val is TriggerTitle => {
+  return typeof val === "object" && val !== null && "title" in val && !(val instanceof Node)
+}
+
+export interface BasicToolProps {
+  icon: IconProps["name"]
+  trigger: TriggerTitle | JSX.Element
+  children?: JSX.Element
+  hideDetails?: boolean
+}
+
+export function BasicTool(props: BasicToolProps) {
+  const resolved = children(() => props.children)
+  return (
+    <Collapsible>
+      <Collapsible.Trigger>
+        <div data-component="tool-trigger">
+          <div data-slot="tool-trigger-content">
+            <Icon name={props.icon} size="small" data-slot="tool-icon" />
+            <div data-slot="tool-info">
+              <Switch>
+                <Match when={isTriggerTitle(props.trigger) && props.trigger}>
+                  {(trigger) => (
+                    <div data-slot="tool-info-structured">
+                      <div data-slot="tool-info-main">
+                        <span
+                          data-slot="tool-title"
+                          classList={{
+                            [trigger().titleClass ?? ""]: !!trigger().titleClass,
+                          }}
+                        >
+                          {trigger().title}
+                        </span>
+                        <Show when={trigger().subtitle}>
+                          <span
+                            data-slot="tool-subtitle"
+                            classList={{
+                              [trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
+                            }}
+                          >
+                            {trigger().subtitle}
+                          </span>
+                        </Show>
+                        <Show when={trigger().args?.length}>
+                          <For each={trigger().args}>
+                            {(arg) => (
+                              <span
+                                data-slot="tool-arg"
+                                classList={{
+                                  [trigger().argsClass ?? ""]: !!trigger().argsClass,
+                                }}
+                              >
+                                {arg}
+                              </span>
+                            )}
+                          </For>
+                        </Show>
+                      </div>
+                      <Show when={trigger().action}>{trigger().action}</Show>
+                    </div>
+                  )}
+                </Match>
+                <Match when={true}>{props.trigger as JSX.Element}</Match>
+              </Switch>
+            </div>
+          </div>
+          <Show when={resolved() && !props.hideDetails}>
+            <Collapsible.Arrow />
+          </Show>
+        </div>
+      </Collapsible.Trigger>
+      <Show when={resolved() && !props.hideDetails}>
+        <Collapsible.Content>{resolved()}</Collapsible.Content>
+      </Show>
+    </Collapsible>
+  )
+}
+
+export function GenericTool(props: { tool: string; hideDetails?: boolean }) {
+  return <BasicTool icon="mcp" trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
+}

+ 33 - 0
packages/ui/src/components/tool-registry.tsx

@@ -0,0 +1,33 @@
+import { Component } from "solid-js"
+
+export interface ToolProps {
+  input: Record<string, any>
+  metadata: Record<string, any>
+  tool: string
+  output?: string
+  hideDetails?: boolean
+}
+
+export type ToolComponent = Component<ToolProps>
+
+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,
+}

+ 3 - 0
packages/ui/src/styles/index.css

@@ -9,15 +9,18 @@
 @import "../components/button.css" layer(components);
 @import "../components/checkbox.css" layer(components);
 @import "../components/diff.css" layer(components);
+@import "../components/diff-changes.css" layer(components);
 @import "../components/collapsible.css" layer(components);
 @import "../components/dialog.css" layer(components);
 @import "../components/icon.css" layer(components);
 @import "../components/icon-button.css" layer(components);
 @import "../components/input.css" layer(components);
 @import "../components/list.css" layer(components);
+@import "../components/message-part.css" layer(components);
 @import "../components/select.css" layer(components);
 @import "../components/select-dialog.css" layer(components);
 @import "../components/tabs.css" layer(components);
+@import "../components/tool-display.css" layer(components);
 @import "../components/tooltip.css" layer(components);
 
 @import "./utilities.css" layer(utilities);