Просмотр исходного кода

refactor(tui): replace v2 sync with data context (#31826)

Dax 2 месяцев назад
Родитель
Сommit
47a45601fd

+ 3 - 3
packages/tui/src/app.tsx

@@ -34,7 +34,7 @@ import { useEvent } from "./context/event"
 import { SDKProvider, useSDK } from "./context/sdk"
 import { StartupLoading } from "./component/startup-loading"
 import { SyncProvider, useSync } from "./context/sync"
-import { SyncProviderV2 } from "./context/sync-v2"
+import { DataProvider } from "./context/data"
 import { LocalProvider, useLocal } from "./context/local"
 import { DialogModel } from "./component/dialog-model"
 import { useConnected } from "./component/use-connected"
@@ -294,7 +294,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
                                         >
                                           <ProjectProvider>
                                             <SyncProvider>
-                                              <SyncProviderV2>
+                                              <DataProvider>
                                                 <ThemeProvider mode={mode}>
                                                   <LocalProvider>
                                                     <PromptStashProvider>
@@ -315,7 +315,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
                                                     </PromptStashProvider>
                                                   </LocalProvider>
                                                 </ThemeProvider>
-                                              </SyncProviderV2>
+                                              </DataProvider>
                                             </SyncProvider>
                                           </ProjectProvider>
                                         </SDKProvider>

+ 48 - 43
packages/tui/src/component/dialog-model.tsx

@@ -1,17 +1,17 @@
 import { createMemo, createSignal } from "solid-js"
 import { useLocal } from "../context/local"
-import { useSync } from "../context/sync"
-import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
+import { map, pipe, filter, sortBy, take } from "remeda"
 import { DialogSelect } from "../ui/dialog-select"
 import { useDialog } from "../ui/dialog"
 import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
 import { DialogVariant } from "./dialog-variant"
 import * as fuzzysort from "fuzzysort"
 import { useConnected } from "./use-connected"
+import { useData } from "../context/data"
 
 export function DialogModel(props: { providerID?: string }) {
   const local = useLocal()
-  const sync = useSync()
+  const data = useData()
   const dialog = useDialog()
   const [query, setQuery] = createSignal("")
 
@@ -29,19 +29,21 @@ export function DialogModel(props: { providerID?: string }) {
     function toOptions(items: typeof favorites, category: string) {
       if (!showSections) return []
       return items.flatMap((item) => {
-        const provider = sync.data.provider.find((x) => x.id === item.providerID)
+        const provider = data.location.provider.list()?.find((provider) => provider.id === item.providerID)
         if (!provider) return []
-        const model = provider.models[item.modelID]
+        const model = data.location.model
+          .list()
+          ?.find((model) => model.providerID === item.providerID && model.id === item.modelID)
         if (!model) return []
         return [
           {
             key: item,
             value: { providerID: provider.id, modelID: model.id },
-            title: model.name ?? item.modelID,
+            title: model.name,
             description: provider.name,
             category,
             disabled: provider.id === "opencode" && model.id.includes("-nano"),
-            footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
+            footer: model.cost[0]?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
             onSelect: () => {
               onSelect(provider.id, model.id)
             },
@@ -59,42 +61,45 @@ export function DialogModel(props: { providerID?: string }) {
     )
 
     const providerOptions = pipe(
-      sync.data.provider,
+      data.location.model.list() ?? [],
+      filter((model) => model.status !== "deprecated"),
+      filter((model) => (props.providerID ? model.providerID === props.providerID : true)),
       sortBy(
-        (provider) => provider.id !== "opencode",
-        (provider) => provider.name,
-      ),
-      flatMap((provider) =>
-        pipe(
-          provider.models,
-          entries(),
-          filter(([_, info]) => info.status !== "deprecated"),
-          filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
-          map(([model, info]) => ({
-            value: { providerID: provider.id, modelID: model },
-            title: info.name ?? model,
-            releaseDate: info.release_date,
-            description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
-              ? "(Favorite)"
-              : undefined,
-            category: connected() ? provider.name : undefined,
-            disabled: provider.id === "opencode" && model.includes("-nano"),
-            footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
-            onSelect() {
-              onSelect(provider.id, model)
-            },
-          })),
-          filter((x) => {
-            if (!showSections) return true
-            if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
-              return false
-            if (recents.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
-              return false
-            return true
-          }),
-          (options) => sortModelOptions(options, props.providerID !== undefined),
-        ),
+        (model) => model.providerID !== "opencode",
+        (model) => data.location.provider.list()?.find((provider) => provider.id === model.providerID)?.name ?? "",
+        [(model) => model.time.released, "desc"],
       ),
+      map((model) => ({
+        value: { providerID: model.providerID, modelID: model.id },
+        title: model.name,
+        releaseDate: model.time.released,
+        description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
+          ? "(Favorite)"
+          : undefined,
+        category: connected()
+          ? data.location.provider.list()?.find((provider) => provider.id === model.providerID)?.name
+          : undefined,
+        disabled: !model.enabled || (model.providerID === "opencode" && model.id.includes("-nano")),
+        footer: model.cost[0]?.input === 0 && model.providerID === "opencode" ? "Free" : undefined,
+        onSelect() {
+          onSelect(model.providerID, model.id)
+        },
+      })),
+      filter((option) => {
+        if (!showSections) return true
+        if (
+          favorites.some(
+            (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
+          )
+        )
+          return false
+        if (
+          recents.some((item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID)
+        )
+          return false
+        return true
+      }),
+      (options) => sortModelOptions(options, props.providerID !== undefined),
     )
 
     const popularProviders = !connected()
@@ -119,7 +124,7 @@ export function DialogModel(props: { providerID?: string }) {
   })
 
   const provider = createMemo(() =>
-    props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null,
+    props.providerID ? data.location.provider.list()?.find((item) => item.id === props.providerID) : null,
   )
 
   const title = createMemo(() => {
@@ -172,7 +177,7 @@ export function DialogModel(props: { providerID?: string }) {
   )
 }
 
-export function sortModelOptions<T extends { footer?: string; releaseDate: string; title: string }>(
+export function sortModelOptions<T extends { footer?: string; releaseDate: string | number; title: string }>(
   options: T[],
   newestFirst: boolean,
 ) {

+ 1 - 1
packages/tui/src/component/dialog-workspace-create.tsx

@@ -132,7 +132,7 @@ export async function warpWorkspaceSession(input: {
 
   input.project.workspace.set(input.workspaceID)
 
-  await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
+  await input.sync.bootstrap()
 
   const dir = input.project.instance.directory() || input.sync.path.directory
   if (dir) {

+ 1 - 1
packages/tui/src/component/dialog-workspace-list.tsx

@@ -82,7 +82,7 @@ export function DialogWorkspaceList() {
       route.navigate({ type: "home" })
     }
     await project.workspace.sync()
-    await sync.bootstrap({ fatal: false }).catch(() => undefined)
+    await sync.bootstrap()
     setRemoving(undefined)
   }
 

+ 17 - 19
packages/tui/src/component/prompt/autocomplete.tsx

@@ -9,7 +9,7 @@ import { useEditorContext } from "../../context/editor"
 import { useProject } from "../../context/project"
 import { useSDK } from "../../context/sdk"
 import { useSync } from "../../context/sync"
-import { useSyncV2 } from "../../context/sync-v2"
+import { useData } from "../../context/data"
 import { getScrollAcceleration } from "../../util/scroll"
 import { useTuiPaths } from "../../context/runtime"
 import { useTuiConfig } from "../../config"
@@ -85,7 +85,7 @@ export function Autocomplete(props: {
   const editor = useEditorContext()
   const sdk = useSDK()
   const sync = useSync()
-  const syncV2 = useSyncV2()
+  const data = useData()
   const project = useProject()
   const slashes = useCommandSlashes()
   const modeStack = useOpencodeModeStack()
@@ -273,12 +273,14 @@ export function Autocomplete(props: {
     }
   }
 
+  const references = createMemo(() => data.location.reference.list() ?? [])
+
   const referenceMatch = createMemo(() => {
     if (!store.visible || store.visible === "/") return
     const { baseQuery } = extractLineRange(search())
     const slash = baseQuery.indexOf("/")
     const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
-    return syncV2.data.reference.find((item) => !item.hidden && item.name === alias)
+    return references().find((item) => !item.hidden && item.name === alias)
   })
 
   function normalizeMentionPath(filePath: string) {
@@ -312,14 +314,12 @@ export function Autocomplete(props: {
     async (query) => {
       if (!store.visible || store.visible === "/") return []
       if (referenceMatch()) return []
-
       const { lineRange, baseQuery } = extractLineRange(query ?? "")
 
       // Get files from SDK
-      const result = await sdk.client.v2.fs.find({
+      const result = await sdk.client.find.files({
         query: baseQuery,
-        limit: "20",
-        location: { workspace: project.workspace.current() },
+        workspace: project.workspace.current(),
       })
 
       const options: AutocompleteOption[] = []
@@ -329,14 +329,15 @@ export function Autocomplete(props: {
       if (!result.error && result.data) {
         const width = props.anchor().width - 4
         options.push(
-          ...result.data.data.map((item): AutocompleteOption => {
-            const { filename, url, part } = createFilePart(item.path, lineRange)
+          ...result.data.map((item): AutocompleteOption => {
+            const { filename, url, part } = createFilePart(item, lineRange)
 
+            const isDir = item.endsWith("/")
             return {
               display: Locale.truncateMiddle(filename, width),
               value: filename,
-              isDirectory: item.type === "directory",
-              path: item.path,
+              isDirectory: isDir,
+              path: item,
               onSelect: () => {
                 insertPart(filename, part)
               },
@@ -389,16 +390,15 @@ export function Autocomplete(props: {
   })
 
   const agents = createMemo(() => {
-    const agents = sync.data.agent
-    return agents
+    return (data.location.agent.list() ?? [])
       .filter((agent) => !agent.hidden && agent.mode !== "primary")
       .map(
         (agent): AutocompleteOption => ({
-          display: "@" + agent.name,
+          display: "@" + agent.id,
           onSelect: () => {
-            insertPart(agent.name, {
+            insertPart(agent.id, {
               type: "agent",
-              name: agent.name,
+              name: agent.id,
               source: {
                 start: 0,
                 end: 0,
@@ -411,7 +411,7 @@ export function Autocomplete(props: {
   })
 
   const referenceAliases = createMemo(() =>
-    syncV2.data.reference
+    references()
       .filter((reference) => !reference.hidden)
       .map(
         (reference): AutocompleteOption => ({
@@ -471,8 +471,6 @@ export function Autocomplete(props: {
     const commandsValue = commands()
     const searchValue = search()
 
-    // @<alias>/... — narrow to the matched reference, files come from fff
-    // already ranked so there is no re-ranking here.
     if (store.visible === "@" && referenceMatchValue) {
       return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
     }

+ 3 - 5
packages/tui/src/component/use-connected.tsx

@@ -1,9 +1,7 @@
 import { createMemo } from "solid-js"
-import { useSync } from "../context/sync"
+import { useData } from "../context/data"
 
 export function useConnected() {
-  const sync = useSync()
-  return createMemo(() =>
-    sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
-  )
+  const data = useData()
+  return createMemo(() => (data.location.provider.list() ?? []).some((provider) => provider.enabled !== false))
 }

+ 567 - 0
packages/tui/src/context/data.tsx

@@ -0,0 +1,567 @@
+import { useEvent } from "./event"
+import type {
+  AgentV2Info,
+  CommandV2Info,
+  Event,
+  LocationRef,
+  ModelV2Info,
+  PermissionSavedInfo,
+  PermissionV2Request,
+  ProviderV2Info,
+  QuestionV2Request,
+  ReferenceInfo,
+  SessionMessage,
+  SessionMessageAssistant,
+  SessionMessageAssistantReasoning,
+  SessionMessageAssistantText,
+  SessionMessageAssistantTool,
+  SessionV2Info,
+  SkillV2Info,
+} from "@opencode-ai/sdk/v2"
+import { createStore, produce } from "solid-js/store"
+import { createSimpleContext } from "./helper"
+import { useSDK } from "./sdk"
+import { createSignal, onMount } from "solid-js"
+
+type LocationData = {
+  agent?: AgentV2Info[]
+  command?: CommandV2Info[]
+  model?: ModelV2Info[]
+  provider?: ProviderV2Info[]
+  reference?: ReferenceInfo[]
+  skill?: SkillV2Info[]
+}
+
+type Data = {
+  session: {
+    info: Record<string, SessionV2Info>
+    message: Record<string, SessionMessage[]>
+    permission: Record<string, PermissionV2Request[]>
+    question: Record<string, QuestionV2Request[]>
+  }
+  project: {
+    permission: Record<string, PermissionSavedInfo[]>
+  }
+  location: Record<string, LocationData>
+}
+
+function locationKey(location: LocationRef) {
+  return JSON.stringify([location.directory, location.workspaceID])
+}
+
+function locationQuery(ref?: LocationRef) {
+  return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
+}
+
+export const { use: useData, provider: DataProvider } = createSimpleContext({
+  name: "Data",
+  init: () => {
+    const [store, setStore] = createStore<Data>({
+      session: {
+        info: {},
+        message: {},
+        permission: {},
+        question: {},
+      },
+      project: {
+        permission: {},
+      },
+      location: {},
+    })
+
+    const event = useEvent()
+    const sdk = useSDK()
+    const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
+      directory: sdk.directory ?? process.cwd(),
+    })
+
+    const message = {
+      update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
+        setStore(
+          "session",
+          "message",
+          produce((draft) => {
+            fn((draft[sessionID] ??= []))
+          }),
+        )
+      },
+      prepend(messages: SessionMessage[], item: SessionMessage) {
+        if (messages.some((existing) => existing.id === item.id)) return
+        messages.unshift(item)
+      },
+      activeAssistant(messages: SessionMessage[]) {
+        const item = messages.find((item) => item.type === "assistant" && !item.time.completed)
+        return item?.type === "assistant" ? item : undefined
+      },
+      assistant(messages: SessionMessage[], messageID: string) {
+        const item = messages.find((item) => item.type === "assistant" && item.id === messageID)
+        return item?.type === "assistant" ? item : undefined
+      },
+      activeShell(messages: SessionMessage[], callID: string) {
+        const item = messages.find((item) => item.type === "shell" && item.callID === callID)
+        return item?.type === "shell" ? item : undefined
+      },
+      latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
+        return assistant?.content.findLast(
+          (item): item is SessionMessageAssistantTool =>
+            item.type === "tool" && (callID === undefined || item.id === callID),
+        )
+      },
+      latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
+        return assistant?.content.findLast(
+          (item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
+        )
+      },
+      latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
+        return assistant?.content.findLast(
+          (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
+        )
+      },
+    }
+
+    event.subscribe((event) => {
+      switch (event.type) {
+        case "session.next.agent.switched":
+          message.update(event.properties.sessionID, (draft) => {
+            message.prepend(draft, {
+              id: event.properties.messageID,
+              type: "agent-switched",
+              agent: event.properties.agent,
+              time: { created: event.properties.timestamp },
+            })
+          })
+          break
+        case "session.next.model.switched":
+          message.update(event.properties.sessionID, (draft) => {
+            message.prepend(draft, {
+              id: event.properties.messageID,
+              type: "model-switched",
+              model: event.properties.model,
+              time: { created: event.properties.timestamp },
+            })
+          })
+          break
+        case "session.next.prompted": {
+          message.update(event.properties.sessionID, (draft) => {
+            message.prepend(draft, {
+              id: event.properties.messageID,
+              type: "user",
+              text: event.properties.prompt.text,
+              files: event.properties.prompt.files,
+              agents: event.properties.prompt.agents,
+              time: { created: event.properties.timestamp },
+            })
+          })
+          break
+        }
+        case "session.next.prompt.admitted":
+          break
+        case "session.next.prompt.promoted":
+          message.update(event.properties.sessionID, (draft) => {
+            message.prepend(draft, {
+              id: event.properties.messageID,
+              type: "user",
+              text: event.properties.prompt.text,
+              files: event.properties.prompt.files,
+              agents: event.properties.prompt.agents,
+              time: { created: event.properties.timeCreated },
+            })
+          })
+          break
+        case "session.next.context.updated":
+          message.update(event.properties.sessionID, (draft) => {
+            message.prepend(draft, {
+              id: event.properties.messageID,
+              type: "system",
+              text: event.properties.text,
+              time: { created: event.properties.timestamp },
+            })
+          })
+          break
+        case "session.next.synthetic":
+          message.update(event.properties.sessionID, (draft) => {
+            message.prepend(draft, {
+              id: event.properties.messageID,
+              type: "synthetic",
+              sessionID: event.properties.sessionID,
+              text: event.properties.text,
+              time: { created: event.properties.timestamp },
+            })
+          })
+          break
+        case "session.next.shell.started":
+          message.update(event.properties.sessionID, (draft) => {
+            message.prepend(draft, {
+              id: event.properties.messageID,
+              type: "shell",
+              callID: event.properties.callID,
+              command: event.properties.command,
+              output: "",
+              time: { created: event.properties.timestamp },
+            })
+          })
+          break
+        case "session.next.shell.ended":
+          message.update(event.properties.sessionID, (draft) => {
+            const match = message.activeShell(draft, event.properties.callID)
+            if (!match) return
+            match.output = event.properties.output
+            match.time.completed = event.properties.timestamp
+          })
+          break
+        case "session.next.step.started":
+          message.update(event.properties.sessionID, (draft) => {
+            if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
+            const currentAssistant = message.activeAssistant(draft)
+            if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
+            message.prepend(draft, {
+              id: event.properties.assistantMessageID,
+              type: "assistant",
+              agent: event.properties.agent,
+              model: event.properties.model,
+              content: [],
+              snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,
+              time: { created: event.properties.timestamp },
+            })
+          })
+          break
+        case "session.next.step.ended":
+          message.update(event.properties.sessionID, (draft) => {
+            const currentAssistant = message.assistant(draft, event.properties.assistantMessageID)
+            if (!currentAssistant) return
+            currentAssistant.time.completed = event.properties.timestamp
+            currentAssistant.finish = event.properties.finish
+            currentAssistant.cost = event.properties.cost
+            currentAssistant.tokens = event.properties.tokens
+            if (event.properties.snapshot)
+              currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
+          })
+          break
+        case "session.next.step.failed":
+          message.update(event.properties.sessionID, (draft) => {
+            const currentAssistant = message.assistant(draft, event.properties.assistantMessageID)
+            if (!currentAssistant) return
+            currentAssistant.time.completed = event.properties.timestamp
+            currentAssistant.finish = "error"
+            currentAssistant.error = event.properties.error
+          })
+          break
+        case "session.next.text.started":
+          message.update(event.properties.sessionID, (draft) => {
+            message.assistant(draft, event.properties.assistantMessageID)?.content.push({
+              type: "text",
+              id: event.properties.textID,
+              text: "",
+            })
+          })
+          break
+        case "session.next.text.delta":
+          message.update(event.properties.sessionID, (draft) => {
+            const match = message.latestText(
+              message.assistant(draft, event.properties.assistantMessageID),
+              event.properties.textID,
+            )
+            if (match) match.text += event.properties.delta
+          })
+          break
+        case "session.next.text.ended":
+          message.update(event.properties.sessionID, (draft) => {
+            const match = message.latestText(
+              message.assistant(draft, event.properties.assistantMessageID),
+              event.properties.textID,
+            )
+            if (match) match.text = event.properties.text
+          })
+          break
+        case "session.next.tool.input.started":
+          message.update(event.properties.sessionID, (draft) => {
+            message.assistant(draft, event.properties.assistantMessageID)?.content.push({
+              type: "tool",
+              id: event.properties.callID,
+              name: event.properties.name,
+              time: { created: event.properties.timestamp },
+              state: { status: "pending", input: "" },
+            })
+          })
+          break
+        case "session.next.tool.input.delta":
+          message.update(event.properties.sessionID, (draft) => {
+            const match = message.latestTool(
+              message.assistant(draft, event.properties.assistantMessageID),
+              event.properties.callID,
+            )
+            if (match?.state.status === "pending") match.state.input += event.properties.delta
+          })
+          break
+        case "session.next.tool.input.ended":
+          message.update(event.properties.sessionID, (draft) => {
+            const match = message.latestTool(
+              message.assistant(draft, event.properties.assistantMessageID),
+              event.properties.callID,
+            )
+            if (match?.state.status === "pending") match.state.input = event.properties.text
+          })
+          break
+        case "session.next.tool.called":
+          message.update(event.properties.sessionID, (draft) => {
+            const match = message.latestTool(
+              message.assistant(draft, event.properties.assistantMessageID),
+              event.properties.callID,
+            )
+            if (!match) return
+            match.time.ran = event.properties.timestamp
+            match.provider = event.properties.provider
+            match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
+          })
+          break
+        case "session.next.tool.progress":
+          message.update(event.properties.sessionID, (draft) => {
+            const match = message.latestTool(
+              message.assistant(draft, event.properties.assistantMessageID),
+              event.properties.callID,
+            )
+            if (match?.state.status !== "running") return
+            match.state.structured = event.properties.structured
+            match.state.content = [...event.properties.content]
+          })
+          break
+        case "session.next.tool.success":
+          message.update(event.properties.sessionID, (draft) => {
+            const match = message.latestTool(
+              message.assistant(draft, event.properties.assistantMessageID),
+              event.properties.callID,
+            )
+            if (match?.state.status !== "running") return
+            match.state = {
+              status: "completed",
+              input: match.state.input,
+              structured: event.properties.structured,
+              content: [...event.properties.content],
+              result: event.properties.result,
+            }
+            match.provider = {
+              executed: event.properties.provider.executed || match.provider?.executed === true,
+              metadata: match.provider?.metadata,
+              resultMetadata: event.properties.provider.metadata,
+            }
+            match.time.completed = event.properties.timestamp
+          })
+          break
+        case "session.next.tool.failed":
+          message.update(event.properties.sessionID, (draft) => {
+            const match = message.latestTool(
+              message.assistant(draft, event.properties.assistantMessageID),
+              event.properties.callID,
+            )
+            if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
+            match.state = {
+              status: "error",
+              error: event.properties.error,
+              input: typeof match.state.input === "string" ? {} : match.state.input,
+              structured: match.state.status === "running" ? match.state.structured : {},
+              content: match.state.status === "running" ? match.state.content : [],
+              result: event.properties.result,
+            }
+            match.provider = {
+              executed: event.properties.provider.executed || match.provider?.executed === true,
+              metadata: match.provider?.metadata,
+              resultMetadata: event.properties.provider.metadata,
+            }
+            match.time.completed = event.properties.timestamp
+          })
+          break
+        case "session.next.reasoning.started":
+          message.update(event.properties.sessionID, (draft) => {
+            message.assistant(draft, event.properties.assistantMessageID)?.content.push({
+              type: "reasoning",
+              id: event.properties.reasoningID,
+              text: "",
+              providerMetadata: event.properties.providerMetadata,
+            })
+          })
+          break
+        case "session.next.reasoning.delta":
+          message.update(event.properties.sessionID, (draft) => {
+            const match = message.latestReasoning(
+              message.assistant(draft, event.properties.assistantMessageID),
+              event.properties.reasoningID,
+            )
+            if (match) match.text += event.properties.delta
+          })
+          break
+        case "session.next.reasoning.ended":
+          message.update(event.properties.sessionID, (draft) => {
+            const match = message.latestReasoning(
+              message.assistant(draft, event.properties.assistantMessageID),
+              event.properties.reasoningID,
+            )
+            if (match) {
+              match.text = event.properties.text
+              if (event.properties.providerMetadata !== undefined)
+                match.providerMetadata = event.properties.providerMetadata
+            }
+          })
+          break
+        case "session.next.retried":
+        case "session.next.compaction.started":
+        case "session.next.compaction.delta":
+          break
+        case "session.next.compaction.ended":
+          message.update(event.properties.sessionID, (draft) => {
+            message.prepend(draft, {
+              id: event.properties.messageID,
+              type: "compaction",
+              reason: event.properties.reason,
+              summary: event.properties.text,
+              recent: event.properties.recent,
+              time: { created: event.properties.timestamp },
+            })
+          })
+          break
+        case "reference.updated":
+          void result.location.reference.refresh()
+          break
+      }
+    })
+
+    const result = {
+      session: {
+        get(sessionID: string) {
+          return store.session.info[sessionID]
+        },
+        async refresh(sessionID: string) {
+          const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true })
+          setStore("session", "info", sessionID, result.data.data)
+        },
+        message: {
+          list(sessionID: string) {
+            return store.session.message[sessionID]
+          },
+          async refresh(sessionID: string) {
+            const result = await sdk.client.v2.session.messages({ sessionID }, { throwOnError: true })
+            setStore("session", "message", sessionID, result.data.data)
+          },
+        },
+        permission: {
+          list(sessionID: string) {
+            return store.session.permission[sessionID]
+          },
+          async refresh(sessionID: string) {
+            const result = await sdk.client.v2.session.permission.list({ sessionID }, { throwOnError: true })
+            setStore("session", "permission", sessionID, result.data.data)
+          },
+        },
+        question: {
+          list(sessionID: string) {
+            return store.session.question[sessionID]
+          },
+          async refresh(sessionID: string) {
+            const result = await sdk.client.v2.session.question.list({ sessionID }, { throwOnError: true })
+            setStore("session", "question", sessionID, result.data.data)
+          },
+        },
+      },
+      project: {
+        permission: {
+          list(projectID: string) {
+            return store.project.permission[projectID]
+          },
+          async refresh(projectID: string) {
+            const result = await sdk.client.v2.permission.saved.list({ projectID }, { throwOnError: true })
+            setStore("project", "permission", projectID, result.data.data)
+          },
+        },
+      },
+      location: {
+        default() {
+          return defaultLocation()
+        },
+        async refresh(ref?: LocationRef) {
+          const response = await sdk.client.v2.location.get({ location: locationQuery(ref) }, { throwOnError: true })
+          const location = response.data
+          const key = locationKey(location)
+          if (!store.location[key]) setStore("location", key, {})
+          if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
+        },
+        agent: {
+          list(location?: LocationRef) {
+            return store.location[locationKey(location ?? defaultLocation())]?.agent
+          },
+          async refresh(ref?: LocationRef) {
+            const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true })
+            const key = locationKey(result.data.location)
+            setStore("location", key, "agent", result.data.data)
+          },
+        },
+        command: {
+          list(location?: LocationRef) {
+            return store.location[locationKey(location ?? defaultLocation())]?.command
+          },
+          async refresh(ref?: LocationRef) {
+            const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true })
+            const key = locationKey(result.data.location)
+            setStore("location", key, "command", result.data.data)
+          },
+        },
+        model: {
+          list(location?: LocationRef) {
+            return store.location[locationKey(location ?? defaultLocation())]?.model
+          },
+          async refresh(ref?: LocationRef) {
+            const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true })
+            const key = locationKey(result.data.location)
+            setStore("location", key, "model", result.data.data)
+          },
+        },
+        provider: {
+          list(location?: LocationRef) {
+            return store.location[locationKey(location ?? defaultLocation())]?.provider
+          },
+          async refresh(ref?: LocationRef) {
+            const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true })
+            const key = locationKey(result.data.location)
+            setStore("location", key, "provider", result.data.data)
+          },
+        },
+        reference: {
+          list(location?: LocationRef) {
+            return store.location[locationKey(location ?? defaultLocation())]?.reference
+          },
+          async refresh(ref?: LocationRef) {
+            const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true })
+            const key = locationKey(result.data.location)
+            setStore("location", key, "reference", result.data.data)
+          },
+        },
+        skill: {
+          list(location?: LocationRef) {
+            return store.location[locationKey(location ?? defaultLocation())]?.skill
+          },
+          async refresh(ref?: LocationRef) {
+            const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true })
+            const key = locationKey(result.data.location)
+            setStore("location", key, "skill", result.data.data)
+          },
+        },
+      },
+    }
+
+    onMount(() => {
+      void Promise.allSettled([
+        result.location.refresh(),
+        result.location.agent.refresh(),
+        result.location.model.refresh(),
+        result.location.provider.refresh(),
+        result.location.reference.refresh(),
+        result.location.command.refresh(),
+        result.location.skill.refresh(),
+      ])
+        .then((settled) => {
+          for (const failure of settled.filter((item) => item.status === "rejected"))
+            console.error("Failed to refresh default location data", failure.reason)
+        })
+    })
+
+    return result
+  },
+})

+ 25 - 18
packages/tui/src/context/local.tsx

@@ -12,6 +12,7 @@ import { readJson, writeJsonAtomic } from "../util/persistence"
 import { useTheme } from "./theme"
 import { useToast } from "../ui/toast"
 import { useRoute } from "./route"
+import { useData } from "./data"
 
 export type LocalTheme = {
   secondary: RGBA
@@ -51,15 +52,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
   name: "Local",
   init: () => {
     const sync = useSync()
+    const data = useData()
     const sdk = useSDK()
     const toast = useToast()
     const theme = useTheme().theme
     const route = useRoute()
     const paths = useTuiPaths()
+    const providers = createMemo(() => data.location.provider.list() ?? [])
+    const models = createMemo(() => data.location.model.list() ?? [])
 
     function isModelValid(model: { providerID: string; modelID: string }) {
-      const provider = sync.data.provider.find((x) => x.id === model.providerID)
-      return !!provider?.models[model.modelID]
+      return models().some((item) => item.providerID === model.providerID && item.id === model.modelID && item.enabled)
     }
 
     function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) {
@@ -71,8 +74,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     }
 
     function createAgent() {
-      const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
-      const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
+      const all = createMemo(() =>
+        (data.location.agent.list() ?? []).map((agent) => ({
+          ...agent,
+          name: agent.id,
+          native: false,
+          model: agent.model
+            ? { providerID: agent.model.providerID, modelID: agent.model.id, variant: agent.model.variant }
+            : undefined,
+        })),
+      )
+      const agents = createMemo(() => all().filter((agent) => agent.mode !== "subagent" && !agent.hidden))
+      const visibleAgents = createMemo(() => all().filter((agent) => !agent.hidden))
       const [agentStore, setAgentStore] = createStore({
         current: undefined as string | undefined,
       })
@@ -218,15 +231,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           }
         }
 
-        const provider = sync.data.provider[0]
-        if (!provider) return undefined
-        const defaultModel = sync.data.provider_default[provider.id]
-        const firstModel = Object.values(provider.models)[0]
-        const model = defaultModel ?? firstModel?.id
+        const model = models().find((item) => item.enabled && providers().some((provider) => provider.id === item.providerID))
         if (!model) return undefined
         return {
-          providerID: provider.id,
-          modelID: model,
+          providerID: model.providerID,
+          modelID: model.id,
         }
       })
 
@@ -261,12 +270,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
               reasoning: false,
             }
           }
-          const provider = sync.data.provider.find((x) => x.id === value.providerID)
-          const info = provider?.models[value.modelID]
+          const provider = providers().find((item) => item.id === value.providerID)
+          const info = models().find((item) => item.providerID === value.providerID && item.id === value.modelID)
           return {
             provider: provider?.name ?? value.providerID,
             model: info?.name ?? value.modelID,
-            reasoning: info?.capabilities?.reasoning ?? false,
+            reasoning: false,
           }
         }),
         cycle(direction: 1 | -1) {
@@ -372,10 +381,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           list() {
             const m = currentModel()
             if (!m) return []
-            const provider = sync.data.provider.find((x) => x.id === m.providerID)
-            const info = provider?.models[m.modelID]
-            if (!info?.variants) return []
-            return Object.keys(info.variants)
+            const info = models().find((item) => item.providerID === m.providerID && item.id === m.modelID)
+            return info?.variants.map((variant) => variant.id) ?? []
           },
           set(value: string | undefined) {
             const m = currentModel()

+ 0 - 465
packages/tui/src/context/sync-v2.tsx

@@ -1,465 +0,0 @@
-import { useEvent } from "./event"
-import type {
-  Event,
-  ReferenceInfo,
-  SessionMessage,
-  SessionMessageAssistant,
-  SessionMessageAssistantReasoning,
-  SessionMessageAssistantText,
-  SessionMessageAssistantTool,
-} from "@opencode-ai/sdk/v2"
-import { createStore, produce, reconcile } from "solid-js/store"
-import { createSimpleContext } from "./helper"
-import { useSDK } from "./sdk"
-import { useProject } from "./project"
-import { createEffect } from "solid-js"
-
-function activeAssistant(messages: SessionMessage[]) {
-  const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
-  if (index < 0) return
-  const assistant = messages[index]
-  return assistant?.type === "assistant" ? assistant : undefined
-}
-
-function ownedAssistant(messages: SessionMessage[], messageID: string) {
-  const message = messages.find((message) => message.type === "assistant" && message.id === messageID)
-  return message?.type === "assistant" ? message : undefined
-}
-
-function activeShell(messages: SessionMessage[], callID: string) {
-  const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
-  if (index < 0) return
-  const shell = messages[index]
-  return shell?.type === "shell" ? shell : undefined
-}
-
-function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
-  return assistant?.content.findLast(
-    (item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID),
-  )
-}
-
-function latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
-  return assistant?.content.findLast(
-    (item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
-  )
-}
-
-function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
-  return assistant?.content.findLast(
-    (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
-  )
-}
-
-function prepend(messages: SessionMessage[], message: SessionMessage) {
-  if (messages.some((item) => item.id === message.id)) return
-  messages.unshift(message)
-}
-
-export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({
-  name: "SyncV2",
-  init: () => {
-    const [store, setStore] = createStore<{
-      messages: {
-        [sessionID: string]: SessionMessage[]
-      }
-      reference: ReferenceInfo[]
-    }>({
-      messages: {},
-      reference: [],
-    })
-
-    const event = useEvent()
-    const sdk = useSDK()
-    const project = useProject()
-    const applied = new Set<string>()
-    const buffering = new Map<string, Event[]>()
-    const syncing = new Map<string, Promise<void>>()
-
-    function duplicate(id: string) {
-      if (applied.has(id)) return true
-      applied.add(id)
-      if (applied.size <= 1000) return false
-      const oldest = applied.values().next()
-      if (!oldest.done) applied.delete(oldest.value)
-      return false
-    }
-
-    function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
-      setStore(
-        "messages",
-        produce((draft) => {
-          fn((draft[sessionID] ??= []))
-        }),
-      )
-    }
-
-    async function hydrate(sessionID: string) {
-      const pending: Event[] = []
-      const before = JSON.parse(JSON.stringify(store.messages[sessionID] ?? [])) as SessionMessage[]
-      buffering.set(sessionID, pending)
-      try {
-        const response = await sdk.client.v2.session.messages({ sessionID })
-        const messages = response.data?.data ?? []
-        const snapshotIDs = new Set(messages.map((message) => message.id))
-        setStore(
-          "messages",
-          sessionID,
-          reconcile([...messages, ...before.filter((message) => !snapshotIDs.has(message.id))]),
-        )
-        buffering.delete(sessionID)
-        for (const event of pending) apply(event)
-      } catch (error) {
-        buffering.delete(sessionID)
-        throw error
-      }
-    }
-
-    function sync(sessionID: string) {
-      const existing = syncing.get(sessionID)
-      if (existing) return existing
-      const result = hydrate(sessionID).finally(() => syncing.delete(sessionID))
-      syncing.set(sessionID, result)
-      return result
-    }
-
-    async function syncReferences(workspace = project.workspace.current()) {
-      const result = await sdk.client.v2.reference.list({ location: { workspace } })
-      if (workspace !== project.workspace.current()) return
-      setStore("reference", reconcile(result.data?.data ?? []))
-    }
-
-    createEffect(() => {
-      project.workspace.current()
-      void syncReferences()
-    })
-
-    function apply(event: Event) {
-      switch (event.type) {
-        case "session.next.agent.switched":
-          update(event.properties.sessionID, (draft) => {
-            prepend(draft, {
-              id: event.properties.messageID,
-              type: "agent-switched",
-              agent: event.properties.agent,
-              time: { created: event.properties.timestamp },
-            })
-          })
-          break
-        case "session.next.model.switched":
-          update(event.properties.sessionID, (draft) => {
-            prepend(draft, {
-              id: event.properties.messageID,
-              type: "model-switched",
-              model: event.properties.model,
-              time: { created: event.properties.timestamp },
-            })
-          })
-          break
-        case "session.next.prompted": {
-          update(event.properties.sessionID, (draft) => {
-            prepend(draft, {
-              id: event.properties.messageID,
-              type: "user",
-              text: event.properties.prompt.text,
-              files: event.properties.prompt.files,
-              agents: event.properties.prompt.agents,
-              time: { created: event.properties.timestamp },
-            })
-          })
-          break
-        }
-        case "session.next.prompt.admitted":
-          break
-        case "session.next.prompt.promoted":
-          update(event.properties.sessionID, (draft) => {
-            prepend(draft, {
-              id: event.properties.messageID,
-              type: "user",
-              text: event.properties.prompt.text,
-              files: event.properties.prompt.files,
-              agents: event.properties.prompt.agents,
-              time: { created: event.properties.timeCreated },
-            })
-          })
-          break
-        case "session.next.context.updated":
-          update(event.properties.sessionID, (draft) => {
-            prepend(draft, {
-              id: event.properties.messageID,
-              type: "system",
-              text: event.properties.text,
-              time: { created: event.properties.timestamp },
-            })
-          })
-          break
-        case "session.next.synthetic":
-          update(event.properties.sessionID, (draft) => {
-            prepend(draft, {
-              id: event.properties.messageID,
-              type: "synthetic",
-              sessionID: event.properties.sessionID,
-              text: event.properties.text,
-              time: { created: event.properties.timestamp },
-            })
-          })
-          break
-        case "session.next.shell.started":
-          update(event.properties.sessionID, (draft) => {
-            prepend(draft, {
-              id: event.properties.messageID,
-              type: "shell",
-              callID: event.properties.callID,
-              command: event.properties.command,
-              output: "",
-              time: { created: event.properties.timestamp },
-            })
-          })
-          break
-        case "session.next.shell.ended":
-          update(event.properties.sessionID, (draft) => {
-            const match = activeShell(draft, event.properties.callID)
-            if (!match) return
-            match.output = event.properties.output
-            match.time.completed = event.properties.timestamp
-          })
-          break
-        case "session.next.step.started":
-          update(event.properties.sessionID, (draft) => {
-            if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
-            const currentAssistant = activeAssistant(draft)
-            if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
-            prepend(draft, {
-              id: event.properties.assistantMessageID,
-              type: "assistant",
-              agent: event.properties.agent,
-              model: event.properties.model,
-              content: [],
-              snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,
-              time: { created: event.properties.timestamp },
-            })
-          })
-          break
-        case "session.next.step.ended":
-          update(event.properties.sessionID, (draft) => {
-            const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID)
-            if (!currentAssistant) return
-            currentAssistant.time.completed = event.properties.timestamp
-            currentAssistant.finish = event.properties.finish
-            currentAssistant.cost = event.properties.cost
-            currentAssistant.tokens = event.properties.tokens
-            if (event.properties.snapshot)
-              currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
-          })
-          break
-        case "session.next.step.failed":
-          update(event.properties.sessionID, (draft) => {
-            const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID)
-            if (!currentAssistant) return
-            currentAssistant.time.completed = event.properties.timestamp
-            currentAssistant.finish = "error"
-            currentAssistant.error = event.properties.error
-          })
-          break
-        case "session.next.text.started":
-          update(event.properties.sessionID, (draft) => {
-            ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
-              type: "text",
-              id: event.properties.textID,
-              text: "",
-            })
-          })
-          break
-        case "session.next.text.delta":
-          update(event.properties.sessionID, (draft) => {
-            const match = latestText(
-              ownedAssistant(draft, event.properties.assistantMessageID),
-              event.properties.textID,
-            )
-            if (match) match.text += event.properties.delta
-          })
-          break
-        case "session.next.text.ended":
-          update(event.properties.sessionID, (draft) => {
-            const match = latestText(
-              ownedAssistant(draft, event.properties.assistantMessageID),
-              event.properties.textID,
-            )
-            if (match) match.text = event.properties.text
-          })
-          break
-        case "session.next.tool.input.started":
-          update(event.properties.sessionID, (draft) => {
-            ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
-              type: "tool",
-              id: event.properties.callID,
-              name: event.properties.name,
-              time: { created: event.properties.timestamp },
-              state: { status: "pending", input: "" },
-            })
-          })
-          break
-        case "session.next.tool.input.delta":
-          update(event.properties.sessionID, (draft) => {
-            const match = latestTool(
-              ownedAssistant(draft, event.properties.assistantMessageID),
-              event.properties.callID,
-            )
-            if (match?.state.status === "pending") match.state.input += event.properties.delta
-          })
-          break
-        case "session.next.tool.input.ended":
-          update(event.properties.sessionID, (draft) => {
-            const match = latestTool(
-              ownedAssistant(draft, event.properties.assistantMessageID),
-              event.properties.callID,
-            )
-            if (match?.state.status === "pending") match.state.input = event.properties.text
-          })
-          break
-        case "session.next.tool.called":
-          update(event.properties.sessionID, (draft) => {
-            const match = latestTool(
-              ownedAssistant(draft, event.properties.assistantMessageID),
-              event.properties.callID,
-            )
-            if (!match) return
-            match.time.ran = event.properties.timestamp
-            match.provider = event.properties.provider
-            match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
-          })
-          break
-        case "session.next.tool.progress":
-          update(event.properties.sessionID, (draft) => {
-            const match = latestTool(
-              ownedAssistant(draft, event.properties.assistantMessageID),
-              event.properties.callID,
-            )
-            if (match?.state.status !== "running") return
-            match.state.structured = event.properties.structured
-            match.state.content = [...event.properties.content]
-          })
-          break
-        case "session.next.tool.success":
-          update(event.properties.sessionID, (draft) => {
-            const match = latestTool(
-              ownedAssistant(draft, event.properties.assistantMessageID),
-              event.properties.callID,
-            )
-            if (match?.state.status !== "running") return
-            match.state = {
-              status: "completed",
-              input: match.state.input,
-              structured: event.properties.structured,
-              content: [...event.properties.content],
-              result: event.properties.result,
-            }
-            match.provider = {
-              executed: event.properties.provider.executed || match.provider?.executed === true,
-              metadata: match.provider?.metadata,
-              resultMetadata: event.properties.provider.metadata,
-            }
-            match.time.completed = event.properties.timestamp
-          })
-          break
-        case "session.next.tool.failed":
-          update(event.properties.sessionID, (draft) => {
-            const match = latestTool(
-              ownedAssistant(draft, event.properties.assistantMessageID),
-              event.properties.callID,
-            )
-            if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
-            match.state = {
-              status: "error",
-              error: event.properties.error,
-              input: typeof match.state.input === "string" ? {} : match.state.input,
-              structured: match.state.status === "running" ? match.state.structured : {},
-              content: match.state.status === "running" ? match.state.content : [],
-              result: event.properties.result,
-            }
-            match.provider = {
-              executed: event.properties.provider.executed || match.provider?.executed === true,
-              metadata: match.provider?.metadata,
-              resultMetadata: event.properties.provider.metadata,
-            }
-            match.time.completed = event.properties.timestamp
-          })
-          break
-        case "session.next.reasoning.started":
-          update(event.properties.sessionID, (draft) => {
-            ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
-              type: "reasoning",
-              id: event.properties.reasoningID,
-              text: "",
-              providerMetadata: event.properties.providerMetadata,
-            })
-          })
-          break
-        case "session.next.reasoning.delta":
-          update(event.properties.sessionID, (draft) => {
-            const match = latestReasoning(
-              ownedAssistant(draft, event.properties.assistantMessageID),
-              event.properties.reasoningID,
-            )
-            if (match) match.text += event.properties.delta
-          })
-          break
-        case "session.next.reasoning.ended":
-          update(event.properties.sessionID, (draft) => {
-            const match = latestReasoning(
-              ownedAssistant(draft, event.properties.assistantMessageID),
-              event.properties.reasoningID,
-            )
-            if (match) {
-              match.text = event.properties.text
-              if (event.properties.providerMetadata !== undefined)
-                match.providerMetadata = event.properties.providerMetadata
-            }
-          })
-          break
-        case "session.next.retried":
-        case "session.next.compaction.started":
-        case "session.next.compaction.delta":
-          break
-        case "session.next.compaction.ended":
-          update(event.properties.sessionID, (draft) => {
-            prepend(draft, {
-              id: event.properties.messageID,
-              type: "compaction",
-              reason: event.properties.reason,
-              summary: event.properties.text,
-              recent: event.properties.recent,
-              time: { created: event.properties.timestamp },
-            })
-          })
-          break
-        case "reference.updated":
-          void syncReferences()
-          break
-      }
-    }
-
-    event.subscribe((event) => {
-      if (duplicate(event.id)) return
-      if ("sessionID" in event.properties && typeof event.properties.sessionID === "string")
-        buffering.get(event.properties.sessionID)?.push(event)
-      apply(event)
-    })
-
-    const result = {
-      data: store,
-      session: {
-        message: {
-          sync,
-          fromSession(sessionID: string) {
-            const messages = store.messages[sessionID]
-            if (!messages) return []
-            return messages
-          },
-        },
-      },
-    }
-
-    return result
-  },
-})

+ 0 - 2
packages/tui/src/feature-plugins/builtins.ts

@@ -10,7 +10,6 @@ import SidebarTodo from "./sidebar/todo"
 import DiffViewer from "./system/diff-viewer"
 import Notifications from "./system/notifications"
 import PluginManager from "./system/plugins"
-import SessionV2Debug from "./system/session-v2"
 import WhichKey from "./system/which-key"
 
 export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
@@ -33,6 +32,5 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean
     PluginManager,
     WhichKey,
     DiffViewer,
-    ...(options.experimentalEventSystem ? [SessionV2Debug] : []),
   ]
 }

+ 5 - 3
packages/tui/src/feature-plugins/home/footer.tsx

@@ -4,19 +4,21 @@ import { createMemo, Match, Show, Switch } from "solid-js"
 import { abbreviateHome } from "../../runtime"
 import { useTuiPaths } from "../../context/runtime"
 import { useHomeSessionDestination } from "../../routes/home/session-destination"
+import { useData } from "../../context/data"
 
 const id = "internal:home-footer"
 
 function Directory(props: { api: TuiPluginApi }) {
   const theme = () => props.api.theme.current
   const destination = useHomeSessionDestination()
+  const data = useData()
   const paths = useTuiPaths()
   const dir = createMemo(() => {
     const selected = destination?.destination()
-    if (!selected || selected.type === "new") return
-    const out = abbreviateHome(selected.directory, paths.home)
+    const directory = !selected || selected.type === "new" ? data.location.default().directory : selected.directory
+    const out = abbreviateHome(directory, paths.home)
     const branch =
-      selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined
+      directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined
     if (branch) return out + ":" + branch
     return out
   })

+ 0 - 1196
packages/tui/src/feature-plugins/system/session-v2.tsx

@@ -1,1196 +0,0 @@
-import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
-import type { BuiltinTuiPlugin } from "../builtins"
-import { useSyncV2 } from "../../context/sync-v2"
-import { SplitBorder } from "../../ui/border"
-import { Spinner } from "../../component/spinner"
-import { useTheme } from "../../context/theme"
-import { useLocal } from "../../context/local"
-import { reasoningSummary, useThinkingMode } from "../../context/thinking"
-import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
-import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
-import { useBindings } from "../../keymap"
-import { Locale } from "../../util/locale"
-import { useTuiPaths } from "../../context/runtime"
-import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
-import { toolDisplayMetadata, webSearchProviderLabel } from "../../util/tool-display"
-import path from "path"
-import stripAnsi from "strip-ansi"
-import type {
-  SessionMessage,
-  SessionMessageAgentSwitched,
-  SessionMessageAssistant,
-  SessionMessageAssistantReasoning,
-  SessionMessageAssistantText,
-  SessionMessageAssistantTool,
-  SessionMessageCompaction,
-  SessionMessageModelSwitched,
-  SessionMessageShell,
-  SessionMessageUser,
-  ToolFileContent,
-  ToolTextContent,
-} from "@opencode-ai/sdk/v2"
-import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
-import { collapseToolOutput } from "../../util/collapse-tool-output"
-import { setPreLayoutSiblingMargin } from "../../util/layout"
-
-const id = "internal:session-v2-debug"
-const route = "session.v2.messages"
-
-function currentSessionID(api: TuiPluginApi) {
-  const current = api.route.current
-  if (current.name !== "session") return
-  const sessionID = current.params?.sessionID
-  return typeof sessionID === "string" ? sessionID : undefined
-}
-
-function View(props: { api: TuiPluginApi; sessionID: string }) {
-  const sync = useSyncV2()
-  const dimensions = useTerminalDimensions()
-  const { theme, syntax, subtleSyntax } = useTheme()
-  const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
-  const renderedMessages = createMemo(() => messages().toReversed())
-  const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
-  const lastUserCreated = (index: number) =>
-    renderedMessages()
-      .slice(0, index)
-      .findLast((message) => message.type === "user")?.time.created
-
-  createEffect(() => {
-    void sync.session.message.sync(props.sessionID)
-  })
-
-  useBindings(() => ({
-    bindings: [
-      {
-        key: "escape",
-        desc: "Back to session",
-        group: "Session",
-        cmd() {
-          props.api.route.navigate("session", { sessionID: props.sessionID })
-        },
-      },
-    ],
-  }))
-
-  return (
-    <box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
-      <box flexDirection="row">
-        <box flexGrow={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
-          <scrollbox
-            viewportOptions={{ paddingRight: 0 }}
-            verticalScrollbarOptions={{ visible: false }}
-            stickyScroll={true}
-            stickyStart="bottom"
-            flexGrow={1}
-          >
-            <box height={1} />
-            <Show when={messages().length === 0}>
-              <MissingData label="Messages" detail="No v2 messages loaded from useSyncV2 yet." />
-            </Show>
-            <For each={renderedMessages()}>
-              {(message, index) => (
-                <Switch>
-                  <Match when={message.type === "user"}>
-                    <UserMessage message={message as SessionMessageUser} index={index()} />
-                  </Match>
-                  <Match when={message.type === "assistant"}>
-                    <AssistantMessage
-                      message={message as SessionMessageAssistant}
-                      sessionID={props.sessionID}
-                      last={lastAssistant()?.id === message.id}
-                      syntax={syntax()}
-                      subtleSyntax={subtleSyntax()}
-                      start={lastUserCreated(index())}
-                    />
-                  </Match>
-                  <Match when={message.type === "synthetic"}>
-                    <></>
-                  </Match>
-                  <Match when={message.type === "system"}>
-                    <></>
-                  </Match>
-                  <Match when={message.type === "shell"}>
-                    <ShellMessage message={message as SessionMessageShell} />
-                  </Match>
-                  <Match when={message.type === "compaction"}>
-                    <CompactionMessage message={message as SessionMessageCompaction} />
-                  </Match>
-                  <Match when={message.type === "agent-switched"}>
-                    <AgentSwitchedMessage message={message as SessionMessageAgentSwitched} />
-                  </Match>
-                  <Match when={message.type === "model-switched"}>
-                    <ModelSwitchedMessage message={message as SessionMessageModelSwitched} />
-                  </Match>
-                  <Match when={true}>
-                    <UnknownMessage message={message} />
-                  </Match>
-                </Switch>
-              )}
-            </For>
-          </scrollbox>
-          <MissingData
-            label="Session prompt, permission prompt, question prompt, sidebar"
-            detail="The v2 message endpoint only exposes messages, so these session UI regions cannot be rendered here. Press Esc to return to the live session."
-          />
-        </box>
-      </box>
-    </box>
-  )
-}
-
-function MissingData(props: { label: string; detail: string }) {
-  const { theme } = useTheme()
-  return (
-    <box
-      border={["left"]}
-      customBorderChars={SplitBorder.customBorderChars}
-      borderColor={theme.warning}
-      backgroundColor={theme.backgroundPanel}
-      paddingLeft={2}
-      paddingTop={1}
-      paddingBottom={1}
-      marginTop={1}
-      flexShrink={0}
-    >
-      <text fg={theme.text}>
-        <span style={{ bg: theme.warning, fg: theme.background, bold: true }}> MISSING DATA </span> {props.label}
-      </text>
-      <text fg={theme.textMuted}>{props.detail}</text>
-    </box>
-  )
-}
-
-function UserMessage(props: { message: SessionMessageUser; index: number }) {
-  const { theme } = useTheme()
-  const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])])
-  return (
-    <box
-      id={props.message.id}
-      border={["left"]}
-      borderColor={theme.secondary}
-      customBorderChars={SplitBorder.customBorderChars}
-      marginTop={props.index === 0 ? 0 : 1}
-      flexShrink={0}
-      paddingTop={1}
-      paddingBottom={1}
-      paddingLeft={2}
-      backgroundColor={theme.backgroundPanel}
-    >
-      <text fg={theme.text}>{props.message.text}</text>
-      <Show when={attachments().length}>
-        <box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
-          <For each={props.message.files ?? []}>
-            {(file) => (
-              <text fg={theme.text}>
-                <span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
-                <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
-              </text>
-            )}
-          </For>
-          <For each={props.message.agents ?? []}>
-            {(agent) => (
-              <text fg={theme.text}>
-                <span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
-                <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
-              </text>
-            )}
-          </For>
-        </box>
-      </Show>
-    </box>
-  )
-}
-
-function ShellMessage(props: { message: SessionMessageShell }) {
-  const { theme } = useTheme()
-  const dimensions = useTerminalDimensions()
-  const output = createMemo(() => stripAnsi(props.message.output.trim()))
-  const [expanded, setExpanded] = createSignal(false)
-  const maxLines = 10
-  const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
-  const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
-  const limited = createMemo(() => {
-    if (expanded() || !collapsed().overflow) return output()
-    return collapsed().output
-  })
-  return (
-    <BlockTool
-      title="# Shell"
-      spinner={!props.message.time.completed}
-      onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
-    >
-      <box gap={1}>
-        <text fg={theme.text}>$ {props.message.command}</text>
-        <Show when={output()}>
-          <text fg={theme.text}>{limited()}</text>
-        </Show>
-        <Show when={collapsed().overflow}>
-          <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
-        </Show>
-      </box>
-    </BlockTool>
-  )
-}
-
-function CompactionMessage(props: { message: SessionMessageCompaction }) {
-  const { theme } = useTheme()
-  return (
-    <box
-      marginTop={1}
-      border={["top"]}
-      title={props.message.reason === "auto" ? " Auto Compaction " : " Compaction "}
-      titleAlignment="center"
-      borderColor={theme.borderActive}
-      flexShrink={0}
-    />
-  )
-}
-
-function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) {
-  const { theme } = useTheme()
-  const local = useLocal()
-  return (
-    <box paddingLeft={3} marginTop={1} flexShrink={0}>
-      <text>
-        <span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>
-        <span style={{ fg: theme.textMuted }}>Switched agent to </span>
-        <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
-      </text>
-    </box>
-  )
-}
-
-function ModelSwitchedMessage(props: { message: SessionMessageModelSwitched }) {
-  const { theme } = useTheme()
-  const model = createMemo(() => {
-    const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
-    return `${props.message.model.providerID}/${props.message.model.id}${variant}`
-  })
-  return (
-    <box paddingLeft={3} marginTop={1} flexShrink={0}>
-      <text>
-        <span style={{ fg: theme.secondary }}>◇ </span>
-        <span style={{ fg: theme.textMuted }}>Switched model to </span>
-        <span style={{ fg: theme.text }}>{model()}</span>
-      </text>
-    </box>
-  )
-}
-
-function UnknownMessage(props: { message: SessionMessage }) {
-  return <MissingData label="Unknown message type" detail={JSON.stringify(props.message)} />
-}
-
-function AssistantMessage(props: {
-  message: SessionMessageAssistant
-  sessionID: string
-  last: boolean
-  syntax: SyntaxStyle
-  subtleSyntax: SyntaxStyle
-  start?: number
-}) {
-  const { theme } = useTheme()
-  const local = useLocal()
-  const duration = createMemo(() => {
-    if (!props.message.time.completed) return 0
-    return props.message.time.completed - (props.start ?? props.message.time.created)
-  })
-  const model = createMemo(() => {
-    const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
-    return `${props.message.model.providerID}/${props.message.model.id}${variant}`
-  })
-  const final = createMemo(() => props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish))
-  return (
-    <>
-      <For each={props.message.content}>
-        {(part) => (
-          <Switch>
-            <Match when={part.type === "text"}>
-              <AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
-            </Match>
-            <Match when={part.type === "reasoning"}>
-              <AssistantReasoning
-                part={part as SessionMessageAssistantReasoning}
-                subtleSyntax={props.subtleSyntax}
-                completedAt={() => props.message.time.completed}
-              />
-            </Match>
-            <Match when={part.type === "tool"}>
-              <AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
-            </Match>
-          </Switch>
-        )}
-      </For>
-      <Show when={props.message.content.length === 0}>
-        <MissingData label="Assistant content" detail={`Assistant message ${props.message.id} has no content items.`} />
-      </Show>
-      <Show when={props.message.error}>
-        <box
-          border={["left"]}
-          paddingTop={1}
-          paddingBottom={1}
-          paddingLeft={2}
-          marginTop={1}
-          backgroundColor={theme.backgroundPanel}
-          customBorderChars={SplitBorder.customBorderChars}
-          borderColor={theme.error}
-          flexShrink={0}
-        >
-          <text fg={theme.textMuted}>{props.message.error}</text>
-        </box>
-      </Show>
-      <Show when={props.last || final() || props.message.error}>
-        <box paddingLeft={3} flexShrink={0}>
-          <text marginTop={1}>
-            <span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>
-            <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
-            <span style={{ fg: theme.textMuted }}> · {model()}</span>
-            <Show when={duration()}>
-              <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
-            </Show>
-          </text>
-        </box>
-      </Show>
-    </>
-  )
-}
-
-function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) {
-  const { theme } = useTheme()
-  return (
-    <Show when={props.part.text.trim()}>
-      <box paddingLeft={3} marginTop={1} flexShrink={0} id={`text-${props.part.id}`}>
-        <code
-          filetype="markdown"
-          drawUnstyledText={false}
-          streaming={true}
-          syntaxStyle={props.syntax}
-          content={props.part.text.trim()}
-          conceal={true}
-          fg={theme.text}
-        />
-      </box>
-    </Show>
-  )
-}
-
-function AssistantReasoning(props: {
-  part: SessionMessageAssistantReasoning
-  subtleSyntax: SyntaxStyle
-  completedAt: () => number | undefined
-}) {
-  const { theme } = useTheme()
-  const thinking = useThinkingMode()
-  const [expanded, setExpanded] = createSignal(false)
-  const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
-  const inMinimal = createMemo(() => thinking.mode() === "hide")
-  // v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
-  // in the v2 SDK); we settle on parent-message completion instead.
-  const isDone = createMemo(() => props.completedAt() !== undefined)
-  const summary = createMemo(() => reasoningSummary(content()))
-
-  const toggle = () => {
-    if (!inMinimal()) return
-    setExpanded((prev) => !prev)
-  }
-
-  return (
-    <Show when={content()}>
-      <box paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
-        <box onMouseUp={toggle}>
-          <ReasoningHeader
-            toggleable={inMinimal()}
-            open={!inMinimal() || expanded()}
-            done={isDone()}
-            title={summary().title}
-          />
-        </box>
-        <Show when={(!inMinimal() || expanded()) && summary().body}>
-          <box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
-            <code
-              filetype="markdown"
-              drawUnstyledText={false}
-              streaming={true}
-              syntaxStyle={props.subtleSyntax}
-              content={summary().body}
-              conceal={true}
-              fg={theme.textMuted}
-            />
-          </box>
-        </Show>
-      </box>
-    </Show>
-  )
-}
-
-function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: boolean; title: string | null }) {
-  const { theme } = useTheme()
-  const fg = () =>
-    props.open
-      ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
-      : theme.warning
-
-  return (
-    <Switch>
-      <Match when={!props.done}>
-        <box flexDirection="row">
-          <Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
-        </box>
-      </Match>
-      <Match when={true}>
-        <text fg={fg()} wrapMode="none">
-          <Show when={props.toggleable}>
-            <span>{props.open ? "- " : "+ "}</span>
-          </Show>
-          <span>Thought</span>
-          <Show when={props.title}>
-            <span>: </span>
-            <span>{props.title}</span>
-          </Show>
-        </text>
-      </Match>
-    </Switch>
-  )
-}
-
-function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: string }) {
-  const input = createMemo(() => toolInputRecord(props.part.state.input))
-  const toolprops = {
-    get input() {
-      return input()
-    },
-    get metadata() {
-      return toolDisplayMetadata(props.part.state)
-    },
-    get output() {
-      return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content)
-    },
-    sessionID: props.sessionID,
-    part: props.part,
-  }
-  return (
-    <Switch>
-      <Match when={props.part.name === "bash"}>
-        <Bash {...toolprops} />
-      </Match>
-      <Match when={props.part.name === "glob"}>
-        <Glob {...toolprops} />
-      </Match>
-      <Match when={props.part.name === "read"}>
-        <Read {...toolprops} />
-      </Match>
-      <Match when={props.part.name === "grep"}>
-        <Grep {...toolprops} />
-      </Match>
-      <Match when={props.part.name === "webfetch"}>
-        <WebFetch {...toolprops} />
-      </Match>
-      <Match when={props.part.name === "websearch"}>
-        <WebSearch {...toolprops} />
-      </Match>
-      <Match when={props.part.name === "write"}>
-        <Write {...toolprops} />
-      </Match>
-      <Match when={props.part.name === "edit"}>
-        <Edit {...toolprops} />
-      </Match>
-      <Match when={props.part.name === "apply_patch"}>
-        <ApplyPatch {...toolprops} />
-      </Match>
-      <Match when={props.part.name === "todowrite"}>
-        <TodoWrite {...toolprops} />
-      </Match>
-      <Match when={props.part.name === "question"}>
-        <Question {...toolprops} />
-      </Match>
-      <Match when={props.part.name === "skill"}>
-        <Skill {...toolprops} />
-      </Match>
-      <Match when={props.part.name === "task"}>
-        <Task {...toolprops} />
-      </Match>
-      <Match when={true}>
-        <GenericTool {...toolprops} />
-      </Match>
-    </Switch>
-  )
-}
-
-type ToolProps = {
-  input: Record<string, unknown>
-  metadata: Record<string, unknown>
-  output?: string
-  sessionID: string
-  part: SessionMessageAssistantTool
-}
-
-function GenericTool(props: ToolProps) {
-  const { theme } = useTheme()
-  const dimensions = useTerminalDimensions()
-  const output = createMemo(() => props.output?.trim() ?? "")
-  const [expanded, setExpanded] = createSignal(false)
-  const maxLines = 3
-  const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
-  const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
-  const limited = createMemo(() => {
-    if (expanded() || !collapsed().overflow) return output()
-    return collapsed().output
-  })
-  return (
-    <Show
-      when={output()}
-      fallback={
-        <InlineTool icon="⚙" pending="Writing command..." complete={toolComplete(props.part)} part={props.part}>
-          {props.part.name} {input(props.input)}
-        </InlineTool>
-      }
-    >
-      <BlockTool
-        title={`# ${props.part.name} ${input(props.input)}`}
-        part={props.part}
-        onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
-      >
-        <box gap={1}>
-          <text fg={theme.text}>{limited()}</text>
-          <Show when={collapsed().overflow}>
-            <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
-          </Show>
-        </box>
-      </BlockTool>
-    </Show>
-  )
-}
-
-function InlineTool(props: {
-  icon: string
-  complete: unknown
-  pending: string
-  spinner?: boolean
-  children: JSX.Element
-  part: SessionMessageAssistantTool
-}) {
-  const { theme } = useTheme()
-  const renderer = useRenderer()
-  const [hover, setHover] = createSignal(false)
-  const [showError, setShowError] = createSignal(false)
-  const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
-  const complete = createMemo(() => !!props.complete)
-  const denied = createMemo(() => {
-    const message = error()
-    if (!message) return false
-    return (
-      message.includes("QuestionRejectedError") ||
-      message.includes("rejected permission") ||
-      message.includes("specified a rule") ||
-      message.includes("user dismissed")
-    )
-  })
-  const fg = createMemo(() => {
-    if (error()) return theme.error
-    if (complete()) return theme.textMuted
-    return theme.text
-  })
-  const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined))
-  return (
-    <box
-      paddingLeft={3}
-      flexShrink={0}
-      flexDirection="row"
-      gap={1}
-      backgroundColor={hover() && error() ? theme.backgroundMenu : undefined}
-      onMouseOver={() => error() && setHover(true)}
-      onMouseOut={() => setHover(false)}
-      onMouseUp={() => {
-        if (!error()) return
-        if (renderer.getSelection()?.getSelectedText()) return
-        setShowError((prev) => !prev)
-      }}
-      ref={(el: BoxRenderable) => {
-        setPreLayoutSiblingMargin(el, (previous) => (previous?.id.startsWith("text-") ? 1 : 0))
-      }}
-    >
-      <box flexShrink={0}>
-        <Switch>
-          <Match when={props.spinner}>
-            <Spinner color={theme.text} />
-          </Match>
-          <Match when={complete()}>
-            <text fg={fg()} attributes={attributes()}>
-              {props.icon}
-            </text>
-          </Match>
-          <Match when={true}>
-            <text fg={fg()} attributes={attributes()}>
-              ~
-            </text>
-          </Match>
-        </Switch>
-      </box>
-      <box flexGrow={1}>
-        <box>
-          <Switch>
-            <Match when={complete()}>
-              <text fg={fg()} attributes={attributes()}>
-                {props.children}
-              </text>
-            </Match>
-            <Match when={true}>
-              <text fg={fg()} attributes={attributes()}>
-                {props.pending}
-              </text>
-            </Match>
-          </Switch>
-        </box>
-        <Show when={showError() && error()}>
-          <box>
-            <text fg={theme.error}>{error()}</text>
-          </box>
-        </Show>
-      </box>
-    </box>
-  )
-}
-
-function BlockTool(props: {
-  title: string
-  children: JSX.Element
-  part?: SessionMessageAssistantTool
-  onClick?: () => void
-  spinner?: boolean
-}) {
-  const { theme } = useTheme()
-  const renderer = useRenderer()
-  const [hover, setHover] = createSignal(false)
-  const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined))
-  return (
-    <box
-      border={["left"]}
-      paddingTop={1}
-      paddingBottom={1}
-      paddingLeft={2}
-      marginTop={1}
-      gap={1}
-      backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
-      customBorderChars={SplitBorder.customBorderChars}
-      borderColor={theme.background}
-      onMouseOver={() => props.onClick && setHover(true)}
-      onMouseOut={() => setHover(false)}
-      onMouseUp={() => {
-        if (renderer.getSelection()?.getSelectedText()) return
-        props.onClick?.()
-      }}
-      flexShrink={0}
-    >
-      <Show
-        when={props.spinner}
-        fallback={
-          <text paddingLeft={3} fg={theme.textMuted}>
-            {props.title}
-          </text>
-        }
-      >
-        <Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
-      </Show>
-      {props.children}
-      <Show when={error()}>
-        <text fg={theme.error}>{error()}</text>
-      </Show>
-    </box>
-  )
-}
-
-function Bash(props: ToolProps) {
-  const { theme } = useTheme()
-  const dimensions = useTerminalDimensions()
-  const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
-  const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
-  const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
-  const [expanded, setExpanded] = createSignal(false)
-  const maxLines = 10
-  const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
-  const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
-  const limited = createMemo(() => {
-    if (expanded() || !collapsed().overflow) return output()
-    return collapsed().output
-  })
-  return (
-    <Switch>
-      <Match when={output()}>
-        <BlockTool
-          title={title()}
-          part={props.part}
-          spinner={props.part.state.status === "running"}
-          onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
-        >
-          <box gap={1}>
-            <text fg={theme.text}>$ {command()}</text>
-            <text fg={theme.text}>{limited()}</text>
-            <Show when={collapsed().overflow}>
-              <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
-            </Show>
-          </box>
-        </BlockTool>
-      </Match>
-      <Match when={true}>
-        <InlineTool icon="$" pending="Writing command..." complete={command()} part={props.part}>
-          {command()}
-        </InlineTool>
-      </Match>
-    </Switch>
-  )
-}
-
-function Glob(props: ToolProps) {
-  const normalizePath = usePathNormalizer()
-  return (
-    <InlineTool icon="✱" pending="Finding files..." complete={toolComplete(props.part)} part={props.part}>
-      Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
-      <Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
-      <Show when={numberValue(props.metadata.count)}>
-        {(count) => (
-          <>
-            ({count()} {count() === 1 ? "match" : "matches"})
-          </>
-        )}
-      </Show>
-    </InlineTool>
-  )
-}
-
-function Read(props: ToolProps) {
-  const normalizePath = usePathNormalizer()
-  const { theme } = useTheme()
-  const loaded = createMemo(() =>
-    arrayValue(props.metadata.loaded).filter((item): item is string => typeof item === "string"),
-  )
-  return (
-    <>
-      <InlineTool
-        icon="→"
-        pending="Reading file..."
-        complete={stringValue(props.input.filePath) ?? pendingInput(props.part)}
-        spinner={props.part.state.status === "running"}
-        part={props.part}
-      >
-        Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "}
-        {input(props.input, ["filePath"])}
-      </InlineTool>
-      <For each={loaded()}>
-        {(filepath) => (
-          <box paddingLeft={3} flexShrink={0}>
-            <text paddingLeft={3} fg={theme.textMuted}>
-              ↳ Loaded {normalizePath(filepath)}
-            </text>
-          </box>
-        )}
-      </For>
-    </>
-  )
-}
-
-function Grep(props: ToolProps) {
-  const normalizePath = usePathNormalizer()
-  return (
-    <InlineTool icon="✱" pending="Searching content..." complete={toolComplete(props.part)} part={props.part}>
-      Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
-      <Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
-      <Show when={numberValue(props.metadata.matches)}>
-        {(matches) => (
-          <>
-            ({matches()} {matches() === 1 ? "match" : "matches"})
-          </>
-        )}
-      </Show>
-    </InlineTool>
-  )
-}
-
-function WebFetch(props: ToolProps) {
-  return (
-    <InlineTool icon="%" pending="Fetching from the web..." complete={toolComplete(props.part)} part={props.part}>
-      WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)}
-    </InlineTool>
-  )
-}
-
-function WebSearch(props: ToolProps) {
-  const label = createMemo(() => webSearchProviderLabel(props.metadata.provider))
-  return (
-    <InlineTool icon="◈" pending="Searching web..." complete={toolComplete(props.part)} part={props.part}>
-      {label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "}
-      <Show when={numberValue(props.metadata.numResults)}>{(results) => <>({results()} results)</>}</Show>
-    </InlineTool>
-  )
-}
-
-function Write(props: ToolProps) {
-  const normalizePath = usePathNormalizer()
-  const { theme, syntax } = useTheme()
-  const filePath = createMemo(() => stringValue(props.input.filePath) ?? "")
-  const content = createMemo(() => stringValue(props.input.content) ?? "")
-  return (
-    <Switch>
-      <Match when={content() && props.part.state.status === "completed"}>
-        <BlockTool title={"# Wrote " + normalizePath(filePath())} part={props.part}>
-          <line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
-            <code
-              conceal={false}
-              fg={theme.text}
-              filetype={filetype(filePath())}
-              syntaxStyle={syntax()}
-              content={content()}
-            />
-          </line_number>
-          <Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
-        </BlockTool>
-      </Match>
-      <Match when={true}>
-        <InlineTool icon="←" pending="Preparing write..." complete={filePath()} part={props.part}>
-          Write {normalizePath(filePath())}
-        </InlineTool>
-      </Match>
-    </Switch>
-  )
-}
-
-function Edit(props: ToolProps) {
-  const normalizePath = usePathNormalizer()
-  const { theme, syntax } = useTheme()
-  const dimensions = useTerminalDimensions()
-  const filePath = createMemo(() => stringValue(props.input.filePath) ?? "")
-  const diff = createMemo(() => stringValue(props.metadata.diff))
-  return (
-    <Switch>
-      <Match when={diff()}>
-        {(diff) => (
-          <BlockTool title={"← Edit " + normalizePath(filePath())} part={props.part}>
-            <box paddingLeft={1}>
-              <diff
-                diff={diff()}
-                view={dimensions().width > 120 ? "split" : "unified"}
-                filetype={filetype(filePath())}
-                syntaxStyle={syntax()}
-                showLineNumbers={true}
-                width="100%"
-                wrapMode="word"
-                fg={theme.text}
-                addedBg={theme.diffAddedBg}
-                removedBg={theme.diffRemovedBg}
-                contextBg={theme.diffContextBg}
-                addedSignColor={theme.diffHighlightAdded}
-                removedSignColor={theme.diffHighlightRemoved}
-                lineNumberFg={theme.diffLineNumber}
-                lineNumberBg={theme.diffContextBg}
-                addedLineNumberBg={theme.diffAddedLineNumberBg}
-                removedLineNumberBg={theme.diffRemovedLineNumberBg}
-              />
-            </box>
-            <Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
-          </BlockTool>
-        )}
-      </Match>
-      <Match when={true}>
-        <InlineTool icon="←" pending="Preparing edit..." complete={filePath()} part={props.part}>
-          Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })}
-        </InlineTool>
-      </Match>
-    </Switch>
-  )
-}
-
-function ApplyPatch(props: ToolProps) {
-  const normalizePath = usePathNormalizer()
-  const { theme, syntax } = useTheme()
-  const dimensions = useTerminalDimensions()
-  const files = createMemo(() => arrayValue(props.metadata.files).flatMap((item) => (isRecord(item) ? [item] : [])))
-  const fileTitle = (file: Record<string, unknown>) => {
-    const type = stringValue(file.type)
-    const relativePath = stringValue(file.relativePath) ?? stringValue(file.filePath) ?? "patch"
-    if (type === "delete") return "# Deleted " + relativePath
-    if (type === "add") return "# Created " + relativePath
-    if (type === "move") return "# Moved " + normalizePath(stringValue(file.filePath)) + " → " + relativePath
-    return "← Patched " + relativePath
-  }
-  return (
-    <Switch>
-      <Match when={files().length > 0}>
-        <For each={files()}>
-          {(file) => (
-            <BlockTool title={fileTitle(file)} part={props.part}>
-              <Show
-                when={stringValue(file.patch)}
-                fallback={
-                  <text fg={theme.diffRemoved}>
-                    -{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"}
-                  </text>
-                }
-              >
-                {(patch) => (
-                  <box paddingLeft={1}>
-                    <diff
-                      diff={patch()}
-                      view={dimensions().width > 120 ? "split" : "unified"}
-                      filetype={filetype(stringValue(file.filePath) ?? stringValue(file.relativePath))}
-                      syntaxStyle={syntax()}
-                      showLineNumbers={true}
-                      width="100%"
-                      wrapMode="word"
-                      fg={theme.text}
-                      addedBg={theme.diffAddedBg}
-                      removedBg={theme.diffRemovedBg}
-                      contextBg={theme.diffContextBg}
-                      addedSignColor={theme.diffHighlightAdded}
-                      removedSignColor={theme.diffHighlightRemoved}
-                      lineNumberFg={theme.diffLineNumber}
-                      lineNumberBg={theme.diffContextBg}
-                      addedLineNumberBg={theme.diffAddedLineNumberBg}
-                      removedLineNumberBg={theme.diffRemovedLineNumberBg}
-                    />
-                  </box>
-                )}
-              </Show>
-            </BlockTool>
-          )}
-        </For>
-      </Match>
-      <Match when={true}>
-        <InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
-          Patch
-        </InlineTool>
-      </Match>
-    </Switch>
-  )
-}
-
-function TodoWrite(props: ToolProps) {
-  const { theme } = useTheme()
-  const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : [])))
-  return (
-    <Switch>
-      <Match when={todos().length > 0 && props.part.state.status === "completed"}>
-        <BlockTool title="# Todos" part={props.part}>
-          <box>
-            <For each={todos()}>
-              {(todo) => (
-                <text fg={theme.text}>
-                  {todoIcon(stringValue(todo.status))} {stringValue(todo.content)}
-                </text>
-              )}
-            </For>
-          </box>
-        </BlockTool>
-      </Match>
-      <Match when={true}>
-        <InlineTool icon="⚙" pending="Updating todos..." complete={false} part={props.part}>
-          Updating todos...
-        </InlineTool>
-      </Match>
-    </Switch>
-  )
-}
-
-function Question(props: ToolProps) {
-  const { theme } = useTheme()
-  const questions = createMemo(() =>
-    arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])),
-  )
-  const answers = createMemo(() => arrayValue(props.metadata.answers))
-  return (
-    <Switch>
-      <Match when={answers().length > 0}>
-        <BlockTool title="# Questions" part={props.part}>
-          <box gap={1}>
-            <For each={questions()}>
-              {(question, index) => (
-                <box>
-                  <text fg={theme.textMuted}>{stringValue(question.question)}</text>
-                  <text fg={theme.text}>{formatAnswer(answers()[index()])}</text>
-                </box>
-              )}
-            </For>
-          </box>
-        </BlockTool>
-      </Match>
-      <Match when={true}>
-        <InlineTool icon="→" pending="Asking questions..." complete={questions().length} part={props.part}>
-          Asked {questions().length} question{questions().length === 1 ? "" : "s"}
-        </InlineTool>
-      </Match>
-    </Switch>
-  )
-}
-
-function Skill(props: ToolProps) {
-  return (
-    <InlineTool icon="→" pending="Loading skill..." complete={toolComplete(props.part)} part={props.part}>
-      Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}"
-    </InlineTool>
-  )
-}
-
-function Task(props: ToolProps) {
-  const content = createMemo(() => {
-    const description = stringValue(props.input.description)
-    if (!description) return pendingInput(props.part)
-    return `${Locale.titlecase(stringValue(props.input.subagent_type) ?? "General")} Task — ${description}`
-  })
-  return (
-    <InlineTool
-      icon="│"
-      spinner={props.part.state.status === "running"}
-      complete={toolComplete(props.part)}
-      pending="Delegating..."
-      part={props.part}
-    >
-      {content()}
-    </InlineTool>
-  )
-}
-
-function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
-  const normalizePath = usePathNormalizer()
-  const { theme } = useTheme()
-  const errors = createMemo(() => {
-    if (!isRecord(props.diagnostics)) return []
-    const value = props.diagnostics[normalizePath(props.filePath)] ?? props.diagnostics[props.filePath]
-    return arrayValue(value)
-      .flatMap((item) => (isRecord(item) ? [item] : []))
-      .filter((diagnostic) => diagnostic.severity === 1)
-      .slice(0, 3)
-  })
-  return (
-    <Show when={errors().length}>
-      <box>
-        <For each={errors()}>
-          {(diagnostic) => <text fg={theme.error}>Error {stringValue(diagnostic.message)}</text>}
-        </For>
-      </box>
-    </Show>
-  )
-}
-
-function toolOutput(content?: Array<ToolTextContent | ToolFileContent>) {
-  return (content ?? [])
-    .map((item) => {
-      if (item.type === "text") return item.text.trim()
-      const source = item.uri
-      return `[file ${item.name ?? source}]`
-    })
-    .filter(Boolean)
-    .join("\n")
-}
-
-function toolInputRecord(input: string | Record<string, unknown>) {
-  if (typeof input === "string") return {}
-  return input
-}
-
-function pendingInput(part: SessionMessageAssistantTool) {
-  if (part.state.status !== "pending") return ""
-  return part.state.input.trim()
-}
-
-function toolComplete(part: SessionMessageAssistantTool) {
-  if (part.state.status === "pending") return pendingInput(part)
-  return part.state.status === "completed" || part.state.status === "error" || part.state.status === "running"
-}
-
-function stringValue(value: unknown) {
-  return typeof value === "string" ? value : undefined
-}
-
-function numberValue(value: unknown) {
-  return typeof value === "number" ? value : undefined
-}
-
-function arrayValue(value: unknown): unknown[] {
-  return Array.isArray(value) ? value : []
-}
-
-function isRecord(value: unknown): value is Record<string, unknown> {
-  return !!value && typeof value === "object" && !Array.isArray(value)
-}
-
-function input(input: Record<string, unknown>, omit?: string[]) {
-  const primitives = Object.entries(input).filter(([key, value]) => {
-    if (omit?.includes(key)) return false
-    return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
-  })
-  if (primitives.length === 0) return ""
-  return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
-}
-
-function usePathNormalizer() {
-  const cwd = useTuiPaths().cwd
-  return (input?: string) => normalizePath(input, cwd)
-}
-
-function normalizePath(input: string | undefined, cwd: string) {
-  if (!input) return ""
-  const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
-  const relative = path.relative(cwd, absolute)
-  if (!relative) return "."
-  if (!relative.startsWith("..")) return relative
-  return absolute
-}
-
-function filetype(input?: string) {
-  if (!input) return "none"
-  const language = LANGUAGE_EXTENSIONS[path.extname(input)]
-  if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
-  return language
-}
-
-function todoIcon(status?: string) {
-  if (status === "completed") return "✓"
-  if (status === "in_progress") return "~"
-  if (status === "cancelled") return "✕"
-  return "☐"
-}
-
-function formatAnswer(answer: unknown) {
-  if (!Array.isArray(answer)) return "(no answer)"
-  if (answer.length === 0) return "(no answer)"
-  return answer.filter((item): item is string => typeof item === "string").join(", ")
-}
-
-const tui: TuiPlugin = async (api) => {
-  api.route.register([
-    {
-      name: route,
-      render(input) {
-        const sessionID = input.params?.sessionID
-        if (typeof sessionID !== "string") {
-          return <text fg={api.theme.current.error}>Missing sessionID</text>
-        }
-        return <View api={api} sessionID={sessionID} />
-      },
-    },
-  ])
-
-  api.keymap.registerLayer({
-    commands: [
-      {
-        name: route,
-        title: "View v2 session messages",
-        category: "Debug",
-        namespace: "palette",
-        suggested: () => api.route.current.name === "session",
-        enabled: () => api.route.current.name === "session",
-        run() {
-          const sessionID = currentSessionID(api)
-          if (!sessionID) return
-          api.route.navigate(route, { sessionID })
-          api.ui.dialog.clear()
-        },
-      },
-    ],
-  })
-}
-
-const plugin: BuiltinTuiPlugin = {
-  id,
-  tui,
-}
-
-export default plugin

+ 3 - 5
packages/tui/src/routes/home/session-destination.tsx

@@ -7,8 +7,7 @@ import {
   type ParentProps,
   type Setter,
 } from "solid-js"
-import { useSync } from "../../context/sync"
-import { useTuiPaths } from "../../context/runtime"
+import { useData } from "../../context/data"
 
 export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
 
@@ -21,11 +20,10 @@ type Context = {
 const HomeSessionDestinationContext = createContext<Context>()
 
 export function HomeSessionDestinationProvider(props: ParentProps) {
-  const sync = useSync()
-  const paths = useTuiPaths()
+  const data = useData()
   const [selected, setDestination] = createSignal<HomeSessionDestination>()
   const destination = createMemo<HomeSessionDestination>(
-    () => selected() ?? { type: "directory", directory: sync.path.directory || paths.cwd, subdirectory: false },
+    () => selected() ?? { type: "directory", directory: data.location.default().directory, subdirectory: false },
   )
   return (
     <HomeSessionDestinationContext.Provider

+ 433 - 0
packages/tui/test/cli/tui/data.test.tsx

@@ -0,0 +1,433 @@
+/** @jsxImportSource @opentui/solid */
+import { expect, test } from "bun:test"
+import { testRender } from "@opentui/solid"
+import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
+import { onMount } from "solid-js"
+import { ProjectProvider } from "../../../src/context/project"
+import { SDKProvider } from "../../../src/context/sdk"
+import { DataProvider, useData } from "../../../src/context/data"
+import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
+import { TestTuiContexts } from "../../fixture/tui-environment"
+
+async function wait(fn: () => boolean, timeout = 2000) {
+  const start = Date.now()
+  while (!fn()) {
+    if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
+    await Bun.sleep(10)
+  }
+}
+
+function global(payload: Event): GlobalEvent {
+  return { directory, project: "proj_test", payload }
+}
+
+function emitEvent(events: ReturnType<typeof createEventSource>, payload: Event) {
+  events.emit(global(payload))
+}
+
+test("refreshes resources into reactive getters", async () => {
+  const location = {
+    directory,
+    project: { id: "proj_test", directory },
+  }
+  const calls = createFetch((url) => {
+    if (url.pathname === "/api/session/ses_test")
+      return json({
+        data: {
+          id: "ses_test",
+          projectID: "proj_test",
+          cost: 0,
+          tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+          time: { created: 0, updated: 0 },
+          title: "Test session",
+          location: { directory },
+        },
+      })
+    if (url.pathname === "/api/agent")
+      return json({
+        location,
+        data: [
+          { id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] },
+        ],
+      })
+    return undefined
+  })
+  const events = createEventSource()
+  let data!: ReturnType<typeof useData>
+  let ready!: () => void
+  const mounted = new Promise<void>((resolve) => {
+    ready = resolve
+  })
+
+  function Probe() {
+    data = useData()
+    onMount(ready)
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </SDKProvider>
+    </TestTuiContexts>
+  ))
+
+  try {
+    await mounted
+    expect(data.location.default()).toEqual({ directory })
+    expect(data.session.get("ses_test")).toBeUndefined()
+    expect(data.location.agent.list(location)).toBeUndefined()
+
+    await data.session.refresh("ses_test")
+    await data.location.agent.refresh()
+
+    expect(data.session.get("ses_test")?.title).toBe("Test session")
+    expect(data.location.default()).toEqual({ directory, workspaceID: undefined })
+    expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"])
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
+test("refreshes references after updates", async () => {
+  const events = createEventSource()
+  let requests = 0
+  const calls = createFetch((url) => {
+    if (url.pathname !== "/api/reference") return
+    requests++
+    return json({
+      location: { directory, project: { id: "proj_test", directory } },
+      data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
+    })
+  })
+  let data!: ReturnType<typeof useData>
+  let ready!: () => void
+  const mounted = new Promise<void>((resolve) => {
+    ready = resolve
+  })
+
+  function Probe() {
+    data = useData()
+    onMount(ready)
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </SDKProvider>
+    </TestTuiContexts>
+  ))
+
+  try {
+    await mounted
+    await wait(() => requests === 1)
+    emitEvent(events, { id: "evt_reference_1", type: "reference.updated", properties: {} })
+    await wait(() => data.location.reference.list()?.length === 1)
+    expect(data.location.reference.list()?.[0]?.name).toBe("docs")
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
+test("settles pending tools when a live failure arrives", async () => {
+  const events = createEventSource()
+  const calls = createFetch()
+  let sync!: ReturnType<typeof useData>
+  let ready!: () => void
+  const mounted = new Promise<void>((resolve) => {
+    ready = resolve
+  })
+
+  function Probe() {
+    sync = useData()
+    onMount(ready)
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </SDKProvider>
+    </TestTuiContexts>
+  ))
+
+  try {
+    await mounted
+    emitEvent(events, {
+      id: "evt_agent_1",
+      type: "session.next.agent.switched",
+      properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
+    })
+    emitEvent(events, {
+      id: "evt_model_1",
+      type: "session.next.model.switched",
+      properties: {
+        sessionID: "session-1",
+        messageID: "msg_model_1",
+        timestamp: 0,
+        model: { id: "model-1", providerID: "provider-1" },
+      },
+    })
+    emitEvent(events, {
+      id: "evt_step_started_1",
+      type: "session.next.step.started",
+      properties: {
+        sessionID: "session-1",
+        assistantMessageID: "msg_explicit_assistant_9",
+        timestamp: 1,
+        agent: "build",
+        model: { id: "model-1", providerID: "provider-1" },
+      },
+    })
+    emitEvent(events, {
+      id: "evt_input_1",
+      type: "session.next.tool.input.started",
+      properties: {
+        sessionID: "session-1",
+        assistantMessageID: "msg_explicit_assistant_9",
+        timestamp: 2,
+        callID: "call-1",
+        name: "bash",
+      },
+    })
+    emitEvent(events, {
+      id: "evt_called_1",
+      type: "session.next.tool.called",
+      properties: {
+        sessionID: "session-1",
+        timestamp: 2,
+        assistantMessageID: "msg_explicit_assistant_9",
+        callID: "call-1",
+        tool: "bash",
+        input: {},
+        provider: { executed: false, metadata: { fake: { call: true } } },
+      },
+    })
+    emitEvent(events, {
+      id: "evt_failed_1",
+      type: "session.next.tool.failed",
+      properties: {
+        sessionID: "session-1",
+        timestamp: 3,
+        assistantMessageID: "msg_explicit_assistant_9",
+        callID: "call-1",
+        error: { type: "unknown", message: "aborted" },
+        provider: { executed: false, metadata: { fake: { result: true } } },
+      },
+    })
+
+    await wait(() => {
+      const assistant = sync.session.message.list("session-1")?.[0]
+      return (
+        assistant?.type === "assistant" &&
+        assistant.content[0]?.type === "tool" &&
+        assistant.content[0].state.status === "error"
+      )
+    })
+
+    const assistant = sync.session.message.list("session-1")?.[0]
+    expect(assistant?.type).toBe("assistant")
+    if (assistant?.type !== "assistant") return
+    expect(assistant.id).toBe("msg_explicit_assistant_9")
+    const tool = assistant.content[0]
+    expect(tool?.type).toBe("tool")
+    if (tool?.type !== "tool") return
+    expect(tool.state.status).toBe("error")
+    if (tool.state.status !== "error") return
+    expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
+    expect(tool.state.input).toEqual({})
+    expect(tool.state.structured).toEqual({})
+    expect(tool.state.content).toEqual([])
+    expect(tool.provider).toEqual({
+      executed: false,
+      metadata: { fake: { call: true } },
+      resultMetadata: { fake: { result: true } },
+    })
+    expect((sync.session.message.list("session-1") ?? []).map((message) => message.type)).toEqual([
+      "assistant",
+      "model-switched",
+      "agent-switched",
+    ])
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
+test("renders admitted prompts only after promotion", async () => {
+  const events = createEventSource()
+  const calls = createFetch()
+  let sync!: ReturnType<typeof useData>
+  let ready!: () => void
+  const mounted = new Promise<void>((resolve) => {
+    ready = resolve
+  })
+
+  function Probe() {
+    sync = useData()
+    onMount(ready)
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </SDKProvider>
+    </TestTuiContexts>
+  ))
+
+  try {
+    await mounted
+    emitEvent(events, {
+      id: "evt_admitted_1",
+      type: "session.next.prompt.admitted",
+      properties: {
+        sessionID: "session-1",
+        messageID: "msg_user_1",
+        timestamp: 0,
+        prompt: { text: "hello" },
+        delivery: "steer",
+      },
+    })
+    expect(sync.session.message.list("session-1") ?? []).toEqual([])
+
+    emitEvent(events, {
+      id: "evt_promoted_1",
+      type: "session.next.prompt.promoted",
+      properties: {
+        sessionID: "session-1",
+        messageID: "msg_user_1",
+        timestamp: 1,
+        prompt: { text: "hello" },
+        timeCreated: 0,
+      },
+    })
+
+    await wait(() => sync.session.message.list("session-1")?.length === 1)
+    const message = sync.session.message.list("session-1")?.[0]
+    expect(message?.type).toBe("user")
+    if (message?.type !== "user") return
+    expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
+test("renders a promoted prompt when admission was missed", async () => {
+  const events = createEventSource()
+  const calls = createFetch()
+  let sync!: ReturnType<typeof useData>
+  let ready!: () => void
+  const mounted = new Promise<void>((resolve) => {
+    ready = resolve
+  })
+
+  function Probe() {
+    sync = useData()
+    onMount(ready)
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </SDKProvider>
+    </TestTuiContexts>
+  ))
+
+  try {
+    await mounted
+    emitEvent(events, {
+      id: "evt_promoted_1",
+      type: "session.next.prompt.promoted",
+      properties: {
+        sessionID: "session-1",
+        messageID: "msg_user_1",
+        timestamp: 1,
+        prompt: { text: "hello" },
+        timeCreated: 0,
+      },
+    })
+
+    await wait(() => sync.session.message.list("session-1")?.length === 1)
+    expect(sync.session.message.list("session-1")?.[0]?.id).toBe("msg_user_1")
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
+test("projects live context updates with their message ID", async () => {
+  const events = createEventSource()
+  const calls = createFetch()
+  let sync!: ReturnType<typeof useData>
+  let ready!: () => void
+  const mounted = new Promise<void>((resolve) => {
+    ready = resolve
+  })
+
+  function Probe() {
+    sync = useData()
+    onMount(ready)
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </SDKProvider>
+    </TestTuiContexts>
+  ))
+
+  try {
+    await mounted
+    emitEvent(events, {
+      id: "evt_context_1",
+      type: "session.next.context.updated",
+      properties: {
+        sessionID: "session-1",
+        messageID: "msg_context_1",
+        timestamp: 1,
+        text: "Updated context",
+      },
+    })
+
+    await wait(() => sync.session.message.list("session-1")?.length === 1)
+    expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
+      id: "msg_context_1",
+      type: "system",
+      text: "Updated context",
+    })
+  } finally {
+    app.renderer.destroy()
+  }
+})

+ 0 - 619
packages/tui/test/cli/tui/sync-v2.test.tsx

@@ -1,619 +0,0 @@
-/** @jsxImportSource @opentui/solid */
-import { expect, test } from "bun:test"
-import { testRender } from "@opentui/solid"
-import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
-import { onMount } from "solid-js"
-import { ProjectProvider } from "../../../src/context/project"
-import { SDKProvider } from "../../../src/context/sdk"
-import { SyncProviderV2, useSyncV2 } from "../../../src/context/sync-v2"
-import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
-import { TestTuiContexts } from "../../fixture/tui-environment"
-
-async function wait(fn: () => boolean, timeout = 2000) {
-  const start = Date.now()
-  while (!fn()) {
-    if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
-    await Bun.sleep(10)
-  }
-}
-
-function global(payload: Event): GlobalEvent {
-  return { directory, project: "proj_test", payload }
-}
-
-function emitTwice(events: ReturnType<typeof createEventSource>, payload: Event) {
-  const event = global(payload)
-  events.emit(event)
-  events.emit(event)
-}
-
-test("sync v2 refreshes references after updates", async () => {
-  const events = createEventSource()
-  let requests = 0
-  const calls = createFetch((url) => {
-    if (url.pathname !== "/api/reference") return
-    requests++
-    return json({
-      location: { directory, project: { id: "proj_test", directory } },
-      data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
-    })
-  })
-  let sync!: ReturnType<typeof useSyncV2>
-  let ready!: () => void
-  const mounted = new Promise<void>((resolve) => {
-    ready = resolve
-  })
-
-  function Probe() {
-    sync = useSyncV2()
-    onMount(ready)
-    return <box />
-  }
-
-  const app = await testRender(() => (
-    <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
-        <ProjectProvider>
-          <SyncProviderV2>
-            <Probe />
-          </SyncProviderV2>
-        </ProjectProvider>
-      </SDKProvider>
-    </TestTuiContexts>
-  ))
-
-  try {
-    await mounted
-    await wait(() => requests === 1)
-    events.emit(global({ id: "evt_reference_1", type: "reference.updated", properties: {} }))
-    await wait(() => sync.data.reference.length === 1)
-    expect(sync.data.reference[0]?.name).toBe("docs")
-  } finally {
-    app.renderer.destroy()
-  }
-})
-
-test("sync v2 settles pending tools when a live failure arrives", async () => {
-  const events = createEventSource()
-  const calls = createFetch()
-  let sync!: ReturnType<typeof useSyncV2>
-  let ready!: () => void
-  const mounted = new Promise<void>((resolve) => {
-    ready = resolve
-  })
-
-  function Probe() {
-    sync = useSyncV2()
-    onMount(ready)
-    return <box />
-  }
-
-  const app = await testRender(() => (
-    <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
-        <ProjectProvider>
-          <SyncProviderV2>
-            <Probe />
-          </SyncProviderV2>
-        </ProjectProvider>
-      </SDKProvider>
-    </TestTuiContexts>
-  ))
-
-  try {
-    await mounted
-    emitTwice(events, {
-      id: "evt_agent_1",
-      type: "session.next.agent.switched",
-      properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
-    })
-    emitTwice(events, {
-      id: "evt_model_1",
-      type: "session.next.model.switched",
-      properties: {
-        sessionID: "session-1",
-        messageID: "msg_model_1",
-        timestamp: 0,
-        model: { id: "model-1", providerID: "provider-1" },
-      },
-    })
-    emitTwice(events, {
-      id: "evt_step_started_1",
-      type: "session.next.step.started",
-      properties: {
-        sessionID: "session-1",
-        assistantMessageID: "msg_explicit_assistant_9",
-        timestamp: 1,
-        agent: "build",
-        model: { id: "model-1", providerID: "provider-1" },
-      },
-    })
-    emitTwice(events, {
-      id: "evt_input_1",
-      type: "session.next.tool.input.started",
-      properties: {
-        sessionID: "session-1",
-        assistantMessageID: "msg_explicit_assistant_9",
-        timestamp: 2,
-        callID: "call-1",
-        name: "bash",
-      },
-    })
-    emitTwice(events, {
-      id: "evt_called_1",
-      type: "session.next.tool.called",
-      properties: {
-        sessionID: "session-1",
-        timestamp: 2,
-        assistantMessageID: "msg_explicit_assistant_9",
-        callID: "call-1",
-        tool: "bash",
-        input: {},
-        provider: { executed: false, metadata: { fake: { call: true } } },
-      },
-    })
-    emitTwice(events, {
-      id: "evt_failed_1",
-      type: "session.next.tool.failed",
-      properties: {
-        sessionID: "session-1",
-        timestamp: 3,
-        assistantMessageID: "msg_explicit_assistant_9",
-        callID: "call-1",
-        error: { type: "unknown", message: "aborted" },
-        provider: { executed: false, metadata: { fake: { result: true } } },
-      },
-    })
-
-    await wait(() => {
-      const assistant = sync.session.message.fromSession("session-1")[0]
-      return (
-        assistant?.type === "assistant" &&
-        assistant.content[0]?.type === "tool" &&
-        assistant.content[0].state.status === "error"
-      )
-    })
-
-    const assistant = sync.session.message.fromSession("session-1")[0]
-    expect(assistant?.type).toBe("assistant")
-    if (assistant?.type !== "assistant") return
-    expect(assistant.id).toBe("msg_explicit_assistant_9")
-    const tool = assistant.content[0]
-    expect(tool?.type).toBe("tool")
-    if (tool?.type !== "tool") return
-    expect(tool.state.status).toBe("error")
-    if (tool.state.status !== "error") return
-    expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
-    expect(tool.state.input).toEqual({})
-    expect(tool.state.structured).toEqual({})
-    expect(tool.state.content).toEqual([])
-    expect(tool.provider).toEqual({
-      executed: false,
-      metadata: { fake: { call: true } },
-      resultMetadata: { fake: { result: true } },
-    })
-    expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
-      "assistant",
-      "model-switched",
-      "agent-switched",
-    ])
-  } finally {
-    app.renderer.destroy()
-  }
-})
-
-test("sync v2 renders admitted prompts only after promotion", async () => {
-  const events = createEventSource()
-  const calls = createFetch()
-  let sync!: ReturnType<typeof useSyncV2>
-  let ready!: () => void
-  const mounted = new Promise<void>((resolve) => {
-    ready = resolve
-  })
-
-  function Probe() {
-    sync = useSyncV2()
-    onMount(ready)
-    return <box />
-  }
-
-  const app = await testRender(() => (
-    <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
-        <ProjectProvider>
-          <SyncProviderV2>
-            <Probe />
-          </SyncProviderV2>
-        </ProjectProvider>
-      </SDKProvider>
-    </TestTuiContexts>
-  ))
-
-  try {
-    await mounted
-    emitTwice(events, {
-      id: "evt_admitted_1",
-      type: "session.next.prompt.admitted",
-      properties: {
-        sessionID: "session-1",
-        messageID: "msg_user_1",
-        timestamp: 0,
-        prompt: { text: "hello" },
-        delivery: "steer",
-      },
-    })
-    expect(sync.session.message.fromSession("session-1")).toEqual([])
-
-    emitTwice(events, {
-      id: "evt_promoted_1",
-      type: "session.next.prompt.promoted",
-      properties: {
-        sessionID: "session-1",
-        messageID: "msg_user_1",
-        timestamp: 1,
-        prompt: { text: "hello" },
-        timeCreated: 0,
-      },
-    })
-
-    await wait(() => sync.session.message.fromSession("session-1").length === 1)
-    const message = sync.session.message.fromSession("session-1")[0]
-    expect(message?.type).toBe("user")
-    if (message?.type !== "user") return
-    expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
-  } finally {
-    app.renderer.destroy()
-  }
-})
-
-test("sync v2 renders a promoted prompt when admission was missed", async () => {
-  const events = createEventSource()
-  const calls = createFetch()
-  let sync!: ReturnType<typeof useSyncV2>
-  let ready!: () => void
-  const mounted = new Promise<void>((resolve) => {
-    ready = resolve
-  })
-
-  function Probe() {
-    sync = useSyncV2()
-    onMount(ready)
-    return <box />
-  }
-
-  const app = await testRender(() => (
-    <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
-        <ProjectProvider>
-          <SyncProviderV2>
-            <Probe />
-          </SyncProviderV2>
-        </ProjectProvider>
-      </SDKProvider>
-    </TestTuiContexts>
-  ))
-
-  try {
-    await mounted
-    emitTwice(events, {
-      id: "evt_promoted_1",
-      type: "session.next.prompt.promoted",
-      properties: {
-        sessionID: "session-1",
-        messageID: "msg_user_1",
-        timestamp: 1,
-        prompt: { text: "hello" },
-        timeCreated: 0,
-      },
-    })
-
-    await wait(() => sync.session.message.fromSession("session-1").length === 1)
-    expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1")
-  } finally {
-    app.renderer.destroy()
-  }
-})
-
-test("sync v2 projects live context updates with their message ID", async () => {
-  const events = createEventSource()
-  const calls = createFetch()
-  let sync!: ReturnType<typeof useSyncV2>
-  let ready!: () => void
-  const mounted = new Promise<void>((resolve) => {
-    ready = resolve
-  })
-
-  function Probe() {
-    sync = useSyncV2()
-    onMount(ready)
-    return <box />
-  }
-
-  const app = await testRender(() => (
-    <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
-        <ProjectProvider>
-          <SyncProviderV2>
-            <Probe />
-          </SyncProviderV2>
-        </ProjectProvider>
-      </SDKProvider>
-    </TestTuiContexts>
-  ))
-
-  try {
-    await mounted
-    emitTwice(events, {
-      id: "evt_context_1",
-      type: "session.next.context.updated",
-      properties: {
-        sessionID: "session-1",
-        messageID: "msg_context_1",
-        timestamp: 1,
-        text: "Updated context",
-      },
-    })
-
-    await wait(() => sync.session.message.fromSession("session-1").length === 1)
-    expect(sync.session.message.fromSession("session-1")[0]).toMatchObject({
-      id: "msg_context_1",
-      type: "system",
-      text: "Updated context",
-    })
-  } finally {
-    app.renderer.destroy()
-  }
-})
-
-test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
-  const events = createEventSource()
-  const response = Promise.withResolvers<Response>()
-  const calls = createFetch((url) => {
-    if (url.pathname === "/api/session/session-1/message") return response.promise
-    return undefined
-  })
-  let sync!: ReturnType<typeof useSyncV2>
-  let ready!: () => void
-  const mounted = new Promise<void>((resolve) => {
-    ready = resolve
-  })
-
-  function Probe() {
-    sync = useSyncV2()
-    onMount(ready)
-    return <box />
-  }
-
-  const app = await testRender(() => (
-    <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
-        <ProjectProvider>
-          <SyncProviderV2>
-            <Probe />
-          </SyncProviderV2>
-        </ProjectProvider>
-      </SDKProvider>
-    </TestTuiContexts>
-  ))
-
-  try {
-    await mounted
-    const hydration = sync.session.message.sync("session-1")
-    emitTwice(events, {
-      id: "evt_agent_1",
-      type: "session.next.agent.switched",
-      properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
-    })
-    response.resolve(json({ data: [] }))
-    await hydration
-
-    expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
-      ["msg_agent_1", "agent-switched"],
-    ])
-  } finally {
-    app.renderer.destroy()
-  }
-})
-
-test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => {
-  const events = createEventSource()
-  const response = Promise.withResolvers<Response>()
-  const calls = createFetch((url) => {
-    if (url.pathname === "/api/session/session-1/message") return response.promise
-    return undefined
-  })
-  let sync!: ReturnType<typeof useSyncV2>
-  let ready!: () => void
-  const mounted = new Promise<void>((resolve) => {
-    ready = resolve
-  })
-
-  function Probe() {
-    sync = useSyncV2()
-    onMount(ready)
-    return <box />
-  }
-
-  const app = await testRender(() => (
-    <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
-        <ProjectProvider>
-          <SyncProviderV2>
-            <Probe />
-          </SyncProviderV2>
-        </ProjectProvider>
-      </SDKProvider>
-    </TestTuiContexts>
-  ))
-
-  try {
-    await mounted
-    emitTwice(events, {
-      id: "evt_promoted_1",
-      type: "session.next.prompt.promoted",
-      properties: {
-        sessionID: "session-1",
-        messageID: "msg_user_1",
-        timestamp: 1,
-        prompt: { text: "stale" },
-        timeCreated: 0,
-      },
-    })
-    await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1")
-    const hydration = sync.session.message.sync("session-1")
-    emitTwice(events, {
-      id: "evt_agent_1",
-      type: "session.next.agent.switched",
-      properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" },
-    })
-    await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1")
-    response.resolve(
-      json({
-        data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }],
-      }),
-    )
-    await hydration
-
-    expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
-      ["msg_agent_1", "agent-switched"],
-      ["msg_user_1", "user"],
-    ])
-    expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" })
-  } finally {
-    app.renderer.destroy()
-  }
-})
-
-test("sync v2 preserves snapshot order and metadata for in-flight updates", async () => {
-  const events = createEventSource()
-  const response = Promise.withResolvers<Response>()
-  const calls = createFetch((url) => {
-    if (url.pathname === "/api/session/session-1/message") return response.promise
-    return undefined
-  })
-  let sync!: ReturnType<typeof useSyncV2>
-  let ready!: () => void
-  const mounted = new Promise<void>((resolve) => {
-    ready = resolve
-  })
-
-  function Probe() {
-    sync = useSyncV2()
-    onMount(ready)
-    return <box />
-  }
-
-  const app = await testRender(() => (
-    <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
-        <ProjectProvider>
-          <SyncProviderV2>
-            <Probe />
-          </SyncProviderV2>
-        </ProjectProvider>
-      </SDKProvider>
-    </TestTuiContexts>
-  ))
-
-  try {
-    await mounted
-    emitTwice(events, {
-      id: "evt_step_older",
-      type: "session.next.step.started",
-      properties: {
-        sessionID: "session-1",
-        assistantMessageID: "msg_assistant_older",
-        timestamp: 0,
-        agent: "build",
-        model: { id: "model", providerID: "provider" },
-      },
-    })
-    emitTwice(events, {
-      id: "evt_step_1",
-      type: "session.next.step.started",
-      properties: {
-        sessionID: "session-1",
-        assistantMessageID: "msg_assistant_old",
-        timestamp: 1,
-        agent: "build",
-        model: { id: "model", providerID: "provider" },
-      },
-    })
-    await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old")
-    const hydration = sync.session.message.sync("session-1")
-    emitTwice(events, {
-      id: "evt_text_1",
-      type: "session.next.text.started",
-      properties: {
-        sessionID: "session-1",
-        assistantMessageID: "msg_assistant_old",
-        timestamp: 2,
-        textID: "text-1",
-      },
-    })
-    emitTwice(events, {
-      id: "evt_text_older",
-      type: "session.next.text.started",
-      properties: {
-        sessionID: "session-1",
-        assistantMessageID: "msg_assistant_older",
-        timestamp: 2,
-        textID: "text-older",
-      },
-    })
-    await wait(() => {
-      const messages = sync.session.message.fromSession("session-1")
-      return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text")
-    })
-    response.resolve(
-      json({
-        data: [
-          {
-            id: "msg_assistant_new",
-            type: "assistant",
-            agent: "build",
-            model: { id: "model", providerID: "provider" },
-            content: [],
-            time: { created: 3 },
-          },
-          {
-            id: "msg_assistant_old",
-            type: "assistant",
-            metadata: { source: "snapshot" },
-            agent: "build",
-            model: { id: "model", providerID: "provider" },
-            content: [],
-            time: { created: 1 },
-          },
-        ],
-      }),
-    )
-    await hydration
-    emitTwice(events, {
-      id: "evt_step_late_duplicate",
-      type: "session.next.step.started",
-      properties: {
-        sessionID: "session-1",
-        assistantMessageID: "msg_assistant_old",
-        timestamp: 1,
-        agent: "build",
-        model: { id: "model", providerID: "provider" },
-      },
-    })
-
-    expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([
-      "msg_assistant_new",
-      "msg_assistant_old",
-      "msg_assistant_older",
-    ])
-    expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({
-      metadata: { source: "snapshot" },
-      content: [{ type: "text", id: "text-1", text: "" }],
-    })
-    expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({
-      content: [{ type: "text", id: "text-older", text: "" }],
-    })
-  } finally {
-    app.renderer.destroy()
-  }
-})

+ 7 - 0
packages/tui/test/fixture/tui-sdk.ts

@@ -59,6 +59,13 @@ export function createFetch(override?: FetchHandler) {
     if (url.pathname === "/config/providers") return json({ providers: {}, default: {} })
     if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
     if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
+    if (url.pathname === "/api/location")
+      return json({ directory, project: { id: "proj_test", directory: worktree } })
+    if (["/api/agent", "/api/model", "/api/provider", "/api/command", "/api/skill"].includes(url.pathname))
+      return json({
+        location: { directory, project: { id: "proj_test", directory: worktree } },
+        data: [],
+      })
     if (url.pathname === "/project/current") return json({ id: "proj_test" })
     if (url.pathname === "/api/reference")
       return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })