import type { Message, Agent, Provider, Session, Part, Config, Todo, Command, PermissionRequest, QuestionRequest, LspStatus, McpStatus, McpResource, FormatterStatus, SessionStatus, ProviderListResponse, ProviderAuthMethod, VcsInfo, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { useProject } from "@tui/context/project" import { useEvent } from "@tui/context/event" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/core/util/binary" import { createSimpleContext } from "./helper" import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { useArgs } from "./args" import { batch, onMount } from "solid-js" import * as Log from "@opencode-ai/core/util/log" import { emptyConsoleState, type ConsoleState } from "@opencode-ai/core/v1/config/console-state" import path from "path" import { useKV } from "./kv" import { aggregateFailures } from "./aggregate-failures" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { const [store, setStore] = createStore<{ status: "loading" | "partial" | "complete" provider: Provider[] provider_default: Record provider_next: ProviderListResponse console_state: ConsoleState provider_auth: Record agent: Agent[] command: Command[] permission: { [sessionID: string]: PermissionRequest[] } question: { [sessionID: string]: QuestionRequest[] } config: Config session: Session[] session_status: { [sessionID: string]: SessionStatus } session_diff: { [sessionID: string]: Snapshot.FileDiff[] } todo: { [sessionID: string]: Todo[] } message: { [sessionID: string]: Message[] } part: { [messageID: string]: Part[] } lsp: LspStatus[] mcp: { [key: string]: McpStatus } mcp_resource: { [key: string]: McpResource } formatter: FormatterStatus[] vcs: VcsInfo | undefined }>({ provider_next: { all: [], default: {}, connected: [], }, console_state: emptyConsoleState, provider_auth: {}, config: {}, status: "loading", agent: [], permission: {}, question: {}, command: [], provider: [], provider_default: {}, session: [], session_status: {}, session_diff: {}, todo: {}, message: {}, part: {}, lsp: [], mcp: {}, mcp_resource: {}, formatter: [], vcs: undefined, }) const event = useEvent() const project = useProject() const sdk = useSDK() const kv = useKV() const fullSyncedSessions = new Set() const syncingSessions = new Map>() const hydratingSessions = new Map; parts: Set }>() const touchMessage = (sessionID: string, messageID: string) => { hydratingSessions.get(sessionID)?.messages.add(messageID) } const touchPart = (sessionID: string, partID: string) => { hydratingSessions.get(sessionID)?.parts.add(partID) } function sessionListQuery(): { scope?: "project"; path?: string } { if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" } if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" } return { path: path .relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory) .replaceAll("\\", "/"), } } function listSessions() { return sdk.client.session .list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() }) .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) } event.subscribe((event, { workspace }) => { switch (event.type) { case "server.instance.disposed": void bootstrap() break case "permission.replied": { const requests = store.permission[event.properties.sessionID] if (!requests) break const match = Binary.search(requests, event.properties.requestID, (r) => r.id) if (!match.found) break setStore( "permission", event.properties.sessionID, produce((draft) => { draft.splice(match.index, 1) }), ) break } case "permission.asked": { const request = event.properties const requests = store.permission[request.sessionID] if (!requests) { setStore("permission", request.sessionID, [request]) break } const match = Binary.search(requests, request.id, (r) => r.id) if (match.found) { setStore("permission", request.sessionID, match.index, reconcile(request)) break } setStore( "permission", request.sessionID, produce((draft) => { draft.splice(match.index, 0, request) }), ) break } case "question.replied": case "question.rejected": { const requests = store.question[event.properties.sessionID] if (!requests) break const match = Binary.search(requests, event.properties.requestID, (r) => r.id) if (!match.found) break setStore( "question", event.properties.sessionID, produce((draft) => { draft.splice(match.index, 1) }), ) break } case "question.asked": { const request = event.properties const requests = store.question[request.sessionID] if (!requests) { setStore("question", request.sessionID, [request]) break } const match = Binary.search(requests, request.id, (r) => r.id) if (match.found) { setStore("question", request.sessionID, match.index, reconcile(request)) break } setStore( "question", request.sessionID, produce((draft) => { draft.splice(match.index, 0, request) }), ) break } case "todo.updated": setStore("todo", event.properties.sessionID, event.properties.todos) break case "session.diff": setStore("session_diff", event.properties.sessionID, event.properties.diff) break case "session.deleted": { const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) if (result.found) { setStore( "session", produce((draft) => { draft.splice(result.index, 1) }), ) } break } case "session.updated": { const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) if (result.found) { setStore("session", result.index, reconcile(event.properties.info)) break } setStore( "session", produce((draft) => { draft.splice(result.index, 0, event.properties.info) }), ) break } case "session.next.moved": { const result = Binary.search(store.session, event.properties.sessionID, (s) => s.id) if (!result.found) break setStore( "session", result.index, produce((session) => { session.directory = event.properties.location.directory session.path = event.properties.subdirectory session.workspaceID = event.properties.location.workspaceID session.time.updated = event.properties.timestamp }), ) break } case "session.status": { setStore("session_status", event.properties.sessionID, event.properties.status) break } case "message.updated": { touchMessage(event.properties.info.sessionID, event.properties.info.id) const messages = store.message[event.properties.info.sessionID] if (!messages) { setStore("message", event.properties.info.sessionID, [event.properties.info]) break } const result = Binary.search(messages, event.properties.info.id, (m) => m.id) if (result.found) { setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) break } setStore( "message", event.properties.info.sessionID, produce((draft) => { draft.splice(result.index, 0, event.properties.info) }), ) const updated = store.message[event.properties.info.sessionID] if (updated.length > 100) { const oldest = updated[0] batch(() => { setStore( "message", event.properties.info.sessionID, produce((draft) => { draft.shift() }), ) setStore( "part", produce((draft) => { delete draft[oldest.id] }), ) }) } break } case "message.removed": { touchMessage(event.properties.sessionID, event.properties.messageID) const messages = store.message[event.properties.sessionID] const result = Binary.search(messages, event.properties.messageID, (m) => m.id) if (result.found) { setStore( "message", event.properties.sessionID, produce((draft) => { draft.splice(result.index, 1) }), ) } break } case "message.part.updated": { touchPart(event.properties.part.sessionID, event.properties.part.id) const parts = store.part[event.properties.part.messageID] if (!parts) { setStore("part", event.properties.part.messageID, [event.properties.part]) break } const result = Binary.search(parts, event.properties.part.id, (p) => p.id) if (result.found) { setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) break } setStore( "part", event.properties.part.messageID, produce((draft) => { draft.splice(result.index, 0, event.properties.part) }), ) break } case "message.part.delta": { const parts = store.part[event.properties.messageID] if (!parts) break const result = Binary.search(parts, event.properties.partID, (p) => p.id) if (!result.found) break touchPart(event.properties.sessionID, event.properties.partID) setStore( "part", event.properties.messageID, produce((draft) => { const part = draft[result.index] const field = event.properties.field as keyof typeof part const existing = part[field] as string | undefined ;(part[field] as string) = (existing ?? "") + event.properties.delta }), ) break } case "message.part.removed": { touchPart(event.properties.sessionID, event.properties.partID) const parts = store.part[event.properties.messageID] const result = Binary.search(parts, event.properties.partID, (p) => p.id) if (result.found) { setStore( "part", event.properties.messageID, produce((draft) => { draft.splice(result.index, 1) }), ) } break } case "lsp.updated": { const workspace = project.workspace.current() void sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? [])) break } case "vcs.branch.updated": { if (workspace === project.workspace.current()) { setStore("vcs", { branch: event.properties.branch }) } break } } }) const exit = useExit() const args = useArgs() async function bootstrap(input: { fatal?: boolean } = {}) { const fatal = input.fatal ?? true const workspace = project.workspace.current() const projectPromise = project.sync() const sessionListPromise = projectPromise.then(() => listSessions()) // blocking - include session.list when continuing a session const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true }) const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true }) const consoleStatePromise = sdk.client.experimental.console .get({ workspace }, { throwOnError: true }) .then((x) => x.data) .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) const blockingRequests: { name: string; promise: Promise }[] = [ { name: "config.providers", promise: providersPromise }, { name: "provider.list", promise: providerListPromise }, { name: "app.agents", promise: agentsPromise }, { name: "config.get", promise: configPromise }, { name: "project.sync", promise: projectPromise }, ...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []), ] await Promise.allSettled(blockingRequests.map((r) => r.promise)) .then((settled) => { // Surface every failed endpoint in one labeled message instead of // letting the first rejection drown its siblings as unhandled // rejections. const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] }))) if (failure) throw failure }) .then(async () => { const providersResponse = providersPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!) const consoleStateResponse = consoleStatePromise const agentsResponse = agentsPromise.then((x) => x.data ?? []) const configResponse = configPromise.then((x) => x.data!) const sessionListResponse = args.continue ? sessionListPromise : undefined return Promise.all([ providersResponse, providerListResponse, consoleStateResponse, agentsResponse, configResponse, ...(sessionListResponse ? [sessionListResponse] : []), ]).then((responses) => { const providers = responses[0] const providerList = responses[1] const consoleState = responses[2] const agents = responses[3] const config = responses[4] const sessions = responses[5] batch(() => { setStore("provider", reconcile(providers.providers)) setStore("provider_default", reconcile(providers.default)) setStore("provider_next", reconcile(providerList)) setStore("console_state", reconcile(consoleState)) setStore("agent", reconcile(agents)) setStore("config", reconcile(config)) if (sessions !== undefined) setStore("session", reconcile(sessions)) }) }) }) .then(() => { if (store.status !== "complete") setStore("status", "partial") // non-blocking void Promise.all([ ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))), sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))), sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))), sdk.client.experimental.resource .list({ workspace }) .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))), sdk.client.session.status({ workspace }).then((x) => { setStore("session_status", reconcile(x.data ?? {})) }), sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), project.workspace.sync(), ]).then(() => { setStore("status", "complete") }) }) .catch(async (e) => { Log.Default.error("tui bootstrap failed", { error: e instanceof Error ? e.message : String(e), name: e instanceof Error ? e.name : undefined, stack: e instanceof Error ? e.stack : undefined, }) if (fatal) { await exit(e) } else { throw e } }) } onMount(() => { void bootstrap() }) const result = { data: store, set: setStore, get status() { return store.status }, get ready() { if (process.env.OPENCODE_FAST_BOOT) return true return store.status !== "loading" }, get path() { return project.instance.path() }, session: { get(sessionID: string) { const match = Binary.search(store.session, sessionID, (s) => s.id) if (match.found) return store.session[match.index] return undefined }, query() { return sessionListQuery() }, async refresh() { const list = await listSessions() setStore("session", reconcile(list)) }, status(sessionID: string) { const session = result.session.get(sessionID) if (!session) return "idle" if (session.time.compacting) return "compacting" const messages = store.message[sessionID] ?? [] const last = messages.at(-1) if (!last) return "idle" if (last.role === "user") return "working" return last.time.completed ? "idle" : "working" }, async sync(sessionID: string) { if (fullSyncedSessions.has(sessionID)) return const syncing = syncingSessions.get(sessionID) if (syncing) return syncing const tracker = { messages: new Set(), parts: new Set() } hydratingSessions.set(sessionID, tracker) const task = (async () => { const [session, messages, todo, diff] = await Promise.all([ sdk.client.session.get({ sessionID }, { throwOnError: true }), sdk.client.session.messages({ sessionID, limit: 100 }), sdk.client.session.todo({ sessionID }), sdk.client.session.diff({ sessionID }), ]) setStore( produce((draft) => { const match = Binary.search(draft.session, sessionID, (s) => s.id) if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] const currentMessages = draft.message[sessionID] ?? [] const infos = (messages.data ?? []).flatMap((message) => { if (!tracker.messages.has(message.info.id)) return [message.info] const current = currentMessages.find((item) => item.id === message.info.id) return current ? [current] : [] }) infos.push( ...currentMessages.filter( (message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id), ), ) const removed = infos.slice(0, -100) const visible = infos.slice(-100) const visibleIDs = new Set(visible.map((message) => message.id)) for (const message of messages.data ?? []) { if (!visibleIDs.has(message.info.id)) { delete draft.part[message.info.id] continue } const currentParts = draft.part[message.info.id] ?? [] const parts = message.parts.flatMap((part) => { const current = currentParts.find((item) => item.id === part.id) if (tracker.parts.has(part.id)) return current ? [current] : [] if ( current && (part.type === "text" || part.type === "reasoning") && (current.type === "text" || current.type === "reasoning") && part.text.length === 0 && current.text.length > 0 ) { return [current] } return [part] }) parts.push( ...currentParts.filter( (part) => tracker.parts.has(part.id) && !parts.some((item) => item.id === part.id), ), ) draft.part[message.info.id] = parts } for (const message of removed) delete draft.part[message.id] draft.message[sessionID] = visible draft.session_diff[sessionID] = diff.data ?? [] }), ) fullSyncedSessions.add(sessionID) })().finally(() => { syncingSessions.delete(sessionID) hydratingSessions.delete(sessionID) }) syncingSessions.set(sessionID, task) return task }, }, bootstrap, } return result }, })