import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" import { checksum } from "@opencode-ai/util/encode" 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?: FileSelection } export interface AgentPart extends PartBase { type: "agent" name: string } export interface ImageAttachmentPart { type: "image" id: string filename: string mime: string dataUrl: string } export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart export type Prompt = ContentPart[] export type FileContextItem = { type: "file" path: string selection?: FileSelection comment?: string commentID?: string commentOrigin?: "review" | "file" preview?: string } export type ContextItem = FileContextItem export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] function isSelectionEqual(a?: FileSelection, b?: FileSelection) { if (!a && !b) return true if (!a || !b) return false return ( a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar ) } 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") { const fileA = partA as FileAttachmentPart const fileB = partB as FileAttachmentPart if (fileA.path !== fileB.path) return false if (!isSelectionEqual(fileA.selection, fileB.selection)) return false } if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) { return false } if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { return false } } return true } function cloneSelection(selection?: FileSelection) { if (!selection) return undefined return { ...selection } } function clonePart(part: ContentPart): ContentPart { if (part.type === "text") return { ...part } if (part.type === "image") return { ...part } if (part.type === "agent") return { ...part } return { ...part, selection: cloneSelection(part.selection), } } function clonePrompt(prompt: Prompt): Prompt { return prompt.map(clonePart) } const WORKSPACE_KEY = "__workspace__" const MAX_PROMPT_SESSIONS = 20 type PromptSession = ReturnType type PromptCacheEntry = { value: PromptSession dispose: VoidFunction } function createPromptSession(dir: string, id: string | undefined) { const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2` const [store, setStore, _, ready] = persisted( Persist.scoped(dir, id, "prompt", [legacy]), createStore<{ prompt: Prompt cursor?: number context: { items: (ContextItem & { key: string })[] } }>({ prompt: clonePrompt(DEFAULT_PROMPT), cursor: undefined, context: { items: [], }, }), ) function keyForItem(item: ContextItem) { if (item.type !== "file") return item.type const start = item.selection?.startLine const end = item.selection?.endLine const key = `${item.type}:${item.path}:${start}:${end}` if (item.commentID) { return `${key}:c=${item.commentID}` } const comment = item.comment?.trim() if (!comment) return key const digest = checksum(comment) ?? comment return `${key}:c=${digest.slice(0, 8)}` } return { ready, current: createMemo(() => store.prompt), cursor: createMemo(() => store.cursor), dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), context: { items: createMemo(() => store.context.items), add(item: ContextItem) { const key = keyForItem(item) if (store.context.items.find((x) => x.key === key)) return setStore("context", "items", (items) => [...items, { key, ...item }]) }, remove(key: string) { setStore("context", "items", (items) => items.filter((x) => x.key !== key)) }, }, 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) }) }, } } export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ name: "Prompt", gate: false, init: () => { 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_PROMPT_SESSIONS) { const first = cache.keys().next().value if (!first) return const entry = cache.get(first) entry?.dispose() cache.delete(first) } } const load = (dir: string, id: string | undefined) => { const key = `${dir}:${id ?? WORKSPACE_KEY}` const existing = cache.get(key) if (existing) { cache.delete(key) cache.set(key, existing) return existing.value } const entry = createRoot((dispose) => ({ value: createPromptSession(dir, id), dispose, })) cache.set(key, entry) prune() return entry.value } const session = createMemo(() => load(params.dir!, params.id)) return { ready: () => session().ready(), current: () => session().current(), cursor: () => session().cursor(), dirty: () => session().dirty(), context: { items: () => session().context.items(), add: (item: ContextItem) => session().context.add(item), remove: (key: string) => session().context.remove(key), }, set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition), reset: () => session().reset(), } }, })