diff options
Diffstat (limited to 'packages')
19 files changed, 577 insertions, 373 deletions
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<TextSelection, undefined> 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 ( - <div - class={`min-w-0 max-w-full overflow-auto no-scrollbar text-14-regular text-text-base ${props.class ?? ""}`} - innerHTML={html()} - /> - ) -} 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 <MessageDisplay message={props.message} parts={props.parts} /> -} - -registerPartComponent("text", function TextPartDisplay(props) { - const part = props.part as TextPart - return ( - <Show when={part.text.trim()}> - <Markdown text={part.text.trim()} class="mt-8" /> - </Show> - ) -}) - -registerPartComponent("reasoning", function ReasoningPartDisplay(props) { - const part = props.part as any - return ( - <Show when={part.text.trim()}> - <Markdown text={part.text.trim()} /> - </Show> - ) -}) - -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 ( - <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 class="flex items-center gap-2"> - <Icon name="circle-ban-sign" size="small" class="text-icon-critical-active" /> - <Switch> - <Match when={title}> - <div class="flex items-center gap-2"> - <div class="text-12-medium text-[var(--ember-light-11)] capitalize">{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> -}) - -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 class="whitespace-pre">{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 class="whitespace-pre">{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 class="whitespace-pre">{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 class="size-6 flex items-center justify-center"> - <Icon name="square-arrow-top-right" size="small" /> - </div> - ), - }} - > - <Show when={false && props.output}> - <div class="whitespace-pre">{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 class="whitespace-pre">{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 class="whitespace-pre">{props.output}</div> - </Show> - </BasicTool> - ) - }, -}) - -ToolRegistry.register({ - name: "edit", - render(props) { - return ( - <BasicTool - icon="code-lines" - trigger={ - <div class="flex items-center justify-between w-full"> - <div class="flex items-center gap-2"> - <div class="text-12-medium text-text-base capitalize">Edit</div> - <div class="flex"> - <Show when={props.input.filePath?.includes("/")}> - <span class="text-text-weak">{getDirectory(props.input.filePath!)}</span> - </Show> - <span class="text-text-strong">{getFilename(props.input.filePath ?? "")}</span> - </div> - </div> - <div class="flex gap-4 items-center justify-end"> - <Show when={props.metadata.filediff}> - <DiffChanges diff={props.metadata.filediff} /> - </Show> - </div> - </div> - } - > - <Show when={props.metadata.filediff}> - <div class="border-t border-border-weaker-base"> - <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 class="flex items-center justify-between w-full"> - <div class="flex items-center gap-2"> - <div class="text-12-medium text-text-base capitalize">Write</div> - <div class="flex"> - <Show when={props.input.filePath?.includes("/")}> - <span class="text-text-weak">{getDirectory(props.input.filePath!)}</span> - </Show> - <span class="text-text-strong">{getFilename(props.input.filePath ?? "")}</span> - </div> - </div> - <div class="flex gap-4 items-center justify-end">{/* <DiffChanges diff={diff} /> */}</div> - </div> - } - > - <Show when={false && props.output}> - <div class="whitespace-pre">{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 class="px-12 pt-2.5 pb-6 flex flex-col gap-2"> - <For each={props.input.todos}> - {(todo: any) => ( - <Checkbox readOnly checked={todo.status === "completed"}> - <div classList={{ "line-through text-text-weaker": todo.status === "completed" }}>{todo.content}</div> - </Checkbox> - )} - </For> - </div> - </Show> - </BasicTool> - ) - }, -}) 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/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/tool-display.css b/packages/ui/src/components/basic-tool.css index f3d9f865f..f3d9f865f 100644 --- a/packages/ui/src/components/tool-display.css +++ b/packages/ui/src/components/basic-tool.css diff --git a/packages/ui/src/components/tool-display.tsx b/packages/ui/src/components/basic-tool.tsx index 43574fbb7..43574fbb7 100644 --- a/packages/ui/src/components/tool-display.tsx +++ b/packages/ui/src/components/basic-tool.tsx 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<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 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<MessagePartProps> -const PART_MAPPING: Record<string, PartComponent | undefined> = {} +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 @@ -81,3 +102,345 @@ export function Part(props: MessagePartProps) { </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/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<string, any> - metadata: Record<string, any> - tool: string - output?: string - hideDetails?: boolean -} - -export type ToolComponent = Component<ToolProps> - -const state: Record< - string, - { - name: string - render?: ToolComponent - } -> = {} - -export function registerTool(input: { name: string; render?: ToolComponent }) { - state[input.name] = input - return input -} - -export function getTool(name: string) { - return state[name]?.render -} - -export const ToolRegistry = { - register: registerTool, - render: getTool, -} diff --git a/packages/ui/src/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/desktop/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 18ce4280a..18ce4280a 100644 --- a/packages/desktop/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx diff --git a/packages/desktop/src/context/shiki.tsx b/packages/ui/src/context/shiki.tsx index b6c278bfe..d33b98ab7 100644 --- a/packages/desktop/src/context/shiki.tsx +++ b/packages/ui/src/context/shiki.tsx @@ -373,7 +373,11 @@ const theme: ThemeInput = { }, }, { - scope: ["storage.modifier.import.java", "variable.language.wildcard.java", "storage.modifier.package.java"], + scope: [ + "storage.modifier.import.java", + "variable.language.wildcard.java", + "storage.modifier.package.java", + ], settings: { foreground: "var(--text-base)", }, 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); |
