diff options
Diffstat (limited to 'packages/desktop/src/context')
| -rw-r--r-- | packages/desktop/src/context/event.tsx | 34 | ||||
| -rw-r--r-- | packages/desktop/src/context/helper.tsx | 25 | ||||
| -rw-r--r-- | packages/desktop/src/context/index.ts | 6 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 1054 | ||||
| -rw-r--r-- | packages/desktop/src/context/marked.tsx | 63 | ||||
| -rw-r--r-- | packages/desktop/src/context/sdk.tsx | 56 | ||||
| -rw-r--r-- | packages/desktop/src/context/shiki.tsx | 24 | ||||
| -rw-r--r-- | packages/desktop/src/context/sync.tsx | 301 |
8 files changed, 777 insertions, 786 deletions
diff --git a/packages/desktop/src/context/event.tsx b/packages/desktop/src/context/event.tsx deleted file mode 100644 index a2aa54181..000000000 --- a/packages/desktop/src/context/event.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { createContext, useContext, type ParentProps } from "solid-js" -import { createEventBus } from "@solid-primitives/event-bus" -import type { Event as SDKEvent } from "@opencode-ai/sdk" -import { useSDK } from "@/context" - -export type Event = SDKEvent // can extend with custom events later - -function init() { - const sdk = useSDK() - const bus = createEventBus<Event>() - sdk.event.subscribe().then(async (events) => { - for await (const event of events.stream) { - bus.emit(event) - } - }) - return bus -} - -type EventContext = ReturnType<typeof init> - -const ctx = createContext<EventContext>() - -export function EventProvider(props: ParentProps) { - const value = init() - return <ctx.Provider value={value}>{props.children}</ctx.Provider> -} - -export function useEvent() { - const value = useContext(ctx) - if (!value) { - throw new Error("useEvent must be used within a EventProvider") - } - return value -} diff --git a/packages/desktop/src/context/helper.tsx b/packages/desktop/src/context/helper.tsx new file mode 100644 index 000000000..6be88e775 --- /dev/null +++ b/packages/desktop/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/index.ts b/packages/desktop/src/context/index.ts deleted file mode 100644 index 6ca3bbf97..000000000 --- a/packages/desktop/src/context/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { EventProvider, useEvent } from "./event" -export { LocalProvider, useLocal } from "./local" -export { MarkedProvider, useMarked } from "./marked" -export { SDKProvider, useSDK } from "./sdk" -export { ShikiProvider, useShiki } from "./shiki" -export { SyncProvider, useSync } from "./sync" diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index c60e4520e..981039bb6 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -1,8 +1,19 @@ import { createStore, produce, reconcile } from "solid-js/store" -import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js" -import { uniqueBy } from "remeda" -import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk" -import { useSDK, useEvent, useSync } from "@/context" +import { batch, createEffect, createMemo } from "solid-js" +import { pipe, sumBy, uniqueBy } from "remeda" +import type { + FileContent, + FileNode, + Model, + Provider, + File as FileStatus, + Part, + Message, + AssistantMessage, +} from "@opencode-ai/sdk" +import { createSimpleContext } from "./helper" +import { useSDK } from "./sdk" +import { useSync } from "./sync" export type LocalFile = FileNode & Partial<{ @@ -28,542 +39,567 @@ export type ModelKey = { providerID: string; modelID: string } export type FileContext = { type: "file"; path: string; selection?: TextSelection } export type ContextItem = FileContext -function init() { - const sdk = useSDK() - const sync = useSync() - - const agent = (() => { - const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) - const [store, setStore] = createStore<{ - current: string - }>({ - current: list()[0].name, - }) - return { - list, - current() { - return list().find((x) => x.name === store.current)! - }, - set(name: string | undefined) { - setStore("current", name ?? list()[0].name) - }, - move(direction: 1 | -1) { - let next = list().findIndex((x) => x.name === store.current) + direction - if (next < 0) next = list().length - 1 - if (next >= list().length) next = 0 - const value = list()[next] - setStore("current", value.name) - if (value.model) - model.set({ - providerID: value.model.providerID, - modelID: value.model.modelID, - }) - }, - } - })() - - const model = (() => { - const list = createMemo(() => - sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)), - ) - const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) - - const [store, setStore] = createStore<{ - model: Record<string, ModelKey> - recent: ModelKey[] - }>({ - model: {}, - recent: [], - }) - - const value = localStorage.getItem("model") - setStore("recent", JSON.parse(value ?? "[]")) - createEffect(() => { - localStorage.setItem("model", JSON.stringify(store.recent)) - }) - - const fallback = createMemo(() => { - if (store.recent.length) return store.recent[0] - const provider = sync.data.provider[0] - const model = Object.values(provider.models)[0] - return { modelID: model.id, providerID: provider.id } - }) - - const current = createMemo(() => { - const a = agent.current() - return find(store.model[agent.current().name]) ?? find(a.model ?? fallback()) - }) - - const recent = createMemo(() => store.recent.map(find).filter(Boolean)) - - return { - list, - current, - recent, - set(model: ModelKey | undefined, options?: { recent?: boolean }) { - batch(() => { - setStore("model", agent.current().name, model ?? fallback()) - if (options?.recent && model) { - const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) - if (uniq.length > 5) uniq.pop() - setStore("recent", uniq) - } - }) - }, - } - })() - - const file = (() => { - const [store, setStore] = createStore<{ - node: Record<string, LocalFile> - opened: string[] - active?: string - }>({ - node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])), - opened: [], - }) - - const active = createMemo(() => { - if (!store.active) return undefined - return store.node[store.active] - }) - const opened = createMemo(() => store.opened.map((x) => store.node[x])) - const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path))) - const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b))) - - // createEffect((prev: FileStatus[]) => { - // const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path)) - // for (const p of removed) { - // setStore( - // "node", - // p.path, - // produce((draft) => { - // draft.status = undefined - // draft.view = "raw" - // }), - // ) - // load(p.path) - // } - // for (const p of sync.data.changes) { - // if (store.node[p.path] === undefined) { - // fetch(p.path).then(() => { - // if (store.node[p.path] === undefined) return - // setStore("node", p.path, "status", p) - // }) - // } else { - // setStore("node", p.path, "status", p) - // } - // } - // return sync.data.changes - // }, sync.data.changes) - - const changed = (path: string) => { - const node = store.node[path] - if (node?.status) return true - const set = changeset() - if (set.has(path)) return true - for (const p of set) { - if (p.startsWith(path ? path + "/" : "")) return true +export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ + name: "Local", + init: () => { + const sdk = useSDK() + const sync = useSync() + + const agent = (() => { + const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) + const [store, setStore] = createStore<{ + current: string + }>({ + current: list()[0].name, + }) + return { + list, + current() { + return list().find((x) => x.name === store.current)! + }, + set(name: string | undefined) { + setStore("current", name ?? list()[0].name) + }, + move(direction: 1 | -1) { + let next = list().findIndex((x) => x.name === store.current) + direction + if (next < 0) next = list().length - 1 + if (next >= list().length) next = 0 + const value = list()[next] + setStore("current", value.name) + if (value.model) + model.set({ + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + }, } - return false - } - - const resetNode = (path: string) => { - setStore("node", path, { - loaded: undefined, - pinned: undefined, - content: undefined, - selection: undefined, - scrollTop: undefined, - folded: undefined, - view: undefined, - selectedChange: undefined, + })() + + const model = (() => { + const list = createMemo(() => + sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)), + ) + const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) + + const [store, setStore] = createStore<{ + model: Record<string, ModelKey> + recent: ModelKey[] + }>({ + model: {}, + recent: [], }) - } - const relative = (path: string) => path.replace(sync.data.path.directory + "/", "") - - const load = async (path: string) => { - const relativePath = relative(path) - sdk.file.read({ query: { path: relativePath } }).then((x) => { - setStore( - "node", - relativePath, - produce((draft) => { - draft.loaded = true - draft.content = x.data - }), - ) + const value = localStorage.getItem("model") + setStore("recent", JSON.parse(value ?? "[]")) + createEffect(() => { + localStorage.setItem("model", JSON.stringify(store.recent)) }) - } - const fetch = async (path: string) => { - const relativePath = relative(path) - const parent = relativePath.split("/").slice(0, -1).join("/") - if (parent) { - await list(parent) - } - } + const fallback = createMemo(() => { + if (store.recent.length) return store.recent[0] + const provider = sync.data.provider[0] + const model = Object.values(provider.models)[0] + return { modelID: model.id, providerID: provider.id } + }) - const init = async (path: string) => { - const relativePath = relative(path) - if (!store.node[relativePath]) await fetch(path) - if (store.node[relativePath].loaded) return - return load(relativePath) - } + const current = createMemo(() => { + const a = agent.current() + return find(store.model[agent.current().name]) ?? find(a.model ?? fallback()) + }) - const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => { - const relativePath = relative(path) - if (!store.node[relativePath]) await fetch(path) - setStore("opened", (x) => { - if (x.includes(relativePath)) return x - return [ - ...opened() - .filter((x) => x.pinned) - .map((x) => x.path), - relativePath, - ] + const recent = createMemo(() => store.recent.map(find).filter(Boolean)) + + return { + list, + current, + recent, + set(model: ModelKey | undefined, options?: { recent?: boolean }) { + batch(() => { + setStore("model", agent.current().name, model ?? fallback()) + if (options?.recent && model) { + const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) + if (uniq.length > 5) uniq.pop() + setStore("recent", uniq) + } + }) + }, + } + })() + + const file = (() => { + const [store, setStore] = createStore<{ + node: Record<string, LocalFile> + opened: string[] + active?: string + }>({ + node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])), + opened: [], }) - setStore("active", relativePath) - context.addActive() - if (options?.pinned) setStore("node", path, "pinned", true) - if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view) - if (store.node[relativePath].loaded) return - return load(relativePath) - } - const list = async (path: string) => { - return sdk.file.list({ query: { path: path + "/" } }).then((x) => { - setStore( - "node", - produce((draft) => { - x.data!.forEach((node) => { - if (node.path in draft) return - draft[node.path] = node - }) - }), - ) + const active = createMemo(() => { + if (!store.active) return undefined + return store.node[store.active] }) - } + const opened = createMemo(() => store.opened.map((x) => store.node[x])) + const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path))) + const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b))) + + // createEffect((prev: FileStatus[]) => { + // const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path)) + // for (const p of removed) { + // setStore( + // "node", + // p.path, + // produce((draft) => { + // draft.status = undefined + // draft.view = "raw" + // }), + // ) + // load(p.path) + // } + // for (const p of sync.data.changes) { + // if (store.node[p.path] === undefined) { + // fetch(p.path).then(() => { + // if (store.node[p.path] === undefined) return + // setStore("node", p.path, "status", p) + // }) + // } else { + // setStore("node", p.path, "status", p) + // } + // } + // return sync.data.changes + // }, sync.data.changes) + + const changed = (path: string) => { + const node = store.node[path] + if (node?.status) return true + const set = changeset() + if (set.has(path)) return true + for (const p of set) { + if (p.startsWith(path ? path + "/" : "")) return true + } + return false + } - const search = (query: string) => sdk.find.files({ query: { query } }).then((x) => x.data!) + const resetNode = (path: string) => { + setStore("node", path, { + loaded: undefined, + pinned: undefined, + content: undefined, + selection: undefined, + scrollTop: undefined, + folded: undefined, + view: undefined, + selectedChange: undefined, + }) + } - const bus = useEvent() - bus.listen((event) => { - switch (event.type) { - case "message.part.updated": - const part = event.properties.part - if (part.type === "tool" && part.state.status === "completed") { - switch (part.tool) { - case "read": - break - case "edit": - // load(part.state.input["filePath"] as string) - break - default: - break - } - } - break - case "file.watcher.updated": - setTimeout(sync.load.changes, 1000) - const relativePath = relative(event.properties.file) - if (relativePath.startsWith(".git/")) return - load(relativePath) - break + const relative = (path: string) => path.replace(sync.data.path.directory + "/", "") + + const load = async (path: string) => { + const relativePath = relative(path) + sdk.client.file.read({ query: { path: relativePath } }).then((x) => { + setStore( + "node", + relativePath, + produce((draft) => { + draft.loaded = true + draft.content = x.data + }), + ) + }) } - }) - - return { - active, - opened, - node: (path: string) => store.node[path], - update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)), - open, - load, - init, - close(path: string) { - setStore("opened", (opened) => opened.filter((x) => x !== path)) - if (store.active === path) { - const index = store.opened.findIndex((f) => f === path) - const previous = store.opened[Math.max(0, index - 1)] - setStore("active", previous) + + const fetch = async (path: string) => { + const relativePath = relative(path) + const parent = relativePath.split("/").slice(0, -1).join("/") + if (parent) { + await list(parent) } - resetNode(path) - }, - expand(path: string) { - setStore("node", path, "expanded", true) - if (store.node[path].loaded) return - setStore("node", path, "loaded", true) - list(path) - }, - collapse(path: string) { - setStore("node", path, "expanded", false) - }, - select(path: string, selection: TextSelection | undefined) { - setStore("node", path, "selection", selection) - }, - scroll(path: string, scrollTop: number) { - setStore("node", path, "scrollTop", scrollTop) - }, - move(path: string, to: number) { - const index = store.opened.findIndex((f) => f === path) - if (index === -1) return - setStore( - "opened", - produce((opened) => { - opened.splice(to, 0, opened.splice(index, 1)[0]) - }), - ) - setStore("node", path, "pinned", true) - }, - view(path: string): View { - const n = store.node[path] - return n && n.view ? n.view : "raw" - }, - setView(path: string, view: View) { - setStore("node", path, "view", view) - }, - unfold(path: string, key: string) { - setStore("node", path, "folded", (xs) => { - const a = xs ?? [] - if (a.includes(key)) return a - return [...a, key] + } + + const init = async (path: string) => { + const relativePath = relative(path) + if (!store.node[relativePath]) await fetch(path) + if (store.node[relativePath].loaded) return + return load(relativePath) + } + + const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => { + const relativePath = relative(path) + if (!store.node[relativePath]) await fetch(path) + setStore("opened", (x) => { + if (x.includes(relativePath)) return x + return [ + ...opened() + .filter((x) => x.pinned) + .map((x) => x.path), + relativePath, + ] }) - }, - fold(path: string, key: string) { - setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key)) - }, - folded(path: string) { - const n = store.node[path] - return n && n.folded ? n.folded : [] - }, - changeIndex(path: string) { - return store.node[path]?.selectedChange - }, - setChangeIndex(path: string, index: number | undefined) { - setStore("node", path, "selectedChange", index) - }, - changes, - changed, - children(path: string) { - return Object.values(store.node).filter( - (x) => - x.path.startsWith(path) && - x.path !== path && - !x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"), - ) - }, - search, - relative, - } - })() - - const layout = (() => { - type PaneState = { size: number; visible: boolean } - type LayoutState = { panes: Record<string, PaneState>; order: string[] } - type PaneDefault = number | { size: number; visible?: boolean } - - const [store, setStore] = createStore<Record<string, LayoutState>>({}) - - const raw = localStorage.getItem("layout") - if (raw) { - const data = JSON.parse(raw) - if (data && typeof data === "object" && !Array.isArray(data)) { - const first = Object.values(data)[0] as LayoutState - if (first && typeof first === "object" && "panes" in first) { - setStore(() => data as Record<string, LayoutState>) + setStore("active", relativePath) + context.addActive() + if (options?.pinned) setStore("node", path, "pinned", true) + if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view) + if (store.node[relativePath].loaded) return + return load(relativePath) + } + + const list = async (path: string) => { + return sdk.client.file.list({ query: { path: path + "/" } }).then((x) => { + setStore( + "node", + produce((draft) => { + x.data!.forEach((node) => { + if (node.path in draft) return + draft[node.path] = node + }) + }), + ) + }) + } + + const search = (query: string) => sdk.client.find.files({ query: { query } }).then((x) => x.data!) + + sdk.event.listen((e) => { + const event = e.details + switch (event.type) { + case "message.part.updated": + const part = event.properties.part + if (part.type === "tool" && part.state.status === "completed") { + switch (part.tool) { + case "read": + break + case "edit": + // load(part.state.input["filePath"] as string) + break + default: + break + } + } + break + case "file.watcher.updated": + setTimeout(sync.load.changes, 1000) + const relativePath = relative(event.properties.file) + if (relativePath.startsWith(".git/")) return + load(relativePath) + break } + }) + + return { + active, + opened, + node: (path: string) => store.node[path], + update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)), + open, + load, + init, + close(path: string) { + setStore("opened", (opened) => opened.filter((x) => x !== path)) + if (store.active === path) { + const index = store.opened.findIndex((f) => f === path) + const previous = store.opened[Math.max(0, index - 1)] + setStore("active", previous) + } + resetNode(path) + }, + expand(path: string) { + setStore("node", path, "expanded", true) + if (store.node[path].loaded) return + setStore("node", path, "loaded", true) + list(path) + }, + collapse(path: string) { + setStore("node", path, "expanded", false) + }, + select(path: string, selection: TextSelection | undefined) { + setStore("node", path, "selection", selection) + }, + scroll(path: string, scrollTop: number) { + setStore("node", path, "scrollTop", scrollTop) + }, + move(path: string, to: number) { + const index = store.opened.findIndex((f) => f === path) + if (index === -1) return + setStore( + "opened", + produce((opened) => { + opened.splice(to, 0, opened.splice(index, 1)[0]) + }), + ) + setStore("node", path, "pinned", true) + }, + view(path: string): View { + const n = store.node[path] + return n && n.view ? n.view : "raw" + }, + setView(path: string, view: View) { + setStore("node", path, "view", view) + }, + unfold(path: string, key: string) { + setStore("node", path, "folded", (xs) => { + const a = xs ?? [] + if (a.includes(key)) return a + return [...a, key] + }) + }, + fold(path: string, key: string) { + setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key)) + }, + folded(path: string) { + const n = store.node[path] + return n && n.folded ? n.folded : [] + }, + changeIndex(path: string) { + return store.node[path]?.selectedChange + }, + setChangeIndex(path: string, index: number | undefined) { + setStore("node", path, "selectedChange", index) + }, + changes, + changed, + children(path: string) { + return Object.values(store.node).filter( + (x) => + x.path.startsWith(path) && + x.path !== path && + !x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"), + ) + }, + search, + relative, } - } + })() - createEffect(() => { - localStorage.setItem("layout", JSON.stringify(store)) - }) + const session = (() => { + const [store, setStore] = createStore<{ + active?: string + activeMessage?: string + }>({}) - const normalize = (value: PaneDefault): PaneState => { - if (typeof value === "number") return { size: value, visible: true } - return { size: value.size, visible: value.visible ?? true } - } + const active = createMemo(() => { + if (!store.active) return undefined + return sync.session.get(store.active) + }) - const ensure = (id: string, defaults: Record<string, PaneDefault>) => { - const entries = Object.entries(defaults) - if (!entries.length) return - setStore(id, (current) => { - if (current) return current - return { - panes: Object.fromEntries(entries.map(([pane, config]) => [pane, normalize(config)])), - order: entries.map(([pane]) => pane), - } + createEffect(() => { + if (!store.active) return + sync.session.sync(store.active) }) - for (const [pane, config] of entries) { - if (!store[id]?.panes[pane]) { - setStore(id, "panes", pane, () => normalize(config)) - } - if (!(store[id]?.order ?? []).includes(pane)) { - setStore(id, "order", (list) => [...list, pane]) + + const valid = (part: Part) => { + if (!part) return false + switch (part.type) { + case "step-start": + case "step-finish": + case "file": + case "patch": + return false + case "text": + return !part.synthetic && part.text.trim() + case "reasoning": + return part.text.trim() + case "tool": + switch (part.tool) { + case "todoread": + case "todowrite": + case "list": + case "grep": + return false + } + return true + default: + return true } } - } - const ensurePane = (id: string, pane: string, fallback?: PaneDefault) => { - if (!store[id]) { - const value = normalize(fallback ?? { size: 0, visible: true }) - setStore(id, () => ({ - panes: { [pane]: value }, - order: [pane], - })) - return - } - if (!store[id].panes[pane]) { - const value = normalize(fallback ?? { size: 0, visible: true }) - setStore(id, "panes", pane, () => value) + const hasValidParts = (message: Message) => { + return sync.data.part[message.id]?.filter(valid).length > 0 } - if (!store[id].order.includes(pane)) { - setStore(id, "order", (list) => [...list, pane]) - } - } + // const hasTextPart = (message: Message) => { + // return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text") + // } + + const messages = createMemo(() => (store.active ? (sync.data.message[store.active] ?? []) : [])) + const messagesWithValidParts = createMemo(() => messages().filter(hasValidParts) ?? []) + const userMessages = createMemo(() => + messages() + .filter((m) => m.role === "user") + .sort((a, b) => b.id.localeCompare(a.id)), + ) + + const working = createMemo(() => { + const last = messages()[messages().length - 1] + if (!last) return false + if (last.role === "user") return true + return !last.time.completed + }) - const size = (id: string, pane: string) => store[id]?.panes[pane]?.size ?? 0 - const visible = (id: string, pane: string) => store[id]?.panes[pane]?.visible ?? false + const cost = createMemo(() => { + const total = pipe( + messages(), + sumBy((x) => (x.role === "assistant" ? x.cost : 0)), + ) + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(total) + }) - const setSize = (id: string, pane: string, value: number) => { - if (!store[id]?.panes[pane]) return - const next = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0 - setStore(id, "panes", pane, "size", next) - } + const last = createMemo(() => { + return messages().findLast((x) => x.role === "assistant") as AssistantMessage + }) - const setVisible = (id: string, pane: string, value: boolean) => { - if (!store[id]?.panes[pane]) return - setStore(id, "panes", pane, "visible", value) - } + const lastUserMessage = createMemo(() => { + return userMessages()?.at(0) + }) - const toggle = (id: string, pane: string) => { - setVisible(id, pane, !visible(id, pane)) - } + const activeMessage = createMemo(() => { + if (!store.active || !store.activeMessage) return lastUserMessage() + return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage) + }) - const show = (id: string, pane: string) => setVisible(id, pane, true) - const hide = (id: string, pane: string) => setVisible(id, pane, false) - const order = (id: string) => store[id]?.order ?? [] - - return { - ensure, - ensurePane, - size, - visible, - setSize, - setVisible, - toggle, - show, - hide, - order, - } - })() - - const session = (() => { - const [store, setStore] = createStore<{ - active?: string - }>({}) - - const active = createMemo(() => { - if (!store.active) return undefined - return sync.session.get(store.active) - }) - - createEffect(() => { - if (!store.active) return - sync.session.sync(store.active) - }) - - return { - active, - setActive(sessionId: string | undefined) { - setStore("active", sessionId) - }, - clearActive() { - setStore("active", undefined) - }, - } - })() - - const context = (() => { - const [store, setStore] = createStore<{ - activeTab: boolean - files: string[] - activeFile?: string - items: (ContextItem & { key: string })[] - }>({ - activeTab: true, - files: [], - items: [], - }) - const files = createMemo(() => store.files.map((x) => file.node(x))) - const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined)) - - return { - all() { - return store.items - }, - active() { - return store.activeTab ? file.active() : undefined - }, - addActive() { - setStore("activeTab", true) - }, - removeActive() { - setStore("activeTab", false) - }, - add(item: ContextItem) { - let key = item.type - switch (item.type) { - case "file": - key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}` - break - } - if (store.items.find((x) => x.key === key)) return - setStore("items", (x) => [...x, { key, ...item }]) - }, - remove(key: string) { - setStore("items", (x) => x.filter((x) => x.key !== key)) - }, - files, - openFile(path: string) { - file.init(path).then(() => { - setStore("files", (x) => [...x, path]) - setStore("activeFile", path) - }) - }, - activeFile, - setActiveFile(path: string | undefined) { - setStore("activeFile", path) - }, - } - })() - - const result = { - model, - agent, - file, - layout, - session, - context, - } - return result -} + const activeAssistantMessages = createMemo(() => { + if (!store.active || !activeMessage()) return [] + return sync.data.message[store.active]?.filter( + (m) => m.role === "assistant" && m.parentID == activeMessage()?.id, + ) + }) -type LocalContext = ReturnType<typeof init> + const activeAssistantMessagesWithText = createMemo(() => { + if (!store.active || !activeAssistantMessages()) return [] + return activeAssistantMessages()?.filter((m) => sync.data.part[m.id].find((p) => p.type === "text")) + }) -const ctx = createContext<LocalContext>() + const model = createMemo(() => { + if (!last()) return + const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] + return model + }) -export function LocalProvider(props: ParentProps) { - const value = init() - return <ctx.Provider value={value}>{props.children}</ctx.Provider> -} + const tokens = createMemo(() => { + if (!last()) return + const tokens = last().tokens + const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write + return new Intl.NumberFormat("en-US", { + notation: "compact", + compactDisplay: "short", + }).format(total) + }) -export function useLocal() { - const value = useContext(ctx) - if (!value) { - throw new Error("useLocal must be used within a LocalProvider") - } - return value -} + const context = createMemo(() => { + if (!last()) return + if (!model()?.limit.context) return 0 + const tokens = last().tokens + const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write + return Math.round((total / model()!.limit.context) * 100) + }) + + const getMessageText = (message: Message | Message[] | undefined): string => { + if (!message) return "" + if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ") + return sync.data.part[message.id] + ?.filter((p) => p.type === "text") + ?.filter((p) => !p.synthetic) + .map((p) => p.text) + .join(" ") + } + + return { + active, + activeMessage, + activeAssistantMessages, + activeAssistantMessagesWithText, + lastUserMessage, + cost, + last, + model, + tokens, + context, + messages, + messagesWithValidParts, + userMessages, + working, + getMessageText, + setActive(sessionId: string | undefined) { + setStore("active", sessionId) + setStore("activeMessage", undefined) + }, + clearActive() { + setStore("active", undefined) + setStore("activeMessage", undefined) + }, + setActiveMessage(messageId: string | undefined) { + setStore("activeMessage", messageId) + }, + clearActiveMessage() { + setStore("activeMessage", undefined) + }, + } + })() + + const context = (() => { + const [store, setStore] = createStore<{ + activeTab: boolean + files: string[] + activeFile?: string + items: (ContextItem & { key: string })[] + }>({ + activeTab: true, + files: [], + items: [], + }) + const files = createMemo(() => store.files.map((x) => file.node(x))) + const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined)) + + return { + all() { + return store.items + }, + active() { + return store.activeTab ? file.active() : undefined + }, + addActive() { + setStore("activeTab", true) + }, + removeActive() { + setStore("activeTab", false) + }, + add(item: ContextItem) { + let key = item.type + switch (item.type) { + case "file": + key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}` + break + } + if (store.items.find((x) => x.key === key)) return + setStore("items", (x) => [...x, { key, ...item }]) + }, + remove(key: string) { + setStore("items", (x) => x.filter((x) => x.key !== key)) + }, + files, + openFile(path: string) { + file.init(path).then(() => { + setStore("files", (x) => [...x, path]) + setStore("activeFile", path) + }) + }, + activeFile, + setActiveFile(path: string | undefined) { + setStore("activeFile", path) + }, + } + })() + + const result = { + model, + agent, + file, + session, + context, + } + return result + }, +}) diff --git a/packages/desktop/src/context/marked.tsx b/packages/desktop/src/context/marked.tsx index 550a0456a..18ce4280a 100644 --- a/packages/desktop/src/context/marked.tsx +++ b/packages/desktop/src/context/marked.tsx @@ -1,43 +1,30 @@ -import { createContext, useContext, type ParentProps } from "solid-js" -import { useShiki } from "@/context" import { marked } from "marked" import markedShiki from "marked-shiki" import { bundledLanguages, type BundledLanguage } from "shiki" -function init(highlighter: ReturnType<typeof 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, - }) - }, - }), - ) -} +import { createSimpleContext } from "./helper" +import { useShiki } from "./shiki" -type MarkedContext = ReturnType<typeof init> - -const ctx = createContext<MarkedContext>() - -export function MarkedProvider(props: ParentProps) { - const highlighter = useShiki() - const value = init(highlighter) - return <ctx.Provider value={value}>{props.children}</ctx.Provider> -} - -export function useMarked() { - const value = useContext(ctx) - if (!value) { - throw new Error("useMarked must be used within a MarkedProvider") - } - return value -} +export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({ + name: "Marked", + init: () => { + const highlighter = useShiki() + return marked.use( + markedShiki({ + async highlight(code, lang) { + if (!(lang in bundledLanguages)) { + lang = "text" + } + if (!highlighter.getLoadedLanguages().includes(lang)) { + await highlighter.loadLanguage(lang as BundledLanguage) + } + return highlighter.codeToHtml(code, { + lang: lang || "text", + theme: "opencode", + tabindex: false, + }) + }, + }), + ) + }, +}) diff --git a/packages/desktop/src/context/sdk.tsx b/packages/desktop/src/context/sdk.tsx index 48595cf9d..7ffa30494 100644 --- a/packages/desktop/src/context/sdk.tsx +++ b/packages/desktop/src/context/sdk.tsx @@ -1,29 +1,37 @@ -import { createContext, useContext, type ParentProps } from "solid-js" -import { createOpencodeClient } from "@opencode-ai/sdk/client" +import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client" +import { createSimpleContext } from "./helper" +import { createGlobalEmitter } from "@solid-primitives/event-bus" +import { onCleanup } from "solid-js" -const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1" -const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096" +export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ + name: "SDK", + init: (props: { url: string }) => { + const abort = new AbortController() + const sdk = createOpencodeClient({ + baseUrl: props.url, + signal: abort.signal, + fetch: (req) => { + // @ts-ignore + req.timeout = false + return fetch(req) + }, + }) -function init() { - const client = createOpencodeClient({ - baseUrl: `http://${host}:${port}`, - }) - return client -} + const emitter = createGlobalEmitter<{ + [key in Event["type"]]: Extract<Event, { type: key }> + }>() -type SDKContext = ReturnType<typeof init> + sdk.event.subscribe().then(async (events) => { + for await (const event of events.stream) { + console.log("event", event.type) + emitter.emit(event.type, event) + } + }) -const ctx = createContext<SDKContext>() + onCleanup(() => { + abort.abort() + }) -export function SDKProvider(props: ParentProps) { - const value = init() - return <ctx.Provider value={value}>{props.children}</ctx.Provider> -} - -export function useSDK() { - const value = useContext(ctx) - if (!value) { - throw new Error("useSDK must be used within a SDKProvider") - } - return value -} + return { client: sdk, event: emitter } + }, +}) diff --git a/packages/desktop/src/context/shiki.tsx b/packages/desktop/src/context/shiki.tsx index 1930b907c..e70028419 100644 --- a/packages/desktop/src/context/shiki.tsx +++ b/packages/desktop/src/context/shiki.tsx @@ -1,5 +1,5 @@ +import { createSimpleContext } from "./helper" import { createHighlighter, type ThemeInput } from "shiki" -import { createContext, useContext, type ParentProps } from "solid-js" const theme: ThemeInput = { colors: { @@ -559,24 +559,14 @@ const theme: ThemeInput = { ], type: "dark", } - const highlighter = await createHighlighter({ themes: [theme], langs: [], }) -type ShikiContext = typeof highlighter - -const ctx = createContext<ShikiContext>() - -export function ShikiProvider(props: ParentProps) { - return <ctx.Provider value={highlighter}>{props.children}</ctx.Provider> -} - -export function useShiki() { - const value = useContext(ctx) - if (!value) { - throw new Error("useShiki must be used within a ShikiProvider") - } - return value -} +export const { use: useShiki, provider: ShikiProvider } = createSimpleContext({ + name: "Shiki", + init: () => { + return highlighter + }, +}) diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index 5ba6b1af2..0fea4a421 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -1,177 +1,162 @@ import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk" import { createStore, produce, reconcile } from "solid-js/store" -import { createContext, createMemo, Show, useContext, type ParentProps } from "solid-js" -import { useSDK, useEvent } from "@/context" +import { createMemo } from "solid-js" import { Binary } from "@/utils/binary" +import { createSimpleContext } from "./helper" +import { useSDK } from "./sdk" -function init() { - const [store, setStore] = createStore<{ - ready: boolean - provider: Provider[] - agent: Agent[] - project: Project - config: Config - path: Path - session: Session[] - message: { - [sessionID: string]: Message[] - } - part: { - [messageID: string]: Part[] - } - node: FileNode[] - changes: File[] - }>({ - project: { id: "", worktree: "", time: { created: 0, initialized: 0 } }, - config: {}, - path: { state: "", config: "", worktree: "", directory: "" }, - ready: false, - agent: [], - provider: [], - session: [], - message: {}, - part: {}, - node: [], - changes: [], - }) - - const bus = useEvent() - bus.listen((event) => { - switch (event.type) { - case "session.updated": { - const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) - if (result.found) { - setStore("session", result.index, reconcile(event.properties.info)) - break - } - setStore( - "session", - produce((draft) => { - draft.splice(result.index, 0, event.properties.info) - }), - ) - break +export const { use: useSync, provider: SyncProvider } = createSimpleContext({ + name: "Sync", + init: () => { + const [store, setStore] = createStore<{ + ready: boolean + provider: Provider[] + agent: Agent[] + project: Project + config: Config + path: Path + session: Session[] + message: { + [sessionID: string]: Message[] } - case "message.updated": { - const messages = store.message[event.properties.info.sessionID] - if (!messages) { - setStore("message", event.properties.info.sessionID, [event.properties.info]) - break - } - const result = Binary.search(messages, event.properties.info.id, (m) => m.id) - if (result.found) { - setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) + part: { + [messageID: string]: Part[] + } + node: FileNode[] + changes: File[] + }>({ + project: { id: "", worktree: "", time: { created: 0, initialized: 0 } }, + config: {}, + path: { state: "", config: "", worktree: "", directory: "" }, + ready: false, + agent: [], + provider: [], + session: [], + message: {}, + part: {}, + node: [], + changes: [], + }) + + const sdk = useSDK() + sdk.event.listen((e) => { + const event = e.details + switch (event.type) { + case "session.updated": { + const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) + if (result.found) { + setStore("session", result.index, reconcile(event.properties.info)) + break + } + setStore( + "session", + produce((draft) => { + draft.splice(result.index, 0, event.properties.info) + }), + ) break } - setStore( - "message", - event.properties.info.sessionID, - produce((draft) => { - draft.splice(result.index, 0, event.properties.info) - }), - ) - break - } - case "message.part.updated": { - const parts = store.part[event.properties.part.messageID] - if (!parts) { - setStore("part", event.properties.part.messageID, [event.properties.part]) + case "message.updated": { + const messages = store.message[event.properties.info.sessionID] + if (!messages) { + setStore("message", event.properties.info.sessionID, [event.properties.info]) + break + } + const result = Binary.search(messages, event.properties.info.id, (m) => m.id) + if (result.found) { + setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) + break + } + setStore( + "message", + event.properties.info.sessionID, + produce((draft) => { + draft.splice(result.index, 0, event.properties.info) + }), + ) break } - const result = Binary.search(parts, event.properties.part.id, (p) => p.id) - if (result.found) { - setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) + case "message.part.updated": { + const parts = store.part[event.properties.part.messageID] + if (!parts) { + setStore("part", event.properties.part.messageID, [event.properties.part]) + break + } + const result = Binary.search(parts, event.properties.part.id, (p) => p.id) + if (result.found) { + setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) + break + } + setStore( + "part", + event.properties.part.messageID, + produce((draft) => { + draft.splice(result.index, 0, event.properties.part) + }), + ) break } - setStore( - "part", - event.properties.part.messageID, - produce((draft) => { - draft.splice(result.index, 0, event.properties.part) - }), - ) - break } - } - }) - - const sdk = useSDK() + }) - const load = { - project: () => sdk.project.current().then((x) => setStore("project", x.data!)), - provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)), - path: () => sdk.path.get().then((x) => setStore("path", x.data!)), - agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), - session: () => - sdk.session.list().then((x) => - setStore( - "session", - (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)), + const load = { + project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)), + provider: () => sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)), + path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)), + agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])), + session: () => + sdk.client.session.list().then((x) => + setStore( + "session", + (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)), + ), ), - ), - config: () => sdk.config.get().then((x) => setStore("config", x.data!)), - changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)), - node: () => sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)), - } + config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)), + changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)), + node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)), + } - Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true)) + Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true)) - const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g")) - const sanitize = (text: string) => text.replace(sanitizer(), "") - const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") + const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g")) + const sanitize = (text: string) => text.replace(sanitizer(), "") + const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") - return { - data: store, - set: setStore, - session: { - get(sessionID: string) { - const match = Binary.search(store.session, sessionID, (s) => s.id) - if (match.found) return store.session[match.index] - return undefined + return { + data: store, + set: setStore, + get ready() { + return store.ready }, - async sync(sessionID: string) { - const [session, messages] = await Promise.all([ - sdk.session.get({ path: { id: sessionID } }), - sdk.session.messages({ path: { id: sessionID } }), - ]) - setStore( - produce((draft) => { - const match = Binary.search(draft.session, sessionID, (s) => s.id) - draft.session[match.index] = session.data! - draft.message[sessionID] = messages - .data!.map((x) => x.info) - .slice() - .sort((a, b) => a.id.localeCompare(b.id)) - for (const message of messages.data!) { - draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) - } - }), - ) + session: { + get(sessionID: string) { + const match = Binary.search(store.session, sessionID, (s) => s.id) + if (match.found) return store.session[match.index] + return undefined + }, + async sync(sessionID: string) { + const [session, messages] = await Promise.all([ + sdk.client.session.get({ path: { id: sessionID } }), + sdk.client.session.messages({ path: { id: sessionID } }), + ]) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, sessionID, (s) => s.id) + draft.session[match.index] = session.data! + draft.message[sessionID] = messages + .data!.map((x) => x.info) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)) + for (const message of messages.data!) { + draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) + } + }), + ) + }, }, - }, - load, - absolute, - sanitize, - } -} - -type SyncContext = ReturnType<typeof init> - -const ctx = createContext<SyncContext>() - -export function SyncProvider(props: ParentProps) { - const value = init() - return ( - <Show when={value.data.ready}> - <ctx.Provider value={value}>{props.children}</ctx.Provider> - </Show> - ) -} - -export function useSync() { - const value = useContext(ctx) - if (!value) { - throw new Error("useSync must be used within a SyncProvider") - } - return value -} + load, + absolute, + sanitize, + } + }, +}) |
