From 8b682c42b66d1d4a5a01157288e4e85b2f747506 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 26 Jun 2026 14:57:01 -0400 Subject: [PATCH] refactor(tui): use v2 client transport --- packages/cli/src/tui.ts | 28 +- packages/opencode/src/cli/cmd/attach.ts | 5 +- packages/opencode/src/cli/cmd/tui.ts | 61 +- packages/opencode/src/cli/tui/worker.ts | 26 - packages/opencode/test/fixture/tui-sdk.ts | 82 --- packages/tui/src/app.tsx | 17 +- .../tui/src/component/dialog-move-session.tsx | 6 +- .../tui/src/component/dialog-session-list.tsx | 8 +- .../src/component/dialog-workspace-create.tsx | 2 +- packages/tui/src/component/prompt/index.tsx | 153 ++--- packages/tui/src/component/prompt/move.tsx | 2 +- packages/tui/src/context/data.tsx | 23 +- packages/tui/src/context/project.tsx | 2 +- packages/tui/src/context/sdk.tsx | 92 +-- packages/tui/src/context/sync.tsx | 616 ++---------------- .../feature-plugins/system/notifications.ts | 31 +- packages/tui/src/plugin/adapters.tsx | 8 +- packages/tui/test/app-lifecycle.test.tsx | 52 +- .../test/cli/cmd/tui/notifications.test.ts | 102 +-- .../tui/test/cli/cmd/tui/sync-fixture.tsx | 10 +- .../cli/cmd/tui/sync-live-hydration.test.tsx | 262 -------- .../cmd/tui/sync-undefined-messages.test.tsx | 43 -- packages/tui/test/cli/cmd/tui/sync.test.tsx | 81 +-- packages/tui/test/cli/tui/data.test.tsx | 108 ++- packages/tui/test/cli/tui/use-event.test.tsx | 8 +- packages/tui/test/fixture/tui-sdk.ts | 111 ++-- 26 files changed, 430 insertions(+), 1509 deletions(-) delete mode 100644 packages/opencode/test/fixture/tui-sdk.ts delete mode 100644 packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx delete mode 100644 packages/tui/test/cli/cmd/tui/sync-undefined-messages.test.tsx diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 5db120c1f1..92799b5050 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -9,26 +9,22 @@ export function runTui(transport: { url: string; headers: RequestInit["headers"] const config = TuiConfig.resolve({}, { terminalSuspend: false }) let disposeSlots: (() => void) | undefined return Effect.gen(function* () { - const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers, fetch: gracefulFetch }) - // The long-lived server may have started from a different directory than this CLI. - // Probe the client cwd on the server before making it the TUI's default location. + const options = { baseUrl: transport.url, headers: transport.headers } + const client = createOpencodeClient(options) const directory = yield* Effect.tryPromise(() => client.v2.fs.list({ location: { directory: process.cwd() } }, { throwOnError: true }), ).pipe( Effect.map((response) => response.data.location.directory), Effect.catch(() => - // A client path may not exist on a remote server, so preserve the server's own default in that case. Effect.tryPromise(() => client.v2.location.get(undefined, { throwOnError: true })).pipe( Effect.map((response) => response.data.directory), ), ), ) return yield* run({ - ...transport, - directory, + client: createOpencodeClient({ ...options, directory }), args: {}, config, - fetch: gracefulFetch, pluginHost: { async start(input) { disposeSlots = await loadBuiltinPlugins(input.api, input.runtime) @@ -40,21 +36,3 @@ export function runTui(transport: { url: string; headers: RequestInit["headers"] }) }).pipe(Effect.provide(Global.defaultLayer)) } - -const legacyDefaults: Record = { - "/config/providers": { providers: [], default: {} }, - "/provider": { all: [], default: {}, connected: [] }, - "/agent": [], - "/config": {}, -} - -const gracefulFetch = Object.assign( - async (input: RequestInfo | URL, init?: RequestInit) => { - const response = await fetch(input, init) - if (response.status !== 404) return response - const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname] - if (fallback === undefined) return response - return Response.json(fallback) - }, - { preconnect: fetch.preconnect }, -) diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 6f5aea6a12..288bc38ec6 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -3,6 +3,7 @@ import { UI } from "@/cli/ui" import { errorMessage } from "@opencode-ai/tui/util/error" import { validateSession } from "../tui/validate-session" import { ServerAuth } from "@/server/auth" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" export const AttachCommand = cmd({ command: "attach ", @@ -132,7 +133,7 @@ export const AttachCommand = cmd({ const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") await Effect.runPromise( run({ - url: args.url, + client: createOpencodeClient({ baseUrl: args.url, headers, directory }), config, pluginHost: createLegacyTuiPluginHost(), args: { @@ -140,8 +141,6 @@ export const AttachCommand = cmd({ sessionID: args.session, fork: args.fork, }, - directory, - headers, }), ) }, diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 329874791d..8063656e68 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -8,8 +8,7 @@ import { errorMessage } from "@opencode-ai/tui/util/error" import { withTimeout } from "@/util/timeout" import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" import { Filesystem } from "@/util/filesystem" -import type { GlobalEvent } from "@opencode-ai/sdk/v2" -import type { EventSource } from "@opencode-ai/tui/context/sdk" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { writeHeapSnapshot } from "v8" import { validateSession } from "../tui/validate-session" import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" @@ -18,36 +17,6 @@ declare global { const OPENCODE_WORKER_PATH: string } -type RpcClient = ReturnType> - -function createWorkerFetch(client: RpcClient): typeof fetch { - const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const request = new Request(input, init) - const body = request.body ? await request.text() : undefined - const result = await client.call("fetch", { - url: request.url, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - body, - }) - return new Response(result.body, { - status: result.status, - headers: result.headers, - }) - } - return fn as typeof fetch -} - -function createEventSource(client: RpcClient): EventSource { - return { - subscribe: async (handler) => { - return client.on("global.event", (e) => { - handler(e) - }) - }, - } -} - async function target() { if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH const dist = new URL("./cli/tui/worker.js", import.meta.url) @@ -211,32 +180,13 @@ export const TuiThreadCommand = cmd({ const config = await TuiConfig.get() const network = resolveNetworkOptionsNoConfig(args) - const external = - process.argv.includes("--port") || - process.argv.includes("--hostname") || - process.argv.includes("--mdns") || - network.mdns || - network.port !== 0 || - network.hostname !== "127.0.0.1" - - const transport = external - ? { - url: (await client.call("server", network)).url, - fetch: undefined, - events: undefined, - } - : { - url: "http://opencode.internal", - fetch: createWorkerFetch(client), - events: createEventSource(client), - } + const url = (await client.call("server", network)).url try { await validateSession({ - url: transport.url, + url, sessionID: args.session, directory: cwd, - fetch: transport.fetch, }) } catch (error) { UI.error(errorMessage(error)) @@ -254,7 +204,7 @@ export const TuiThreadCommand = cmd({ const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") await Effect.runPromise( run({ - url: transport.url, + client: createOpencodeClient({ baseUrl: url, directory: cwd }), async onSnapshot() { const tui = writeHeapSnapshot("tui.heapsnapshot") const server = await client.call("snapshot", undefined) @@ -262,9 +212,6 @@ export const TuiThreadCommand = cmd({ }, config, pluginHost: createLegacyTuiPluginHost(), - directory: cwd, - fetch: transport.fetch, - events: transport.events, args: { continue: args.continue, sessionID: args.session, diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 4cf6b2d446..c03b3b7076 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -3,8 +3,6 @@ import { InstanceRuntime } from "@/project/instance-runtime" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" -import { GlobalBus } from "@/bus/global" -import { ServerAuth } from "@/server/auth" import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" import { AppRuntime } from "@/effect/app-runtime" @@ -20,33 +18,9 @@ const onUncaughtException = (_error: Error) => {} process.on("unhandledRejection", onUnhandledRejection) process.on("uncaughtException", onUncaughtException) -// Subscribe to global events and forward them via RPC -GlobalBus.on("event", (event) => { - Rpc.emit("global.event", event) -}) - let server: Awaited> | undefined export const rpc = { - async fetch(input: { url: string; method: string; headers: Record; body?: string }) { - const headers = { ...input.headers } - const auth = ServerAuth.header() - if (auth && !headers["authorization"] && !headers["Authorization"]) { - headers["Authorization"] = auth - } - const request = new Request(input.url, { - method: input.method, - headers, - body: input.body, - }) - const response = await Server.Default().app.fetch(request) - const body = await response.text() - return { - status: response.status, - headers: Object.fromEntries(response.headers.entries()), - body, - } - }, snapshot() { const result = writeHeapSnapshot("server.heapsnapshot") return result diff --git a/packages/opencode/test/fixture/tui-sdk.ts b/packages/opencode/test/fixture/tui-sdk.ts deleted file mode 100644 index b5bbbe4f14..0000000000 --- a/packages/opencode/test/fixture/tui-sdk.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { GlobalEvent } from "@opencode-ai/sdk/v2" -import type { EventSource } from "@opencode-ai/tui/context/sdk" - -export const worktree = "/tmp/opencode" -export const directory = `${worktree}/packages/opencode` - -export function json(data: unknown, init?: ResponseInit) { - return new Response(JSON.stringify(data), { - ...init, - headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, - }) -} - -export function eventSource(): EventSource { - return { subscribe: async () => () => {} } -} - -export function createEventSource() { - let fn: ((event: GlobalEvent) => void) | undefined - - return { - source: { - subscribe: async (handler: (event: GlobalEvent) => void) => { - fn = handler - return () => { - if (fn === handler) fn = undefined - } - }, - } satisfies EventSource, - emit(event: GlobalEvent) { - if (!fn) throw new Error("event source not ready") - fn(event) - }, - } -} - -export type FetchHandler = (url: URL) => Response | Promise | undefined - -export function createFetch(override?: FetchHandler) { - const session = [] as URL[] - const fetch = (async (input: RequestInfo | URL) => { - const url = new URL(input instanceof Request ? input.url : String(input)) - if (url.pathname === "/session") session.push(url) - - const overridden = await override?.(url) - if (overridden) return overridden - - switch (url.pathname) { - case "/agent": - case "/command": - case "/experimental/workspace": - case "/experimental/workspace/status": - case "/formatter": - case "/lsp": - return json([]) - case "/config": - case "/experimental/resource": - case "/mcp": - case "/provider/auth": - case "/session/status": - return json({}) - case "/config/providers": - return json({ providers: {}, default: {} }) - case "/experimental/console": - return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) - case "/path": - return json({ home: "", state: "", config: "", worktree, directory }) - case "/project/current": - return json({ id: "proj_test" }) - case "/provider": - return json({ all: [], default: {}, connected: [] }) - case "/session": - return json([]) - case "/vcs": - return json({ branch: "main" }) - } - - throw new Error(`unexpected request: ${url.pathname}`) - }) as typeof globalThis.fetch - - return { fetch, session } -} diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c38cd8b2a1..a666b6d84c 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -76,7 +76,7 @@ import { useOpencodeKeymap, } from "./keymap" -import type { EventSource } from "./context/sdk" +import type { OpencodeClient } from "@opencode-ai/sdk/v2" import { DialogVariant } from "./component/dialog-variant" import { createTuiAttention } from "./attention" import * as TuiAudio from "./audio" @@ -134,14 +134,10 @@ const appBindingCommands = [ ] as const export type TuiInput = { - url: string + client: OpencodeClient args: Args config: TuiConfig.Resolved onSnapshot?: () => Promise - directory?: string - fetch?: typeof fetch - headers?: RequestInit["headers"] - events?: EventSource pluginHost: TuiPluginHost } @@ -293,13 +289,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > - + @@ -394,6 +384,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi event, sdk, sync, + data, theme: themeState, toast, renderer, diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index e0d5508736..b4fb2f2b45 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -75,7 +75,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { async (projectID, info): Promise => { try { await sdk.client.v2.projectCopy.refresh( - { projectID, location: { directory: sdk.directory } }, + { projectID, location: { directory: projectContext.instance.directory() || paths.cwd } }, { throwOnError: true }, ) const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true }) @@ -224,7 +224,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const result = await sdk.client.v2.projectCopy .remove({ projectID: props.projectID, - location: { directory: sdk.directory }, + location: { directory: projectContext.instance.directory() || paths.cwd }, directory: selected.directory, force: false, }) @@ -246,7 +246,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const forced = await sdk.client.v2.projectCopy .remove({ projectID: props.projectID, - location: { directory: sdk.directory }, + location: { directory: projectContext.instance.directory() || paths.cwd }, directory: selected.directory, force: true, }) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index df08e32b93..398f4bf560 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -13,6 +13,7 @@ import { useLocal } from "../context/local" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" import { useCommandShortcut } from "../keymap" +import { Spinner } from "./spinner" export function DialogSessionList() { const dialog = useDialog() @@ -71,7 +72,12 @@ export function DialogSessionList() { value: session.id, category, footer, - gutter: slot === undefined ? undefined : () => {slot}, + gutter: + data.session.status(session.id) === "running" + ? () => + : slot === undefined + ? undefined + : () => {slot}, } } diff --git a/packages/tui/src/component/dialog-workspace-create.tsx b/packages/tui/src/component/dialog-workspace-create.tsx index 98c71bb000..23e4a8df3a 100644 --- a/packages/tui/src/component/dialog-workspace-create.tsx +++ b/packages/tui/src/component/dialog-workspace-create.tsx @@ -54,7 +54,7 @@ async function loadWorkspaceAdapters(input: { sync: ReturnType toast: ReturnType }) { - const dir = input.sync.path.directory || input.sdk.directory + const dir = input.sync.path.directory || process.cwd() try { const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir }) if (response.error) throw response.error diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 9140c5419c..19a7396afa 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -40,12 +40,10 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { errorMessage } from "../../util/error" -import { formatDuration } from "../../util/format" import { createColors, createFrames } from "../../ui/spinner" import { useDialog } from "../../ui/dialog" import { DialogIntegration } from "../dialog-integration" import { useConnected } from "../use-connected" -import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" @@ -155,10 +153,11 @@ export function Prompt(props: PromptProps) { const route = useRoute() const project = useProject() const sync = useSync() + const data = useData() const tuiConfig = useTuiConfig() const dialog = useDialog() const toast = useToast() - const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) + const status = createMemo(() => data.session.status(props.sessionID ?? "")) const history = usePromptHistory() const stash = usePromptStash() const keymap = useOpencodeKeymap() @@ -207,7 +206,6 @@ export function Prompt(props: PromptProps) { const [auto, setAuto] = createSignal() const workspace = usePromptWorkspace(props.sessionID) const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) - const data = useData() const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const connected = useConnected() @@ -385,7 +383,7 @@ export function Prompt(props: PromptProps) { name: "session.interrupt", category: "Session", hidden: true, - enabled: status().type !== "idle", + enabled: status() === "running", run: () => { if (auto()?.visible) return if (!input.focused) return @@ -1343,7 +1341,7 @@ export function Prompt(props: PromptProps) { const spinnerDef = createMemo(() => { const agent = - status().type !== "idle" + status() === "running" ? (local.agent.list().find((agent) => agent.id === lastUserMessage()?.agent) ?? local.agent.current()) : local.agent.current() const color = agent ? local.agent.color(agent.id) : theme.border @@ -1529,77 +1527,12 @@ export function Prompt(props: PromptProps) { - - - - - [⋯]}> - - - - - {(() => { - const retry = createMemo(() => { - const s = status() - if (s.type !== "retry") return - return s - }) - const message = createMemo(() => { - const r = retry() - if (!r) return - if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) - return "gemini is way too hot right now" - if (r.message.length > 80) return r.message.slice(0, 80) + "..." - return r.message - }) - const isTruncated = createMemo(() => { - const r = retry() - if (!r) return false - return r.message.length > 120 - }) - const [seconds, setSeconds] = createSignal(0) - onMount(() => { - const timer = setInterval(() => { - const next = retry()?.next - if (next) setSeconds(Math.round((next - Date.now()) / 1000)) - }, 1000) - - onCleanup(() => { - clearInterval(timer) - }) - }) - const handleMessageClick = () => { - const r = retry() - if (!r) return - if (isTruncated()) { - void DialogAlert.show(dialog, "Retry Error", r.message) - } - } - - const retryText = () => { - const r = retry() - if (!r) return "" - const baseMessage = message() - const truncatedHint = isTruncated() ? " (click to expand)" : "" - const duration = formatDuration(seconds()) - const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` - return baseMessage + truncatedHint + retryInfo - } - - return ( - - - {retryText()} - - - ) - })()} - + + + + [⋯]}> + + 0 ? theme.primary : theme.text}> esc{" "} @@ -1661,41 +1594,39 @@ export function Prompt(props: PromptProps) { {props.hint ?? } - - - - {(file) => ( - {file()} - )} - - - - - - {(item) => ( - - {[item().context, item().cost].filter(Boolean).join(" · ")} - - )} - - - - {agentShortcut()} agents + + + {(file) => ( + {file()} + )} + + + + + + {(item) => ( + + {[item().context, item().cost].filter(Boolean).join(" · ")} - - - - {paletteShortcut()} commands - - - - - esc exit shell mode - - - - - + )} + + + + {agentShortcut()} agents + + + + + {paletteShortcut()} commands + + + + + esc exit shell mode + + + + string | undefined; sess const result = await sdk.client.v2.projectCopy.create( { projectID, - location: { directory: sdk.directory }, + location: { directory: project.instance.directory() || paths.cwd }, strategy: "git_worktree", directory: path.join(paths.worktree, projectID.slice(0, 6)), name: generated.data.name, diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index fafadd6445..0f3c7dc09c 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -25,6 +25,7 @@ import { createSignal, onCleanup, onMount } from "solid-js" import { createGlobalEmitter } from "@solid-primitives/event-bus" export type DataConnectionStatus = "connecting" | "connected" | "reconnecting" +export type DataSessionStatus = "idle" | "running" export type DataEvent = V2Event type DataEventMap = { [T in DataEvent["type"]]: Extract } @@ -47,6 +48,7 @@ type Data = { } session: { info: Record + status: Record message: Record permission: Record question: Record @@ -75,6 +77,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, session: { info: {}, + status: {}, message: {}, permission: {}, question: {}, @@ -88,7 +91,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const sdk = useSDK() const events = createGlobalEmitter() const [defaultLocation, setDefaultLocation] = createSignal({ - directory: sdk.directory ?? process.cwd(), + directory: process.cwd(), }) const messageIndex = new Map>() @@ -179,6 +182,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.prompted": { + setStore("session", "status", event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: event.data.messageID, @@ -215,6 +219,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.shell.started": + setStore("session", "status", event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: event.data.messageID, @@ -227,6 +232,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.shell.ended": + setStore("session", "status", event.data.sessionID, "idle") message.update(event.data.sessionID, (draft, index) => { const match = message.activeShell(draft, event.data.callID) if (!match) return @@ -235,6 +241,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.step.started": + setStore("session", "status", event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { if (index.has(event.data.assistantMessageID)) return const currentAssistant = message.activeAssistant(draft) @@ -251,6 +258,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.step.ended": + setStore("session", "status", event.data.sessionID, event.data.finish === "tool-calls" ? "running" : "idle") message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) if (!currentAssistant) return @@ -263,6 +271,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.step.failed": + setStore("session", "status", event.data.sessionID, "idle") message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) if (!currentAssistant) return @@ -406,6 +415,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.retried": case "session.next.compaction.started": + setStore("session", "status", event.data.sessionID, "running") + break case "session.next.compaction.delta": break case "session.next.compaction.ended": @@ -490,6 +501,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ get(sessionID: string) { return store.session.info[sessionID] }, + status(sessionID: string) { + return store.session.status[sessionID] ?? "idle" + }, async refresh(sessionID: string) { const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true }) setStore("session", "info", sessionID, result.data.data) @@ -653,6 +667,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }), ), ), + sdk.client.v2.session.active({ throwOnError: true }).then((response) => + setStore( + "session", + "status", + Object.fromEntries(Object.keys(response.data.data).map((sessionID) => [sessionID, "running" as const])), + ), + ), result.location.refresh(), result.location.agent.refresh(), result.location.integration.refresh(), diff --git a/packages/tui/src/context/project.tsx b/packages/tui/src/context/project.tsx index d73a17e7ec..15a7fb27b4 100644 --- a/packages/tui/src/context/project.tsx +++ b/packages/tui/src/context/project.tsx @@ -16,7 +16,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex state: "", config: "", worktree: "", - directory: sdk.directory ?? "", + directory: process.cwd(), } satisfies Path const [store, setStore] = createStore({ diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 93180c6e21..e66930be3d 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -1,37 +1,12 @@ -import { createOpencodeClient } from "@opencode-ai/sdk/v2" -import type { GlobalEvent } from "@opencode-ai/sdk/v2" +import type { GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2" import { Flag } from "@opencode-ai/core/flag/flag" import { createSimpleContext } from "./helper" import { batch, onCleanup, onMount } from "solid-js" -export type EventSource = { - subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void> -} - export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", - init: (props: { - url: string - directory?: string - fetch?: typeof fetch - headers?: RequestInit["headers"] - events?: EventSource - }) => { + init: (props: { client: OpencodeClient }) => { const abort = new AbortController() - let sse: AbortController | undefined - - function createSDK() { - return createOpencodeClient({ - baseUrl: props.url, - signal: abort.signal, - directory: props.directory, - fetch: props.fetch, - headers: props.headers, - }) - } - - let sdk = createSDK() - const handlers = new Set<(event: GlobalEvent) => void>() const emitter = { emit(_type: "event", event: GlobalEvent) { @@ -57,21 +32,15 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ queue = [] timer = undefined last = Date.now() - // Batch all event emissions so all store updates result in a single render batch(() => { - for (const event of events) { - emitter.emit("event", event) - } + for (const event of events) emitter.emit("event", event) }) } const handleEvent = (event: GlobalEvent) => { queue.push(event) const elapsed = Date.now() - last - if (timer) return - // If we just flushed recently (within 16ms), batch this with future events - // Otherwise, process immediately to avoid latency if (elapsed < 16) { timer = setTimeout(flush, 16) return @@ -79,73 +48,42 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ flush() } - function startSSE() { - sse?.abort() - const ctrl = new AbortController() - sse = ctrl - ;(async () => { + onMount(() => { + void (async () => { let attempt = 0 - while (true) { - if (abort.signal.aborted || ctrl.signal.aborted) break - - const events = await sdk.global.event({ - signal: ctrl.signal, + while (!abort.signal.aborted) { + const events = await props.client.global.event({ + signal: abort.signal, sseMaxRetryAttempts: 0, }) - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - // Start syncing workspaces, it's important to do this after - // we've started listening to events - await sdk.sync.start().catch(() => {}) - } + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) await props.client.sync.start().catch(() => {}) for await (const event of events.stream) { - if (ctrl.signal.aborted) break + if (abort.signal.aborted) break handleEvent(event) } if (timer) clearTimeout(timer) if (queue.length > 0) flush() attempt += 1 - if (abort.signal.aborted || ctrl.signal.aborted) break - - // Exponential backoff - const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay) - await new Promise((resolve) => setTimeout(resolve, backoff)) + if (abort.signal.aborted) break + await new Promise((resolve) => + setTimeout(resolve, Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)), + ) } })().catch(() => {}) - } - - onMount(async () => { - if (props.events) { - const unsub = await props.events.subscribe(handleEvent) - onCleanup(unsub) - - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - // Start syncing workspaces, it's important to do this after - // we've started listening to events - await sdk.sync.start().catch(() => {}) - } - } else { - startSSE() - } }) onCleanup(() => { abort.abort() - sse?.abort() if (timer) clearTimeout(timer) handlers.clear() }) return { - get client() { - return sdk - }, - directory: props.directory, + client: props.client, event: emitter, - fetch: props.fetch ?? fetch, - url: props.url, } }, }) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 03db8784de..7e691ad29c 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -1,55 +1,33 @@ import type { - Message, Agent, - Provider, - Session, - Part, - Config, - Todo, Command, - PermissionRequest, - QuestionRequest, - LspStatus, - McpStatus, - McpResource, - FormatterStatus, - SessionStatus, - ProviderListResponse, - ProviderAuthMethod, - VcsInfo, - SnapshotFileDiff, + Config, ConsoleState, + FormatterStatus, + LspStatus, + McpResource, + McpStatus, + Message, + Part, + PermissionRequest, + Provider, + ProviderAuthMethod, + ProviderListResponse, + QuestionRequest, + Session, + SnapshotFileDiff, + Todo, + VcsInfo, } from "@opencode-ai/sdk/v2" -import { createStore, produce, reconcile } from "solid-js/store" -import { useProject } from "./project" -import { useEvent } from "./event" -import { useSDK } from "./sdk" -import { useTuiStartup } from "./runtime" +import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" -import { useExit } from "./exit" -import { useArgs } from "./args" -import { batch, onMount } from "solid-js" -import path from "path" -import { useKV } from "./kv" +import { useProject } from "./project" const emptyConsoleState: ConsoleState = { consoleManagedProviders: [], switchableOrgCount: 0, } -function search(items: T[], target: string, key: (item: T) => string) { - let left = 0 - let right = items.length - 1 - while (left <= right) { - const middle = Math.floor((left + right) / 2) - const value = key(items[middle]) - if (value === target) return { found: true, index: middle } - if (value < target) left = middle + 1 - else right = middle - 1 - } - return { found: false, index: left } -} - export const { context: SyncContext, use: useSync, @@ -57,8 +35,7 @@ export const { } = createSimpleContext({ name: "Sync", init: () => { - const startup = useTuiStartup() - const kv = useKV() + const project = useProject() const [store, setStore] = createStore<{ status: "loading" | "partial" | "complete" provider: Provider[] @@ -71,39 +48,23 @@ export const { provider_auth: Record agent: Agent[] command: Command[] - permission: { - [sessionID: string]: PermissionRequest[] - } - question: { - [sessionID: string]: QuestionRequest[] - } + permission: Record + question: Record config: Config session: Session[] - session_status: { - [sessionID: string]: SessionStatus - } - session_diff: { - [sessionID: string]: SnapshotFileDiff[] - } - todo: { - [sessionID: string]: Todo[] - } - message: { - [sessionID: string]: Message[] - } - part: { - [messageID: string]: Part[] - } + session_diff: Record + todo: Record + message: Record + part: Record lsp: LspStatus[] - mcp: { - [key: string]: McpStatus - } - mcp_resource: { - [key: string]: McpResource - } + mcp: Record + mcp_resource: Record formatter: FormatterStatus[] vcs: VcsInfo | undefined }>({ + status: "complete", + provider: [], + provider_default: {}, provider_next: { all: [], default: {}, @@ -114,16 +75,12 @@ export const { experimentalBackgroundSubagents: false, }, provider_auth: {}, - config: {}, - status: "loading", agent: [], + command: [], permission: {}, question: {}, - command: [], - provider: [], - provider_default: {}, + config: {}, session: [], - session_status: {}, session_diff: {}, todo: {}, message: {}, @@ -135,521 +92,32 @@ export const { vcs: undefined, }) - const event = useEvent() - const project = useProject() - const sdk = useSDK() - - 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 capabilitiesPromise = sdk.client.experimental.capabilities - .get({ workspace }, { throwOnError: true }) - .then((x) => x.data) - .catch(() => undefined) - 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 }) - await Promise.all([ - providersPromise, - providerListPromise, - capabilitiesPromise, - agentsPromise, - configPromise, - projectPromise, - ...(args.continue ? [sessionListPromise] : []), - ]) - .then(async () => { - const providersResponse = providersPromise.then((x) => x.data!) - const providerListResponse = providerListPromise.then((x) => x.data!) - const capabilitiesResponse = capabilitiesPromise - 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, - capabilitiesResponse, - consoleStateResponse, - agentsResponse, - configResponse, - ...(sessionListResponse ? [sessionListResponse] : []), - ]).then((responses) => { - const providers = responses[0] - const providerList = responses[1] - const capabilities = responses[2] - const consoleState = responses[3] - const agents = responses[4] - const config = responses[5] - const sessions = responses[6] - - batch(() => { - setStore("provider", reconcile(providers.providers)) - setStore("provider_default", reconcile(providers.default)) - setStore("provider_next", reconcile(providerList)) - setStore("capabilities", "experimentalBackgroundSubagents", capabilities?.backgroundSubagents === true) - 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) => { - console.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) { - exit(e) - } else { - throw e - } - }) - } - - onMount(() => { - void bootstrap() - }) - - const result = { + return { data: store, set: setStore, get status() { return store.status }, get ready() { - if (startup.skipInitialLoading) return true - return store.status !== "loading" + return true }, get path() { return project.instance.path() }, session: { - get(sessionID: string) { - const match = search(store.session, sessionID, (s) => s.id) - if (match.found) return store.session[match.index] - return undefined + get(_sessionID: string) { + return undefined as Session | undefined }, query() { - return sessionListQuery() + return {} as { scope?: "project"; path?: string } }, - 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 = 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 + async refresh() {}, + status(_sessionID: string) { + return "idle" as const }, + async sync(_sessionID: string) {}, }, - bootstrap, + async bootstrap(_input: { fatal?: boolean } = {}) {}, } - return result }, }) diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index fbadacce92..19ae505148 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -56,15 +56,12 @@ const tui: TuiPlugin = async (api) => { permissions.delete(event.properties.requestID) }) - api.event.on("session.status", (event) => { - const sessionID = event.properties.sessionID - if (event.properties.status.type === "busy" || event.properties.status.type === "retry") { - active.add(sessionID) - errored.delete(sessionID) - return - } + const started = (sessionID: string) => { + active.add(sessionID) + errored.delete(sessionID) + } - if (event.properties.status.type !== "idle") return + const ended = (sessionID: string) => { if (!active.has(sessionID)) return active.delete(sessionID) @@ -75,6 +72,24 @@ const tui: TuiPlugin = async (api) => { const session = api.state.session.get(sessionID) notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") + } + + api.event.on("session.next.prompted", (event) => started(event.properties.sessionID)) + api.event.on("session.next.shell.started", (event) => started(event.properties.sessionID)) + api.event.on("session.next.step.started", (event) => started(event.properties.sessionID)) + api.event.on("session.next.retried", (event) => started(event.properties.sessionID)) + api.event.on("session.next.compaction.started", (event) => started(event.properties.sessionID)) + api.event.on("session.next.shell.ended", (event) => ended(event.properties.sessionID)) + api.event.on("session.next.step.ended", (event) => { + if (event.properties.finish === "tool-calls") return + ended(event.properties.sessionID) + }) + api.event.on("session.next.step.failed", (event) => { + const sessionID = event.properties.sessionID + if (!active.has(sessionID)) return + errored.add(sessionID) + notify(api, sessionID, "Session error", "error") + ended(sessionID) }) api.event.on("session.error", (event) => { diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index fef0ec8eb8..068b51c732 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -4,6 +4,7 @@ import type { useEvent } from "../context/event" import type { useRoute } from "../context/route" import type { useSDK } from "../context/sdk" import type { useSync } from "../context/sync" +import type { useData } from "../context/data" import type { useTheme } from "../context/theme" import { Dialog as DialogUI, type useDialog } from "../ui/dialog" import type { useOpencodeKeymap } from "../keymap" @@ -31,6 +32,7 @@ type Input = { event: ReturnType sdk: ReturnType sync: ReturnType + data: ReturnType theme: ReturnType toast: ReturnType renderer: TuiPluginApi["renderer"] @@ -95,7 +97,7 @@ function mapOptionCb(cb?: (item: TuiDialogSelectOption) => void) { return (item: SelectOption) => cb(pickOption(item)) } -function stateApi(sync: ReturnType): TuiPluginApi["state"] { +function stateApi(sync: ReturnType, data: ReturnType): TuiPluginApi["state"] { return { get ready() { return sync.ready @@ -135,7 +137,7 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { return sync.data.message[sessionID] ?? [] }, status(sessionID) { - return sync.data.session_status[sessionID] + return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" } }, permission(sessionID) { return sync.data.permission[sessionID] ?? [] @@ -297,7 +299,7 @@ export function createTuiApiAdapters(input: Input): Omit { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) @@ -17,8 +17,8 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { setTitle(title) } const listeners = new Set(process.listeners("SIGHUP")) - const events = createEventSource() - const calls = createFetch() + const events = createEventStream() + const calls = createFetch(undefined, events) let started!: () => void const ready = new Promise((resolve) => { started = resolve @@ -29,11 +29,8 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { const { run } = await import("../src/app") const task = Effect.runPromise( run({ - url: "http://test", - directory, + client: createClient(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), - fetch: calls.fetch, - events: events.source, args: {}, pluginHost: { async start() { @@ -63,21 +60,27 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const core = await import("@opentui/core") mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) - const events = createEventSource() - const calls = createFetch((url) => { - if (url.pathname === "/session") - return json([ - { - id: "dummy", - title: "Demo session", - slug: "dummy", - projectID: "project", - directory, - version: "0.0.0-test", - time: { created: 0, updated: 0 }, - }, - ]) - }) + const events = createEventStream() + const calls = createFetch( + (url) => { + if (url.pathname === "/api/session") + return json({ + data: [ + { + id: "dummy", + title: "Demo session", + projectID: "project", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + }, + ], + cursor: {}, + }) + }, + events, + ) const originalWrite = process.stdout.write.bind(process.stdout) let stdout = "" let api: TuiPluginApi | undefined @@ -95,11 +98,8 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { const { run } = await import("../src/app") const task = Effect.runPromise( run({ - url: "http://test", - directory, + client: createClient(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), - fetch: calls.fetch, - events: events.source, args: { continue: true }, pluginHost: { async start(input) { diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 618a225df2..5c31985035 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -83,6 +83,48 @@ function permission(id: string, sessionID = "session"): PermissionRequest { } } +function stepStarted(id: string, sessionID = "session"): Event { + return { + id, + type: "session.next.step.started", + properties: { + sessionID, + assistantMessageID: `msg_${id}`, + timestamp: 0, + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + } +} + +function stepEnded(id: string, sessionID = "session", finish = "stop"): Event { + return { + id, + type: "session.next.step.ended", + properties: { + sessionID, + assistantMessageID: `msg_${id}`, + timestamp: 0, + finish, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + } +} + +function stepFailed(id: string, sessionID = "session"): Event { + return { + id, + type: "session.next.step.failed", + properties: { + sessionID, + assistantMessageID: `msg_${id}`, + timestamp: 0, + error: { type: "unknown", message: "boom" }, + }, + } +} + const questionNotification: TuiAttentionNotifyInput = { title: "Demo session", message: "Question needs input", @@ -139,21 +181,9 @@ describe("internal notifications TUI plugin", () => { test("notifies when an active session becomes idle and suppresses no-op idle", async () => { const harness = await setup() - harness.emit({ - id: "event-1", - type: "session.status", - properties: { sessionID: "session", status: { type: "idle" } }, - }) - harness.emit({ - id: "event-2", - type: "session.status", - properties: { sessionID: "session", status: { type: "busy" } }, - }) - harness.emit({ - id: "event-3", - type: "session.status", - properties: { sessionID: "session", status: { type: "idle" } }, - }) + harness.emit(stepEnded("event-1")) + harness.emit(stepStarted("event-2")) + harness.emit(stepEnded("event-3")) expect(harness.notifications).toEqual([ { @@ -169,16 +199,8 @@ describe("internal notifications TUI plugin", () => { const harness = await setup() harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") }) - harness.emit({ - id: "event-2", - type: "session.status", - properties: { sessionID: "subagent", status: { type: "busy" } }, - }) - harness.emit({ - id: "event-3", - type: "session.status", - properties: { sessionID: "subagent", status: { type: "idle" } }, - }) + harness.emit(stepStarted("event-2", "subagent")) + harness.emit(stepEnded("event-3", "subagent")) expect(harness.notifications).toEqual([ { @@ -199,21 +221,9 @@ describe("internal notifications TUI plugin", () => { test("notifies session errors once and suppresses the following idle done notification", async () => { const harness = await setup() - harness.emit({ - id: "event-1", - type: "session.status", - properties: { sessionID: "session", status: { type: "busy" } }, - }) - harness.emit({ - id: "event-2", - type: "session.error", - properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } }, - }) - harness.emit({ - id: "event-3", - type: "session.status", - properties: { sessionID: "session", status: { type: "idle" } }, - }) + harness.emit(stepStarted("event-1")) + harness.emit(stepFailed("event-2")) + harness.emit(stepEnded("event-3")) expect(harness.notifications).toEqual([ { @@ -228,21 +238,13 @@ describe("internal notifications TUI plugin", () => { test("special-cases aborts and model response timeouts", async () => { const harness = await setup() - harness.emit({ - id: "event-1", - type: "session.status", - properties: { sessionID: "abort", status: { type: "busy" } }, - }) + harness.emit(stepStarted("event-1", "abort")) harness.emit({ id: "event-2", type: "session.error", properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } }, }) - harness.emit({ - id: "event-3", - type: "session.status", - properties: { sessionID: "timeout", status: { type: "busy" } }, - }) + harness.emit(stepStarted("event-3", "timeout")) harness.emit({ id: "event-4", type: "session.error", diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx index 5b7872ee30..f658939bb9 100644 --- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx @@ -7,9 +7,9 @@ import { ProjectProvider, useProject } from "../../../../src/context/project" import { SDKProvider } from "../../../../src/context/sdk" import { SyncProvider, useSync } from "../../../../src/context/sync" import { ExitProvider } from "../../../../src/context/exit" -import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk" +import { createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk" import { TestTuiContexts } from "../../../fixture/tui-environment" -export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk" +export { createEventStream, createFetch, directory, json, worktree } from "../../../fixture/tui-sdk" export async function wait(fn: () => boolean, timeout = 2000) { const start = Date.now() @@ -22,8 +22,8 @@ export async function wait(fn: () => boolean, timeout = 2000) { type Ctx = { kv: ReturnType; project: ReturnType; sync: ReturnType } export async function mount(override?: FetchHandler, state?: string) { - const calls = createFetch(override) - const events = createEventSource() + const events = createEventStream() + const calls = createFetch(override, events) let sync!: ReturnType let project!: ReturnType let kv!: ReturnType @@ -47,7 +47,7 @@ export async function mount(override?: FetchHandler, state?: string) { - + {}}> diff --git a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx b/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx deleted file mode 100644 index 398c5c9ab5..0000000000 --- a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx +++ /dev/null @@ -1,262 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { expect, test } from "bun:test" -import type { GlobalEvent } from "@opencode-ai/sdk/v2" -import { tmpdir } from "../../../fixture/fixture" -import { json, mount, wait } from "./sync-fixture" - -const sessionID = "ses_hydration_race" -const messageID = "msg_hydration_race" -const partID = "prt_hydration_race" -const session = { - id: sessionID, - title: "race", - time: { created: 0, updated: 0 }, - version: "1.15.13", - directory: "/tmp/opencode/packages/opencode", -} -const assistant = { - id: messageID, - sessionID, - role: "assistant" as const, - agent: "build", - modelID: "model", - providerID: "test", - mode: "build", - parentID: "msg_user", - path: { cwd: session.directory, root: session.directory }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, completed: 2 }, -} - -function global(payload: GlobalEvent["payload"]): GlobalEvent { - return { directory: "/tmp/other", project: "proj_test", payload } -} - -test("stale session hydration does not overwrite live message parts", async () => { - await using tmp = await tmpdir() - await Bun.write(`${tmp.path}/kv.json`, "{}") - - let resolveMessages!: (response: Response) => void - const messages = new Promise((resolve) => { - resolveMessages = resolve - }) - let requested = false - const { app, emit, sync } = await mount((url) => { - if (url.pathname === `/session/${sessionID}`) return json(session) - if (url.pathname === `/session/${sessionID}/message`) { - requested = true - return messages - } - if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([]) - return undefined - }, tmp.path) - - try { - const hydrate = sync.session.sync(sessionID) - await wait(() => requested) - emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } })) - emit( - global({ - id: "evt_part", - type: "message.part.updated", - properties: { - sessionID, - time: 2, - part: { id: partID, sessionID, messageID, type: "text", text: "visible live content" }, - }, - }), - ) - await wait(() => sync.data.part[messageID]?.[0]?.type === "text") - - resolveMessages( - json([ - { - info: assistant, - parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }], - }, - ]), - ) - await hydrate - - expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible live content" }) - } finally { - app.renderer.destroy() - } -}) - -test("orphan live deltas do not suppress hydrated parts", async () => { - await using tmp = await tmpdir() - await Bun.write(`${tmp.path}/kv.json`, "{}") - - let resolveMessages!: (response: Response) => void - const messages = new Promise((resolve) => { - resolveMessages = resolve - }) - let requested = false - const { app, emit, sync } = await mount((url) => { - if (url.pathname === `/session/${sessionID}`) return json(session) - if (url.pathname === `/session/${sessionID}/message`) { - requested = true - return messages - } - if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([]) - return undefined - }, tmp.path) - - try { - const hydrate = sync.session.sync(sessionID) - await wait(() => requested) - emit( - global({ - id: "evt_delta", - type: "message.part.delta", - properties: { sessionID, messageID, partID, field: "text", delta: "ignored until part exists" }, - }), - ) - resolveMessages( - json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "hydrated" }] }]), - ) - await hydrate - - expect(sync.data.part[messageID][0]).toMatchObject({ text: "hydrated" }) - } finally { - app.renderer.destroy() - } -}) - -test("hydration does not clear text streamed before it starts", async () => { - await using tmp = await tmpdir() - await Bun.write(`${tmp.path}/kv.json`, "{}") - - let resolveMessages!: (response: Response) => void - const messages = new Promise((resolve) => { - resolveMessages = resolve - }) - let requested = false - const { app, emit, sync } = await mount((url) => { - if (url.pathname === `/session/${sessionID}`) return json(session) - if (url.pathname === `/session/${sessionID}/message`) { - requested = true - return messages - } - if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([]) - return undefined - }, tmp.path) - - try { - emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } })) - emit( - global({ - id: "evt_part", - type: "message.part.updated", - properties: { - sessionID, - time: 1, - part: { id: partID, sessionID, messageID, type: "text", text: "" }, - }, - }), - ) - emit( - global({ - id: "evt_delta", - type: "message.part.delta", - properties: { sessionID, messageID, partID, field: "text", delta: "visible streamed content" }, - }), - ) - await wait(() => sync.data.part[messageID]?.[0]?.type === "text" && sync.data.part[messageID][0].text !== "") - const hydrate = sync.session.sync(sessionID) - await wait(() => requested) - resolveMessages(json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }] }])) - await hydrate - - expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible streamed content" }) - } finally { - app.renderer.destroy() - } -}) - -test("live messages merged during hydration retain the 100 message window", async () => { - await using tmp = await tmpdir() - await Bun.write(`${tmp.path}/kv.json`, "{}") - - let resolveMessages!: (response: Response) => void - const messages = new Promise((resolve) => { - resolveMessages = resolve - }) - let requested = false - const { app, emit, sync } = await mount((url) => { - if (url.pathname === `/session/${sessionID}`) return json(session) - if (url.pathname === `/session/${sessionID}/message`) { - requested = true - return messages - } - if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([]) - return undefined - }, tmp.path) - - try { - const hydrate = sync.session.sync(sessionID) - await wait(() => requested) - const live = { ...assistant, id: "msg_z_live" } - emit(global({ id: "evt_live", type: "message.updated", properties: { sessionID, info: live } })) - await wait(() => sync.data.message[sessionID]?.some((message) => message.id === live.id) ?? false) - resolveMessages( - json( - Array.from({ length: 100 }, (_, index) => { - const id = `msg_${String(index).padStart(3, "0")}` - return { - info: { ...assistant, id }, - parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text: id }], - } - }), - ), - ) - await hydrate - - expect(sync.data.message[sessionID]).toHaveLength(100) - expect(sync.data.message[sessionID].at(-1)?.id).toBe(live.id) - expect(sync.data.message[sessionID].some((message) => message.id === "msg_000")).toBe(false) - expect(sync.data.part.msg_000).toBeUndefined() - } finally { - app.renderer.destroy() - } -}) - -test("a message removed during hydration does not regain stale parts", async () => { - await using tmp = await tmpdir() - await Bun.write(`${tmp.path}/kv.json`, "{}") - - let resolveMessages!: (response: Response) => void - const messages = new Promise((resolve) => { - resolveMessages = resolve - }) - let requested = false - const { app, emit, sync } = await mount((url) => { - if (url.pathname === `/session/${sessionID}`) return json(session) - if (url.pathname === `/session/${sessionID}/message`) { - requested = true - return messages - } - if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([]) - return undefined - }, tmp.path) - - try { - emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } })) - await wait(() => sync.data.message[sessionID]?.length === 1) - const hydrate = sync.session.sync(sessionID) - await wait(() => requested) - emit(global({ id: "evt_removed", type: "message.removed", properties: { sessionID, messageID } })) - await wait(() => sync.data.message[sessionID]?.length === 0) - resolveMessages( - json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "stale" }] }]), - ) - await hydrate - - expect(sync.data.message[sessionID]).toEqual([]) - expect(sync.data.part[messageID]).toBeUndefined() - } finally { - app.renderer.destroy() - } -}) diff --git a/packages/tui/test/cli/cmd/tui/sync-undefined-messages.test.tsx b/packages/tui/test/cli/cmd/tui/sync-undefined-messages.test.tsx deleted file mode 100644 index 6aea128692..0000000000 --- a/packages/tui/test/cli/cmd/tui/sync-undefined-messages.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -/** - * Reproducer for #26560 — TUI crashes with - * `TypeError: undefined is not an object (evaluating 'f.data.map')` - * when entering a session whose messages endpoint returns a non-2xx. - * The failure path is `sync.tsx#sync.session.sync` reading - * `messages.data!` while the SDK leaves `data` undefined on error. - */ -import { describe, expect, test } from "bun:test" -import { tmpdir } from "../../../fixture/fixture" -import { directory, json, mount } from "./sync-fixture" - -const sessionID = "ses_undef" - -describe("tui sync (#26560)", () => { - test("entering a session whose messages endpoint errors does not crash sync", async () => { - await using tmp = await tmpdir() - await Bun.write(`${tmp.path}/kv.json`, "{}") - - const sessionPayload = { - id: sessionID, - title: "broken", - time: { created: 0, updated: 0 }, - version: "1.14.42", - directory, - project_id: "proj_test", - } - const { app, sync } = await mount((url) => { - if (url.pathname === `/session/${sessionID}`) return json(sessionPayload) - if (url.pathname === `/session/${sessionID}/messages`) return json({}, { status: 500 }) - if (url.pathname === `/session/${sessionID}/todo`) return json([]) - if (url.pathname === `/session/${sessionID}/diff`) return json([]) - if (url.pathname === "/session") return json([sessionPayload]) - return undefined - }, tmp.path) - - try { - await expect(sync.session.sync(sessionID)).resolves.toBeUndefined() - } finally { - app.renderer.destroy() - } - }) -}) diff --git a/packages/tui/test/cli/cmd/tui/sync.test.tsx b/packages/tui/test/cli/cmd/tui/sync.test.tsx index 6c70295b69..e93c81f091 100644 --- a/packages/tui/test/cli/cmd/tui/sync.test.tsx +++ b/packages/tui/test/cli/cmd/tui/sync.test.tsx @@ -1,65 +1,24 @@ /** @jsxImportSource @opentui/solid */ -import { describe, expect, test } from "bun:test" -import { tmpdir } from "../../../fixture/fixture" -import { mount, wait } from "./sync-fixture" -import type { GlobalEvent } from "@opencode-ai/sdk/v2" +import { expect, test } from "bun:test" +import { mount } from "./sync-fixture" -function branchEvent(branch: string, workspace?: string): GlobalEvent { - return { - directory: "/tmp/other", - project: "proj_test", - workspace, - payload: { - id: `evt_vcs_${branch}`, - type: "vcs.branch.updated", - properties: { branch }, - }, +test("legacy sync is an inert compatibility context", async () => { + const { app, session, sync } = await mount() + + try { + expect(sync.status).toBe("complete") + expect(sync.ready).toBe(true) + expect(sync.data.session).toEqual([]) + expect(sync.data.message).toEqual({}) + expect(sync.data.provider).toEqual([]) + expect(sync.session.get("ses_test")).toBeUndefined() + + await sync.bootstrap() + await sync.session.refresh() + await sync.session.sync("ses_test") + + expect(session).toEqual([]) + } finally { + app.renderer.destroy() } -} - -describe("tui sync", () => { - test("refresh scopes sessions by default and lists project sessions when disabled", async () => { - await using tmp = await tmpdir() - await Bun.write(`${tmp.path}/kv.json`, "{}") - const { app, kv, sync, session } = await mount(undefined, tmp.path) - - try { - expect(kv.get("session_directory_filter_enabled", true)).toBe(true) - expect(session.at(-1)?.searchParams.get("roots")).toBeNull() - expect(session.at(-1)?.searchParams.get("scope")).toBeNull() - expect(session.at(-1)?.searchParams.get("path")).toBe("packages/tui") - - kv.set("session_directory_filter_enabled", false) - await sync.session.refresh() - - expect(session.at(-1)?.searchParams.get("scope")).toBe("project") - expect(session.at(-1)?.searchParams.get("path")).toBeNull() - expect(session.at(-1)?.searchParams.get("roots")).toBeNull() - } finally { - app.renderer.destroy() - } - }) - - test("vcs branch updates only apply for the active workspace", async () => { - await using tmp = await tmpdir() - await Bun.write(`${tmp.path}/kv.json`, "{}") - const { app, emit, project, sync } = await mount(undefined, tmp.path) - - try { - expect(sync.data.vcs?.branch).toBe("main") - - project.workspace.set("ws_a") - emit(branchEvent("other", "ws_b")) - await Bun.sleep(30) - - expect(sync.data.vcs?.branch).toBe("main") - - emit(branchEvent("feature", "ws_a")) - await wait(() => sync.data.vcs?.branch === "feature") - - expect(sync.data.vcs?.branch).toBe("feature") - } finally { - app.renderer.destroy() - } - }) }) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 83ad21e4e2..e37071269d 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -6,7 +6,7 @@ import { onMount } from "solid-js" import { ProjectProvider } from "../../../src/context/project" import { SDKProvider } from "../../../src/context/sdk" import { DataProvider, useData } from "../../../src/context/data" -import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk" +import { createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk" import { TestTuiContexts } from "../../fixture/tui-environment" async function wait(fn: () => boolean, timeout = 2000) { @@ -21,12 +21,12 @@ function global(payload: Event): GlobalEvent { return { directory, project: "proj_test", payload } } -function emitEvent(events: ReturnType, payload: Event) { +function emitEvent(events: ReturnType, payload: Event) { events.emit(global(payload)) } test("refreshes resources into reactive getters", async () => { - const events = createEventSource() + const events = createEventStream() const location = { directory, project: { id: "proj_test", directory }, @@ -73,7 +73,7 @@ test("refreshes resources into reactive getters", async () => { const app = await testRender(() => ( - + @@ -85,7 +85,7 @@ test("refreshes resources into reactive getters", async () => { try { await mounted - expect(data.location.default()).toEqual({ directory }) + expect(data.location.default()).toEqual({ directory: process.cwd() }) expect(data.session.get("ses_test")).toBeUndefined() expect(data.location.agent.list(location)).toBeUndefined() @@ -106,12 +106,12 @@ test("refreshes resources into reactive getters", async () => { }) test("reconnects the event stream and bootstraps fresh data", async () => { - const events = createEventSource() + const events = createEventStream() const requests = { event: 0, model: 0 } const calls = createFetch((url) => { if (url.pathname === "/api/event") { requests.event++ - return events.response() + return events.v2() } if (url.pathname !== "/api/model") return requests.model++ @@ -143,7 +143,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => { const app = await testRender(() => ( - + @@ -173,8 +173,68 @@ test("reconnects the event stream and bootstraps fresh data", async () => { } }) +test("tracks session status from active sessions and execution events", async () => { + const events = createEventStream() + const calls = createFetch((url) => { + if (url.pathname === "/api/session/active") + return json({ data: { "session-active": { type: "running" } } }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.session.status("session-active") === "running") + expect(data.session.status("session-idle")).toBe("idle") + + emitEvent(events, { + id: "evt_step_started", + type: "session.next.step.started", + properties: { + sessionID: "session-live", + assistantMessageID: "message-live", + timestamp: 1, + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + await wait(() => data.session.status("session-live") === "running") + + emitEvent(events, { + id: "evt_step_ended", + type: "session.next.step.ended", + properties: { + sessionID: "session-live", + assistantMessageID: "message-live", + timestamp: 2, + finish: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }) + await wait(() => data.session.status("session-live") === "idle") + } finally { + app.renderer.destroy() + } +}) + test("refreshes integrations after integration updates", async () => { - const events = createEventSource() + const events = createEventStream() const requests = { integration: 0, model: 0, provider: 0 } const calls = createFetch((url) => { if (url.pathname === "/api/model") { @@ -215,7 +275,7 @@ test("refreshes integrations after integration updates", async () => { const app = await testRender(() => ( - + @@ -241,7 +301,7 @@ test("refreshes integrations after integration updates", async () => { }) test("refreshes effective catalog data after catalog updates", async () => { - const events = createEventSource() + const events = createEventStream() const requests = { model: 0, provider: 0 } const calls = createFetch((url) => { if (url.pathname === "/api/model") { @@ -256,7 +316,7 @@ test("refreshes effective catalog data after catalog updates", async () => { const app = await testRender(() => ( - + @@ -277,7 +337,7 @@ test("refreshes effective catalog data after catalog updates", async () => { }) test("refreshes references after updates", async () => { - const events = createEventSource() + const events = createEventStream() let requests = 0 const calls = createFetch((url) => { if (url.pathname !== "/api/reference") return @@ -301,7 +361,7 @@ test("refreshes references after updates", async () => { const app = await testRender(() => ( - + @@ -323,7 +383,7 @@ test("refreshes references after updates", async () => { }) test("adds and dismisses permission requests from live events", async () => { - const events = createEventSource() + const events = createEventStream() const calls = createFetch(undefined, events) let data!: ReturnType @@ -334,7 +394,7 @@ test("adds and dismisses permission requests from live events", async () => { const app = await testRender(() => ( - + @@ -388,7 +448,7 @@ test("adds and dismisses permission requests from live events", async () => { }) test("adds and dismisses question requests from live events", async () => { - const events = createEventSource() + const events = createEventStream() const calls = createFetch(undefined, events) let data!: ReturnType @@ -399,7 +459,7 @@ test("adds and dismisses question requests from live events", async () => { const app = await testRender(() => ( - + @@ -451,7 +511,7 @@ test("adds and dismisses question requests from live events", async () => { }) test("settles pending tools when a live failure arrives", async () => { - const events = createEventSource() + const events = createEventStream() const calls = createFetch(undefined, events) let sync!: ReturnType let ready!: () => void @@ -467,7 +527,7 @@ test("settles pending tools when a live failure arrives", async () => { const app = await testRender(() => ( - + @@ -580,7 +640,7 @@ test("settles pending tools when a live failure arrives", async () => { }) test("renders admitted prompts only after they become model-visible", async () => { - const events = createEventSource() + const events = createEventStream() const calls = createFetch(undefined, events) let sync!: ReturnType let ready!: () => void @@ -596,7 +656,7 @@ test("renders admitted prompts only after they become model-visible", async () = const app = await testRender(() => ( - + @@ -653,7 +713,7 @@ test("renders admitted prompts only after they become model-visible", async () = }) test("projects live context updates with their message ID", async () => { - const events = createEventSource() + const events = createEventStream() const calls = createFetch(undefined, events) let sync!: ReturnType let ready!: () => void @@ -669,7 +729,7 @@ test("projects live context updates with their message ID", async () => { const app = await testRender(() => ( - + diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 8b29a7f5b8..5f0438456d 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -6,7 +6,7 @@ import { onMount } from "solid-js" import { ProjectProvider, useProject } from "../../../src/context/project" import { SDKProvider } from "../../../src/context/sdk" import { useEvent } from "../../../src/context/event" -import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk" +import { createClient, createEventStream, createFetch, directory } from "../../fixture/tui-sdk" import { TestTuiContexts } from "../../fixture/tui-environment" const projectID = "proj_test" @@ -49,8 +49,8 @@ function update(version: string): Event { } async function mount() { - const events = createEventSource() - const calls = createFetch() + const events = createEventStream() + const calls = createFetch(undefined, events) const seen: Event[] = [] const workspaces: Array = [] let project!: ReturnType @@ -61,7 +61,7 @@ async function mount() { const app = await testRender(() => ( - + { diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 04fe2aee15..dbe4b7a5b1 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -1,5 +1,5 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2" -import type { EventSource } from "../../src/context/sdk" export const worktree = "/tmp/opencode" export const directory = `${worktree}/packages/tui` @@ -11,71 +11,83 @@ export function json(data: unknown, init?: ResponseInit) { }) } -export function eventSource(): EventSource { - return { subscribe: async () => () => {} } -} +export function createEventStream() { + const encoder = new TextEncoder() + const global = new Set>() + const v2 = new Set>() + const pending = { + global: [] as Uint8Array[], + v2: [] as Uint8Array[], + } + const response = ( + controllers: Set>, + queued: Uint8Array[], + initial?: unknown, + ) => { + let current: ReadableStreamDefaultController | undefined + return new Response( + new ReadableStream({ + start(controller) { + current = controller + controllers.add(controller) + if (initial) controller.enqueue(encoder.encode(`data: ${JSON.stringify(initial)}\n\n`)) + for (const chunk of queued.splice(0)) controller.enqueue(chunk) + }, + cancel() { + if (current) controllers.delete(current) + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + } + const send = ( + controllers: Set>, + queued: Uint8Array[], + event: unknown, + ) => { + const chunk = encoder.encode(`data: ${JSON.stringify(event)}\n\n`) + if (controllers.size === 0) { + queued.push(chunk) + return + } + for (const controller of controllers) controller.enqueue(chunk) + } -export function createEventSource() { - let fn: ((event: GlobalEvent) => void) | undefined - let stream: ReadableStreamDefaultController | undefined - const pending: Uint8Array[] = [] return { - source: { - subscribe: async (handler: (event: GlobalEvent) => void) => { - fn = handler - return () => { - if (fn === handler) fn = undefined - } - }, - } satisfies EventSource, emit(event: GlobalEvent) { - if (!fn) throw new Error("event source not ready") - fn(event) + send(global, pending.global, event) if (!("properties" in event.payload)) return - const chunk = new TextEncoder().encode( - `data: ${JSON.stringify({ - ...event.payload, - location: { directory: event.directory, workspaceID: event.workspace }, - data: event.payload.properties, - })}\n\n`, - ) - if (stream) return stream.enqueue(chunk) - pending.push(chunk) + send(v2, pending.v2, { + ...event.payload, + location: { directory: event.directory, workspaceID: event.workspace }, + data: event.payload.properties, + }) }, - response() { - return new Response( - new ReadableStream({ - start(controller) { - stream = controller - controller.enqueue( - new TextEncoder().encode(`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`), - ) - for (const chunk of pending.splice(0)) controller.enqueue(chunk) - }, - cancel() { - stream = undefined - }, - }), - { headers: { "content-type": "text/event-stream" } }, - ) + global() { + return response(global, pending.global) + }, + v2() { + return response(v2, pending.v2, { id: "evt_connected", type: "server.connected", data: {} }) }, disconnect() { - stream?.close() - stream = undefined + for (const controller of [...global, ...v2]) controller.close() + global.clear() + v2.clear() }, } } export type FetchHandler = (url: URL) => Response | Promise | undefined -export function createFetch(override?: FetchHandler, events?: ReturnType) { +export function createFetch(override?: FetchHandler, events?: ReturnType) { const session = [] as URL[] const fetch = (async (input: RequestInfo | URL) => { const url = new URL(input instanceof Request ? input.url : String(input)) if (url.pathname === "/session") session.push(url) const overridden = await override?.(url) if (overridden) return overridden - if (url.pathname === "/api/event" && events) return events.response() + if (url.pathname === "/global/event" && events) return events.global() + if (url.pathname === "/api/event" && events) return events.v2() if ( [ @@ -96,6 +108,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType