From de86d73d19cf09b131f7c219fffa46fa6219b191 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 11 Jul 2026 17:56:25 -0400 Subject: [PATCH] feat(tui): add v2 plugin context --- packages/plugin/package.json | 1 + packages/plugin/src/v2/tui/context.ts | 95 +++++++++++++++++++++++ packages/tui/src/context/data.tsx | 68 +++++++++++++--- packages/tui/src/routes/session/index.tsx | 10 +-- packages/tui/test/cli/tui/data.test.tsx | 28 +++++-- 5 files changed, 175 insertions(+), 27 deletions(-) create mode 100644 packages/plugin/src/v2/tui/context.ts diff --git a/packages/plugin/package.json b/packages/plugin/package.json index bc94f1024cf..65b6bcb9d63 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -15,6 +15,7 @@ "./tui": "./src/tui.ts", "./v2/effect": "./src/v2/effect/index.ts", "./v2/effect/*": "./src/v2/effect/*.ts", + "./v2/tui/*": "./src/v2/tui/*.ts", "./v2": "./src/v2/promise/index.ts", "./v2/*": "./src/v2/promise/*.ts" }, diff --git a/packages/plugin/src/v2/tui/context.ts b/packages/plugin/src/v2/tui/context.ts new file mode 100644 index 00000000000..726bb4b4b23 --- /dev/null +++ b/packages/plugin/src/v2/tui/context.ts @@ -0,0 +1,95 @@ +import type { + AgentInfo, + CommandInfo, + FormInfo, + IntegrationInfo, + LocationRef, + McpResource, + McpServer, + ModelInfo, + OpenCodeClient, + OpenCodeEvent, + PermissionSavedInfo, + PermissionV2Request, + ProviderV2Info, + ReferenceInfo, + SessionInfo, + SessionMessageInfo, + SessionPendingInfo, + ShellInfo, + SkillInfo, +} from "@opencode-ai/client" + +interface LocationCollection { + list(location?: LocationRef): Value[] | undefined + refresh(location?: LocationRef): Promise +} + +export interface Data { + readonly on: ( + type: Type, + handler: (event: Extract) => void, + ) => () => void + readonly listen: (handler: (event: { details: OpenCodeEvent }) => void) => () => void + readonly session: { + list(): SessionInfo[] + get(sessionID: string): SessionInfo | undefined + root(sessionID: string): string + family(sessionID: string): string[] + cost(sessionID: string): number + status(sessionID: string): "idle" | "running" + readonly pending: { + list(sessionID: string): SessionPendingInfo[] + refresh(sessionID: string): Promise + } + refresh(sessionID: string): Promise + readonly message: { + list(sessionID: string): SessionMessageInfo[] + get(sessionID: string, messageID: string): SessionMessageInfo | undefined + refresh(sessionID: string): Promise + } + readonly permission: { + list(sessionID: string): PermissionV2Request[] | undefined + refresh(sessionID: string): Promise + } + readonly form: { + list(sessionID: string, location?: LocationRef): Array | undefined + refresh(sessionID: string, location?: LocationRef): Promise + } + } + readonly project: { + readonly permission: { + list(projectID: string): PermissionSavedInfo[] | undefined + refresh(projectID: string): Promise + } + } + readonly shell: { + list(location?: LocationRef): ShellInfo[] + get(id: string): ShellInfo | undefined + refresh(location?: LocationRef): Promise + } + readonly location: { + default(): LocationRef + refresh(location?: LocationRef): Promise + readonly agent: LocationCollection + readonly command: LocationCollection + readonly integration: LocationCollection + readonly mcp: { + readonly server: LocationCollection + readonly resource: LocationCollection + } + readonly model: LocationCollection + readonly provider: LocationCollection + readonly reference: LocationCollection + readonly skill: LocationCollection + } +} + +export interface UI {} + +export interface Context { + readonly options: Record + readonly client: OpenCodeClient + readonly data: Data + readonly ui: UI +} diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 601db51dddd..c5f4fddd853 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -22,10 +22,12 @@ import type { SessionMessageAssistantText, SessionMessageAssistantTool, SessionInfo, + SessionPendingInfo, ShellInfo, SkillInfo, OpenCodeEvent, } from "@opencode-ai/client" +import type { Data } from "@opencode-ai/plugin/v2/tui/context" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" @@ -57,7 +59,7 @@ type LocationData = { skill?: SkillInfo[] } -type Data = { +type Store = { session: { info: Record // Family index keyed by a family's root (or furthest-known-ancestor when the @@ -66,6 +68,7 @@ type Data = { family: Record status: Record message: Record + pending: Record input: Record compaction: Record permission: Record @@ -89,12 +92,13 @@ function locationQuery(ref?: LocationRef) { export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: () => { - const [store, setStore] = createStore({ + const [store, setStore] = createStore({ session: { info: {}, family: {}, status: {}, message: {}, + pending: {}, input: {}, compaction: {}, permission: {}, @@ -123,6 +127,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "compaction", sessionID, [...(store.session.compaction[sessionID] ?? []), inputID]) } + function addPending(item: SessionPendingInfo) { + if (store.session.pending[item.sessionID]?.some((pending) => pending.id === item.id)) return + setStore("session", "pending", item.sessionID, [...(store.session.pending[item.sessionID] ?? []), item]) + } + + function removePending(sessionID: string, inputID?: string) { + if (!inputID) return + setStore( + "session", + "pending", + sessionID, + (store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID), + ) + } + function removeCompaction(sessionID: string, inputID?: string) { if (!inputID || !store.session.compaction[sessionID]?.includes(inputID)) return setStore( @@ -241,6 +260,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ delete draft.info[sessionID] delete draft.status[sessionID] delete draft.message[sessionID] + delete draft.pending[sessionID] delete draft.input[sessionID] delete draft.compaction[sessionID] delete draft.permission[sessionID] @@ -330,6 +350,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } break case "session.input.promoted": { + removePending(event.data.sessionID, event.data.inputID) message.update(event.data.sessionID, (draft, index) => { const position = index.get(event.data.inputID) if (position === undefined) return @@ -350,6 +371,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break } case "session.input.admitted": + addPending({ + id: event.data.inputID, + sessionID: event.data.sessionID, + admittedSeq: event.durable.seq, + timeCreated: event.created, + ...event.data.input, + }) if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID)) setStore("session", "input", event.data.sessionID, [ ...(store.session.input[event.data.sessionID] ?? []), @@ -633,9 +661,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setSessionStatus(event.data.sessionID, "running") break case "session.compaction.admitted": + addPending({ + id: event.data.inputID, + sessionID: event.data.sessionID, + admittedSeq: event.durable.seq, + timeCreated: event.created, + type: "compaction", + }) addCompaction(event.data.sessionID, event.data.inputID) break case "session.compaction.started": + removePending(event.data.sessionID, event.data.inputID) removeCompaction(event.data.sessionID, event.data.inputID) message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { @@ -713,6 +749,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.compaction.failed": + removePending(event.data.sessionID, event.data.inputID) removeCompaction(event.data.sessionID, event.data.inputID) message.update(event.data.sessionID, (draft, index) => { const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") @@ -854,16 +891,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.compaction[sessionID] ?? [] }, async refresh(sessionID: string) { - if (!store.session.compaction[sessionID]) setStore("session", "compaction", sessionID, []) + await result.session.pending.refresh(sessionID) + }, + }, + pending: { + list(sessionID: string) { + return store.session.pending[sessionID] ?? [] + }, + async refresh(sessionID: string) { + const pending = await sdk.api.session.pending.list({ sessionID }) + setStore("session", "pending", sessionID, reconcile(pending)) + setStore( + "session", + "input", + sessionID, + reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)), + ) setStore( "session", "compaction", sessionID, - reconcile( - (await sdk.api.session.pending.list({ sessionID })) - .filter((item) => item.type === "compaction") - .map((item) => item.id), - ), + reconcile(pending.filter((item) => item.type === "compaction").map((item) => item.id)), ) }, }, @@ -872,9 +920,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ registerSession(sessionID) }, message: { - ids(sessionID: string) { - return (store.session.message[sessionID] ?? []).map((message) => message.id) - }, list(sessionID: string) { return store.session.message[sessionID] ?? [] }, @@ -1063,6 +1108,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, } + result satisfies Data async function bootstrap() { if (bootstrapping) return bootstrapping diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index b0634114578..bce2a7b33c9 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -160,12 +160,7 @@ export function Session() { const { theme } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => data.session.get(route.sessionID)) - const messageIDs = createMemo(() => data.session.message.ids(route.sessionID)) - const sessionMessages = () => - messageIDs().flatMap((id) => { - const message = data.session.message.get(route.sessionID, id) - return message ? [message] : [] - }) + const messages = () => data.session.message.list(route.sessionID) const location = createMemo(() => session()?.location) createEffect(() => { @@ -173,7 +168,6 @@ export function Session() { setEpilogue(sessionEpilogue({ title, sessionID: session()?.id })) }) onCleanup(() => setEpilogue()) - const messages = sessionMessages const descendantSessionIDs = createMemo(() => { if (session()?.parentID) return [] return data.session.family(route.sessionID).filter((id) => id !== route.sessionID) @@ -649,7 +643,7 @@ export function Session() { category: "Session", hidden: true, run: () => { - const messages = sessionMessages() + const messages = data.session.message.list(route.sessionID) if (!messages || !messages.length) return // Find the most recent user message with non-ignored, non-synthetic text parts diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 9e25cc56e90..416085bdb4d 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -111,7 +111,7 @@ test("refreshes resources into reactive getters", async () => { await data.location.agent.refresh() expect(data.session.get("ses_test")?.title).toBe("Test session") - expect(data.session.message.ids("ses_test")).toEqual(["msg_first", "msg_second"]) + expect(data.session.message.list("ses_test").map((message) => message.id)).toEqual(["msg_first", "msg_second"]) expect(data.session.message.get("ses_test", "msg_second")?.id).toBe("msg_second") await app.renderOnce() expect(app.captureCharFrame()).toContain("msg_second") @@ -330,9 +330,9 @@ test("truncates committed revert messages without changing lifetime usage", asyn durable: durable(sessionID, 6), data: { sessionID, to: "msg_revert_later" }, }) - await wait(() => data.session.message.ids(sessionID).length === 1) + await wait(() => data.session.message.list(sessionID).length === 1) expect(data.session.get(sessionID)?.cost).toBe(0.75) - expect(data.session.message.ids(sessionID)).toEqual(["msg_revert_boundary"]) + expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual(["msg_revert_boundary"]) expect(data.session.get(sessionID)?.revert).toBeUndefined() expect(data.session.get(sessionID)?.tokens).toEqual(tokens) } finally { @@ -688,7 +688,7 @@ test("removes committed revert messages from local state", async () => { data: { sessionID, inputID, input: { type: "user", data: { text: inputID }, delivery: "steer" } }, }) } - await wait(() => data.session.message.ids(sessionID).length === 3) + await wait(() => data.session.message.list(sessionID).length === 3) emitEvent(events, { id: EventV2.ID.create(), @@ -698,8 +698,8 @@ test("removes committed revert messages from local state", async () => { data: { sessionID, to: "msg_002" }, }) - await wait(() => data.session.message.ids(sessionID).length === 1) - expect(data.session.message.ids(sessionID)).toEqual(["msg_001"]) + await wait(() => data.session.message.list(sessionID).length === 1) + expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual(["msg_001"]) expect(data.session.message.get(sessionID, "msg_002")).toBeUndefined() expect(data.session.message.get(sessionID, "msg_003")).toBeUndefined() } finally { @@ -2239,6 +2239,17 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn const admitted = sync.session.message.list(sessionID)?.[0] expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" }) expect(admitted?.metadata).toBeUndefined() + expect(sync.session.pending.list(sessionID)).toEqual([ + { + id: messageID, + sessionID, + admittedSeq: 0, + timeCreated: 0, + type: "user", + data: { text: "hello" }, + delivery: "steer", + }, + ]) expect(sync.session.input.list(sessionID)).toEqual([messageID]) await sync.session.message.refresh(sessionID) @@ -2263,9 +2274,10 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn if (message?.type !== "user") return expect(message).toMatchObject({ id: messageID, text: "hello" }) expect(message.metadata).toBeUndefined() + expect(sync.session.pending.list(sessionID)).toEqual([]) expect(sync.session.input.list(sessionID)).toEqual([]) - expect(sync.session.message.ids(sessionID)).toEqual([messageID]) - expect(sync.session.message.ids("missing")).toEqual([]) + expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) + expect(sync.session.message.list("missing")).toEqual([]) expect(sync.session.message.get(sessionID, messageID)).toBe(message) expect(sync.session.message.get(sessionID, "missing")).toBeUndefined() expect(received).toHaveLength(3)