summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-10-30 07:26:06 -0500
committerAdam <[email protected]>2025-10-30 12:02:49 -0500
commit30f4c2cf4c6c01339434c617fb9d930f6e960883 (patch)
treedb5da342a227724e11609e05f9e3c1fd6e2e7741 /packages/ui/src
parent3541fdcb2019676fb82351e909a8e9b740cb8237 (diff)
downloadopencode-30f4c2cf4c6c01339434c617fb9d930f6e960883.tar.gz
opencode-30f4c2cf4c6c01339434c617fb9d930f6e960883.zip
wip: desktop work
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/components/collapsible.css2
-rw-r--r--packages/ui/src/components/diff-changes.css28
-rw-r--r--packages/ui/src/components/diff-changes.tsx24
-rw-r--r--packages/ui/src/components/index.ts4
-rw-r--r--packages/ui/src/components/message-part.css22
-rw-r--r--packages/ui/src/components/message-part.tsx87
-rw-r--r--packages/ui/src/components/tool-display.css76
-rw-r--r--packages/ui/src/components/tool-display.tsx95
-rw-r--r--packages/ui/src/components/tool-registry.tsx33
-rw-r--r--packages/ui/src/styles/index.css3
10 files changed, 373 insertions, 1 deletions
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..afca51474
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/packages/ui/src/components/diff-changes.tsx b/packages/ui/src/components/diff-changes.tsx
new file mode 100644
index 000000000..7661a9741
--- /dev/null
+++ b/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>
+ )
+}
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index 16cbb7d95..4b60ddabc 100644
--- a/packages/ui/src/components/index.ts
+++ b/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"
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
new file mode 100644
index 000000000..8931d3bc6
--- /dev/null
+++ b/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;
+}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
new file mode 100644
index 000000000..eddd796e7
--- /dev/null
+++ b/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>
+ )
+}
diff --git a/packages/ui/src/components/tool-display.css b/packages/ui/src/components/tool-display.css
new file mode 100644
index 000000000..f3d9f865f
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/packages/ui/src/components/tool-display.tsx b/packages/ui/src/components/tool-display.tsx
new file mode 100644
index 000000000..43574fbb7
--- /dev/null
+++ b/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} />
+}
diff --git a/packages/ui/src/components/tool-registry.tsx b/packages/ui/src/components/tool-registry.tsx
new file mode 100644
index 000000000..8ee7d8293
--- /dev/null
+++ b/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,
+}
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index 94fa894d4..3ebe6e9ea 100644
--- a/packages/ui/src/styles/index.css
+++ b/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);