import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" import { Persist, persisted } from "@/utils/persist" export type LocalPTY = { id: string title: string titleNumber: number rows?: number cols?: number buffer?: string scrollY?: number error?: boolean } const WORKSPACE_KEY = "__workspace__" const MAX_TERMINAL_SESSIONS = 20 type TerminalSession = ReturnType type TerminalCacheEntry = { value: TerminalSession dispose: VoidFunction } function createTerminalSession(sdk: ReturnType, dir: string, session?: string) { const legacy = session ? [`${dir}/terminal/${session}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`] const numberFromTitle = (title: string) => { const match = title.match(/^Terminal (\d+)$/) if (!match) return const value = Number(match[1]) if (!Number.isFinite(value) || value <= 0) return return value } const [store, setStore, _, ready] = persisted( Persist.workspace(dir, "terminal", legacy), createStore<{ active?: string all: LocalPTY[] }>({ all: [], }), ) const unsub = sdk.event.on("pty.exited", (event) => { const id = event.properties.id if (!store.all.some((x) => x.id === id)) return batch(() => { setStore( "all", store.all.filter((x) => x.id !== id), ) if (store.active === id) { const remaining = store.all.filter((x) => x.id !== id) setStore("active", remaining[0]?.id) } }) }) onCleanup(unsub) const meta = { migrated: false } createEffect(() => { if (!ready()) return if (meta.migrated) return meta.migrated = true setStore("all", (all) => { const next = all.map((pty) => { const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined if (direct !== undefined) return pty const parsed = numberFromTitle(pty.title) if (parsed === undefined) return pty return { ...pty, titleNumber: parsed } }) if (next.every((pty, index) => pty === all[index])) return all return next }) }) return { ready, all: createMemo(() => Object.values(store.all)), active: createMemo(() => store.active), new() { const existingTitleNumbers = new Set( store.all.flatMap((pty) => { const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined if (direct !== undefined) return [direct] const parsed = numberFromTitle(pty.title) if (parsed === undefined) return [] return [parsed] }), ) const nextNumber = Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find( (number) => !existingTitleNumbers.has(number), ) ?? 1 sdk.client.pty .create({ title: `Terminal ${nextNumber}` }) .then((pty) => { const id = pty.data?.id if (!id) return const newTerminal = { id, title: pty.data?.title ?? "Terminal", titleNumber: nextNumber, } setStore("all", (all) => { const newAll = [...all, newTerminal] return newAll }) setStore("active", id) }) .catch((e) => { console.error("Failed to create terminal", e) }) }, update(pty: Partial & { id: string }) { const index = store.all.findIndex((x) => x.id === pty.id) if (index !== -1) { setStore("all", index, (existing) => ({ ...existing, ...pty })) } sdk.client.pty .update({ ptyID: pty.id, title: pty.title, size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, }) .catch((e) => { console.error("Failed to update terminal", e) }) }, 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, }) .catch((e) => { console.error("Failed to clone terminal", e) return undefined }) 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) }, next() { const index = store.all.findIndex((x) => x.id === store.active) if (index === -1) return const nextIndex = (index + 1) % store.all.length setStore("active", store.all[nextIndex]?.id) }, previous() { const index = store.all.findIndex((x) => x.id === store.active) if (index === -1) return const prevIndex = index === 0 ? store.all.length - 1 : index - 1 setStore("active", store.all[prevIndex]?.id) }, async close(id: string) { batch(() => { const filtered = store.all.filter((x) => x.id !== id) if (store.active === id) { const index = store.all.findIndex((f) => f.id === id) const next = index > 0 ? index - 1 : 0 setStore("active", filtered[next]?.id) } setStore("all", filtered) }) await sdk.client.pty.remove({ ptyID: id }).catch((e) => { console.error("Failed to close terminal", e) }) }, 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]) }), ) }, } } export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ name: "Terminal", gate: false, init: () => { const sdk = useSDK() const params = useParams() const cache = new Map() const disposeAll = () => { for (const entry of cache.values()) { entry.dispose() } cache.clear() } onCleanup(disposeAll) const prune = () => { while (cache.size > MAX_TERMINAL_SESSIONS) { const first = cache.keys().next().value if (!first) return const entry = cache.get(first) entry?.dispose() cache.delete(first) } } const load = (dir: string, session?: string) => { const key = `${dir}:${WORKSPACE_KEY}` const existing = cache.get(key) if (existing) { cache.delete(key) cache.set(key, existing) return existing.value } const entry = createRoot((dispose) => ({ value: createTerminalSession(sdk, dir, session), dispose, })) cache.set(key, entry) prune() return entry.value } const workspace = createMemo(() => load(params.dir!, params.id)) return { ready: () => workspace().ready(), all: () => workspace().all(), active: () => workspace().active(), new: () => workspace().new(), update: (pty: Partial & { id: string }) => workspace().update(pty), clone: (id: string) => workspace().clone(id), open: (id: string) => workspace().open(id), close: (id: string) => workspace().close(id), move: (id: string, to: number) => workspace().move(id, to), next: () => workspace().next(), previous: () => workspace().previous(), } }, })