diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx
index edf9085ae..a05dda1b4 100644
--- a/packages/tui/src/app.tsx
+++ b/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) {
>
-
+
@@ -315,7 +315,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
-
+
diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx
index 15da1a65d..b540e86b2 100644
--- a/packages/tui/src/component/dialog-model.tsx
+++ b/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(
+export function sortModelOptions(
options: T[],
newestFirst: boolean,
) {
diff --git a/packages/tui/src/component/dialog-workspace-create.tsx b/packages/tui/src/component/dialog-workspace-create.tsx
index 98c71bb00..b3e807b2f 100644
--- a/packages/tui/src/component/dialog-workspace-create.tsx
+++ b/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) {
diff --git a/packages/tui/src/component/dialog-workspace-list.tsx b/packages/tui/src/component/dialog-workspace-list.tsx
index eab2acf7c..b37efc622 100644
--- a/packages/tui/src/component/dialog-workspace-list.tsx
+++ b/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)
}
diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx
index b543d7c38..a8a6b1288 100644
--- a/packages/tui/src/component/prompt/autocomplete.tsx
+++ b/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()
- // @/... — 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}`)
}
diff --git a/packages/tui/src/component/use-connected.tsx b/packages/tui/src/component/use-connected.tsx
index 6a9e5f82a..47886d67a 100644
--- a/packages/tui/src/component/use-connected.tsx
+++ b/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))
}
diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx
new file mode 100644
index 000000000..23df05c5f
--- /dev/null
+++ b/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
+ message: Record
+ permission: Record
+ question: Record
+ }
+ project: {
+ permission: Record
+ }
+ location: Record
+}
+
+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({
+ session: {
+ info: {},
+ message: {},
+ permission: {},
+ question: {},
+ },
+ project: {
+ permission: {},
+ },
+ location: {},
+ })
+
+ const event = useEvent()
+ const sdk = useSDK()
+ const [defaultLocation, setDefaultLocation] = createSignal({
+ 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
+ },
+})
diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx
index d9d0c492f..f82ea7d1b 100644
--- a/packages/tui/src/context/local.tsx
+++ b/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()
diff --git a/packages/tui/src/context/sync-v2.tsx b/packages/tui/src/context/sync-v2.tsx
deleted file mode 100644
index c3102d251..000000000
--- a/packages/tui/src/context/sync-v2.tsx
+++ /dev/null
@@ -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()
- const buffering = new Map()
- const syncing = new Map>()
-
- 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
- },
-})
diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts
index f0ed7ab89..b67923f3c 100644
--- a/packages/tui/src/feature-plugins/builtins.ts
+++ b/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 & {
@@ -33,6 +32,5 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean
PluginManager,
WhichKey,
DiffViewer,
- ...(options.experimentalEventSystem ? [SessionV2Debug] : []),
]
}
diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx
index 41bee5da5..ae6a24658 100644
--- a/packages/tui/src/feature-plugins/home/footer.tsx
+++ b/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
})
diff --git a/packages/tui/src/feature-plugins/system/session-v2.tsx b/packages/tui/src/feature-plugins/system/session-v2.tsx
deleted file mode 100644
index 8a03ec5dd..000000000
--- a/packages/tui/src/feature-plugins/system/session-v2.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
-
-
- {(message, index) => (
-
-
-
-
-
-
-
-
- <>>
-
-
- <>>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
-
-
-
-
-
- )
-}
-
-function MissingData(props: { label: string; detail: string }) {
- const { theme } = useTheme()
- return (
-
-
- MISSING DATA {props.label}
-
- {props.detail}
-
- )
-}
-
-function UserMessage(props: { message: SessionMessageUser; index: number }) {
- const { theme } = useTheme()
- const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])])
- return (
-
- {props.message.text}
-
-
-
- {(file) => (
-
- {file.mime}
- {file.name ?? file.uri}
-
- )}
-
-
- {(agent) => (
-
- agent
- {agent.name}
-
- )}
-
-
-
-
- )
-}
-
-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 (
- setExpanded((prev) => !prev) : undefined}
- >
-
- $ {props.message.command}
-
- {limited()}
-
-
- {expanded() ? "Click to collapse" : "Click to expand"}
-
-
-
- )
-}
-
-function CompactionMessage(props: { message: SessionMessageCompaction }) {
- const { theme } = useTheme()
- return (
-
- )
-}
-
-function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) {
- const { theme } = useTheme()
- const local = useLocal()
- return (
-
-
- ▣
- Switched agent to
- {Locale.titlecase(props.message.agent)}
-
-
- )
-}
-
-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 (
-
-
- ◇
- Switched model to
- {model()}
-
-
- )
-}
-
-function UnknownMessage(props: { message: SessionMessage }) {
- return
-}
-
-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 (
- <>
-
- {(part) => (
-
-
-
-
-
- props.message.time.completed}
- />
-
-
-
-
-
- )}
-
-
-
-
-
-
- {props.message.error}
-
-
-
-
-
- ▣
- {Locale.titlecase(props.message.agent)}
- · {model()}
-
- · {Locale.duration(duration())}
-
-
-
-
- >
- )
-}
-
-function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) {
- const { theme } = useTheme()
- return (
-
-
-
-
-
- )
-}
-
-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 (
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-
-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 (
-
-
-
- {props.title ? "Thinking: " + props.title : "Thinking"}
-
-
-
-
-
- {props.open ? "- " : "+ "}
-
- Thought
-
- :
- {props.title}
-
-
-
-
- )
-}
-
-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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-
-type ToolProps = {
- input: Record
- metadata: Record
- 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 (
-
- {props.part.name} {input(props.input)}
-
- }
- >
- setExpanded((prev) => !prev) : undefined}
- >
-
- {limited()}
-
- {expanded() ? "Click to collapse" : "Click to expand"}
-
-
-
-
- )
-}
-
-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 (
- 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))
- }}
- >
-
-
-
-
-
-
-
- {props.icon}
-
-
-
-
- ~
-
-
-
-
-
-
-
-
-
- {props.children}
-
-
-
-
- {props.pending}
-
-
-
-
-
-
- {error()}
-
-
-
-
- )
-}
-
-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 (
- props.onClick && setHover(true)}
- onMouseOut={() => setHover(false)}
- onMouseUp={() => {
- if (renderer.getSelection()?.getSelectedText()) return
- props.onClick?.()
- }}
- flexShrink={0}
- >
-
- {props.title}
-
- }
- >
- {props.title.replace(/^# /, "")}
-
- {props.children}
-
- {error()}
-
-
- )
-}
-
-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 (
-
-
- setExpanded((prev) => !prev) : undefined}
- >
-
- $ {command()}
- {limited()}
-
- {expanded() ? "Click to collapse" : "Click to expand"}
-
-
-
-
-
-
- {command()}
-
-
-
- )
-}
-
-function Glob(props: ToolProps) {
- const normalizePath = usePathNormalizer()
- return (
-
- Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
- in {normalizePath(stringValue(props.input.path))}
-
- {(count) => (
- <>
- ({count()} {count() === 1 ? "match" : "matches"})
- >
- )}
-
-
- )
-}
-
-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 (
- <>
-
- Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "}
- {input(props.input, ["filePath"])}
-
-
- {(filepath) => (
-
-
- ↳ Loaded {normalizePath(filepath)}
-
-
- )}
-
- >
- )
-}
-
-function Grep(props: ToolProps) {
- const normalizePath = usePathNormalizer()
- return (
-
- Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
- in {normalizePath(stringValue(props.input.path))}
-
- {(matches) => (
- <>
- ({matches()} {matches() === 1 ? "match" : "matches"})
- >
- )}
-
-
- )
-}
-
-function WebFetch(props: ToolProps) {
- return (
-
- WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)}
-
- )
-}
-
-function WebSearch(props: ToolProps) {
- const label = createMemo(() => webSearchProviderLabel(props.metadata.provider))
- return (
-
- {label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "}
- {(results) => <>({results()} results)>}
-
- )
-}
-
-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 (
-
-
-
-
-
-
-
-
-
-
-
- Write {normalizePath(filePath())}
-
-
-
- )
-}
-
-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 (
-
-
- {(diff) => (
-
-
- 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}
- />
-
-
-
- )}
-
-
-
- Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })}
-
-
-
- )
-}
-
-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) => {
- 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 (
-
- 0}>
-
- {(file) => (
-
-
- -{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"}
-
- }
- >
- {(patch) => (
-
- 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}
- />
-
- )}
-
-
- )}
-
-
-
-
- Patch
-
-
-
- )
-}
-
-function TodoWrite(props: ToolProps) {
- const { theme } = useTheme()
- const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : [])))
- return (
-
- 0 && props.part.state.status === "completed"}>
-
-
-
- {(todo) => (
-
- {todoIcon(stringValue(todo.status))} {stringValue(todo.content)}
-
- )}
-
-
-
-
-
-
- Updating todos...
-
-
-
- )
-}
-
-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 (
-
- 0}>
-
-
-
- {(question, index) => (
-
- {stringValue(question.question)}
- {formatAnswer(answers()[index()])}
-
- )}
-
-
-
-
-
-
- Asked {questions().length} question{questions().length === 1 ? "" : "s"}
-
-
-
- )
-}
-
-function Skill(props: ToolProps) {
- return (
-
- Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}"
-
- )
-}
-
-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 (
-
- {content()}
-
- )
-}
-
-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 (
-
-
-
- {(diagnostic) => Error {stringValue(diagnostic.message)}}
-
-
-
- )
-}
-
-function toolOutput(content?: Array) {
- 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) {
- 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 {
- return !!value && typeof value === "object" && !Array.isArray(value)
-}
-
-function input(input: Record, 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 Missing sessionID
- }
- return
- },
- },
- ])
-
- 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
diff --git a/packages/tui/src/routes/home/session-destination.tsx b/packages/tui/src/routes/home/session-destination.tsx
index 352010840..1e4c65032 100644
--- a/packages/tui/src/routes/home/session-destination.tsx
+++ b/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()
export function HomeSessionDestinationProvider(props: ParentProps) {
- const sync = useSync()
- const paths = useTuiPaths()
+ const data = useData()
const [selected, setDestination] = createSignal()
const destination = createMemo(
- () => selected() ?? { type: "directory", directory: sync.path.directory || paths.cwd, subdirectory: false },
+ () => selected() ?? { type: "directory", directory: data.location.default().directory, subdirectory: false },
)
return (
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, 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
+ let ready!: () => void
+ const mounted = new Promise((resolve) => {
+ ready = resolve
+ })
+
+ function Probe() {
+ data = useData()
+ onMount(ready)
+ return
+ }
+
+ const app = await testRender(() => (
+
+
+
+
+
+
+
+
+
+ ))
+
+ 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
+ let ready!: () => void
+ const mounted = new Promise((resolve) => {
+ ready = resolve
+ })
+
+ function Probe() {
+ data = useData()
+ onMount(ready)
+ return
+ }
+
+ const app = await testRender(() => (
+
+
+
+
+
+
+
+
+
+ ))
+
+ 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
+ let ready!: () => void
+ const mounted = new Promise((resolve) => {
+ ready = resolve
+ })
+
+ function Probe() {
+ sync = useData()
+ onMount(ready)
+ return
+ }
+
+ const app = await testRender(() => (
+
+
+
+
+
+
+
+
+
+ ))
+
+ 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
+ let ready!: () => void
+ const mounted = new Promise((resolve) => {
+ ready = resolve
+ })
+
+ function Probe() {
+ sync = useData()
+ onMount(ready)
+ return
+ }
+
+ const app = await testRender(() => (
+
+
+
+
+
+
+
+
+
+ ))
+
+ 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
+ let ready!: () => void
+ const mounted = new Promise((resolve) => {
+ ready = resolve
+ })
+
+ function Probe() {
+ sync = useData()
+ onMount(ready)
+ return
+ }
+
+ const app = await testRender(() => (
+
+
+
+
+
+
+
+
+
+ ))
+
+ 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
+ let ready!: () => void
+ const mounted = new Promise((resolve) => {
+ ready = resolve
+ })
+
+ function Probe() {
+ sync = useData()
+ onMount(ready)
+ return
+ }
+
+ const app = await testRender(() => (
+
+
+
+
+
+
+
+
+
+ ))
+
+ 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()
+ }
+})
diff --git a/packages/tui/test/cli/tui/sync-v2.test.tsx b/packages/tui/test/cli/tui/sync-v2.test.tsx
deleted file mode 100644
index 7d6ba5f83..000000000
--- a/packages/tui/test/cli/tui/sync-v2.test.tsx
+++ /dev/null
@@ -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, 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
- let ready!: () => void
- const mounted = new Promise((resolve) => {
- ready = resolve
- })
-
- function Probe() {
- sync = useSyncV2()
- onMount(ready)
- return
- }
-
- const app = await testRender(() => (
-
-
-
-
-
-
-
-
-
- ))
-
- 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
- let ready!: () => void
- const mounted = new Promise((resolve) => {
- ready = resolve
- })
-
- function Probe() {
- sync = useSyncV2()
- onMount(ready)
- return
- }
-
- const app = await testRender(() => (
-
-
-
-
-
-
-
-
-
- ))
-
- 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
- let ready!: () => void
- const mounted = new Promise((resolve) => {
- ready = resolve
- })
-
- function Probe() {
- sync = useSyncV2()
- onMount(ready)
- return
- }
-
- const app = await testRender(() => (
-
-
-
-
-
-
-
-
-
- ))
-
- 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
- let ready!: () => void
- const mounted = new Promise((resolve) => {
- ready = resolve
- })
-
- function Probe() {
- sync = useSyncV2()
- onMount(ready)
- return
- }
-
- const app = await testRender(() => (
-
-
-
-
-
-
-
-
-
- ))
-
- 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
- let ready!: () => void
- const mounted = new Promise((resolve) => {
- ready = resolve
- })
-
- function Probe() {
- sync = useSyncV2()
- onMount(ready)
- return
- }
-
- const app = await testRender(() => (
-
-
-
-
-
-
-
-
-
- ))
-
- 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()
- const calls = createFetch((url) => {
- if (url.pathname === "/api/session/session-1/message") return response.promise
- return undefined
- })
- let sync!: ReturnType
- let ready!: () => void
- const mounted = new Promise((resolve) => {
- ready = resolve
- })
-
- function Probe() {
- sync = useSyncV2()
- onMount(ready)
- return
- }
-
- const app = await testRender(() => (
-
-
-
-
-
-
-
-
-
- ))
-
- 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()
- const calls = createFetch((url) => {
- if (url.pathname === "/api/session/session-1/message") return response.promise
- return undefined
- })
- let sync!: ReturnType
- let ready!: () => void
- const mounted = new Promise((resolve) => {
- ready = resolve
- })
-
- function Probe() {
- sync = useSyncV2()
- onMount(ready)
- return
- }
-
- const app = await testRender(() => (
-
-
-
-
-
-
-
-
-
- ))
-
- 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()
- const calls = createFetch((url) => {
- if (url.pathname === "/api/session/session-1/message") return response.promise
- return undefined
- })
- let sync!: ReturnType
- let ready!: () => void
- const mounted = new Promise((resolve) => {
- ready = resolve
- })
-
- function Probe() {
- sync = useSyncV2()
- onMount(ready)
- return
- }
-
- const app = await testRender(() => (
-
-
-
-
-
-
-
-
-
- ))
-
- 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()
- }
-})
diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts
index d18228fca..9866b9132 100644
--- a/packages/tui/test/fixture/tui-sdk.ts
+++ b/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: [] })