diff options
| author | David Hill <[email protected]> | 2025-12-16 10:54:51 +0000 |
|---|---|---|
| committer | David Hill <[email protected]> | 2025-12-16 10:54:51 +0000 |
| commit | 05e0759878cb0f24c981c69ae26f6be3ea5583c6 (patch) | |
| tree | 39aaf6b86a6f2b8e653120e80d8f0facb528051b /packages/desktop/src/context | |
| parent | 2330ec6dc3000ae8b86810e9d59b414ad4f05f47 (diff) | |
| parent | 75e5130cf8f58b32ee3f3ba2249d5917e7e3d6fc (diff) | |
| download | opencode-05e0759878cb0f24c981c69ae26f6be3ea5583c6.tar.gz opencode-05e0759878cb0f24c981c69ae26f6be3ea5583c6.zip | |
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/desktop/src/context')
| -rw-r--r-- | packages/desktop/src/context/command.tsx | 239 | ||||
| -rw-r--r-- | packages/desktop/src/context/global-sync.tsx | 51 | ||||
| -rw-r--r-- | packages/desktop/src/context/layout.tsx | 92 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 3 | ||||
| -rw-r--r-- | packages/desktop/src/context/notification.tsx | 26 | ||||
| -rw-r--r-- | packages/desktop/src/context/prompt.tsx | 112 | ||||
| -rw-r--r-- | packages/desktop/src/context/session.tsx | 321 | ||||
| -rw-r--r-- | packages/desktop/src/context/terminal.tsx | 106 |
8 files changed, 614 insertions, 336 deletions
diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx new file mode 100644 index 000000000..8fd76ee21 --- /dev/null +++ b/packages/desktop/src/context/command.tsx @@ -0,0 +1,239 @@ +import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" + +const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) + +export type KeybindConfig = string + +export interface Keybind { + key: string + ctrl: boolean + meta: boolean + shift: boolean + alt: boolean +} + +export interface CommandOption { + id: string + title: string + description?: string + category?: string + keybind?: KeybindConfig + slash?: string + suggested?: boolean + disabled?: boolean + onSelect?: (source?: "palette" | "keybind" | "slash") => void +} + +export function parseKeybind(config: string): Keybind[] { + if (!config || config === "none") return [] + + return config.split(",").map((combo) => { + const parts = combo.trim().toLowerCase().split("+") + const keybind: Keybind = { + key: "", + ctrl: false, + meta: false, + shift: false, + alt: false, + } + + for (const part of parts) { + switch (part) { + case "ctrl": + case "control": + keybind.ctrl = true + break + case "meta": + case "cmd": + case "command": + keybind.meta = true + break + case "mod": + if (IS_MAC) keybind.meta = true + else keybind.ctrl = true + break + case "alt": + case "option": + keybind.alt = true + break + case "shift": + keybind.shift = true + break + default: + keybind.key = part + break + } + } + + return keybind + }) +} + +export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean { + const eventKey = event.key.toLowerCase() + + for (const kb of keybinds) { + const keyMatch = kb.key === eventKey + const ctrlMatch = kb.ctrl === (event.ctrlKey || false) + const metaMatch = kb.meta === (event.metaKey || false) + const shiftMatch = kb.shift === (event.shiftKey || false) + const altMatch = kb.alt === (event.altKey || false) + + if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) { + return true + } + } + + return false +} + +export function formatKeybind(config: string): string { + if (!config || config === "none") return "" + + const keybinds = parseKeybind(config) + if (keybinds.length === 0) return "" + + const kb = keybinds[0] + const parts: string[] = [] + + if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl") + if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt") + if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift") + if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") + + if (kb.key) { + const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1) + parts.push(displayKey) + } + + return IS_MAC ? parts.join("") : parts.join("+") +} + +function DialogCommand(props: { options: CommandOption[] }) { + const dialog = useDialog() + + return ( + <Dialog title="Commands"> + <List + class="px-2.5" + search={{ placeholder: "Search commands", autofocus: true }} + emptyMessage="No commands found" + items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)} + key={(x) => x?.id} + filterKeys={["title", "description", "category"]} + groupBy={(x) => x.category ?? ""} + onSelect={(option) => { + if (option) { + dialog.close() + option.onSelect?.("palette") + } + }} + > + {(option) => ( + <div class="w-full flex items-center justify-between gap-4"> + <div class="flex items-center gap-2 min-w-0"> + <span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span> + <Show when={option.description}> + <span class="text-14-regular text-text-weak truncate">{option.description}</span> + </Show> + </div> + <Show when={option.keybind}> + <span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span> + </Show> + </div> + )} + </List> + </Dialog> + ) +} + +export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ + name: "Command", + init: () => { + const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([]) + const [suspendCount, setSuspendCount] = createSignal(0) + const dialog = useDialog() + + const options = createMemo(() => { + const all = registrations().flatMap((x) => x()) + const suggested = all.filter((x) => x.suggested && !x.disabled) + return [ + ...suggested.map((x) => ({ + ...x, + id: "suggested." + x.id, + category: "Suggested", + })), + ...all, + ] + }) + + const suspended = () => suspendCount() > 0 + + const showPalette = () => { + if (!dialog.active) { + dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />) + } + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (suspended()) return + + const paletteKeybinds = parseKeybind("mod+shift+p") + if (matchKeybind(paletteKeybinds, event)) { + event.preventDefault() + showPalette() + return + } + + for (const option of options()) { + if (option.disabled) continue + if (!option.keybind) continue + + const keybinds = parseKeybind(option.keybind) + if (matchKeybind(keybinds, event)) { + event.preventDefault() + option.onSelect?.("keybind") + return + } + } + } + + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + + return { + register(cb: () => CommandOption[]) { + const results = createMemo(cb) + setRegistrations((arr) => [results, ...arr]) + onCleanup(() => { + setRegistrations((arr) => arr.filter((x) => x !== results)) + }) + }, + trigger(id: string, source?: "palette" | "keybind" | "slash") { + for (const option of options()) { + if (option.id === id || option.id === "suggested." + id) { + option.onSelect?.(source) + return + } + } + }, + show: showPalette, + keybinds(enabled: boolean) { + setSuspendCount((count) => count + (enabled ? -1 : 1)) + }, + suspended, + get options() { + return options() + }, + } + }, +}) diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 8151a2c6f..ad3a3bf18 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -13,6 +13,7 @@ import { type SessionStatus, type ProviderListResponse, type ProviderAuthResponse, + type Command, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -24,6 +25,7 @@ import { onMount } from "solid-js" type State = { ready: boolean agent: Agent[] + command: Command[] project: string provider: ProviderListResponse config: Config @@ -79,6 +81,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple path: { state: "", config: "", worktree: "", directory: "", home: "" }, ready: false, agent: [], + command: [], session: [], session_status: {}, session_diff: {}, @@ -97,11 +100,17 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple async function loadSessions(directory: string) { globalSDK.client.session.list({ directory }).then((x) => { - const sessions = (x.data ?? []) + const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 + const nonArchived = (x.data ?? []) .slice() .filter((s) => !s.time.archived) .sort((a, b) => a.id.localeCompare(b.id)) - .slice(0, 5) + // Include at least 5 sessions, plus any updated in the last hour + const sessions = nonArchived.filter((s, i) => { + if (i < 5) return true + const updated = new Date(s.time.updated).getTime() + return updated > fourHoursAgo + }) const [, setStore] = child(directory) setStore("session", sessions) }) @@ -118,6 +127,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)), path: () => sdk.path.get().then((x) => setStore("path", x.data!)), agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), + command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])), session: () => loadSessions(directory), status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)), @@ -128,11 +138,12 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple } globalSDK.event.listen((e) => { + console.log(e) const directory = e.name const event = e.details if (directory === "global") { - switch (event.type) { + switch (event?.type) { case "global.disposed": { bootstrap() break @@ -216,6 +227,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.removed": { + const messages = store.message[event.properties.sessionID] + if (!messages) break + const result = Binary.search(messages, event.properties.messageID, (m) => m.id) + if (result.found) { + setStore( + "message", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } case "message.part.updated": { const part = event.properties.part const parts = store.part[part.messageID] @@ -237,6 +263,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.part.removed": { + const parts = store.part[event.properties.messageID] + if (!parts) break + const result = Binary.search(parts, event.properties.partID, (p) => p.id) + if (result.found) { + setStore( + "part", + event.properties.messageID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } } }) @@ -248,9 +289,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple globalSDK.client.project.list().then(async (x) => { setGlobalStore( "project", - x - .data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs) - .sort((a, b) => a.id.localeCompare(b.id)), + x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)), ) }), globalSDK.client.provider.list().then((x) => { diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 925bf4d4c..af71c6a00 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -1,5 +1,5 @@ -import { createStore } from "solid-js/store" -import { createMemo, onMount } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { batch, createMemo, onMount } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" import { useGlobalSync } from "./global-sync" @@ -22,6 +22,11 @@ export function getAvatarColors(key?: string) { } } +type SessionTabs = { + active?: string + all: string[] +} + export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -41,9 +46,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { state: "pane" as "pane" | "tab", }, + sessionTabs: {} as Record<string, SessionTabs>, }), { - name: "layout.v2", + name: "layout.v3", }, ) @@ -155,6 +161,86 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, + tabs(sessionKey: string) { + const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) + return { + tabs, + active: createMemo(() => tabs().active), + all: createMemo(() => tabs().all), + setActive(tab: string | undefined) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + setAll(all: string[]) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all, active: undefined }) + } else { + setStore("sessionTabs", sessionKey, "all", all) + } + }, + async open(tab: string) { + if (tab === "chat") { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: undefined }) + } else { + setStore("sessionTabs", sessionKey, "active", undefined) + } + return + } + const current = store.sessionTabs[sessionKey] ?? { all: [] } + if (tab !== "review") { + if (!current.all.includes(tab)) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [tab], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "all", [...current.all, tab]) + setStore("sessionTabs", sessionKey, "active", tab) + } + return + } + } + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + close(tab: string) { + const current = store.sessionTabs[sessionKey] + if (!current) return + batch(() => { + setStore( + "sessionTabs", + sessionKey, + "all", + current.all.filter((x) => x !== tab), + ) + if (current.active === tab) { + const index = current.all.findIndex((f) => f === tab) + const previous = current.all[Math.max(0, index - 1)] + setStore("sessionTabs", sessionKey, "active", previous) + } + }) + }, + move(tab: string, to: number) { + const current = store.sessionTabs[sessionKey] + if (!current) return + const index = current.all.findIndex((f) => f === tab) + if (index === -1) return + setStore( + "sessionTabs", + sessionKey, + "all", + produce((opened) => { + opened.splice(to, 0, opened.splice(index, 1)[0]) + }), + ) + }, + } + }, } }, }) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 56154c5ba..b12679210 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -249,6 +249,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ set(model: ModelKey | undefined, options?: { recent?: boolean }) { batch(() => { setEphemeral("model", agent.current().name, model ?? fallbackModel()) + if (model) updateVisibility(model, "show") if (options?.recent && model) { const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) if (uniq.length > 5) uniq.pop() @@ -405,7 +406,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ case "file.watcher.updated": const relativePath = relative(event.properties.file) if (relativePath.startsWith(".git/")) return - load(relativePath) + if (store.node[relativePath]) load(relativePath) break } }) diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx index 744e4fdf3..ee15bc34a 100644 --- a/packages/desktop/src/context/notification.tsx +++ b/packages/desktop/src/context/notification.tsx @@ -2,9 +2,12 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" import { useGlobalSDK } from "./global-sdk" +import { useGlobalSync } from "./global-sync" +import { Binary } from "@opencode-ai/util/binary" import { EventSessionError } from "@opencode-ai/sdk/v2" import { makeAudioPlayer } from "@solid-primitives/audio" import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" +import errorSound from "@opencode-ai/ui/audio/nope-03.aac" type NotificationBase = { directory?: string @@ -29,7 +32,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi name: "Notification", init: () => { const idlePlayer = makeAudioPlayer(idleSound) + const errorPlayer = makeAudioPlayer(errorSound) const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() const [store, setStore] = makePersisted( createStore({ @@ -46,6 +51,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi // }) globalSDK.event.listen((e) => { + console.log(e) const directory = e.name const event = e.details const base = { @@ -55,22 +61,32 @@ export const { use: useNotification, provider: NotificationProvider } = createSi } switch (event.type) { case "session.idle": { + const sessionID = event.properties.sessionID + const [syncStore] = globalSync.child(directory) + const match = Binary.search(syncStore.session, sessionID, (s) => s.id) + const isChild = match.found && syncStore.session[match.index].parentID + if (isChild) break idlePlayer.play() - const session = event.properties.sessionID setStore("list", store.list.length, { ...base, type: "turn-complete", - session, + session: sessionID, }) break } case "session.error": { - const session = event.properties.sessionID ?? "global" - // errorPlayer.play() + const sessionID = event.properties.sessionID + if (sessionID) { + const [syncStore] = globalSync.child(directory) + const match = Binary.search(syncStore.session, sessionID, (s) => s.id) + const isChild = match.found && syncStore.session[match.index].parentID + if (isChild) break + } + errorPlayer.play() setStore("list", store.list.length, { ...base, type: "error", - session, + session: sessionID ?? "global", error: "error" in event.properties ? event.properties.error : undefined, }) break diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx new file mode 100644 index 000000000..2da0a08d5 --- /dev/null +++ b/packages/desktop/src/context/prompt.tsx @@ -0,0 +1,112 @@ +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { makePersisted } from "@solid-primitives/storage" +import { useParams } from "@solidjs/router" +import { TextSelection } from "./local" + +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: TextSelection +} + +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart +export type Prompt = ContentPart[] + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + const partA = promptA[i] + const partB = promptB[i] + if (partA.type !== partB.type) return false + if (partA.type === "text" && partA.content !== (partB as TextPart).content) { + return false + } + if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { + return false + } + if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { + return false + } + } + return true +} + +function cloneSelection(selection?: TextSelection) { + if (!selection) return undefined + return { ...selection } +} + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + return { + ...part, + selection: cloneSelection(part.selection), + } +} + +function clonePrompt(prompt: Prompt): Prompt { + return prompt.map(clonePart) +} + +export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ + name: "Prompt", + init: () => { + const params = useParams() + const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore] = makePersisted( + createStore<{ + prompt: Prompt + cursor?: number + }>({ + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: undefined, + }), + { + name: name(), + }, + ) + + return { + current: createMemo(() => store.prompt), + cursor: createMemo(() => store.cursor), + dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } + }, +}) diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx deleted file mode 100644 index 860c1a14f..000000000 --- a/packages/desktop/src/context/session.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { createStore, produce } from "solid-js/store" -import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo } from "solid-js" -import { useSync } from "./sync" -import { makePersisted } from "@solid-primitives/storage" -import { TextSelection } from "./local" -import { pipe, sumBy } from "remeda" -import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" -import { useParams } from "@solidjs/router" -import { useSDK } from "./sdk" - -export type LocalPTY = { - id: string - title: string - rows?: number - cols?: number - buffer?: string - scrollY?: number -} - -export const { use: useSession, provider: SessionProvider } = createSimpleContext({ - name: "Session", - init: () => { - const sdk = useSDK() - const params = useParams() - const sync = useSync() - const name = createMemo(() => `${params.dir}/session${params.id ? "/" + params.id : ""}.v3`) - - const [store, setStore] = makePersisted( - createStore<{ - messageId?: string - tabs: { - active?: string - all: string[] - } - prompt: Prompt - cursor?: number - terminals: { - active?: string - all: LocalPTY[] - } - }>({ - tabs: { - all: [], - }, - prompt: clonePrompt(DEFAULT_PROMPT), - cursor: undefined, - terminals: { all: [] }, - }), - { - name: name(), - }, - ) - - createEffect(() => { - if (!params.id) return - sync.session.sync(params.id) - }) - - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const userMessages = createMemo(() => - messages() - .filter((m) => m.role === "user") - .sort((a, b) => a.id.localeCompare(b.id)), - ) - const lastUserMessage = createMemo(() => { - return userMessages()?.at(-1) - }) - const activeMessage = createMemo(() => { - if (!store.messageId) return lastUserMessage() - return userMessages()?.find((m) => m.id === store.messageId) - }) - const status = createMemo( - () => - sync.data.session_status[params.id ?? ""] ?? { - type: "idle", - }, - ) - const working = createMemo(() => status()?.type !== "idle") - - 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 last = createMemo( - () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, - ) - const model = createMemo(() => - last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, - ) - const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) - - const tokens = createMemo(() => { - if (!last()) return - const tokens = last().tokens - return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write - }) - - const context = createMemo(() => { - const total = tokens() - const limit = model()?.limit.context - if (!total || !limit) return 0 - return Math.round((total / limit) * 100) - }) - - return { - get id() { - return params.id - }, - info, - status, - working, - diffs, - prompt: { - current: createMemo(() => store.prompt), - cursor: createMemo(() => store.cursor), - dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), - set(prompt: Prompt, cursorPosition?: number) { - const next = clonePrompt(prompt) - batch(() => { - setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) - }, - }, - messages: { - all: messages, - user: userMessages, - last: lastUserMessage, - active: activeMessage, - setActive(message: UserMessage | undefined) { - setStore("messageId", message?.id) - }, - }, - usage: { - tokens, - cost, - context, - }, - layout: { - tabs: store.tabs, - setActiveTab(tab: string | undefined) { - setStore("tabs", "active", tab) - }, - setOpenedTabs(tabs: string[]) { - setStore("tabs", "all", tabs) - }, - async openTab(tab: string) { - if (tab === "chat") { - setStore("tabs", "active", undefined) - return - } - if (tab !== "review") { - if (!store.tabs.all.includes(tab)) { - setStore("tabs", "all", [...store.tabs.all, tab]) - } - } - setStore("tabs", "active", tab) - }, - closeTab(tab: string) { - batch(() => { - setStore( - "tabs", - "all", - store.tabs.all.filter((x) => x !== tab), - ) - if (store.tabs.active === tab) { - const index = store.tabs.all.findIndex((f) => f === tab) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("tabs", "active", previous) - } - }) - }, - moveTab(tab: string, to: number) { - const index = store.tabs.all.findIndex((f) => f === tab) - if (index === -1) return - setStore( - "tabs", - "all", - produce((opened) => { - opened.splice(to, 0, opened.splice(index, 1)[0]) - }), - ) - }, - }, - terminal: { - all: createMemo(() => Object.values(store.terminals.all)), - active: createMemo(() => store.terminals.active), - new() { - sdk.client.pty.create({ title: `Terminal ${store.terminals.all.length + 1}` }).then((pty) => { - const id = pty.data?.id - if (!id) return - setStore("terminals", "all", [ - ...store.terminals.all, - { - id, - title: pty.data?.title ?? "Terminal", - }, - ]) - setStore("terminals", "active", id) - }) - }, - update(pty: Partial<LocalPTY> & { id: string }) { - setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) - sdk.client.pty.update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - }, - async clone(id: string) { - const index = store.terminals.all.findIndex((x) => x.id === id) - const pty = store.terminals.all[index] - if (!pty) return - const clone = await sdk.client.pty.create({ - title: pty.title, - }) - if (!clone.data) return - setStore("terminals", "all", index, { - ...pty, - ...clone.data, - }) - if (store.terminals.active === pty.id) { - setStore("terminals", "active", clone.data.id) - } - }, - open(id: string) { - setStore("terminals", "active", id) - }, - async close(id: string) { - batch(() => { - setStore( - "terminals", - "all", - store.terminals.all.filter((x) => x.id !== id), - ) - if (store.terminals.active === id) { - const index = store.terminals.all.findIndex((f) => f.id === id) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("terminals", "active", previous) - } - }) - await sdk.client.pty.remove({ ptyID: id }) - }, - move(id: string, to: number) { - const index = store.terminals.all.findIndex((f) => f.id === id) - if (index === -1) return - setStore( - "terminals", - "all", - produce((all) => { - all.splice(to, 0, all.splice(index, 1)[0]) - }), - ) - }, - }, - } - }, -}) - -interface PartBase { - content: string - start: number - end: number -} - -export interface TextPart extends PartBase { - type: "text" -} - -export interface FileAttachmentPart extends PartBase { - type: "file" - path: string - selection?: TextSelection -} - -export type ContentPart = TextPart | FileAttachmentPart -export type Prompt = ContentPart[] - -export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] - -export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { - if (promptA.length !== promptB.length) return false - for (let i = 0; i < promptA.length; i++) { - const partA = promptA[i] - const partB = promptB[i] - if (partA.type !== partB.type) return false - if (partA.type === "text" && partA.content !== (partB as TextPart).content) { - return false - } - if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { - return false - } - } - return true -} - -function cloneSelection(selection?: TextSelection) { - if (!selection) return undefined - return { ...selection } -} - -function clonePart(part: ContentPart): ContentPart { - if (part.type === "text") return { ...part } - return { - ...part, - selection: cloneSelection(part.selection), - } -} - -function clonePrompt(prompt: Prompt): Prompt { - return prompt.map(clonePart) -} diff --git a/packages/desktop/src/context/terminal.tsx b/packages/desktop/src/context/terminal.tsx new file mode 100644 index 000000000..cf9b5a5b9 --- /dev/null +++ b/packages/desktop/src/context/terminal.tsx @@ -0,0 +1,106 @@ +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { makePersisted } from "@solid-primitives/storage" +import { useParams } from "@solidjs/router" +import { useSDK } from "./sdk" + +export type LocalPTY = { + id: string + title: string + rows?: number + cols?: number + buffer?: string + scrollY?: number +} + +export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ + name: "Terminal", + init: () => { + const sdk = useSDK() + const params = useParams() + const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore] = makePersisted( + createStore<{ + active?: string + all: LocalPTY[] + }>({ + all: [], + }), + { + name: name(), + }, + ) + + return { + all: createMemo(() => Object.values(store.all)), + active: createMemo(() => store.active), + new() { + sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => { + const id = pty.data?.id + if (!id) return + setStore("all", [ + ...store.all, + { + id, + title: pty.data?.title ?? "Terminal", + }, + ]) + setStore("active", id) + }) + }, + update(pty: Partial<LocalPTY> & { id: string }) { + setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) + sdk.client.pty.update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + }, + async clone(id: string) { + const index = store.all.findIndex((x) => x.id === id) + const pty = store.all[index] + if (!pty) return + const clone = await sdk.client.pty.create({ + title: pty.title, + }) + if (!clone.data) return + setStore("all", index, { + ...pty, + ...clone.data, + }) + if (store.active === pty.id) { + setStore("active", clone.data.id) + } + }, + open(id: string) { + setStore("active", id) + }, + async close(id: string) { + batch(() => { + setStore( + "all", + store.all.filter((x) => x.id !== id), + ) + if (store.active === id) { + const index = store.all.findIndex((f) => f.id === id) + const previous = store.all[Math.max(0, index - 1)] + setStore("active", previous?.id) + } + }) + await sdk.client.pty.remove({ ptyID: id }) + }, + move(id: string, to: number) { + const index = store.all.findIndex((f) => f.id === id) + if (index === -1) return + setStore( + "all", + produce((all) => { + all.splice(to, 0, all.splice(index, 1)[0]) + }), + ) + }, + } + }, +}) |
