summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src/context
diff options
context:
space:
mode:
Diffstat (limited to 'packages/desktop/src/context')
-rw-r--r--packages/desktop/src/context/command.tsx239
-rw-r--r--packages/desktop/src/context/global-sync.tsx51
-rw-r--r--packages/desktop/src/context/layout.tsx92
-rw-r--r--packages/desktop/src/context/local.tsx3
-rw-r--r--packages/desktop/src/context/notification.tsx26
-rw-r--r--packages/desktop/src/context/prompt.tsx112
-rw-r--r--packages/desktop/src/context/session.tsx321
-rw-r--r--packages/desktop/src/context/terminal.tsx106
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])
+ }),
+ )
+ },
+ }
+ },
+})