From 30f4c2cf4c6c01339434c617fb9d930f6e960883 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 07:26:06 -0500 Subject: wip: desktop work --- packages/ui/src/components/collapsible.css | 2 +- packages/ui/src/components/diff-changes.css | 28 ++++++++ packages/ui/src/components/diff-changes.tsx | 24 +++++++ packages/ui/src/components/index.ts | 4 ++ packages/ui/src/components/message-part.css | 22 +++++++ packages/ui/src/components/message-part.tsx | 87 +++++++++++++++++++++++++ packages/ui/src/components/tool-display.css | 76 ++++++++++++++++++++++ packages/ui/src/components/tool-display.tsx | 95 ++++++++++++++++++++++++++++ packages/ui/src/components/tool-registry.tsx | 33 ++++++++++ packages/ui/src/styles/index.css | 3 + 10 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/diff-changes.css create mode 100644 packages/ui/src/components/diff-changes.tsx create mode 100644 packages/ui/src/components/message-part.css create mode 100644 packages/ui/src/components/message-part.tsx create mode 100644 packages/ui/src/components/tool-display.css create mode 100644 packages/ui/src/components/tool-display.tsx create mode 100644 packages/ui/src/components/tool-registry.tsx (limited to 'packages/ui/src') 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 ( + 0}> +
+ {`+${additions()}`} + {`-${deletions()}`} +
+
+ ) +} 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 + +const PART_MAPPING: Record = {} + +export function registerPartComponent(type: string, component: PartComponent) { + PART_MAPPING[type] = component +} + +export function Message(props: MessageProps) { + return ( + + + {(userMessage) => ( + + )} + + + {(assistantMessage) => ( + + )} + + + ) +} + +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 ( +
+ {(part) => } +
+ ) +} + +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
{text()}
+} + +export function Part(props: MessagePartProps) { + const component = createMemo(() => PART_MAPPING[props.part.type]) + return ( + + + + ) +} 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 ( + + +
+
+ +
+ + + {(trigger) => ( +
+
+ + {trigger().title} + + + + {trigger().subtitle} + + + + + {(arg) => ( + + {arg} + + )} + + +
+ {trigger().action} +
+ )} +
+ {props.trigger as JSX.Element} +
+
+
+ + + +
+
+ + {resolved()} + +
+ ) +} + +export function GenericTool(props: { tool: string; hideDetails?: boolean }) { + return +} 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 + metadata: Record + tool: string + output?: string + hideDetails?: boolean +} + +export type ToolComponent = Component + +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); -- cgit v1.2.3 From 3b2093595957d988ef39a3f2b66d9e37feac8c26 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 08:37:47 -0500 Subject: wip: desktop work --- .../desktop/src/components/progress-circle.tsx | 48 ----------------- packages/desktop/src/pages/index.tsx | 4 +- packages/ui/src/components/index.ts | 1 + packages/ui/src/components/progress-circle.css | 12 +++++ packages/ui/src/components/progress-circle.tsx | 63 ++++++++++++++++++++++ packages/ui/src/styles/index.css | 1 + 6 files changed, 79 insertions(+), 50 deletions(-) delete mode 100644 packages/desktop/src/components/progress-circle.tsx create mode 100644 packages/ui/src/components/progress-circle.css create mode 100644 packages/ui/src/components/progress-circle.tsx (limited to 'packages/ui/src') diff --git a/packages/desktop/src/components/progress-circle.tsx b/packages/desktop/src/components/progress-circle.tsx deleted file mode 100644 index d56197ed3..000000000 --- a/packages/desktop/src/components/progress-circle.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Component, createMemo } from "solid-js" - -interface ProgressCircleProps { - percentage: number - size?: number - strokeWidth?: number -} - -export const ProgressCircle: Component = (props) => { - // --- Set default values for props --- - const size = () => props.size || 16 - const strokeWidth = () => props.strokeWidth || 3 - - // --- Constants for SVG calculation --- - const viewBoxSize = 16 - const center = viewBoxSize / 2 - const radius = () => center - strokeWidth() / 2 - const circumference = createMemo(() => 2 * Math.PI * radius()) - - // --- Reactive Calculation for the progress offset --- - const offset = createMemo(() => { - const clampedPercentage = Math.max(0, Math.min(100, props.percentage || 0)) - const progress = clampedPercentage / 100 - return circumference() * (1 - progress) - }) - - return ( - - - - - ) -} diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 2a6761623..30635a5ec 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -10,6 +10,8 @@ import { Diff, Collapsible, Part, + DiffChanges, + ProgressCircle, } from "@opencode-ai/ui" import { FileIcon } from "@/ui" import FileTree from "@/components/file-tree" @@ -33,10 +35,8 @@ import type { JSX } from "solid-js" import { Code } from "@/components/code" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" -import { ProgressCircle } from "@/components/progress-circle" import { Message } from "@/components/message" import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" -import { DiffChanges } from "@opencode-ai/ui" import { Markdown } from "@/components/markdown" export default function Page() { diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 4b60ddabc..63be24d58 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -11,6 +11,7 @@ export * from "./input" export * from "./fonts" export * from "./list" export * from "./message-part" +export * from "./progress-circle" export * from "./select" export * from "./select-dialog" export * from "./tabs" 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, "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 ( + + + + + ) +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 3ebe6e9ea..9742caa0a 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -17,6 +17,7 @@ @import "../components/input.css" layer(components); @import "../components/list.css" layer(components); @import "../components/message-part.css" layer(components); +@import "../components/progress-circle.css" layer(components); @import "../components/select.css" layer(components); @import "../components/select-dialog.css" layer(components); @import "../components/tabs.css" layer(components); -- cgit v1.2.3 From 19974daa67b034062e991cff6611477741c0a09d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:16:45 -0500 Subject: wip: desktop work --- packages/desktop/src/components/message.tsx | 25 +++++++++++++++++++++++-- packages/ui/src/components/card.css | 29 +++++++++++++++++++++++++++++ packages/ui/src/components/card.tsx | 22 ++++++++++++++++++++++ packages/ui/src/components/icon.tsx | 1 + packages/ui/src/components/index.ts | 1 + packages/ui/src/styles/index.css | 1 + 6 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/card.css create mode 100644 packages/ui/src/components/card.tsx (limited to 'packages/ui/src') diff --git a/packages/desktop/src/components/message.tsx b/packages/desktop/src/components/message.tsx index 9e9e06d35..78be29d75 100644 --- a/packages/desktop/src/components/message.tsx +++ b/packages/desktop/src/components/message.tsx @@ -1,8 +1,8 @@ import type { Part, TextPart, ToolPart, Message } from "@opencode-ai/sdk" -import { createMemo, For, Show } from "solid-js" +import { createMemo, For, Match, Show, Switch } from "solid-js" import { Dynamic } from "solid-js/web" import { Markdown } from "./markdown" -import { Checkbox, Diff, Icon } from "@opencode-ai/ui" +import { Card, 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" @@ -36,6 +36,27 @@ registerPartComponent("tool", function ToolPartDisplay(props) { const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {}) const input = part.state.status === "completed" ? part.state.input : {} + if (part.state.status === "error") { + const error = part.state.error.replace("Error: ", "") + const [title, ...rest] = error.split(": ") + return ( + +
+ + + +
+
{title}
+ {rest.join(": ")} +
+
+ {error} +
+
+
+ ) + } + return ( { + variant?: "normal" | "error" | "warning" | "success" | "info" +} + +export function Card(props: CardProps) { + const [split, rest] = splitProps(props, ["variant", "class", "classList"]) + return ( +
+ {props.children} +
+ ) +} 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: ``, "code-lines": ``, "square-arrow-top-right": ``, + "circle-ban-sign": ``, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 63be24d58..29e8cfe3b 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -1,5 +1,6 @@ export * from "./accordion" export * from "./button" +export * from "./card" export * from "./checkbox" export * from "./collapsible" export * from "./dialog" diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 9742caa0a..4fe13055a 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -7,6 +7,7 @@ @import "../components/accordion.css" layer(components); @import "../components/button.css" layer(components); +@import "../components/card.css" layer(components); @import "../components/checkbox.css" layer(components); @import "../components/diff.css" layer(components); @import "../components/diff-changes.css" layer(components); -- cgit v1.2.3 From dce287a42d5b4bb965ee74707bf7b0b0709d61f1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:33:45 -0500 Subject: wip: desktop work --- bun.lock | 4 +- package.json | 2 +- packages/desktop/src/components/message.tsx | 63 ++++++++++++++++------------- packages/desktop/src/pages/index.tsx | 2 +- packages/ui/src/components/diff.tsx | 10 ++--- packages/ui/src/components/message-part.tsx | 6 +-- 6 files changed, 42 insertions(+), 45 deletions(-) (limited to 'packages/ui/src') diff --git a/bun.lock b/bun.lock index bb1d65673..c651e3a68 100644 --- a/bun.lock +++ b/bun.lock @@ -347,7 +347,7 @@ "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/precision-diffs": "0.3.6", + "@pierre/precision-diffs": "0.4.1", "@solidjs/meta": "0.29.4", "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", @@ -941,7 +941,7 @@ "@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="], - "@pierre/precision-diffs": ["@pierre/precision-diffs@0.3.6", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-cKM3HcMmyr5wPFll0bHYcgHplcHgMlL6Dw4Pi4giL0jVt7ySlGwwVyXTRFW5Fva43stOL+EWB+9U5VBDSktBJA=="], + "@pierre/precision-diffs": ["@pierre/precision-diffs@0.4.1", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-AoozHakINGyNJFgbYc/1PlDK0yunrAxbtXEMBe9fdu8RLkNjVtYRTLw7EF2mM/YuVoVRjj2HT/2VJ4a2rMyDOA=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], diff --git a/package.json b/package.json index 32e974f5b..2a0c07d88 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@tsconfig/bun": "1.0.9", "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/precision-diffs": "0.3.6", + "@pierre/precision-diffs": "0.4.1", "@solidjs/meta": "0.29.4", "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", diff --git a/packages/desktop/src/components/message.tsx b/packages/desktop/src/components/message.tsx index 78be29d75..70d03591a 100644 --- a/packages/desktop/src/components/message.tsx +++ b/packages/desktop/src/components/message.tsx @@ -15,7 +15,7 @@ registerPartComponent("text", function TextPartDisplay(props) { const part = props.part as TextPart return ( - + ) }) @@ -36,36 +36,41 @@ registerPartComponent("tool", function ToolPartDisplay(props) { const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {}) const input = part.state.status === "completed" ? part.state.input : {} - if (part.state.status === "error") { - const error = part.state.error.replace("Error: ", "") - const [title, ...rest] = error.split(": ") - return ( - -
- - - + return ( + + + {(error) => { + const cleaned = error().replace("Error: ", "") + const [title, ...rest] = cleaned.split(": ") + return ( +
-
{title}
- {rest.join(": ")} + + + +
+
{title}
+ {rest.join(": ")} +
+
+ {cleaned} +
-
- {error} -
-
-
- ) - } - - return ( - + + ) + }} + + + + + ) }) diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 1ae39a6b3..0ff4423ad 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -832,7 +832,7 @@ export default function Page() { -
+
{(assistantMessage) => { const parts = createMemo( diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index 731b1bfe0..36b57c56a 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 = Omit, "themes"> & { +export type DiffProps = FileDiffOptions & { before: FileContents after: FileContents annotations?: DiffLineAnnotation[] @@ -54,13 +54,9 @@ export function Diff(props: DiffProps) { // 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({ // 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", diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index eddd796e7..06f5046dc 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -55,11 +55,7 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part return x.type !== "tool" || (x as ToolPart).tool !== "todoread" }) }) - return ( -
- {(part) => } -
- ) + return {(part) => } } export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { -- cgit v1.2.3 From 582ed7c363fec4faa8cf393e023024ac90e65b82 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:43:04 -0500 Subject: wip: desktop work --- packages/ui/src/components/diff.css | 4 ++++ packages/ui/src/components/diff.tsx | 7 +++++-- packages/ui/src/styles/theme.css | 6 ++++++ 3 files changed, 15 insertions(+), 2 deletions(-) (limited to 'packages/ui/src') 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 36b57c56a..e9e46d6b0 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -109,8 +109,11 @@ export function Diff(props: DiffProps) { 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 }, @@ -166,7 +169,7 @@ export function Diff(props: DiffProps) { "--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/styles/theme.css b/packages/ui/src/styles/theme.css index 600de584a..c401dcae9 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -59,10 +59,14 @@ 0 0 0 3px var(--border-weak-selected, rgba(1, 103, 255, 0.29)), 0 0 0 1px var(--border-selected, rgba(0, 74, 255, 0.99)), 0 1px 2px -1px rgba(19, 16, 16, 0.25), 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12); + + --text-mix-blend-mode: multiply; } :root { /* OC-1-Light */ + --text-mix-blend-mode: multiply; + color-scheme: light; --background-base: #f8f7f7; --background-weak: var(--smoke-light-3); @@ -292,6 +296,8 @@ --button-ghost-hover2: var(--smoke-light-alpha-3); @media (prefers-color-scheme: dark) { + --text-mix-blend-mode: plus-lighter; + /* OC-1-Dark */ color-scheme: dark; --background-base: var(--smoke-dark-1); -- cgit v1.2.3 From ee7612a31c02ea442d8bf3e5d3b75ff572fac26a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:02:44 -0500 Subject: wip: desktop work --- packages/desktop/src/pages/index.tsx | 91 +---------------------- packages/ui/src/components/diff-changes.css | 11 +++ packages/ui/src/components/diff-changes.tsx | 110 ++++++++++++++++++++++++++-- 3 files changed, 117 insertions(+), 95 deletions(-) (limited to 'packages/ui/src') diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 0ff4423ad..9c27ab514 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -535,101 +535,14 @@ export default function Page() { > {(message) => { - const countLines = (text: string) => { - if (!text) return 0 - return text.split("\n").length - } - - const additions = createMemo( - () => - message.summary?.diffs.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) ?? 0, - ) - - const deletions = createMemo( - () => - message.summary?.diffs.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) ?? 0, - ) - - const totalBeforeLines = createMemo( - () => - message.summary?.diffs.reduce((acc, diff) => acc + countLines(diff.before), 0) ?? - 0, - ) - - const blockCounts = createMemo(() => { - const TOTAL_BLOCKS = 5 - - const adds = additions() - const dels = deletions() - const unchanged = Math.max(0, totalBeforeLines() - dels) - - const totalActivity = unchanged + adds + dels - - if (totalActivity === 0) { - return { added: 0, deleted: 0, neutral: TOTAL_BLOCKS } - } - - const percentAdded = adds / totalActivity - const percentDeleted = dels / totalActivity - const added_raw = percentAdded * TOTAL_BLOCKS - const deleted_raw = percentDeleted * TOTAL_BLOCKS - - let added = adds > 0 ? Math.ceil(added_raw) : 0 - let deleted = dels > 0 ? Math.ceil(deleted_raw) : 0 - - let total_allocated = added + deleted - if (total_allocated > TOTAL_BLOCKS) { - if (added_raw < deleted_raw) { - added = Math.floor(added_raw) - } else { - deleted = Math.floor(deleted_raw) - } - - total_allocated = added + deleted - if (total_allocated > TOTAL_BLOCKS) { - if (added_raw < deleted_raw) { - deleted = Math.floor(deleted_raw) - } else { - added = Math.floor(added_raw) - } - } - } - - const neutral = Math.max(0, TOTAL_BLOCKS - added - deleted) - - 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) - }) + const diffs = createMemo(() => message.summary?.diffs ?? []) return (
  • local.session.setActiveMessage(message.id)} > -
    - - - - {(color, i) => ( - - )} - - - -
    +
    props.variant ?? "default" -export function DiffChanges(props: { diff: FileDiff | FileDiff[] }) { const additions = createMemo(() => Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) @@ -13,11 +15,107 @@ export function DiffChanges(props: { diff: FileDiff | FileDiff[] }) { : 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 ( - 0}> -
    - {`+${additions()}`} - {`-${deletions()}`} + 0 : true}> +
    + + + + + + {(color, i) => } + + + + + + {`+${additions()}`} + {`-${deletions()}`} + +
    ) -- cgit v1.2.3 From dc6e54503cb400ea2533740c9a92d09c8a50d077 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:49:29 -0500 Subject: wip: desktop work --- bun.lock | 4 + packages/desktop/src/components/code.tsx | 2 +- packages/desktop/src/components/markdown.tsx | 23 -- packages/desktop/src/components/message.tsx | 304 -------------- packages/desktop/src/context/local.tsx | 2 - packages/desktop/src/context/marked.tsx | 30 -- packages/desktop/src/context/shiki.tsx | 573 -------------------------- packages/desktop/src/index.tsx | 4 +- packages/desktop/src/pages/index.tsx | 4 +- packages/ui/package.json | 4 + packages/ui/src/components/basic-tool.css | 76 ++++ packages/ui/src/components/basic-tool.tsx | 95 +++++ packages/ui/src/components/index.ts | 8 +- packages/ui/src/components/markdown.css | 24 ++ packages/ui/src/components/markdown.tsx | 36 ++ packages/ui/src/components/message-part.css | 107 +++++ packages/ui/src/components/message-part.tsx | 365 ++++++++++++++++- packages/ui/src/components/tool-display.css | 76 ---- packages/ui/src/components/tool-display.tsx | 95 ----- packages/ui/src/components/tool-registry.tsx | 33 -- packages/ui/src/context/helper.tsx | 25 ++ packages/ui/src/context/marked.tsx | 30 ++ packages/ui/src/context/shiki.tsx | 577 +++++++++++++++++++++++++++ packages/ui/src/styles/index.css | 3 +- 24 files changed, 1354 insertions(+), 1146 deletions(-) delete mode 100644 packages/desktop/src/components/markdown.tsx delete mode 100644 packages/desktop/src/components/message.tsx delete mode 100644 packages/desktop/src/context/marked.tsx delete mode 100644 packages/desktop/src/context/shiki.tsx create mode 100644 packages/ui/src/components/basic-tool.css create mode 100644 packages/ui/src/components/basic-tool.tsx create mode 100644 packages/ui/src/components/markdown.css create mode 100644 packages/ui/src/components/markdown.tsx delete mode 100644 packages/ui/src/components/tool-display.css delete mode 100644 packages/ui/src/components/tool-display.tsx delete mode 100644 packages/ui/src/components/tool-registry.tsx create mode 100644 packages/ui/src/context/helper.tsx create mode 100644 packages/ui/src/context/marked.tsx create mode 100644 packages/ui/src/context/shiki.tsx (limited to 'packages/ui/src') diff --git a/bun.lock b/bun.lock index 3dbe4eac2..977ce9de7 100644 --- a/bun.lock +++ b/bun.lock @@ -281,10 +281,14 @@ "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@pierre/precision-diffs": "catalog:", + "@shikijs/transformers": "3.9.2", "@solidjs/meta": "catalog:", "fuzzysort": "catalog:", "luxon": "catalog:", + "marked": "16.2.0", + "marked-shiki": "1.2.1", "remeda": "catalog:", + "shiki": "3.9.2", "solid-js": "catalog:", "solid-list": "catalog:", "virtua": "catalog:", diff --git a/packages/desktop/src/components/code.tsx b/packages/desktop/src/components/code.tsx index 11518e73a..c214fd5e6 100644 --- a/packages/desktop/src/components/code.tsx +++ b/packages/desktop/src/components/code.tsx @@ -2,7 +2,7 @@ import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "s import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js" import { useLocal, type TextSelection } from "@/context/local" import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils" -import { useShiki } from "@/context/shiki" +import { useShiki } from "@opencode-ai/ui" type DefinedSelection = Exclude diff --git a/packages/desktop/src/components/markdown.tsx b/packages/desktop/src/components/markdown.tsx deleted file mode 100644 index e0f185f5f..000000000 --- a/packages/desktop/src/components/markdown.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useMarked } from "@/context/marked" -import { createResource } 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: { text: string; class?: string }) { - const marked = useMarked() - const [html] = createResource( - () => strip(props.text), - async (markdown) => { - return marked.parse(markdown) - }, - ) - return ( -
    - ) -} diff --git a/packages/desktop/src/components/message.tsx b/packages/desktop/src/components/message.tsx deleted file mode 100644 index 70d03591a..000000000 --- a/packages/desktop/src/components/message.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import type { Part, TextPart, ToolPart, Message } from "@opencode-ai/sdk" -import { createMemo, For, Match, Show, Switch } from "solid-js" -import { Dynamic } from "solid-js/web" -import { Markdown } from "./markdown" -import { Card, 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" - -export function Message(props: { message: Message; parts: Part[] }) { - return -} - -registerPartComponent("text", function TextPartDisplay(props) { - const part = props.part as TextPart - return ( - - - - ) -}) - -registerPartComponent("reasoning", function ReasoningPartDisplay(props) { - const part = props.part as any - return ( - - - - ) -}) - -registerPartComponent("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 ( - - - {(error) => { - const cleaned = error().replace("Error: ", "") - const [title, ...rest] = cleaned.split(": ") - return ( - -
    - - - -
    -
    {title}
    - {rest.join(": ")} -
    -
    - {cleaned} -
    -
    -
    - ) - }} -
    - - - -
    - ) - }) - - return {component()} -}) - -ToolRegistry.register({ - name: "read", - render(props) { - return ( - - ) - }, -}) - -ToolRegistry.register({ - name: "list", - render(props) { - return ( - - -
    {props.output}
    -
    -
    - ) - }, -}) - -ToolRegistry.register({ - name: "glob", - render(props) { - return ( - {props.output}
    - - - ) - }, -}) - -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 ( - - -
    {props.output}
    -
    -
    - ) - }, -}) - -ToolRegistry.register({ - name: "webfetch", - render(props) { - return ( - - -
    - ), - }} - > - -
    {props.output}
    -
    - - ) - }, -}) - -ToolRegistry.register({ - name: "task", - render(props) { - return ( - - -
    {props.output}
    -
    -
    - ) - }, -}) - -ToolRegistry.register({ - name: "bash", - render(props) { - return ( - - -
    {props.output}
    -
    -
    - ) - }, -}) - -ToolRegistry.register({ - name: "edit", - render(props) { - return ( - -
    -
    Edit
    -
    - - {getDirectory(props.input.filePath!)} - - {getFilename(props.input.filePath ?? "")} -
    -
    -
    - - - -
    -
    - } - > - -
    - -
    -
    - - ) - }, -}) - -ToolRegistry.register({ - name: "write", - render(props) { - return ( - -
    -
    Write
    -
    - - {getDirectory(props.input.filePath!)} - - {getFilename(props.input.filePath ?? "")} -
    -
    -
    {/* */}
    -
  • - } - > - -
    {props.output}
    -
    - - ) - }, -}) - -ToolRegistry.register({ - name: "todowrite", - render(props) { - return ( - t.status === "completed").length}/${props.input.todos?.length}`, - }} - > - -
    - - {(todo: any) => ( - -
    {todo.content}
    -
    - )} -
    -
    -
    -
    - ) - }, -}) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 9c4d70fc5..09fce6350 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -480,8 +480,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const getMessageText = (message: Message | Message[] | undefined): string => { if (!message) return "" if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ") - const fileParts = sync.data.part[message.id]?.filter((p) => p.type === "file") - return sync.data.part[message.id] ?.filter((p) => p.type === "text") ?.filter((p) => !p.synthetic) diff --git a/packages/desktop/src/context/marked.tsx b/packages/desktop/src/context/marked.tsx deleted file mode 100644 index 18ce4280a..000000000 --- a/packages/desktop/src/context/marked.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { marked } from "marked" -import markedShiki from "marked-shiki" -import { bundledLanguages, type BundledLanguage } from "shiki" - -import { createSimpleContext } from "./helper" -import { useShiki } from "./shiki" - -export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({ - name: "Marked", - init: () => { - const highlighter = useShiki() - return marked.use( - markedShiki({ - async highlight(code, lang) { - if (!(lang in bundledLanguages)) { - lang = "text" - } - if (!highlighter.getLoadedLanguages().includes(lang)) { - await highlighter.loadLanguage(lang as BundledLanguage) - } - return highlighter.codeToHtml(code, { - lang: lang || "text", - theme: "opencode", - tabindex: false, - }) - }, - }), - ) - }, -}) diff --git a/packages/desktop/src/context/shiki.tsx b/packages/desktop/src/context/shiki.tsx deleted file mode 100644 index b6c278bfe..000000000 --- a/packages/desktop/src/context/shiki.tsx +++ /dev/null @@ -1,573 +0,0 @@ -import { createSimpleContext } from "./helper" -import { createHighlighter, type ThemeInput } from "shiki" - -const theme: ThemeInput = { - colors: { - "actionBar.toggledBackground": "var(--surface-raised-base)", - "activityBarBadge.background": "var(--surface-brand-base)", - "checkbox.border": "var(--border-base)", - "editor.background": "transparent", - "editor.foreground": "var(--text-base)", - "editor.inactiveSelectionBackground": "var(--surface-raised-base)", - "editor.selectionHighlightBackground": "var(--border-active)", - "editorIndentGuide.activeBackground1": "var(--border-weak-base)", - "editorIndentGuide.background1": "var(--border-weak-base)", - "input.placeholderForeground": "var(--text-weak)", - "list.activeSelectionIconForeground": "var(--text-base)", - "list.dropBackground": "var(--surface-raised-base)", - "menu.background": "var(--surface-base)", - "menu.border": "var(--border-base)", - "menu.foreground": "var(--text-base)", - "menu.selectionBackground": "var(--surface-interactive-base)", - "menu.separatorBackground": "var(--border-base)", - "ports.iconRunningProcessForeground": "var(--icon-success-base)", - "sideBarSectionHeader.background": "transparent", - "sideBarSectionHeader.border": "var(--border-weak-base)", - "sideBarTitle.foreground": "var(--text-weak)", - "statusBarItem.remoteBackground": "var(--surface-success-base)", - "statusBarItem.remoteForeground": "var(--text-base)", - "tab.lastPinnedBorder": "var(--border-weak-base)", - "tab.selectedBackground": "var(--surface-raised-base)", - "tab.selectedForeground": "var(--text-weak)", - "terminal.inactiveSelectionBackground": "var(--surface-raised-base)", - "widget.border": "var(--border-base)", - }, - displayName: "opencode", - name: "opencode", - semanticHighlighting: true, - semanticTokenColors: { - customLiteral: "var(--syntax-function)", - newOperator: "var(--syntax-operator)", - numberLiteral: "var(--syntax-number)", - stringLiteral: "var(--syntax-string)", - }, - tokenColors: [ - { - scope: [ - "meta.embedded", - "source.groovy.embedded", - "string meta.image.inline.markdown", - "variable.legacy.builtin.python", - ], - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: "emphasis", - settings: { - fontStyle: "italic", - }, - }, - { - scope: "strong", - settings: { - fontStyle: "bold", - }, - }, - { - scope: "header", - settings: { - foreground: "var(--markdown-heading)", - }, - }, - { - scope: "comment", - settings: { - foreground: "var(--syntax-comment)", - }, - }, - { - scope: "constant.language", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: [ - "constant.numeric", - "variable.other.enummember", - "keyword.operator.plus.exponent", - "keyword.operator.minus.exponent", - ], - settings: { - foreground: "var(--syntax-number)", - }, - }, - { - scope: "constant.regexp", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "entity.name.tag", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["entity.name.tag.css", "entity.name.tag.less"], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "entity.other.attribute-name", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: [ - "entity.other.attribute-name.class.css", - "source.css entity.other.attribute-name.class", - "entity.other.attribute-name.id.css", - "entity.other.attribute-name.parent-selector.css", - "entity.other.attribute-name.parent.less", - "source.css entity.other.attribute-name.pseudo-class", - "entity.other.attribute-name.pseudo-element.css", - "source.css.less entity.other.attribute-name.id", - "entity.other.attribute-name.scss", - ], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "invalid", - settings: { - foreground: "var(--syntax-critical)", - }, - }, - { - scope: "markup.underline", - settings: { - fontStyle: "underline", - }, - }, - { - scope: "markup.bold", - settings: { - fontStyle: "bold", - foreground: "var(--markdown-strong)", - }, - }, - { - scope: "markup.heading", - settings: { - fontStyle: "bold", - foreground: "var(--theme-markdown-heading)", - }, - }, - { - scope: "markup.italic", - settings: { - fontStyle: "italic", - }, - }, - { - scope: "markup.strikethrough", - settings: { - fontStyle: "strikethrough", - }, - }, - { - scope: "markup.inserted", - settings: { - foreground: "var(--text-diff-add-base)", - }, - }, - { - scope: "markup.deleted", - settings: { - foreground: "var(--text-diff-delete-base)", - }, - }, - { - scope: "markup.changed", - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: "punctuation.definition.quote.begin.markdown", - settings: { - foreground: "var(--markdown-block-quote)", - }, - }, - { - scope: "punctuation.definition.list.begin.markdown", - settings: { - foreground: "var(--markdown-list-enumeration)", - }, - }, - { - scope: "markup.inline.raw", - settings: { - foreground: "var(--markdown-code)", - }, - }, - { - scope: "punctuation.definition.tag", - settings: { - foreground: "var(--syntax-punctuation)", - }, - }, - { - scope: ["meta.preprocessor", "entity.name.function.preprocessor"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "meta.preprocessor.string", - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "meta.preprocessor.numeric", - settings: { - foreground: "var(--syntax-number)", - }, - }, - { - scope: "meta.structure.dictionary.key.python", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "meta.diff.header", - settings: { - foreground: "var(--text-weak)", - }, - }, - { - scope: "storage", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "storage.type", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["storage.modifier", "keyword.operator.noexcept"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["string", "meta.embedded.assembly"], - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "string.tag", - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "string.value", - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: "string.regexp", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: [ - "punctuation.definition.template-expression.begin", - "punctuation.definition.template-expression.end", - "punctuation.section.embedded", - ], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: ["meta.template.expression"], - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: [ - "support.type.vendored.property-name", - "support.type.property-name", - "source.css variable", - "source.coffee.embedded", - ], - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "keyword", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "keyword.control", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "keyword.operator", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: [ - "keyword.operator.new", - "keyword.operator.expression", - "keyword.operator.cast", - "keyword.operator.sizeof", - "keyword.operator.alignof", - "keyword.operator.typeid", - "keyword.operator.alignas", - "keyword.operator.instanceof", - "keyword.operator.logical.python", - "keyword.operator.wordlike", - ], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "keyword.other.unit", - settings: { - foreground: "var(--syntax-number)", - }, - }, - { - scope: ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "support.function.git-rebase", - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: "constant.sha.git-rebase", - settings: { - foreground: "var(--syntax-number)", - }, - }, - { - scope: ["storage.modifier.import.java", "variable.language.wildcard.java", "storage.modifier.package.java"], - settings: { - foreground: "var(--text-base)", - }, - }, - { - scope: "variable.language", - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: [ - "entity.name.function", - "support.function", - "support.constant.handlebars", - "source.powershell variable.other.member", - "entity.name.operator.custom-literal", - ], - settings: { - foreground: "var(--syntax-function)", - }, - }, - { - scope: [ - "support.class", - "support.type", - "entity.name.type", - "entity.name.namespace", - "entity.other.attribute", - "entity.name.scope-resolution", - "entity.name.class", - "storage.type.numeric.go", - "storage.type.byte.go", - "storage.type.boolean.go", - "storage.type.string.go", - "storage.type.uintptr.go", - "storage.type.error.go", - "storage.type.rune.go", - "storage.type.cs", - "storage.type.generic.cs", - "storage.type.modifier.cs", - "storage.type.variable.cs", - "storage.type.annotation.java", - "storage.type.generic.java", - "storage.type.java", - "storage.type.object.array.java", - "storage.type.primitive.array.java", - "storage.type.primitive.java", - "storage.type.token.java", - "storage.type.groovy", - "storage.type.annotation.groovy", - "storage.type.parameters.groovy", - "storage.type.generic.groovy", - "storage.type.object.array.groovy", - "storage.type.primitive.array.groovy", - "storage.type.primitive.groovy", - ], - settings: { - foreground: "var(--syntax-type)", - }, - }, - { - scope: [ - "meta.type.cast.expr", - "meta.type.new.expr", - "support.constant.math", - "support.constant.dom", - "support.constant.json", - "entity.other.inherited-class", - "punctuation.separator.namespace.ruby", - ], - settings: { - foreground: "var(--syntax-type)", - }, - }, - { - scope: [ - "keyword.control", - "source.cpp keyword.operator.new", - "keyword.operator.delete", - "keyword.other.using", - "keyword.other.directive.using", - "keyword.other.operator", - "entity.name.operator", - ], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: [ - "variable", - "meta.definition.variable.name", - "support.variable", - "entity.name.variable", - "constant.other.placeholder", - ], - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: ["variable.other.constant", "variable.other.enummember"], - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: ["meta.object-literal.key"], - settings: { - foreground: "var(--syntax-variable)", - }, - }, - { - scope: [ - "support.constant.property-value", - "support.constant.font-name", - "support.constant.media-type", - "support.constant.media", - "constant.other.color.rgb-value", - "constant.other.rgb-value", - "support.constant.color", - ], - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: [ - "punctuation.definition.group.regexp", - "punctuation.definition.group.assertion.regexp", - "punctuation.definition.character-class.regexp", - "punctuation.character.set.begin.regexp", - "punctuation.character.set.end.regexp", - "keyword.operator.negation.regexp", - "support.other.parenthesis.regexp", - ], - settings: { - foreground: "var(--syntax-string)", - }, - }, - { - scope: [ - "constant.character.character-class.regexp", - "constant.other.character-class.set.regexp", - "constant.other.character-class.regexp", - "constant.character.set.regexp", - ], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"], - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "keyword.operator.quantifier.regexp", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: ["constant.character", "constant.other.option"], - settings: { - foreground: "var(--syntax-keyword)", - }, - }, - { - scope: "constant.character.escape", - settings: { - foreground: "var(--syntax-operator)", - }, - }, - { - scope: "entity.name.label", - settings: { - foreground: "var(--text-weak)", - }, - }, - ], - type: "dark", -} - -const highlighter = await createHighlighter({ - themes: [theme], - langs: [], -}) - -export const { use: useShiki, provider: ShikiProvider } = createSimpleContext({ - name: "Shiki", - init: () => { - return highlighter - }, -}) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 9c7a07fe6..0d631a5a0 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -3,9 +3,7 @@ import "@/index.css" import { render } from "solid-js/web" import { Router, Route } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" -import { Fonts } from "@opencode-ai/ui" -import { ShikiProvider } from "./context/shiki" -import { MarkedProvider } from "./context/marked" +import { Fonts, ShikiProvider, MarkedProvider } from "@opencode-ai/ui" import { SDKProvider } from "./context/sdk" import { SyncProvider } from "./context/sync" import { LocalProvider } from "./context/local" diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 552269eba..5237d78bb 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -12,6 +12,7 @@ import { Part, DiffChanges, ProgressCircle, + Message, } from "@opencode-ai/ui" import { FileIcon } from "@/ui" import FileTree from "@/components/file-tree" @@ -35,9 +36,8 @@ import type { JSX } from "solid-js" import { Code } from "@/components/code" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" -import { Message } from "@/components/message" import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" -import { Markdown } from "@/components/markdown" +import { Markdown } from "@opencode-ai/ui" export default function Page() { const local = useLocal() diff --git a/packages/ui/package.json b/packages/ui/package.json index 0b3064e3a..609c9fba7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -28,10 +28,14 @@ "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@pierre/precision-diffs": "catalog:", + "@shikijs/transformers": "3.9.2", "@solidjs/meta": "catalog:", "fuzzysort": "catalog:", "luxon": "catalog:", + "marked": "16.2.0", + "marked-shiki": "1.2.1", "remeda": "catalog:", + "shiki": "3.9.2", "solid-js": "catalog:", "solid-list": "catalog:", "virtua": "catalog:" 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 ( + + +
    +
    + +
    + + + {(trigger) => ( +
    +
    + + {trigger().title} + + + + {trigger().subtitle} + + + + + {(arg) => ( + + {arg} + + )} + + +
    + {trigger().action} +
    + )} +
    + {props.trigger as JSX.Element} +
    +
    +
    + + + +
    +
    + + {resolved()} + +
    + ) +} + +export function GenericTool(props: { tool: string; hideDetails?: boolean }) { + return +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 29e8cfe3b..8d6ddc89c 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -11,11 +11,15 @@ 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 "./tool-display" -export * from "./tool-registry" +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..ddf8b7872 --- /dev/null +++ b/packages/ui/src/components/markdown.css @@ -0,0 +1,24 @@ +[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; + } + + /* p { */ + /* margin-top: 8px; */ + /* margin-bottom: 8px; */ + /* } */ +} 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 + }, +) { + const [local, others] = splitProps(props, ["text", "class", "classList"]) + const marked = useMarked() + const [html] = createResource( + () => strip(local.text), + async (markdown) => { + return marked.parse(markdown) + }, + ) + return ( +
    + ) +} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 8931d3bc6..fa251a2b3 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -20,3 +20,110 @@ -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 index 06f5046dc..1aaab751a 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -8,6 +8,14 @@ import { 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 @@ -22,7 +30,20 @@ export interface MessagePartProps { export type PartComponent = Component -const PART_MAPPING: Record = {} +export const PART_MAPPING: Record = {} + +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 @@ -81,3 +102,345 @@ export function Part(props: MessagePartProps) { ) } + +export interface ToolProps { + input: Record + metadata: Record + tool: string + output?: string + hideDetails?: boolean +} + +export type ToolComponent = Component + +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 ( + + + {(error) => { + const cleaned = error().replace("Error: ", "") + const [title, ...rest] = cleaned.split(": ") + return ( + +
    + + + +
    +
    {title}
    + {rest.join(": ")} +
    +
    + {cleaned} +
    +
    +
    + ) + }} +
    + + + +
    + ) + }) + + return {component()} +} + +PART_MAPPING["text"] = function TextPartDisplay(props) { + const part = props.part as TextPart + return ( + +
    + +
    +
    + ) +} + +PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { + const part = props.part as any + return ( + +
    + +
    +
    + ) +} + +ToolRegistry.register({ + name: "read", + render(props) { + return ( + + ) + }, +}) + +ToolRegistry.register({ + name: "list", + render(props) { + return ( + + +
    {props.output}
    +
    +
    + ) + }, +}) + +ToolRegistry.register({ + name: "glob", + render(props) { + return ( + {props.output}
    + +
    + ) + }, +}) + +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 ( + + +
    {props.output}
    +
    +
    + ) + }, +}) + +ToolRegistry.register({ + name: "webfetch", + render(props) { + return ( + + +
    + ), + }} + > + +
    {props.output}
    +
    +
    + ) + }, +}) + +ToolRegistry.register({ + name: "task", + render(props) { + return ( + + +
    {props.output}
    +
    +
    + ) + }, +}) + +ToolRegistry.register({ + name: "bash", + render(props) { + return ( + + +
    {props.output}
    +
    +
    + ) + }, +}) + +ToolRegistry.register({ + name: "edit", + render(props) { + return ( + +
    +
    Edit
    +
    + + {getDirectory(props.input.filePath!)} + + {getFilename(props.input.filePath ?? "")} +
    +
    +
    + + + +
    + + } + > + +
    + +
    +
    +
    + ) + }, +}) + +ToolRegistry.register({ + name: "write", + render(props) { + return ( + +
    +
    Write
    +
    + + {getDirectory(props.input.filePath!)} + + {getFilename(props.input.filePath ?? "")} +
    +
    +
    {/* */}
    + + } + > + +
    {props.output}
    +
    +
    + ) + }, +}) + +ToolRegistry.register({ + name: "todowrite", + render(props) { + return ( + t.status === "completed").length}/${props.input.todos?.length}`, + }} + > + +
    + + {(todo: any) => ( + +
    + {todo.content} +
    +
    + )} +
    +
    +
    +
    + ) + }, +}) diff --git a/packages/ui/src/components/tool-display.css b/packages/ui/src/components/tool-display.css deleted file mode 100644 index f3d9f865f..000000000 --- a/packages/ui/src/components/tool-display.css +++ /dev/null @@ -1,76 +0,0 @@ -[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 deleted file mode 100644 index 43574fbb7..000000000 --- a/packages/ui/src/components/tool-display.tsx +++ /dev/null @@ -1,95 +0,0 @@ -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 ( - - -
    -
    - -
    - - - {(trigger) => ( -
    -
    - - {trigger().title} - - - - {trigger().subtitle} - - - - - {(arg) => ( - - {arg} - - )} - - -
    - {trigger().action} -
    - )} -
    - {props.trigger as JSX.Element} -
    -
    -
    - - - -
    -
    - - {resolved()} - -
    - ) -} - -export function GenericTool(props: { tool: string; hideDetails?: boolean }) { - return -} diff --git a/packages/ui/src/components/tool-registry.tsx b/packages/ui/src/components/tool-registry.tsx deleted file mode 100644 index 8ee7d8293..000000000 --- a/packages/ui/src/components/tool-registry.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Component } from "solid-js" - -export interface ToolProps { - input: Record - metadata: Record - tool: string - output?: string - hideDetails?: boolean -} - -export type ToolComponent = Component - -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/context/helper.tsx b/packages/ui/src/context/helper.tsx new file mode 100644 index 000000000..6be88e775 --- /dev/null +++ b/packages/ui/src/context/helper.tsx @@ -0,0 +1,25 @@ +import { createContext, Show, useContext, type ParentProps } from "solid-js" + +export function createSimpleContext>(input: { + name: string + init: ((input: Props) => T) | (() => T) +}) { + const ctx = createContext() + + return { + provider: (props: ParentProps) => { + const init = input.init(props) + return ( + // @ts-expect-error + + {props.children} + + ) + }, + use() { + const value = useContext(ctx) + if (!value) throw new Error(`${input.name} context must be used within a context provider`) + return value + }, + } +} diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx new file mode 100644 index 000000000..18ce4280a --- /dev/null +++ b/packages/ui/src/context/marked.tsx @@ -0,0 +1,30 @@ +import { marked } from "marked" +import markedShiki from "marked-shiki" +import { bundledLanguages, type BundledLanguage } from "shiki" + +import { createSimpleContext } from "./helper" +import { useShiki } from "./shiki" + +export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({ + name: "Marked", + init: () => { + const highlighter = useShiki() + return marked.use( + markedShiki({ + async highlight(code, lang) { + if (!(lang in bundledLanguages)) { + lang = "text" + } + if (!highlighter.getLoadedLanguages().includes(lang)) { + await highlighter.loadLanguage(lang as BundledLanguage) + } + return highlighter.codeToHtml(code, { + lang: lang || "text", + theme: "opencode", + tabindex: false, + }) + }, + }), + ) + }, +}) diff --git a/packages/ui/src/context/shiki.tsx b/packages/ui/src/context/shiki.tsx new file mode 100644 index 000000000..d33b98ab7 --- /dev/null +++ b/packages/ui/src/context/shiki.tsx @@ -0,0 +1,577 @@ +import { createSimpleContext } from "./helper" +import { createHighlighter, type ThemeInput } from "shiki" + +const theme: ThemeInput = { + colors: { + "actionBar.toggledBackground": "var(--surface-raised-base)", + "activityBarBadge.background": "var(--surface-brand-base)", + "checkbox.border": "var(--border-base)", + "editor.background": "transparent", + "editor.foreground": "var(--text-base)", + "editor.inactiveSelectionBackground": "var(--surface-raised-base)", + "editor.selectionHighlightBackground": "var(--border-active)", + "editorIndentGuide.activeBackground1": "var(--border-weak-base)", + "editorIndentGuide.background1": "var(--border-weak-base)", + "input.placeholderForeground": "var(--text-weak)", + "list.activeSelectionIconForeground": "var(--text-base)", + "list.dropBackground": "var(--surface-raised-base)", + "menu.background": "var(--surface-base)", + "menu.border": "var(--border-base)", + "menu.foreground": "var(--text-base)", + "menu.selectionBackground": "var(--surface-interactive-base)", + "menu.separatorBackground": "var(--border-base)", + "ports.iconRunningProcessForeground": "var(--icon-success-base)", + "sideBarSectionHeader.background": "transparent", + "sideBarSectionHeader.border": "var(--border-weak-base)", + "sideBarTitle.foreground": "var(--text-weak)", + "statusBarItem.remoteBackground": "var(--surface-success-base)", + "statusBarItem.remoteForeground": "var(--text-base)", + "tab.lastPinnedBorder": "var(--border-weak-base)", + "tab.selectedBackground": "var(--surface-raised-base)", + "tab.selectedForeground": "var(--text-weak)", + "terminal.inactiveSelectionBackground": "var(--surface-raised-base)", + "widget.border": "var(--border-base)", + }, + displayName: "opencode", + name: "opencode", + semanticHighlighting: true, + semanticTokenColors: { + customLiteral: "var(--syntax-function)", + newOperator: "var(--syntax-operator)", + numberLiteral: "var(--syntax-number)", + stringLiteral: "var(--syntax-string)", + }, + tokenColors: [ + { + scope: [ + "meta.embedded", + "source.groovy.embedded", + "string meta.image.inline.markdown", + "variable.legacy.builtin.python", + ], + settings: { + foreground: "var(--text-base)", + }, + }, + { + scope: "emphasis", + settings: { + fontStyle: "italic", + }, + }, + { + scope: "strong", + settings: { + fontStyle: "bold", + }, + }, + { + scope: "header", + settings: { + foreground: "var(--markdown-heading)", + }, + }, + { + scope: "comment", + settings: { + foreground: "var(--syntax-comment)", + }, + }, + { + scope: "constant.language", + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: [ + "constant.numeric", + "variable.other.enummember", + "keyword.operator.plus.exponent", + "keyword.operator.minus.exponent", + ], + settings: { + foreground: "var(--syntax-number)", + }, + }, + { + scope: "constant.regexp", + settings: { + foreground: "var(--syntax-operator)", + }, + }, + { + scope: "entity.name.tag", + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: ["entity.name.tag.css", "entity.name.tag.less"], + settings: { + foreground: "var(--syntax-operator)", + }, + }, + { + scope: "entity.other.attribute-name", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: [ + "entity.other.attribute-name.class.css", + "source.css entity.other.attribute-name.class", + "entity.other.attribute-name.id.css", + "entity.other.attribute-name.parent-selector.css", + "entity.other.attribute-name.parent.less", + "source.css entity.other.attribute-name.pseudo-class", + "entity.other.attribute-name.pseudo-element.css", + "source.css.less entity.other.attribute-name.id", + "entity.other.attribute-name.scss", + ], + settings: { + foreground: "var(--syntax-operator)", + }, + }, + { + scope: "invalid", + settings: { + foreground: "var(--syntax-critical)", + }, + }, + { + scope: "markup.underline", + settings: { + fontStyle: "underline", + }, + }, + { + scope: "markup.bold", + settings: { + fontStyle: "bold", + foreground: "var(--markdown-strong)", + }, + }, + { + scope: "markup.heading", + settings: { + fontStyle: "bold", + foreground: "var(--theme-markdown-heading)", + }, + }, + { + scope: "markup.italic", + settings: { + fontStyle: "italic", + }, + }, + { + scope: "markup.strikethrough", + settings: { + fontStyle: "strikethrough", + }, + }, + { + scope: "markup.inserted", + settings: { + foreground: "var(--text-diff-add-base)", + }, + }, + { + scope: "markup.deleted", + settings: { + foreground: "var(--text-diff-delete-base)", + }, + }, + { + scope: "markup.changed", + settings: { + foreground: "var(--text-base)", + }, + }, + { + scope: "punctuation.definition.quote.begin.markdown", + settings: { + foreground: "var(--markdown-block-quote)", + }, + }, + { + scope: "punctuation.definition.list.begin.markdown", + settings: { + foreground: "var(--markdown-list-enumeration)", + }, + }, + { + scope: "markup.inline.raw", + settings: { + foreground: "var(--markdown-code)", + }, + }, + { + scope: "punctuation.definition.tag", + settings: { + foreground: "var(--syntax-punctuation)", + }, + }, + { + scope: ["meta.preprocessor", "entity.name.function.preprocessor"], + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: "meta.preprocessor.string", + settings: { + foreground: "var(--syntax-string)", + }, + }, + { + scope: "meta.preprocessor.numeric", + settings: { + foreground: "var(--syntax-number)", + }, + }, + { + scope: "meta.structure.dictionary.key.python", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: "meta.diff.header", + settings: { + foreground: "var(--text-weak)", + }, + }, + { + scope: "storage", + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: "storage.type", + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: ["storage.modifier", "keyword.operator.noexcept"], + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: ["string", "meta.embedded.assembly"], + settings: { + foreground: "var(--syntax-string)", + }, + }, + { + scope: "string.tag", + settings: { + foreground: "var(--syntax-string)", + }, + }, + { + scope: "string.value", + settings: { + foreground: "var(--syntax-string)", + }, + }, + { + scope: "string.regexp", + settings: { + foreground: "var(--syntax-operator)", + }, + }, + { + scope: [ + "punctuation.definition.template-expression.begin", + "punctuation.definition.template-expression.end", + "punctuation.section.embedded", + ], + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: ["meta.template.expression"], + settings: { + foreground: "var(--text-base)", + }, + }, + { + scope: [ + "support.type.vendored.property-name", + "support.type.property-name", + "source.css variable", + "source.coffee.embedded", + ], + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: "keyword", + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: "keyword.control", + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: "keyword.operator", + settings: { + foreground: "var(--syntax-operator)", + }, + }, + { + scope: [ + "keyword.operator.new", + "keyword.operator.expression", + "keyword.operator.cast", + "keyword.operator.sizeof", + "keyword.operator.alignof", + "keyword.operator.typeid", + "keyword.operator.alignas", + "keyword.operator.instanceof", + "keyword.operator.logical.python", + "keyword.operator.wordlike", + ], + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: "keyword.other.unit", + settings: { + foreground: "var(--syntax-number)", + }, + }, + { + scope: ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"], + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: "support.function.git-rebase", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: "constant.sha.git-rebase", + settings: { + foreground: "var(--syntax-number)", + }, + }, + { + scope: [ + "storage.modifier.import.java", + "variable.language.wildcard.java", + "storage.modifier.package.java", + ], + settings: { + foreground: "var(--text-base)", + }, + }, + { + scope: "variable.language", + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: [ + "entity.name.function", + "support.function", + "support.constant.handlebars", + "source.powershell variable.other.member", + "entity.name.operator.custom-literal", + ], + settings: { + foreground: "var(--syntax-function)", + }, + }, + { + scope: [ + "support.class", + "support.type", + "entity.name.type", + "entity.name.namespace", + "entity.other.attribute", + "entity.name.scope-resolution", + "entity.name.class", + "storage.type.numeric.go", + "storage.type.byte.go", + "storage.type.boolean.go", + "storage.type.string.go", + "storage.type.uintptr.go", + "storage.type.error.go", + "storage.type.rune.go", + "storage.type.cs", + "storage.type.generic.cs", + "storage.type.modifier.cs", + "storage.type.variable.cs", + "storage.type.annotation.java", + "storage.type.generic.java", + "storage.type.java", + "storage.type.object.array.java", + "storage.type.primitive.array.java", + "storage.type.primitive.java", + "storage.type.token.java", + "storage.type.groovy", + "storage.type.annotation.groovy", + "storage.type.parameters.groovy", + "storage.type.generic.groovy", + "storage.type.object.array.groovy", + "storage.type.primitive.array.groovy", + "storage.type.primitive.groovy", + ], + settings: { + foreground: "var(--syntax-type)", + }, + }, + { + scope: [ + "meta.type.cast.expr", + "meta.type.new.expr", + "support.constant.math", + "support.constant.dom", + "support.constant.json", + "entity.other.inherited-class", + "punctuation.separator.namespace.ruby", + ], + settings: { + foreground: "var(--syntax-type)", + }, + }, + { + scope: [ + "keyword.control", + "source.cpp keyword.operator.new", + "keyword.operator.delete", + "keyword.other.using", + "keyword.other.directive.using", + "keyword.other.operator", + "entity.name.operator", + ], + settings: { + foreground: "var(--syntax-operator)", + }, + }, + { + scope: [ + "variable", + "meta.definition.variable.name", + "support.variable", + "entity.name.variable", + "constant.other.placeholder", + ], + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: ["variable.other.constant", "variable.other.enummember"], + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: ["meta.object-literal.key"], + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: [ + "support.constant.property-value", + "support.constant.font-name", + "support.constant.media-type", + "support.constant.media", + "constant.other.color.rgb-value", + "constant.other.rgb-value", + "support.constant.color", + ], + settings: { + foreground: "var(--syntax-string)", + }, + }, + { + scope: [ + "punctuation.definition.group.regexp", + "punctuation.definition.group.assertion.regexp", + "punctuation.definition.character-class.regexp", + "punctuation.character.set.begin.regexp", + "punctuation.character.set.end.regexp", + "keyword.operator.negation.regexp", + "support.other.parenthesis.regexp", + ], + settings: { + foreground: "var(--syntax-string)", + }, + }, + { + scope: [ + "constant.character.character-class.regexp", + "constant.other.character-class.set.regexp", + "constant.other.character-class.regexp", + "constant.character.set.regexp", + ], + settings: { + foreground: "var(--syntax-operator)", + }, + }, + { + scope: ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"], + settings: { + foreground: "var(--syntax-operator)", + }, + }, + { + scope: "keyword.operator.quantifier.regexp", + settings: { + foreground: "var(--syntax-operator)", + }, + }, + { + scope: ["constant.character", "constant.other.option"], + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: "constant.character.escape", + settings: { + foreground: "var(--syntax-operator)", + }, + }, + { + scope: "entity.name.label", + settings: { + foreground: "var(--text-weak)", + }, + }, + ], + type: "dark", +} + +const highlighter = await createHighlighter({ + themes: [theme], + langs: [], +}) + +export const { use: useShiki, provider: ShikiProvider } = createSimpleContext({ + name: "Shiki", + init: () => { + return highlighter + }, +}) diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 4fe13055a..cea5a082d 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -6,6 +6,7 @@ @import "./base.css" layer(base); @import "../components/accordion.css" layer(components); +@import "../components/basic-tool.css" layer(components); @import "../components/button.css" layer(components); @import "../components/card.css" layer(components); @import "../components/checkbox.css" layer(components); @@ -17,12 +18,12 @@ @import "../components/icon-button.css" layer(components); @import "../components/input.css" layer(components); @import "../components/list.css" layer(components); +@import "../components/markdown.css" layer(components); @import "../components/message-part.css" layer(components); @import "../components/progress-circle.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); -- cgit v1.2.3 From 4cebd69bf03fb9f00e2f793eb483251ecbd9a45a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:54:52 -0500 Subject: wip: desktop work --- packages/ui/src/components/markdown.css | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) (limited to 'packages/ui/src') diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index ddf8b7872..abc505a9e 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -17,8 +17,21 @@ display: none; } - /* p { */ - /* margin-top: 8px; */ - /* margin-bottom: 8px; */ - /* } */ + 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; + } } -- cgit v1.2.3 From 48f50cf55ea1cff07b69bede05e6010ea50778f0 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:41:13 -0500 Subject: wip: desktop work --- packages/desktop/src/components/prompt-input.tsx | 15 ++++- packages/desktop/src/context/local.tsx | 79 ++++++++++++++++++++---- packages/ui/src/components/tabs.css | 3 - packages/ui/src/hooks/use-filtered-list.tsx | 13 ++-- 4 files changed, 87 insertions(+), 23 deletions(-) (limited to 'packages/ui/src') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index d6276c158..f1c19388e 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -71,7 +71,7 @@ export const PromptInput: Component = (props) => { } }) - const { flat, active, onInput, onKeyDown } = useFilteredList({ + const { flat, active, onInput, onKeyDown, refetch } = useFilteredList({ items: local.file.search, key: (x) => x, onSelect: (path) => { @@ -81,6 +81,11 @@ export const PromptInput: Component = (props) => { }, }) + createEffect(() => { + local.model.recent() + refetch() + }) + createEffect( on( () => store.contentParts, @@ -369,16 +374,20 @@ export const PromptInput: Component = (props) => { items={local.model.list()} current={local.model.current()} filterKeys={["provider.name", "name", "id"]} - groupBy={(x) => x.provider.name} + groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} sortGroupsBy={(a, b) => { const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] + if (a.category === "Recent" && b.category !== "Recent") return -1 + if (b.category === "Recent" && a.category !== "Recent") return 1 const aProvider = a.items[0].provider.id const bProvider = b.items[0].provider.id if (order.includes(aProvider) && !order.includes(bProvider)) return -1 if (!order.includes(aProvider) && order.includes(bProvider)) return 1 return order.indexOf(aProvider) - order.indexOf(bProvider) }} - onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)} + onSelect={(x) => + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true }) + } trigger={