Skip to content

Commit fc115ea

Browse files
committed
wip: desktop work
1 parent d03b79e commit fc115ea

13 files changed

Lines changed: 854 additions & 297 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/desktop/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@
1212
},
1313
"license": "MIT",
1414
"devDependencies": {
15+
"opencode": "workspace:*",
1516
"@tailwindcss/vite": "catalog:",
1617
"@tsconfig/bun": "1.0.9",
1718
"@types/luxon": "3.7.1",
1819
"@types/node": "catalog:",
19-
"typescript": "catalog:",
2020
"@typescript/native-preview": "catalog:",
21+
"typescript": "catalog:",
2122
"vite": "catalog:",
2223
"vite-plugin-icons-spritesheet": "3.0.1",
2324
"vite-plugin-solid": "catalog:"
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
import type { Part, AssistantMessage, ReasoningPart, TextPart, ToolPart } from "@opencode-ai/sdk"
2+
import type { Tool } from "opencode/tool/tool"
3+
import type { ReadTool } from "opencode/tool/read"
4+
import { children, Component, createMemo, For, Match, Show, Switch, type JSX } from "solid-js"
5+
import { Dynamic } from "solid-js/web"
6+
import { Markdown } from "./markdown"
7+
import { Collapsible, Icon, IconProps } from "@opencode-ai/ui"
8+
import { getDirectory, getFilename } from "@/utils"
9+
import { ListTool } from "opencode/tool/ls"
10+
import { GlobTool } from "opencode/tool/glob"
11+
import { GrepTool } from "opencode/tool/grep"
12+
import { WebFetchTool } from "opencode/tool/webfetch"
13+
import { TaskTool } from "opencode/tool/task"
14+
import { BashTool } from "opencode/tool/bash"
15+
import { EditTool } from "opencode/tool/edit"
16+
import { DiffChanges } from "./diff-changes"
17+
import { WriteTool } from "opencode/tool/write"
18+
19+
export function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) {
20+
return (
21+
<div class="w-full flex flex-col items-start gap-4">
22+
<For each={props.parts}>
23+
{(part) => {
24+
const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
25+
return (
26+
<Show when={component()}>
27+
<Dynamic component={component()} part={part as any} message={props.message} />
28+
</Show>
29+
)
30+
}}
31+
</For>
32+
</div>
33+
)
34+
}
35+
36+
const PART_MAPPING = {
37+
text: TextPart,
38+
tool: ToolPart,
39+
reasoning: ReasoningPart,
40+
}
41+
42+
function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }) {
43+
return null
44+
// return (
45+
// <Show when={props.part.text.trim()}>
46+
// <div>{props.part.text}</div>
47+
// </Show>
48+
// )
49+
}
50+
51+
function TextPart(props: { part: TextPart; message: AssistantMessage }) {
52+
return (
53+
<Show when={props.part.text.trim()}>
54+
<Markdown text={props.part.text.trim()} />
55+
</Show>
56+
)
57+
}
58+
59+
function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
60+
// const sync = useSync()
61+
62+
const component = createMemo(() => {
63+
const render = ToolRegistry.render(props.part.tool) ?? GenericTool
64+
65+
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
66+
const input = props.part.state.status === "completed" ? props.part.state.input : {}
67+
// const permissions = sync.data.permission[props.message.sessionID] ?? []
68+
// const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
69+
// const permission = permissions[permissionIndex]
70+
71+
return (
72+
<>
73+
<Dynamic
74+
component={render}
75+
input={input}
76+
tool={props.part.tool}
77+
metadata={metadata}
78+
// permission={permission?.metadata ?? {}}
79+
output={props.part.state.status === "completed" ? props.part.state.output : undefined}
80+
/>
81+
{/* <Show when={props.part.state.status === "error"}>{props.part.state.error.replace("Error: ", "")}</Show> */}
82+
</>
83+
)
84+
})
85+
86+
return <Show when={component()}>{component()}</Show>
87+
}
88+
89+
type TriggerTitle = {
90+
title: string
91+
subtitle?: string
92+
args?: string[]
93+
action?: JSX.Element
94+
}
95+
96+
const isTriggerTitle = (val: any): val is TriggerTitle => {
97+
return typeof val === "object" && val !== null && "title" in val && !(val instanceof Node)
98+
}
99+
100+
function BasicTool(props: { icon: IconProps["name"]; trigger: TriggerTitle | JSX.Element; children?: JSX.Element }) {
101+
const resolved = children(() => props.children)
102+
103+
return (
104+
<Collapsible>
105+
<Collapsible.Trigger>
106+
<div class="w-full flex items-center self-stretch gap-5 justify-between">
107+
<div class="w-full flex items-center self-stretch gap-5">
108+
<Icon name={props.icon} size="small" />
109+
<Switch>
110+
<Match when={isTriggerTitle(props.trigger)}>
111+
<div class="w-full flex items-center gap-2 justify-between">
112+
<div class="flex items-center gap-2">
113+
<span class="text-12-medium text-text-base capitalize">
114+
{(props.trigger as TriggerTitle).title}
115+
</span>
116+
<Show when={(props.trigger as TriggerTitle).subtitle}>
117+
<span class="text-12-medium text-text-weak">{(props.trigger as TriggerTitle).subtitle}</span>
118+
</Show>
119+
<Show when={(props.trigger as TriggerTitle).args?.length}>
120+
<For each={(props.trigger as TriggerTitle).args}>
121+
{(arg) => <span class="text-12-regular text-text-weaker">{arg}</span>}
122+
</For>
123+
</Show>
124+
</div>
125+
<Show when={(props.trigger as TriggerTitle).action}>{(props.trigger as TriggerTitle).action}</Show>
126+
</div>
127+
</Match>
128+
<Match when={true}>{props.trigger as JSX.Element}</Match>
129+
</Switch>
130+
</div>
131+
<Show when={resolved()}>
132+
<Collapsible.Arrow />
133+
</Show>
134+
</div>
135+
</Collapsible.Trigger>
136+
<Show when={props.children}>
137+
<Collapsible.Content>{props.children}</Collapsible.Content>
138+
</Show>
139+
</Collapsible>
140+
)
141+
}
142+
143+
function GenericTool(props: ToolProps<any>) {
144+
return <BasicTool icon="mcp" trigger={{ title: props.tool }} />
145+
}
146+
147+
type ToolProps<T extends Tool.Info> = {
148+
input: Partial<Tool.InferParameters<T>>
149+
metadata: Partial<Tool.InferMetadata<T>>
150+
// permission: Record<string, any>
151+
tool: string
152+
output?: string
153+
}
154+
155+
const ToolRegistry = (() => {
156+
const state: Record<
157+
string,
158+
{
159+
name: string
160+
render?: Component<ToolProps<any>>
161+
}
162+
> = {}
163+
function register<T extends Tool.Info>(input: { name: string; render?: Component<ToolProps<T>> }) {
164+
state[input.name] = input
165+
return input
166+
}
167+
return {
168+
register,
169+
render(name: string) {
170+
return state[name]?.render
171+
},
172+
}
173+
})()
174+
175+
ToolRegistry.register<typeof ReadTool>({
176+
name: "read",
177+
render(props) {
178+
return (
179+
<BasicTool
180+
icon="glasses"
181+
trigger={{ title: props.tool, subtitle: props.input.filePath ? getFilename(props.input.filePath) : "" }}
182+
/>
183+
)
184+
},
185+
})
186+
187+
ToolRegistry.register<typeof ListTool>({
188+
name: "list",
189+
render(props) {
190+
return (
191+
<BasicTool icon="bullet-list" trigger={{ title: props.tool, subtitle: props.input.path || "/" }}>
192+
<Show when={false && props.output}>
193+
<div class="whitespace-pre">{props.output}</div>
194+
</Show>
195+
</BasicTool>
196+
)
197+
},
198+
})
199+
200+
ToolRegistry.register<typeof GlobTool>({
201+
name: "glob",
202+
render(props) {
203+
return (
204+
<BasicTool
205+
icon="magnifying-glass-menu"
206+
trigger={{
207+
title: props.tool,
208+
subtitle: props.input.path || "/",
209+
args: props.input.pattern ? ["pattern=" + props.input.pattern] : [],
210+
}}
211+
>
212+
<Show when={false && props.output}>
213+
<div class="whitespace-pre">{props.output}</div>
214+
</Show>
215+
</BasicTool>
216+
)
217+
},
218+
})
219+
220+
ToolRegistry.register<typeof GrepTool>({
221+
name: "grep",
222+
render(props) {
223+
const args = []
224+
if (props.input.pattern) args.push("pattern=" + props.input.pattern)
225+
if (props.input.include) args.push("include=" + props.input.include)
226+
return (
227+
<BasicTool
228+
icon="magnifying-glass-menu"
229+
trigger={{
230+
title: props.tool,
231+
subtitle: props.input.path || "/",
232+
args,
233+
}}
234+
>
235+
<Show when={false && props.output}>
236+
<div class="whitespace-pre">{props.output}</div>
237+
</Show>
238+
</BasicTool>
239+
)
240+
},
241+
})
242+
243+
ToolRegistry.register<typeof WebFetchTool>({
244+
name: "webfetch",
245+
render(props) {
246+
return (
247+
<BasicTool
248+
icon="window-cursor"
249+
trigger={{
250+
title: props.tool,
251+
subtitle: props.input.url || "",
252+
args: props.input.format ? ["format=" + props.input.format] : [],
253+
action: (
254+
<div class="size-6 flex items-center justify-center">
255+
<Icon name="square-arrow-top-right" size="small" />
256+
</div>
257+
),
258+
}}
259+
>
260+
<Show when={false && props.output}>
261+
<div class="whitespace-pre">{props.output}</div>
262+
</Show>
263+
</BasicTool>
264+
)
265+
},
266+
})
267+
268+
ToolRegistry.register<typeof TaskTool>({
269+
name: "task",
270+
render(props) {
271+
return (
272+
<BasicTool
273+
icon="task"
274+
trigger={{
275+
title: `${props.input.subagent_type || props.tool} Agent`,
276+
subtitle: props.input.description,
277+
}}
278+
>
279+
<Show when={false && props.output}>
280+
<div class="whitespace-pre">{props.output}</div>
281+
</Show>
282+
</BasicTool>
283+
)
284+
},
285+
})
286+
287+
ToolRegistry.register<typeof BashTool>({
288+
name: "bash",
289+
render(props) {
290+
return (
291+
<BasicTool
292+
icon="console"
293+
trigger={{
294+
title: "Shell",
295+
subtitle: "Ran " + props.input.command,
296+
}}
297+
>
298+
<Show when={false && props.output}>
299+
<div class="whitespace-pre">{props.output}</div>
300+
</Show>
301+
</BasicTool>
302+
)
303+
},
304+
})
305+
306+
ToolRegistry.register<typeof EditTool>({
307+
name: "edit",
308+
render(props) {
309+
return (
310+
<BasicTool
311+
icon="code-lines"
312+
trigger={
313+
<div class="flex items-center justify-between w-full">
314+
<div class="flex items-center gap-5">
315+
<div class="text-12-medium text-text-base capitalize">Edit</div>
316+
<div class="flex">
317+
<Show when={props.input.filePath?.includes("/")}>
318+
<span class="text-text-weak">{getDirectory(props.input.filePath!)}/</span>
319+
</Show>
320+
<span class="text-text-strong">{getFilename(props.input.filePath ?? "")}</span>
321+
</div>
322+
</div>
323+
<div class="flex gap-4 items-center justify-end">{/* <DiffChanges diff={diff} /> */}</div>
324+
</div>
325+
}
326+
>
327+
<Show when={false && props.output}>
328+
<div class="whitespace-pre">{props.output}</div>
329+
</Show>
330+
</BasicTool>
331+
)
332+
},
333+
})
334+
335+
ToolRegistry.register<typeof WriteTool>({
336+
name: "write",
337+
render(props) {
338+
return (
339+
<BasicTool
340+
icon="code-lines"
341+
trigger={
342+
<div class="flex items-center justify-between w-full">
343+
<div class="flex items-center gap-5">
344+
<div class="text-12-medium text-text-base capitalize">Write</div>
345+
<div class="flex">
346+
<Show when={props.input.filePath?.includes("/")}>
347+
<span class="text-text-weak">{getDirectory(props.input.filePath!)}/</span>
348+
</Show>
349+
<span class="text-text-strong">{getFilename(props.input.filePath ?? "")}</span>
350+
</div>
351+
</div>
352+
<div class="flex gap-4 items-center justify-end">{/* <DiffChanges diff={diff} /> */}</div>
353+
</div>
354+
}
355+
>
356+
<Show when={false && props.output}>
357+
<div class="whitespace-pre">{props.output}</div>
358+
</Show>
359+
</BasicTool>
360+
)
361+
},
362+
})

0 commit comments

Comments
 (0)