summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/components
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2025-10-30 22:53:00 +0000
committerDavid Hill <[email protected]>2025-10-30 22:53:00 +0000
commit2f9f189f390401b3314c74bd9f83e64b2420cd65 (patch)
treeb275ca785719a54115752a79d4b66984ba125670 /packages/ui/src/components
parentf3c70f4ea8818bde5b1228482f72af9b10c71796 (diff)
parenta3ba740de41eb1e4825a99dc8f519e1225357e55 (diff)
downloadopencode-2f9f189f390401b3314c74bd9f83e64b2420cd65.tar.gz
opencode-2f9f189f390401b3314c74bd9f83e64b2420cd65.zip
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/ui/src/components')
-rw-r--r--packages/ui/src/components/basic-tool.css76
-rw-r--r--packages/ui/src/components/basic-tool.tsx95
-rw-r--r--packages/ui/src/components/card.css29
-rw-r--r--packages/ui/src/components/card.tsx22
-rw-r--r--packages/ui/src/components/collapsible.css2
-rw-r--r--packages/ui/src/components/diff-changes.css39
-rw-r--r--packages/ui/src/components/diff-changes.tsx122
-rw-r--r--packages/ui/src/components/diff.css4
-rw-r--r--packages/ui/src/components/diff.tsx17
-rw-r--r--packages/ui/src/components/icon.tsx1
-rw-r--r--packages/ui/src/components/index.ts10
-rw-r--r--packages/ui/src/components/markdown.css37
-rw-r--r--packages/ui/src/components/markdown.tsx36
-rw-r--r--packages/ui/src/components/message-part.css129
-rw-r--r--packages/ui/src/components/message-part.tsx446
-rw-r--r--packages/ui/src/components/progress-circle.css12
-rw-r--r--packages/ui/src/components/progress-circle.tsx63
-rw-r--r--packages/ui/src/components/tabs.css3
18 files changed, 1130 insertions, 13 deletions
diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css
new file mode 100644
index 000000000..f3d9f865f
--- /dev/null
+++ b/packages/ui/src/components/basic-tool.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);
+ }
+}
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx
new file mode 100644
index 000000000..43574fbb7
--- /dev/null
+++ b/packages/ui/src/components/basic-tool.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} />
+}
diff --git a/packages/ui/src/components/card.css b/packages/ui/src/components/card.css
new file mode 100644
index 000000000..a9b7c8784
--- /dev/null
+++ b/packages/ui/src/components/card.css
@@ -0,0 +1,29 @@
+[data-component="card"] {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ background-color: var(--surface-inset-base);
+ border: 1px solid var(--border-weaker-base);
+ transition: background-color 0.15s ease;
+ border-radius: 8px;
+ padding: 6px 12px;
+ overflow: clip;
+
+ &[data-variant="error"] {
+ background-color: var(--surface-critical-weak);
+ border: 1px solid var(--border-critical-base);
+ color: rgba(218, 51, 25, 0.6);
+
+ /* text-12-regular */
+ 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); /* 166.667% */
+ letter-spacing: var(--letter-spacing-normal);
+
+ &[data-component="icon"] {
+ color: var(--icon-critical-active);
+ }
+ }
+}
diff --git a/packages/ui/src/components/card.tsx b/packages/ui/src/components/card.tsx
new file mode 100644
index 000000000..3fb225ab2
--- /dev/null
+++ b/packages/ui/src/components/card.tsx
@@ -0,0 +1,22 @@
+import { type ComponentProps, splitProps } from "solid-js"
+
+export interface CardProps extends ComponentProps<"div"> {
+ variant?: "normal" | "error" | "warning" | "success" | "info"
+}
+
+export function Card(props: CardProps) {
+ const [split, rest] = splitProps(props, ["variant", "class", "classList"])
+ return (
+ <div
+ {...rest}
+ data-component="card"
+ data-variant={split.variant || "normal"}
+ classList={{
+ ...(split.classList ?? {}),
+ [split.class ?? ""]: !!split.class,
+ }}
+ >
+ {props.children}
+ </div>
+ )
+}
diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css
index 3d8c8ebea..4b2c14d4d 100644
--- a/packages/ui/src/components/collapsible.css
+++ b/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;
diff --git a/packages/ui/src/components/diff-changes.css b/packages/ui/src/components/diff-changes.css
new file mode 100644
index 000000000..eb95c4676
--- /dev/null
+++ b/packages/ui/src/components/diff-changes.css
@@ -0,0 +1,39 @@
+[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);
+ }
+}
+
+[data-component="diff-changes"][data-variant="bars"] {
+ width: 18px;
+ flex-shrink: 0;
+
+ svg {
+ display: block;
+ width: 100%;
+ height: auto;
+ }
+}
diff --git a/packages/ui/src/components/diff-changes.tsx b/packages/ui/src/components/diff-changes.tsx
new file mode 100644
index 000000000..433c47f39
--- /dev/null
+++ b/packages/ui/src/components/diff-changes.tsx
@@ -0,0 +1,122 @@
+import type { FileDiff } from "@opencode-ai/sdk"
+import { createMemo, For, Match, Show, Switch } from "solid-js"
+
+export function DiffChanges(props: { diff: FileDiff | FileDiff[]; variant?: "default" | "bars" }) {
+ const variant = () => props.variant ?? "default"
+
+ 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))
+
+ const countLines = (text: string) => {
+ if (!text) return 0
+ return text.split("\n").length
+ }
+
+ const totalBeforeLines = createMemo(() => {
+ if (!Array.isArray(props.diff)) return countLines(props.diff.before || "")
+ return props.diff.reduce((acc, diff) => acc + countLines(diff.before || ""), 0)
+ })
+
+ const blockCounts = createMemo(() => {
+ const TOTAL_BLOCKS = 5
+
+ const adds = additions() ?? 0
+ const dels = deletions() ?? 0
+
+ if (adds === 0 && dels === 0) {
+ return { added: 0, deleted: 0, neutral: TOTAL_BLOCKS }
+ }
+
+ const total = adds + dels
+
+ if (total < 5) {
+ const added = adds > 0 ? 1 : 0
+ const deleted = dels > 0 ? 1 : 0
+ const neutral = TOTAL_BLOCKS - added - deleted
+ return { added, deleted, neutral }
+ }
+
+ const ratio = adds > dels ? adds / dels : dels / adds
+ let BLOCKS_FOR_COLORS = TOTAL_BLOCKS
+
+ if (total < 20) {
+ BLOCKS_FOR_COLORS = TOTAL_BLOCKS - 1
+ } else if (ratio < 4) {
+ BLOCKS_FOR_COLORS = TOTAL_BLOCKS - 1
+ }
+
+ const percentAdded = adds / total
+ const percentDeleted = dels / total
+
+ const added_raw = percentAdded * BLOCKS_FOR_COLORS
+ const deleted_raw = percentDeleted * BLOCKS_FOR_COLORS
+
+ let added = adds > 0 ? Math.max(1, Math.round(added_raw)) : 0
+ let deleted = dels > 0 ? Math.max(1, Math.round(deleted_raw)) : 0
+
+ // Cap bars based on actual change magnitude
+ if (adds > 0 && adds <= 5) added = Math.min(added, 1)
+ if (adds > 5 && adds <= 10) added = Math.min(added, 2)
+ if (dels > 0 && dels <= 5) deleted = Math.min(deleted, 1)
+ if (dels > 5 && dels <= 10) deleted = Math.min(deleted, 2)
+
+ let total_allocated = added + deleted
+ if (total_allocated > BLOCKS_FOR_COLORS) {
+ if (added_raw > deleted_raw) {
+ added = BLOCKS_FOR_COLORS - deleted
+ } else {
+ deleted = BLOCKS_FOR_COLORS - added
+ }
+ total_allocated = added + deleted
+ }
+
+ const neutral = Math.max(0, TOTAL_BLOCKS - total_allocated)
+
+ return { added, deleted, neutral }
+ })
+
+ const ADD_COLOR = "var(--icon-diff-add-base)"
+ const DELETE_COLOR = "var(--icon-diff-delete-base)"
+ const NEUTRAL_COLOR = "var(--icon-weak-base)"
+
+ const visibleBlocks = createMemo(() => {
+ const counts = blockCounts()
+ const blocks = [
+ ...Array(counts.added).fill(ADD_COLOR),
+ ...Array(counts.deleted).fill(DELETE_COLOR),
+ ...Array(counts.neutral).fill(NEUTRAL_COLOR),
+ ]
+ return blocks.slice(0, 5)
+ })
+
+ return (
+ <Show when={variant() === "default" ? total() > 0 : true}>
+ <div data-component="diff-changes" data-variant={variant()}>
+ <Switch>
+ <Match when={variant() === "bars"}>
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
+ <g>
+ <For each={visibleBlocks()}>
+ {(color, i) => <rect x={i() * 4} width="2" height="12" rx="1" fill={color} />}
+ </For>
+ </g>
+ </svg>
+ </Match>
+ <Match when={variant() === "default"}>
+ <span data-slot="additions">{`+${additions()}`}</span>
+ <span data-slot="deletions">{`-${deletions()}`}</span>
+ </Match>
+ </Switch>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/ui/src/components/diff.css b/packages/ui/src/components/diff.css
index c4e831879..860e3b1d1 100644
--- a/packages/ui/src/components/diff.css
+++ b/packages/ui/src/components/diff.css
@@ -22,5 +22,9 @@
width: var(--pjs-column-content-width);
left: var(--pjs-column-number-width);
padding-left: 8px;
+
+ [data-slot="diff-hunk-separator-content-span"] {
+ mix-blend-mode: var(--text-mix-blend-mode);
+ }
}
}
diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx
index 731b1bfe0..e9e46d6b0 100644
--- a/packages/ui/src/components/diff.tsx
+++ b/packages/ui/src/components/diff.tsx
@@ -3,12 +3,12 @@ import {
FileDiff,
type DiffLineAnnotation,
type HunkData,
- DiffFileRendererOptions,
+ FileDiffOptions,
// registerCustomTheme,
} from "@pierre/precision-diffs"
import { ComponentProps, createEffect, splitProps } from "solid-js"
-export type DiffProps<T = {}> = Omit<DiffFileRendererOptions<T>, "themes"> & {
+export type DiffProps<T = {}> = FileDiffOptions<T> & {
before: FileContents
after: FileContents
annotations?: DiffLineAnnotation<T>[]
@@ -54,13 +54,9 @@ export function Diff<T>(props: DiffProps<T>) {
// When ready to render, simply call .render with old/new file, optional
// annotations and a container element to hold the diff
createEffect(() => {
- // @ts-expect-error
const instance = new FileDiff<T>({
// theme: "pierre-light",
- // theme: "pierre-light",
- // Or can also provide a 'themes' prop, which allows the code to adapt
- // to your OS light or dark theme
- themes: { dark: "pierre-dark", light: "pierre-light" },
+ theme: { dark: "pierre-dark", light: "pierre-light" },
// When using the 'themes' prop, 'themeType' allows you to force 'dark'
// or 'light' theme, or inherit from the OS ('system') theme.
themeType: "system",
@@ -113,8 +109,11 @@ export function Diff<T>(props: DiffProps<T>) {
numCol.dataset["slot"] = "diff-hunk-separator-line-number"
fragment.appendChild(numCol)
const contentCol = document.createElement("div")
- contentCol.textContent = `${hunkData.lines} unmodified lines`
contentCol.dataset["slot"] = "diff-hunk-separator-content"
+ const span = document.createElement("span")
+ span.dataset["slot"] = "diff-hunk-separator-content-span"
+ span.textContent = `${hunkData.lines} unmodified lines`
+ contentCol.appendChild(span)
fragment.appendChild(contentCol)
return fragment
},
@@ -170,7 +169,7 @@ export function Diff<T>(props: DiffProps<T>) {
"--pjs-font-family": "var(--font-family-mono)",
"--pjs-font-size": "var(--font-size-small)",
"--pjs-line-height": "24px",
- "--pjs-tab-size": 4,
+ "--pjs-tab-size": 2,
"--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
"--pjs-header-font-family": "var(--font-family-sans)",
"--pjs-gap-block": 0,
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 5736146e5..a2e127290 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -149,6 +149,7 @@ const newIcons = {
console: `<path d="M3.75 5.4165L8.33333 9.99984L3.75 14.5832M10.4167 14.5832H16.25" stroke="currentColor" stroke-linecap="square"/>`,
"code-lines": `<path d="M2.08325 3.75H11.2499M14.5833 3.75H17.9166M2.08325 10L7.08325 10M10.4166 10L17.9166 10M2.08325 16.25L8.74992 16.25M12.0833 16.25L17.9166 16.25" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round"/>`,
"square-arrow-top-right": `<path d="M7.91675 2.9165H2.91675V17.0832H17.0834V12.0832M12.0834 2.9165H17.0834V7.9165M9.58342 10.4165L16.6667 3.33317" stroke="currentColor" stroke-linecap="square"/>`,
+ "circle-ban-sign": `<path d="M15.3675 4.63087L4.55742 15.441M17.9163 9.9987C17.9163 14.371 14.3719 17.9154 9.99967 17.9154C7.81355 17.9154 5.83438 17.0293 4.40175 15.5966C2.96911 14.164 2.08301 12.1848 2.08301 9.9987C2.08301 5.62644 5.62742 2.08203 9.99967 2.08203C12.1858 2.08203 14.165 2.96813 15.5976 4.40077C17.0302 5.8334 17.9163 7.81257 17.9163 9.9987Z" stroke="currentColor" stroke-linecap="round"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index 16cbb7d95..8d6ddc89c 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -1,15 +1,25 @@
export * from "./accordion"
export * from "./button"
+export * from "./card"
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 "./markdown"
+export * from "./message-part"
+export * from "./progress-circle"
export * from "./select"
export * from "./select-dialog"
export * from "./tabs"
+export * from "./basic-tool"
export * from "./tooltip"
+
+export * from "../context/helper"
+export * from "../context/shiki"
+export * from "../context/marked"
diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css
new file mode 100644
index 000000000..abc505a9e
--- /dev/null
+++ b/packages/ui/src/components/markdown.css
@@ -0,0 +1,37 @@
+[data-component="markdown"] {
+ min-width: 0;
+ max-width: 100%;
+ overflow: auto;
+ scrollbar-width: none;
+ color: var(--text-base);
+
+ /* text-14-regular */
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-base);
+ font-style: normal;
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-large); /* 142.857% */
+ letter-spacing: var(--letter-spacing-normal);
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ h1,
+ h2,
+ h3 {
+ margin-top: 16px;
+ margin-bottom: 8px;
+ font-weight: var(--font-weight-medium);
+ }
+
+ p {
+ margin-bottom: 8px;
+ }
+
+ ul,
+ ol {
+ margin-top: 16px;
+ margin-bottom: 16px;
+ }
+}
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
new file mode 100644
index 000000000..071132e80
--- /dev/null
+++ b/packages/ui/src/components/markdown.tsx
@@ -0,0 +1,36 @@
+import { useMarked } from "../context/marked"
+import { ComponentProps, createResource, splitProps } from "solid-js"
+
+function strip(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
+}
+
+export function Markdown(
+ props: ComponentProps<"div"> & {
+ text: string
+ class?: string
+ classList?: Record<string, boolean>
+ },
+) {
+ const [local, others] = splitProps(props, ["text", "class", "classList"])
+ const marked = useMarked()
+ const [html] = createResource(
+ () => strip(local.text),
+ async (markdown) => {
+ return marked.parse(markdown)
+ },
+ )
+ return (
+ <div
+ data-component="markdown"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ innerHTML={html()}
+ {...others}
+ />
+ )
+}
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
new file mode 100644
index 000000000..fa251a2b3
--- /dev/null
+++ b/packages/ui/src/components/message-part.css
@@ -0,0 +1,129 @@
+[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;
+}
+
+[data-component="text-part"] {
+ [data-component="markdown"] {
+ margin-top: 32px;
+ }
+}
+
+[data-component="tool-error"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ [data-slot="icon"] {
+ color: var(--icon-critical-active);
+ }
+
+ [data-slot="content"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ [data-slot="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(--ember-light-11);
+ text-transform: capitalize;
+ }
+}
+
+[data-component="tool-output"] {
+ white-space: pre;
+}
+
+[data-component="edit-trigger"],
+[data-component="write-trigger"] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+
+ [data-slot="title-area"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ [data-slot="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);
+ text-transform: capitalize;
+ }
+
+ [data-slot="path"] {
+ display: flex;
+ }
+
+ [data-slot="directory"] {
+ color: var(--text-weak);
+ }
+
+ [data-slot="filename"] {
+ color: var(--text-strong);
+ }
+
+ [data-slot="actions"] {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ justify-content: flex-end;
+ }
+}
+
+[data-component="edit-content"] {
+ border-top: 1px solid var(--border-weaker-base);
+}
+
+[data-component="tool-action"] {
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+[data-component="todos"] {
+ padding: 10px 12px 24px 48px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ [data-slot="todo-content"] {
+ &[data-completed="completed"] {
+ text-decoration: line-through;
+ color: var(--text-weaker);
+ }
+ }
+}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
new file mode 100644
index 000000000..1aaab751a
--- /dev/null
+++ b/packages/ui/src/components/message-part.tsx
@@ -0,0 +1,446 @@
+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"
+import { BasicTool } from "./basic-tool"
+import { GenericTool } from "./basic-tool"
+import { Card } from "./card"
+import { Icon } from "./icon"
+import { Checkbox } from "./checkbox"
+import { Diff } from "./diff"
+import { DiffChanges } from "./diff-changes"
+import { Markdown } from "./markdown"
+
+export interface MessageProps {
+ message: MessageType
+ parts: PartType[]
+}
+
+export interface MessagePartProps {
+ part: PartType
+ message: MessageType
+ hideDetails?: boolean
+}
+
+export type PartComponent = Component<MessagePartProps>
+
+export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
+
+function getFilename(path: string) {
+ if (!path) return ""
+ const trimmed = path.replace(/[\/]+$/, "")
+ const parts = trimmed.split("/")
+ return parts[parts.length - 1] ?? ""
+}
+
+function getDirectory(path: string) {
+ const parts = path.split("/")
+ const dir = parts.slice(0, parts.length - 1).join("/")
+ return dir ? dir + "/" : ""
+}
+
+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 <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
+}
+
+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>
+ )
+}
+
+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,
+}
+
+PART_MAPPING["tool"] = function ToolPartDisplay(props) {
+ const part = props.part as ToolPart
+ const component = createMemo(() => {
+ 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 (
+ <Switch>
+ <Match when={part.state.status === "error" && part.state.error}>
+ {(error) => {
+ const cleaned = error().replace("Error: ", "")
+ const [title, ...rest] = cleaned.split(": ")
+ return (
+ <Card variant="error">
+ <div data-component="tool-error">
+ <Icon name="circle-ban-sign" size="small" data-slot="icon" />
+ <Switch>
+ <Match when={title}>
+ <div data-slot="content">
+ <div data-slot="title">{title}</div>
+ <span>{rest.join(": ")}</span>
+ </div>
+ </Match>
+ <Match when={true}>{cleaned}</Match>
+ </Switch>
+ </div>
+ </Card>
+ )
+ }}
+ </Match>
+ <Match when={true}>
+ <Dynamic
+ component={render}
+ input={input}
+ tool={part.tool}
+ metadata={metadata}
+ output={part.state.status === "completed" ? part.state.output : undefined}
+ hideDetails={props.hideDetails}
+ />
+ </Match>
+ </Switch>
+ )
+ })
+
+ return <Show when={component()}>{component()}</Show>
+}
+
+PART_MAPPING["text"] = function TextPartDisplay(props) {
+ const part = props.part as TextPart
+ return (
+ <Show when={part.text.trim()}>
+ <div data-component="text-part">
+ <Markdown text={part.text.trim()} />
+ </div>
+ </Show>
+ )
+}
+
+PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
+ const part = props.part as any
+ return (
+ <Show when={part.text.trim()}>
+ <div data-component="reasoning-part">
+ <Markdown text={part.text.trim()} />
+ </div>
+ </Show>
+ )
+}
+
+ToolRegistry.register({
+ name: "read",
+ render(props) {
+ return (
+ <BasicTool
+ icon="glasses"
+ trigger={{
+ title: "Read",
+ subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
+ }}
+ />
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "list",
+ render(props) {
+ return (
+ <BasicTool
+ icon="bullet-list"
+ trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}
+ >
+ <Show when={false && props.output}>
+ <div data-component="tool-output">{props.output}</div>
+ </Show>
+ </BasicTool>
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "glob",
+ render(props) {
+ return (
+ <BasicTool
+ icon="magnifying-glass-menu"
+ trigger={{
+ title: "Glob",
+ subtitle: getDirectory(props.input.path || "/"),
+ args: props.input.pattern ? ["pattern=" + props.input.pattern] : [],
+ }}
+ >
+ <Show when={false && props.output}>
+ <div data-component="tool-output">{props.output}</div>
+ </Show>
+ </BasicTool>
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "grep",
+ render(props) {
+ const args = []
+ if (props.input.pattern) args.push("pattern=" + props.input.pattern)
+ if (props.input.include) args.push("include=" + props.input.include)
+ return (
+ <BasicTool
+ icon="magnifying-glass-menu"
+ trigger={{
+ title: "Grep",
+ subtitle: getDirectory(props.input.path || "/"),
+ args,
+ }}
+ >
+ <Show when={false && props.output}>
+ <div data-component="tool-output">{props.output}</div>
+ </Show>
+ </BasicTool>
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "webfetch",
+ render(props) {
+ return (
+ <BasicTool
+ icon="window-cursor"
+ trigger={{
+ title: "Webfetch",
+ subtitle: props.input.url || "",
+ args: props.input.format ? ["format=" + props.input.format] : [],
+ action: (
+ <div data-component="tool-action">
+ <Icon name="square-arrow-top-right" size="small" />
+ </div>
+ ),
+ }}
+ >
+ <Show when={false && props.output}>
+ <div data-component="tool-output">{props.output}</div>
+ </Show>
+ </BasicTool>
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "task",
+ render(props) {
+ return (
+ <BasicTool
+ icon="task"
+ trigger={{
+ title: `${props.input.subagent_type || props.tool} Agent`,
+ titleClass: "capitalize",
+ subtitle: props.input.description,
+ }}
+ >
+ <Show when={false && props.output}>
+ <div data-component="tool-output">{props.output}</div>
+ </Show>
+ </BasicTool>
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "bash",
+ render(props) {
+ return (
+ <BasicTool
+ icon="console"
+ trigger={{
+ title: "Shell",
+ subtitle: "Ran " + props.input.command,
+ }}
+ >
+ <Show when={false && props.output}>
+ <div data-component="tool-output">{props.output}</div>
+ </Show>
+ </BasicTool>
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "edit",
+ render(props) {
+ return (
+ <BasicTool
+ icon="code-lines"
+ trigger={
+ <div data-component="edit-trigger">
+ <div data-slot="title-area">
+ <div data-slot="title">Edit</div>
+ <div data-slot="path">
+ <Show when={props.input.filePath?.includes("/")}>
+ <span data-slot="directory">{getDirectory(props.input.filePath!)}</span>
+ </Show>
+ <span data-slot="filename">{getFilename(props.input.filePath ?? "")}</span>
+ </div>
+ </div>
+ <div data-slot="actions">
+ <Show when={props.metadata.filediff}>
+ <DiffChanges diff={props.metadata.filediff} />
+ </Show>
+ </div>
+ </div>
+ }
+ >
+ <Show when={props.metadata.filediff}>
+ <div data-component="edit-content">
+ <Diff
+ before={{
+ name: getFilename(props.metadata.filediff.path),
+ contents: props.metadata.filediff.before,
+ }}
+ after={{
+ name: getFilename(props.metadata.filediff.path),
+ contents: props.metadata.filediff.after,
+ }}
+ />
+ </div>
+ </Show>
+ </BasicTool>
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "write",
+ render(props) {
+ return (
+ <BasicTool
+ icon="code-lines"
+ trigger={
+ <div data-component="write-trigger">
+ <div data-slot="title-area">
+ <div data-slot="title">Write</div>
+ <div data-slot="path">
+ <Show when={props.input.filePath?.includes("/")}>
+ <span data-slot="directory">{getDirectory(props.input.filePath!)}</span>
+ </Show>
+ <span data-slot="filename">{getFilename(props.input.filePath ?? "")}</span>
+ </div>
+ </div>
+ <div data-slot="actions">{/* <DiffChanges diff={diff} /> */}</div>
+ </div>
+ }
+ >
+ <Show when={false && props.output}>
+ <div data-component="tool-output">{props.output}</div>
+ </Show>
+ </BasicTool>
+ )
+ },
+})
+
+ToolRegistry.register({
+ name: "todowrite",
+ render(props) {
+ return (
+ <BasicTool
+ icon="checklist"
+ trigger={{
+ title: "To-dos",
+ subtitle: `${props.input.todos?.filter((t: any) => t.status === "completed").length}/${props.input.todos?.length}`,
+ }}
+ >
+ <Show when={props.input.todos?.length}>
+ <div data-component="todos">
+ <For each={props.input.todos}>
+ {(todo: any) => (
+ <Checkbox readOnly checked={todo.status === "completed"}>
+ <div data-slot="todo-content" data-completed={todo.status === "completed"}>
+ {todo.content}
+ </div>
+ </Checkbox>
+ )}
+ </For>
+ </div>
+ </Show>
+ </BasicTool>
+ )
+ },
+})
diff --git a/packages/ui/src/components/progress-circle.css b/packages/ui/src/components/progress-circle.css
new file mode 100644
index 000000000..591825183
--- /dev/null
+++ b/packages/ui/src/components/progress-circle.css
@@ -0,0 +1,12 @@
+[data-component="progress-circle"] {
+ transform: rotate(-90deg);
+
+ [data-slot="background"] {
+ stroke: var(--border-weak-base);
+ }
+
+ [data-slot="progress"] {
+ stroke: var(--border-active);
+ transition: stroke-dashoffset 0.35s cubic-bezier(0.65, 0, 0.35, 1);
+ }
+}
diff --git a/packages/ui/src/components/progress-circle.tsx b/packages/ui/src/components/progress-circle.tsx
new file mode 100644
index 000000000..a659c0f2e
--- /dev/null
+++ b/packages/ui/src/components/progress-circle.tsx
@@ -0,0 +1,63 @@
+import { type ComponentProps, createMemo, splitProps } from "solid-js"
+
+export interface ProgressCircleProps extends Pick<ComponentProps<"svg">, "class" | "classList"> {
+ percentage: number
+ size?: number
+ strokeWidth?: number
+}
+
+export function ProgressCircle(props: ProgressCircleProps) {
+ const [split, rest] = splitProps(props, [
+ "percentage",
+ "size",
+ "strokeWidth",
+ "class",
+ "classList",
+ ])
+
+ const size = () => split.size || 16
+ const strokeWidth = () => split.strokeWidth || 3
+
+ const viewBoxSize = 16
+ const center = viewBoxSize / 2
+ const radius = () => center - strokeWidth() / 2
+ const circumference = createMemo(() => 2 * Math.PI * radius())
+
+ const offset = createMemo(() => {
+ const clampedPercentage = Math.max(0, Math.min(100, split.percentage || 0))
+ const progress = clampedPercentage / 100
+ return circumference() * (1 - progress)
+ })
+
+ return (
+ <svg
+ {...rest}
+ width={size()}
+ height={size()}
+ viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
+ fill="none"
+ data-component="progress-circle"
+ classList={{
+ ...(split.classList ?? {}),
+ [split.class ?? ""]: !!split.class,
+ }}
+ >
+ <circle
+ cx={center}
+ cy={center}
+ r={radius()}
+ data-slot="background"
+ stroke-width={strokeWidth()}
+ />
+ <circle
+ cx={center}
+ cy={center}
+ r={radius()}
+ data-slot="progress"
+ stroke-width={strokeWidth()}
+ stroke-dasharray={circumference().toString()}
+ stroke-dashoffset={offset()}
+ />
+ </svg>
+ )
+}
diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css
index 29057fc87..1d786fb4a 100644
--- a/packages/ui/src/components/tabs.css
+++ b/packages/ui/src/components/tabs.css
@@ -57,9 +57,6 @@
border-bottom: 1px solid var(--border-weak-base);
border-right: 1px solid var(--border-weak-base);
background-color: var(--background-base);
- transition:
- background-color 0.15s ease,
- color 0.15s ease;
&:disabled {
pointer-events: none;