refactor(tui): use v2 client transport
This commit is contained in:
parent
ad4f1c1018
commit
8b682c42b6
26 changed files with 428 additions and 1507 deletions
|
|
@ -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<string, unknown> = {
|
||||
"/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 },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 <url>",
|
||||
|
|
@ -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,
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<typeof Rpc.client<typeof rpc>>
|
||||
|
||||
function createWorkerFetch(client: RpcClient): typeof fetch {
|
||||
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
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<GlobalEvent>("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,
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof Server.listen>> | undefined
|
||||
|
||||
export const rpc = {
|
||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; 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
|
||||
|
|
|
|||
|
|
@ -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<Response> | 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 }
|
||||
}
|
||||
|
|
@ -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<string[]>
|
||||
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) {
|
|||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SDKProvider client={input.client}>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<DataProvider>
|
||||
|
|
@ -394,6 +384,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
event,
|
||||
sdk,
|
||||
sync,
|
||||
data,
|
||||
theme: themeState,
|
||||
toast,
|
||||
renderer,
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||
async (projectID, info): Promise<ProjectDirectory[] | undefined> => {
|
||||
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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 : () => <text fg={theme.accent}>{slot}</text>,
|
||||
gutter:
|
||||
data.session.status(session.id) === "running"
|
||||
? () => <Spinner />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={theme.accent}>{slot}</text>,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ async function loadWorkspaceAdapters(input: {
|
|||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
}) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<AutocompleteRef>()
|
||||
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) {
|
|||
</box>
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between">
|
||||
<Switch>
|
||||
<Match when={status().type !== "idle"}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
||||
>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<box marginLeft={1}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
{(() => {
|
||||
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 (
|
||||
<Show when={retry()}>
|
||||
<box onMouseUp={handleMessageClick}>
|
||||
<text fg={theme.error}>{retryText()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
</box>
|
||||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
<box marginLeft={1}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||
esc{" "}
|
||||
|
|
@ -1661,41 +1594,39 @@ export function Prompt(props: PromptProps) {
|
|||
</Match>
|
||||
<Match when={true}>{props.hint ?? <text />}</Match>
|
||||
</Switch>
|
||||
<Show when={status().type !== "retry"}>
|
||||
<box gap={2} flexDirection="row">
|
||||
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
|
||||
{(file) => (
|
||||
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
|
||||
)}
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Switch>
|
||||
<Match when={usage()}>
|
||||
{(item) => (
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{[item().context, item().cost].filter(Boolean).join(" · ")}
|
||||
</text>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={theme.text}>
|
||||
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
|
||||
<box gap={2} flexDirection="row">
|
||||
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
|
||||
{(file) => (
|
||||
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
|
||||
)}
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Switch>
|
||||
<Match when={usage()}>
|
||||
{(item) => (
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{[item().context, item().cost].filter(Boolean).join(" · ")}
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={theme.text}>
|
||||
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</Show>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={theme.text}>
|
||||
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={theme.text}>
|
||||
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
<Autocomplete
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function usePromptMove(input: { projectID: () => 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,
|
||||
|
|
|
|||
|
|
@ -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<DataEvent, { type: T }> }
|
||||
|
|
@ -47,6 +48,7 @@ type Data = {
|
|||
}
|
||||
session: {
|
||||
info: Record<string, SessionV2Info>
|
||||
status: Record<string, DataSessionStatus>
|
||||
message: Record<string, SessionMessage[]>
|
||||
permission: Record<string, PermissionV2Request[]>
|
||||
question: Record<string, QuestionV2Request[]>
|
||||
|
|
@ -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<DataEventMap>()
|
||||
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
|
||||
directory: sdk.directory ?? process.cwd(),
|
||||
directory: process.cwd(),
|
||||
})
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<T>(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<string, ProviderAuthMethod[]>
|
||||
agent: Agent[]
|
||||
command: Command[]
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
}
|
||||
question: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
permission: Record<string, PermissionRequest[]>
|
||||
question: Record<string, QuestionRequest[]>
|
||||
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<string, SnapshotFileDiff[]>
|
||||
todo: Record<string, Todo[]>
|
||||
message: Record<string, Message[]>
|
||||
part: Record<string, Part[]>
|
||||
lsp: LspStatus[]
|
||||
mcp: {
|
||||
[key: string]: McpStatus
|
||||
}
|
||||
mcp_resource: {
|
||||
[key: string]: McpResource
|
||||
}
|
||||
mcp: Record<string, McpStatus>
|
||||
mcp_resource: Record<string, McpResource>
|
||||
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<string>()
|
||||
const syncingSessions = new Map<string, Promise<void>>()
|
||||
const hydratingSessions = new Map<string, { messages: Set<string>; parts: Set<string> }>()
|
||||
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<string>(), parts: new Set<string>() }
|
||||
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
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<typeof useEvent>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
data: ReturnType<typeof useData>
|
||||
theme: ReturnType<typeof useTheme>
|
||||
toast: ReturnType<typeof useToast>
|
||||
renderer: TuiPluginApi["renderer"]
|
||||
|
|
@ -95,7 +97,7 @@ function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
|
|||
return (item: SelectOption<Value>) => cb(pickOption(item))
|
||||
}
|
||||
|
||||
function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||
function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
|
||||
return {
|
||||
get ready() {
|
||||
return sync.ready
|
||||
|
|
@ -135,7 +137,7 @@ function stateApi(sync: ReturnType<typeof useSync>): 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<TuiPluginApi, "lifecycl
|
|||
return input.kv.ready
|
||||
},
|
||||
},
|
||||
state: stateApi(input.sync),
|
||||
state: stateApi(input.sync, input.data),
|
||||
get client() {
|
||||
return input.sdk.client
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { createTestRenderer } from "@opentui/core/testing"
|
|||
import { Effect } from "effect"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||
import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk"
|
||||
import { createClient, createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
|
||||
|
||||
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
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<void>((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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
|
||||
|
||||
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<typeof useSync>
|
||||
let project!: ReturnType<typeof useProject>
|
||||
let kv!: ReturnType<typeof useKV>
|
||||
|
|
@ -47,7 +47,7 @@ export async function mount(override?: FetchHandler, state?: string) {
|
|||
<TestTuiContexts paths={state ? { state } : undefined}>
|
||||
<ArgsProvider>
|
||||
<KVProvider>
|
||||
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<ExitProvider exit={() => {}}>
|
||||
<SyncProvider>
|
||||
|
|
|
|||
|
|
@ -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<Response>((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<Response>((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<Response>((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<Response>((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<Response>((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()
|
||||
}
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<typeof createEventSource>, payload: Event) {
|
||||
function emitEvent(events: ReturnType<typeof createEventStream>, 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(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
|
|
@ -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(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
|
|
@ -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<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
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(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
|
|
@ -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(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<box />
|
||||
|
|
@ -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(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
|
|
@ -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<typeof useData>
|
||||
|
||||
|
|
@ -334,7 +394,7 @@ test("adds and dismisses permission requests from live events", async () => {
|
|||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
|
|
@ -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<typeof useData>
|
||||
|
||||
|
|
@ -399,7 +459,7 @@ test("adds and dismisses question requests from live events", async () => {
|
|||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
|
|
@ -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<typeof useData>
|
||||
let ready!: () => void
|
||||
|
|
@ -467,7 +527,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
|
|
@ -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<typeof useData>
|
||||
let ready!: () => void
|
||||
|
|
@ -596,7 +656,7 @@ test("renders admitted prompts only after they become model-visible", async () =
|
|||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
|
|
@ -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<typeof useData>
|
||||
let ready!: () => void
|
||||
|
|
@ -669,7 +729,7 @@ test("projects live context updates with their message ID", async () => {
|
|||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
|
|
|
|||
|
|
@ -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<string | undefined> = []
|
||||
let project!: ReturnType<typeof useProject>
|
||||
|
|
@ -61,7 +61,7 @@ async function mount() {
|
|||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<SDKProvider client={createClient(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<Probe
|
||||
onReady={async (ctx) => {
|
||||
|
|
|
|||
|
|
@ -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<ReadableStreamDefaultController<Uint8Array>>()
|
||||
const v2 = new Set<ReadableStreamDefaultController<Uint8Array>>()
|
||||
const pending = {
|
||||
global: [] as Uint8Array[],
|
||||
v2: [] as Uint8Array[],
|
||||
}
|
||||
const response = (
|
||||
controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
|
||||
queued: Uint8Array[],
|
||||
initial?: unknown,
|
||||
) => {
|
||||
let current: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
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<ReadableStreamDefaultController<Uint8Array>>,
|
||||
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<Uint8Array> | 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<Uint8Array>({
|
||||
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<Response> | undefined
|
||||
|
||||
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventSource>) {
|
||||
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventStream>) {
|
||||
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<typeof
|
|||
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
||||
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json({ data: {} })
|
||||
if (
|
||||
["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(
|
||||
url.pathname,
|
||||
|
|
@ -115,3 +128,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
|||
}) as typeof globalThis.fetch
|
||||
return { fetch, session }
|
||||
}
|
||||
|
||||
export function createClient(fetch: typeof globalThis.fetch) {
|
||||
return createOpencodeClient({ baseUrl: "http://test", fetch })
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue