diff options
| author | Adam <[email protected]> | 2025-10-30 07:26:06 -0500 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-10-30 12:02:49 -0500 |
| commit | 30f4c2cf4c6c01339434c617fb9d930f6e960883 (patch) | |
| tree | db5da342a227724e11609e05f9e3c1fd6e2e7741 /packages/ui/src | |
| parent | 3541fdcb2019676fb82351e909a8e9b740cb8237 (diff) | |
| download | opencode-30f4c2cf4c6c01339434c617fb9d930f6e960883.tar.gz opencode-30f4c2cf4c6c01339434c617fb9d930f6e960883.zip | |
wip: desktop work
Diffstat (limited to 'packages/ui/src')
| -rw-r--r-- | packages/ui/src/components/collapsible.css | 2 | ||||
| -rw-r--r-- | packages/ui/src/components/diff-changes.css | 28 | ||||
| -rw-r--r-- | packages/ui/src/components/diff-changes.tsx | 24 | ||||
| -rw-r--r-- | packages/ui/src/components/index.ts | 4 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.css | 22 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 87 | ||||
| -rw-r--r-- | packages/ui/src/components/tool-display.css | 76 | ||||
| -rw-r--r-- | packages/ui/src/components/tool-display.tsx | 95 | ||||
| -rw-r--r-- | packages/ui/src/components/tool-registry.tsx | 33 | ||||
| -rw-r--r-- | packages/ui/src/styles/index.css | 3 |
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); |
