Bladeren bron

feat(tui): add v2 plugin context

Dax Raad 1 maand geleden
bovenliggende
commit
de86d73d19

+ 1 - 0
packages/plugin/package.json

@@ -15,6 +15,7 @@
     "./tui": "./src/tui.ts",
     "./tui": "./src/tui.ts",
     "./v2/effect": "./src/v2/effect/index.ts",
     "./v2/effect": "./src/v2/effect/index.ts",
     "./v2/effect/*": "./src/v2/effect/*.ts",
     "./v2/effect/*": "./src/v2/effect/*.ts",
+    "./v2/tui/*": "./src/v2/tui/*.ts",
     "./v2": "./src/v2/promise/index.ts",
     "./v2": "./src/v2/promise/index.ts",
     "./v2/*": "./src/v2/promise/*.ts"
     "./v2/*": "./src/v2/promise/*.ts"
   },
   },

+ 95 - 0
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<Value> {
+  list(location?: LocationRef): Value[] | undefined
+  refresh(location?: LocationRef): Promise<void>
+}
+
+export interface Data {
+  readonly on: <Type extends OpenCodeEvent["type"]>(
+    type: Type,
+    handler: (event: Extract<OpenCodeEvent, { type: Type }>) => 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<void>
+    }
+    refresh(sessionID: string): Promise<void>
+    readonly message: {
+      list(sessionID: string): SessionMessageInfo[]
+      get(sessionID: string, messageID: string): SessionMessageInfo | undefined
+      refresh(sessionID: string): Promise<void>
+    }
+    readonly permission: {
+      list(sessionID: string): PermissionV2Request[] | undefined
+      refresh(sessionID: string): Promise<void>
+    }
+    readonly form: {
+      list(sessionID: string, location?: LocationRef): Array<FormInfo & { readonly location?: LocationRef }> | undefined
+      refresh(sessionID: string, location?: LocationRef): Promise<void>
+    }
+  }
+  readonly project: {
+    readonly permission: {
+      list(projectID: string): PermissionSavedInfo[] | undefined
+      refresh(projectID: string): Promise<void>
+    }
+  }
+  readonly shell: {
+    list(location?: LocationRef): ShellInfo[]
+    get(id: string): ShellInfo | undefined
+    refresh(location?: LocationRef): Promise<void>
+  }
+  readonly location: {
+    default(): LocationRef
+    refresh(location?: LocationRef): Promise<void>
+    readonly agent: LocationCollection<AgentInfo>
+    readonly command: LocationCollection<CommandInfo>
+    readonly integration: LocationCollection<IntegrationInfo>
+    readonly mcp: {
+      readonly server: LocationCollection<McpServer>
+      readonly resource: LocationCollection<McpResource>
+    }
+    readonly model: LocationCollection<ModelInfo>
+    readonly provider: LocationCollection<ProviderV2Info>
+    readonly reference: LocationCollection<ReferenceInfo>
+    readonly skill: LocationCollection<SkillInfo>
+  }
+}
+
+export interface UI {}
+
+export interface Context {
+  readonly options: Record<string, any>
+  readonly client: OpenCodeClient
+  readonly data: Data
+  readonly ui: UI
+}

+ 57 - 11
packages/tui/src/context/data.tsx

@@ -22,10 +22,12 @@ import type {
   SessionMessageAssistantText,
   SessionMessageAssistantText,
   SessionMessageAssistantTool,
   SessionMessageAssistantTool,
   SessionInfo,
   SessionInfo,
+  SessionPendingInfo,
   ShellInfo,
   ShellInfo,
   SkillInfo,
   SkillInfo,
   OpenCodeEvent,
   OpenCodeEvent,
 } from "@opencode-ai/client"
 } from "@opencode-ai/client"
+import type { Data } from "@opencode-ai/plugin/v2/tui/context"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { createSimpleContext } from "./helper"
 import { createSimpleContext } from "./helper"
 import { useSDK } from "./sdk"
 import { useSDK } from "./sdk"
@@ -57,7 +59,7 @@ type LocationData = {
   skill?: SkillInfo[]
   skill?: SkillInfo[]
 }
 }
 
 
