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