-type Data = {
+type Store = {
   session: {
   session: {
     info: Record<string, SessionInfo>
     info: Record<string, SessionInfo>
     // Family index keyed by a family's root (or furthest-known-ancestor when the
     // Family index keyed by a family's root (or furthest-known-ancestor when the
@@ -66,6 +68,7 @@ type Data = {
     family: Record<string, string[]>
     family: Record<string, string[]>
     status: Record<string, DataSessionStatus>
     status: Record<string, DataSessionStatus>
     message: Record<string, SessionMessageInfo[]>
     message: Record<string, SessionMessageInfo[]>
+    pending: Record<string, SessionPendingInfo[]>
     input: Record<string, string[]>
     input: Record<string, string[]>
     compaction: Record<string, string[]>
     compaction: Record<string, string[]>
     permission: Record<string, PermissionV2Request[]>
     permission: Record<string, PermissionV2Request[]>
@@ -89,12 +92,13 @@ function locationQuery(ref?: LocationRef) {
 export const { use: useData, provider: DataProvider } = createSimpleContext({
 export const { use: useData, provider: DataProvider } = createSimpleContext({
   name: "Data",
   name: "Data",
   init: () => {
   init: () => {
-    const [store, setStore] = createStore<Data>({
+    const [store, setStore] = createStore<Store>({
       session: {
       session: {
         info: {},
         info: {},
         family: {},
         family: {},
         status: {},
         status: {},
         message: {},
         message: {},
+        pending: {},
         input: {},
         input: {},
         compaction: {},
         compaction: {},
         permission: {},
         permission: {},
@@ -123,6 +127,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
       setStore("session", "compaction", sessionID, [...(store.session.compaction[sessionID] ?? []), inputID])
       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) {
     function removeCompaction(sessionID: string, inputID?: string) {
       if (!inputID || !store.session.compaction[sessionID]?.includes(inputID)) return
       if (!inputID || !store.session.compaction[sessionID]?.includes(inputID)) return
       setStore(
       setStore(
@@ -241,6 +260,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           delete draft.info[sessionID]
           delete draft.info[sessionID]
           delete draft.status[sessionID]
           delete draft.status[sessionID]
           delete draft.message[sessionID]
           delete draft.message[sessionID]
+          delete draft.pending[sessionID]
           delete draft.input[sessionID]
           delete draft.input[sessionID]
           delete draft.compaction[sessionID]
           delete draft.compaction[sessionID]
           delete draft.permission[sessionID]
           delete draft.permission[sessionID]
@@ -330,6 +350,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           }
           }
           break
           break
         case "session.input.promoted": {
         case "session.input.promoted": {
+          removePending(event.data.sessionID, event.data.inputID)
           message.update(event.data.sessionID, (draft, index) => {
           message.update(event.data.sessionID, (draft, index) => {
             const position = index.get(event.data.inputID)
             const position = index.get(event.data.inputID)
             if (position === undefined) return
             if (position === undefined) return
@@ -350,6 +371,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           break
           break
         }
         }
         case "session.input.admitted":
         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))
           if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID))
             setStore("session", "input", event.data.sessionID, [
             setStore("session", "input", event.data.sessionID, [
               ...(store.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")
           setSessionStatus(event.data.sessionID, "running")
           break
           break
         case "session.compaction.admitted":
         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)
           addCompaction(event.data.sessionID, event.data.inputID)
           break
           break
         case "session.compaction.started":
         case "session.compaction.started":
+          removePending(event.data.sessionID, event.data.inputID)
           removeCompaction(event.data.sessionID, event.data.inputID)
           removeCompaction(event.data.sessionID, event.data.inputID)
           message.update(event.data.sessionID, (draft, index) => {
           message.update(event.data.sessionID, (draft, index) => {
             message.append(draft, index, {
             message.append(draft, index, {
@@ -713,6 +749,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           })
           })
           break
           break
         case "session.compaction.failed":
         case "session.compaction.failed":
+          removePending(event.data.sessionID, event.data.inputID)
           removeCompaction(event.data.sessionID, event.data.inputID)
           removeCompaction(event.data.sessionID, event.data.inputID)
           message.update(event.data.sessionID, (draft, index) => {
           message.update(event.data.sessionID, (draft, index) => {
             const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
             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] ?? []
             return store.session.compaction[sessionID] ?? []
           },
           },
           async refresh(sessionID: string) {
           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(
             setStore(
               "session",
               "session",
               "compaction",
               "compaction",
               sessionID,
               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)
           registerSession(sessionID)
         },
         },
         message: {
         message: {
-          ids(sessionID: string) {
-            return (store.session.message[sessionID] ?? []).map((message) => message.id)
-          },
           list(sessionID: string) {
           list(sessionID: string) {
             return store.session.message[sessionID] ?? []
             return store.session.message[sessionID] ?? []
           },
           },
@@ -1063,6 +1108,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         },
         },
       },
       },
     }
     }
+    result satisfies Data
 
 
     async function bootstrap() {
     async function bootstrap() {
       if (bootstrapping) return bootstrapping
       if (bootstrapping) return bootstrapping

+ 2 - 8
packages/tui/src/routes/session/index.tsx

@@ -160,12 +160,7 @@ export function Session() {
   const { theme } = useTheme()
   const { theme } = useTheme()
   const promptRef = usePromptRef()
   const promptRef = usePromptRef()
   const session = createMemo(() => data.session.get(route.sessionID))
   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)
   const location = createMemo(() => session()?.location)
 
 
   createEffect(() => {
   createEffect(() => {
@@ -173,7 +168,6 @@ export function Session() {
     setEpilogue(sessionEpilogue({ title, sessionID: session()?.id }))
     setEpilogue(sessionEpilogue({ title, sessionID: session()?.id }))
   })
   })
   onCleanup(() => setEpilogue())
   onCleanup(() => setEpilogue())
-  const messages = sessionMessages
   const descendantSessionIDs = createMemo(() => {
   const descendantSessionIDs = createMemo(() => {
     if (session()?.parentID) return []
     if (session()?.parentID) return []
     return data.session.family(route.sessionID).filter((id) => id !== route.sessionID)
     return data.session.family(route.sessionID).filter((id) => id !== route.sessionID)
@@ -649,7 +643,7 @@ export function Session() {
       category: "Session",
       category: "Session",
       hidden: true,
       hidden: true,
       run: () => {
       run: () => {
-        const messages = sessionMessages()
+        const messages = data.session.message.list(route.sessionID)
         if (!messages || !messages.length) return
         if (!messages || !messages.length) return
 
 
         // Find the most recent user message with non-ignored, non-synthetic text parts
         // Find the most recent user message with non-ignored, non-synthetic text parts

+ 20 - 8
packages/tui/test/cli/tui/data.test.tsx

@@ -111,7 +111,7 @@ test("refreshes resources into reactive getters", async () => {
     await data.location.agent.refresh()
     await data.location.agent.refresh()
 
 
     expect(data.session.get("ses_test")?.title).toBe("Test session")
     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")
     expect(data.session.message.get("ses_test", "msg_second")?.id).toBe("msg_second")
     await app.renderOnce()
     await app.renderOnce()
     expect(app.captureCharFrame()).toContain("msg_second")
     expect(app.captureCharFrame()).toContain("msg_second")
@@ -330,9 +330,9 @@ test("truncates committed revert messages without changing lifetime usage", asyn
       durable: durable(sessionID, 6),
       durable: durable(sessionID, 6),
       data: { sessionID, to: "msg_revert_later" },
       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.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)?.revert).toBeUndefined()
     expect(data.session.get(sessionID)?.tokens).toEqual(tokens)
     expect(data.session.get(sessionID)?.tokens).toEqual(tokens)
   } finally {
   } 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" } },
         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, {
     emitEvent(events, {
       id: EventV2.ID.create(),
       id: EventV2.ID.create(),
@@ -698,8 +698,8 @@ test("removes committed revert messages from local state", async () => {
       data: { sessionID, to: "msg_002" },
       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_002")).toBeUndefined()
     expect(data.session.message.get(sessionID, "msg_003")).toBeUndefined()
     expect(data.session.message.get(sessionID, "msg_003")).toBeUndefined()
   } finally {
   } finally {
@@ -2239,6 +2239,17 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
     const admitted = sync.session.message.list(sessionID)?.[0]
     const admitted = sync.session.message.list(sessionID)?.[0]
     expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" })
     expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" })
     expect(admitted?.metadata).toBeUndefined()
     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])
     expect(sync.session.input.list(sessionID)).toEqual([messageID])
 
 
     await sync.session.message.refresh(sessionID)
     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
     if (message?.type !== "user") return
     expect(message).toMatchObject({ id: messageID, text: "hello" })
     expect(message).toMatchObject({ id: messageID, text: "hello" })
     expect(message.metadata).toBeUndefined()
     expect(message.metadata).toBeUndefined()
+    expect(sync.session.pending.list(sessionID)).toEqual([])
     expect(sync.session.input.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, messageID)).toBe(message)
     expect(sync.session.message.get(sessionID, "missing")).toBeUndefined()
     expect(sync.session.message.get(sessionID, "missing")).toBeUndefined()
     expect(received).toHaveLength(3)
     expect(received).toHaveLength(3)