Compare commits

...

5 Commits

Author SHA1 Message Date
Simon Klee 3489fa77bf refactor(tui): narrow mini compatibility surfaces 2026-07-22 08:20:54 +02:00
Simon Klee d5aac23603 fix(tui): set mini terminal title on Linux 2026-07-22 07:49:23 +02:00
Simon Klee 816e64907f fix(tui): submit prompt when resuming session 2026-07-22 07:44:42 +02:00
Simon Klee b7e29ff4e8 fix(tui): hide project commands from mini palette 2026-07-22 07:44:42 +02:00
Simon Klee eef1e38bec location: add catalog readiness wait API
Plugin activation is async, so catalog snapshots can lag startup.
Move initial settlement behind POST /api/location/wait and drop
client-side polling so timeout and settled absence stay distinct.
2026-07-22 07:44:42 +02:00
48 changed files with 584 additions and 410 deletions
-1
View File
@@ -11,7 +11,6 @@
"bin"
],
"exports": {
"./daemon": "./src/daemon.ts",
"./run": "./src/run/index.ts",
"./server-process": "./src/server-process.ts"
},
+1
View File
@@ -32,6 +32,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
Flag.withDescription("Session ID to continue"),
Flag.optional,
),
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
},
commands: [
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
@@ -55,7 +55,11 @@ export default Runtime.handler(Commands, (input) =>
}
: undefined,
},
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
args: {
continue: input.continue,
sessionID: Option.getOrUndefined(input.session),
prompt: Option.getOrUndefined(input.prompt),
},
config: {
path: config.path,
get: () => runPromise(config.get()),
+20 -22
View File
@@ -2,7 +2,6 @@ import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { setTimeout } from "node:timers/promises"
import { waitForCatalogReady } from "./services/catalog"
import { readStdin } from "./util/io"
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
@@ -215,14 +214,17 @@ function parseModel(value?: string) {
function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
return async (input) => {
if (input.model)
await waitForCatalogReady({
sdk: input.client,
directory: input.location.directory,
workspace: input.location.workspaceID,
model: { providerID: input.model.providerID, modelID: input.model.id },
signal: input.signal,
})
if (input.model || requestedAgent)
await input.client.location
.wait(
{
location: { directory: input.location.directory, workspace: input.location.workspaceID },
},
{ signal: input.signal },
)
.catch((error) => {
if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error
})
return {
model: input.model,
agent: requestedAgent
@@ -246,26 +248,22 @@ async function validateAgent(
signal?: AbortSignal,
) {
if (!name) return
const deadline = Date.now() + 5_000
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
while (Date.now() < deadline && !signal?.aborted) {
agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => {
const agents = await sdk.agent
.list({ location: { directory, workspace } }, { signal })
.catch((error) => {
if (signal && error instanceof ClientError && error.reason === "Transport") throw error
return undefined
})
const agent = agents?.data.find((item) => item.id === name)
if (agent?.mode === "subagent") {
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
return
}
if (agent) return name
await setTimeout(25, undefined, { signal }).catch(() => {})
}
if (signal?.aborted) return
if (!agents) {
warning("failed to list agents. Falling back to default agent")
return
}
const agent = agents.data.find((item) => item.id === name)
if (agent?.mode === "subagent") {
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
return
}
if (agent) return name
warning(`agent "${name}" not found. Falling back to default agent`)
}
+6 -15
View File
@@ -5,7 +5,6 @@ import { open } from "node:fs/promises"
import path from "node:path"
import { readStdin } from "../util/io"
import { ServerConnection } from "../services/server-connection"
import { waitForCatalogReady } from "../services/catalog"
import { parseSessionTargetModel, resolveSessionTarget } from "../session-target"
import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
import { runNonInteractivePrompt } from "./noninteractive"
@@ -93,10 +92,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
: undefined,
agent: input.agent,
prepare: async (next) => {
const location = { location: { directory: next.location.directory, workspace: next.location.workspaceID } }
if (next.model) await client.location.wait(location)
const selected =
next.model ??
(await client.model
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.default(location)
.then((result) => result.data))
const model = selected
? {
@@ -105,21 +106,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
variant: options.variant ?? ("variant" in selected ? selected.variant : undefined),
}
: undefined
const models = await client.model.list(location).then((result) => result.data)
if ((options.variant ?? explicit?.variant) && !model)
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
if (model) {
await waitForCatalogReady({
sdk: client,
directory: next.location.directory,
workspace: next.location.workspaceID,
model: { providerID: model.providerID, modelID: model.id },
})
const available = await client.model.list({
location: { directory: next.location.directory, workspace: next.location.workspaceID },
})
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.id))
throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id)
}
if (model && !models.some((item) => item.providerID === model.providerID && item.id === model.id))
throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id)
return {
model,
agent: input.agent
-43
View File
@@ -1,43 +0,0 @@
import { ClientError, type OpenCodeClient } from "@opencode-ai/client/promise"
// Location plugins initialize asynchronously, so explicit model selection must
// wait for that exact model before prompt admission. The execution path owns
// the authoritative error if readiness times out.
export async function waitForCatalogReady(input: {
sdk: OpenCodeClient
directory: string
workspace?: string
model: { providerID: string; modelID: string }
timeoutMs?: number
signal?: AbortSignal
}) {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline && !input.signal?.aborted) {
const models = await input.sdk.model
.list(
{ location: { directory: input.directory, workspace: input.workspace } },
{ signal: input.signal },
)
.then((result) => result.data)
.catch((error) => {
if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error
return undefined
})
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
await wait(25, input.signal)
}
}
function wait(delay: number, signal?: AbortSignal) {
if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, delay))
if (signal.aborted) return Promise.resolve()
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal?.removeEventListener("abort", done)
resolve()
}
})
}
@@ -16,7 +16,30 @@ export default defineScript({
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
const session = `mini-stage2-${process.pid}`
const snapshots = path.join(artifacts, "mini-stage2")
yield* Effect.promise(() => mkdir(snapshots, { recursive: true }))
const explicitDirectory = path.join(artifacts, "explicit-model")
yield* Effect.promise(() => Promise.all([snapshots, explicitDirectory].map((dir) => mkdir(dir, { recursive: true }))))
/** @param {string} directory @param {string | undefined} model */
const mini = (directory, model) => [
"env",
`PWD=${directory}`,
`OPENCODE_PASSWORD=${registration.password}`,
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
`OPENCODE_TEST_HOME=${artifacts}`,
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DIRECT_TRACE=1",
process.execPath,
"--conditions=browser",
`--preload=${preload}`,
path.join(root, "packages/cli/src/index.ts"),
"mini",
"--server",
registration.url,
...(model ? ["--model", model] : []),
]
yield* llm.queue(
Llm.toolCall({
@@ -42,26 +65,7 @@ export default defineScript({
"-y",
"30",
"--",
"env",
`PWD=${path.join(artifacts, "files")}`,
`OPENCODE_PASSWORD=${registration.password}`,
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
`OPENCODE_TEST_HOME=${artifacts}`,
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DIRECT_TRACE=1",
process.execPath,
"--conditions=browser",
`--preload=${preload}`,
path.join(root, "packages/cli/src/index.ts"),
"mini",
"--server",
registration.url,
"--model",
"simulation/gpt-sim-model",
...mini(path.join(artifacts, "files"), undefined),
]),
),
)
@@ -153,6 +157,63 @@ export default defineScript({
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
throw new Error("Mini exit splash was not rendered before teardown")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
yield* Effect.promise(() => tmux(["clear-history", "-t", session]))
yield* Effect.promise(() =>
tmux([
"respawn-pane",
"-k",
"-t",
session,
"--",
...mini(explicitDirectory, "simulation/gpt-sim-model"),
]),
)
const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForPane(session, "Press ctrl+c again to exit"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForDeadPane(session))
if ((yield* Effect.promise(() => paneDeadStatus(session))) !== 0)
throw new Error("Explicit-model Mini did not exit cleanly")
yield* Effect.promise(async () => {
const child = Bun.spawn(
[
process.execPath,
path.join(root, "packages/cli/src/index.ts"),
"run",
"--server",
registration.url,
"--model",
"simulation/definitely-missing",
"catalog readiness check",
],
{
cwd: path.join(root, "packages/cli"),
env: {
...process.env,
PWD: path.join(artifacts, "files"),
OPENCODE_PASSWORD: registration.password,
OPENCODE_CONFIG_DIR: path.join(artifacts, "files/.opencode"),
OPENCODE_DISABLE_AUTOUPDATE: "1",
},
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
},
)
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
await Bun.write(path.join(snapshots, "08-unavailable-model.txt"), stdout + stderr)
if (exitCode !== 1) throw new Error(`Unavailable model run exited with status ${exitCode}`)
if (!stderr.includes("Model unavailable: simulation/definitely-missing"))
throw new Error(`Unavailable model was not diagnosed after catalog settlement: ${stderr}`)
})
})
yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
@@ -18,10 +18,12 @@ describe("CLI frontend import boundaries", () => {
test("exposes only the intentional package entrypoints", async () => {
const run = await import("@opencode-ai/cli/run")
const mini = await import("@opencode-ai/tui/mini")
const tool = await import("@opencode-ai/tui/mini/tool")
const cli = await Bun.file(path.join(root, "packages/cli/package.json")).json()
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
expect(Object.keys(tool).sort()).toEqual(["toolInlineInfo", "toolOutputText"])
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
})
+42 -2
View File
@@ -138,8 +138,9 @@ describe("mini command", () => {
expect(result.stderr).not.toContain("You must provide a message")
})
test("preserves a run failure exit code", async () => {
test("validates an explicit model after one readiness wait", async () => {
let modelRequests = 0
let waitRequests = 0
const server = Bun.serve({
port: 0,
fetch(request) {
@@ -148,11 +149,15 @@ describe("mini command", () => {
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
if (url.pathname === "/api/location")
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
if (url.pathname === "/api/location/wait") {
waitRequests++
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
}
if (url.pathname === "/api/model") {
modelRequests++
return Response.json({
location: { directory: process.cwd(), project: { id: "global", directory: process.cwd() } },
data: modelRequests === 1 ? [{ id: "missing", providerID: "definitely" }] : [],
data: [],
})
}
return new Response(undefined, { status: 404 })
@@ -164,6 +169,41 @@ describe("mini command", () => {
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("Model unavailable: definitely/missing")
expect(waitRequests).toBe(1)
expect(modelRequests).toBe(1)
} finally {
server.stop(true)
}
})
test("distinguishes catalog initialization timeout from settled model absence", async () => {
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health")
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
if (url.pathname === "/api/location")
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
if (url.pathname === "/api/location/wait")
return Response.json(
{
_tag: "ServiceUnavailableError",
message: "Location catalog initialization timed out",
service: "location.catalog",
},
{ status: 503 },
)
return new Response(undefined, { status: 404 })
},
})
try {
const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("Location catalog initialization timed out")
expect(result.stderr).not.toContain("Model unavailable")
} finally {
server.stop(true)
}
+6
View File
@@ -32,8 +32,14 @@ export type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]
export type Endpoint2_0Output = EffectValue<ReturnType<RawClient["server.location"]["location.get"]>>
export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
type Endpoint2_1Request = Parameters<RawClient["server.location"]["location.wait"]>[0]
export type Endpoint2_1Input = { readonly location?: Endpoint2_1Request["query"]["location"] }
export type Endpoint2_1Output = EffectValue<ReturnType<RawClient["server.location"]["location.wait"]>>
export type LocationWaitOperation<E = never> = (input?: Endpoint2_1Input) => Effect.Effect<Endpoint2_1Output, E>
export interface LocationApi<E = never> {
readonly get: LocationGetOperation<E>
readonly wait: LocationWaitOperation<E>
}
type Endpoint3_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
@@ -33,7 +33,12 @@ type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["locat
const Endpoint2_0 = (raw: RawClient["server.location"]) => (input?: Endpoint2_0Input) =>
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup2 = (raw: RawClient["server.location"]) => ({ get: Endpoint2_0(raw) })
type Endpoint2_1Request = Parameters<RawClient["server.location"]["location.wait"]>[0]
type Endpoint2_1Input = { readonly location?: Endpoint2_1Request["query"]["location"] }
const Endpoint2_1 = (raw: RawClient["server.location"]) => (input?: Endpoint2_1Input) =>
raw["location.wait"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup2 = (raw: RawClient["server.location"]) => ({ get: Endpoint2_0(raw), wait: Endpoint2_1(raw) })
type Endpoint3_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
type Endpoint3_0Input = { readonly location?: Endpoint3_0Request["query"]["location"] }
@@ -5,6 +5,8 @@ import type {
ServerGetOutput,
LocationGetInput,
LocationGetOutput,
LocationWaitInput,
LocationWaitOutput,
AgentListInput,
AgentListOutput,
PluginListInput,
@@ -385,6 +387,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
wait: (input?: LocationWaitInput, requestOptions?: RequestOptions) =>
request<LocationWaitOutput>(
{
method: "POST",
path: `/api/location/wait`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
},
agent: {
list: (input?: AgentListInput, requestOptions?: RequestOptions) =>
+16 -8
View File
@@ -2390,6 +2390,14 @@ export type InvalidRequestError = {
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2443,14 +2451,6 @@ export type SkillNotFoundError = {
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
@@ -2562,6 +2562,14 @@ export type LocationGetInput = {
export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
export type LocationWaitInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type LocationWaitOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
export type AgentListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+23
View File
@@ -48,6 +48,29 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
})
test("location.wait uses the explicit initial-settlement barrier", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({
directory: "/tmp/project",
workspaceID: "wrk_test",
project: { id: "proj_test", directory: "/tmp/project" },
})
},
})
const result = await client.location.wait({ location: { directory: "/tmp/project", workspace: "wrk_test" } })
expect(result.workspaceID).toBe("wrk_test")
expect(request?.method).toBe("POST")
expect(request?.url).toBe(
"http://localhost:3000/api/location/wait?location%5Bdirectory%5D=%2Ftmp%2Fproject&location%5Bworkspace%5D=wrk_test",
)
})
test("server.get uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
+16
View File
@@ -1,6 +1,7 @@
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ServiceUnavailableError } from "../errors.js"
export const LocationQuery = Schema.Struct({
location: Schema.optional(
@@ -41,4 +42,19 @@ export const LocationGroup = HttpApiGroup.make("server.location")
}),
),
)
.add(
HttpApiEndpoint.post("location.wait", "/api/location/wait", {
query: LocationQuery,
success: Location.Info,
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.location.wait",
summary: "Wait for location",
description: "Wait for the location's initial plugin generation to settle.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "location" }))
+2 -1
View File
@@ -17,7 +17,8 @@ export const ModelGroup = HttpApiGroup.make("server.model")
OpenApi.annotations({
identifier: "v2.model.list",
summary: "List models",
description: "Retrieve available models ordered by release date.",
description:
"Retrieve the current snapshot of available models ordered by release date. The snapshot may precede initial plugin settlement.",
}),
),
)
+36 -11
View File
@@ -1,18 +1,43 @@
import { Location } from "@opencode-ai/core/location"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) =>
handlers.handle(
"location.get",
Effect.fn(function* () {
const location = yield* Location.Service
return new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
})
}),
),
handlers
.handle(
"location.get",
Effect.fn(function* () {
return info(yield* Location.Service)
}),
)
.handle(
"location.wait",
Effect.fn(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush.pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () =>
Effect.fail(
new ServiceUnavailableError({
message: "Location catalog initialization timed out",
service: "location.catalog",
}),
),
}),
)
return info(yield* Location.Service)
}),
),
)
function info(location: Location.Interface) {
return new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
})
}
@@ -0,0 +1,6 @@
export default {
id: "slow-location-wait-test",
async setup() {
await Bun.sleep(750)
},
}
+44
View File
@@ -0,0 +1,44 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import path from "node:path"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
it.live("keeps catalog snapshots non-blocking and waits for initial plugin settlement explicitly", () =>
Effect.gen(function* () {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
models: { fetch: false },
config: {
content: JSON.stringify({ plugins: [path.join(import.meta.dir, "fixture/slow-plugin.ts")] }),
},
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const request = (pathname: string, method = "GET") =>
fetch(new URL(pathname, base), {
method,
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
})
const snapshot = yield* Effect.promise(() => request("/api/model"))
expect(snapshot.status).toBe(200)
let settled = false
const waiting = request("/api/location/wait", "POST").then((response) => {
settled = true
return response
})
yield* Effect.sleep("50 millis")
expect(settled).toBe(false)
const response = yield* Effect.promise(() => waiting)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: process.cwd() })
}),
)
+1 -1
View File
@@ -30,7 +30,7 @@
"./editor": "./src/editor.ts",
"./editor-zed": "./src/editor-zed.ts",
"./mini": "./src/mini/index.ts",
"./mini/tool": "./src/mini/tool.ts",
"./mini/tool": "./src/mini/tool.public.ts",
"./model-preference": "./src/model-preference.ts",
"./runtime": "./src/runtime.tsx",
"./terminal-win32": "./src/terminal-win32.ts",
+5 -3
View File
@@ -525,6 +525,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
})
const args = useArgs()
const startupPrompt = args.prompt ? { text: args.prompt, files: [], agents: [], pasted: [] } : undefined
onMount(() => {
batch(() => {
if (args.agent) local.agent.set(args.agent)
@@ -542,6 +543,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
route.navigate({
type: "session",
sessionID: args.sessionID,
prompt: startupPrompt,
})
}
})
@@ -564,12 +566,12 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
const match = response.data[0]?.id
if (!match) return
if (!args.fork) {
route.navigate({ type: "session", sessionID: match })
route.navigate({ type: "session", sessionID: match, prompt: startupPrompt })
return
}
void client.api.session
.fork({ sessionID: match })
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
.then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt }))
.catch(toast.error)
})
.catch(toast.error)
@@ -582,7 +584,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
forked = true
void client.api.session
.fork({ sessionID: args.sessionID })
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
.then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt }))
.catch(toast.error)
})
-61
View File
@@ -85,67 +85,6 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[
return [...grouped.values()]
}
export async function waitForDefaultModel(input: {
sdk: OpenCodeClient
location: LocationRef
timeoutMs?: number
requestTimeoutMs?: number
active?: () => boolean
signal?: AbortSignal
}): Promise<{ providerID: string; modelID: string } | undefined> {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline && !input.signal?.aborted && (input.active?.() ?? true)) {
const controller = new AbortController()
const timeout = setTimeout(
() => controller.abort(),
Math.min(input.requestTimeoutMs ?? 1_000, Math.max(1, deadline - Date.now())),
)
const abort = () => controller.abort()
input.signal?.addEventListener("abort", abort, { once: true })
const model = await abortable(
input.sdk.model
.default(location(input.location), { signal: controller.signal })
.then((result) => result.data)
.catch(() => undefined),
controller.signal,
).finally(() => {
clearTimeout(timeout)
input.signal?.removeEventListener("abort", abort)
})
if (model) return { providerID: model.providerID, modelID: model.id }
await wait(25, input.signal)
}
}
function abortable<A>(task: Promise<A>, signal: AbortSignal): Promise<A | undefined> {
if (signal.aborted) return Promise.resolve(undefined)
return new Promise((resolve) => {
const abort = () => {
signal.removeEventListener("abort", abort)
resolve(undefined)
}
signal.addEventListener("abort", abort, { once: true })
void task.then((value) => {
signal.removeEventListener("abort", abort)
resolve(value)
})
})
}
function wait(delay: number, signal?: AbortSignal) {
if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, delay))
if (signal.aborted) return Promise.resolve()
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal?.removeEventListener("abort", done)
resolve()
}
})
}
export async function loadRunAgents(sdk: OpenCodeClient, ref: LocationRef, signal?: AbortSignal): Promise<RunAgent[]> {
const result = await sdk.agent.list(location(ref), ...requestOptions(signal))
return result.data.map(runAgent)
+16 -42
View File
@@ -16,8 +16,9 @@
// the synthetic tool parts through the same callbacks used by the live footer.
import path from "path"
import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { parseSlashHead } from "../prompt/parse"
import { writeSessionOutput } from "./stream"
import { toolCommit } from "./stream-v2.subagent"
import { toolCommit, toolFinalPhase } from "./stream-v2.subagent"
import type {
FooterApi,
FooterView,
@@ -110,7 +111,6 @@ const SAMPLE_TABLE = [
type Ref = {
msg: string
part: string
call: string
tool: string
input: Record<string, JsonValue>
@@ -126,7 +126,6 @@ type FormRequest = {
type Perm = {
ref: Ref
done: {
title: string
output: string
metadata?: Record<string, JsonValue>
}
@@ -203,7 +202,6 @@ function showSubagent(
description: input.description,
status: input.status,
title: input.title,
lastUpdatedAt: Date.now(),
},
],
details: {
@@ -337,7 +335,6 @@ async function emitReasoning(state: State, body: string, signal?: AbortSignal):
function make(state: State, tool: string, input: Record<string, JsonValue>): Ref {
return {
msg: open(state),
part: take(state, "part", "part"),
call: take(state, "call", "call"),
tool,
input,
@@ -346,7 +343,7 @@ function make(state: State, tool: string, input: Record<string, JsonValue>): Ref
}
function startTool(state: State, ref: Ref, structured: Record<string, JsonValue> = {}): SessionMessageAssistantTool {
state.started.add(ref.part)
state.started.add(ref.call)
const part = {
type: "tool" as const,
id: ref.call,
@@ -386,12 +383,11 @@ function doneTool(
state: State,
ref: Ref,
output: {
title: string
output: string
metadata?: Record<string, JsonValue>
},
): void {
if (!state.started.has(ref.part)) startTool(state, ref)
if (!state.started.has(ref.call)) startTool(state, ref)
const part: SessionMessageAssistantTool = {
type: "tool",
id: ref.call,
@@ -404,11 +400,11 @@ function doneTool(
},
time: { created: ref.start, ran: ref.start, completed: Date.now() },
}
present(state, [toolCommit(part, ref.msg, output.output ? "progress" : "final")])
present(state, [toolCommit(part, ref.msg, toolFinalPhase(part))])
}
function failTool(state: State, ref: Ref, error: string): void {
if (!state.started.has(ref.part)) startTool(state, ref)
if (!state.started.has(ref.call)) startTool(state, ref)
present(state, [
toolCommit(
{
@@ -443,7 +439,6 @@ async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
startTool(state, ref)
await wait(70, signal)
doneTool(state, ref, {
title: "git status",
output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`,
metadata: {
exit: 0,
@@ -458,7 +453,6 @@ function emitWrite(state: State): void {
content: "export const demo = 42\n",
})
doneTool(state, ref, {
title: "write",
output: "",
metadata: {},
})
@@ -470,7 +464,6 @@ function emitEdit(state: State): void {
path: file,
})
doneTool(state, ref, {
title: "edit",
output: "",
metadata: {
files: [
@@ -490,7 +483,6 @@ function emitPatch(state: State): void {
patchText: "*** Begin Patch\n*** End Patch",
})
doneTool(state, ref, {
title: "patch",
output: "",
metadata: {
files: [
@@ -517,10 +509,9 @@ function emitTask(state: State): void {
agent: "explore",
})
doneTool(state, ref, {
title: "Reducer touchpoints found",
output: "",
metadata: {
sessionID: "sub_demo_1",
sessionID: "ses_demo_child",
status: "completed",
output: "",
},
@@ -542,7 +533,7 @@ function emitTask(state: State): void {
time: { created: Date.now(), ran: Date.now() },
} satisfies SessionMessageAssistantTool
showSubagent(state, {
sessionID: "sub_demo_1",
sessionID: "ses_demo_child",
label: "Explore",
description: "Scan run/* for reducer touchpoints",
status: "completed",
@@ -562,16 +553,7 @@ function emitTask(state: State): void {
messageID: "sub_demo_msg_reasoning",
partID: "sub_demo_reasoning_1",
},
{
kind: "tool",
text: "running read",
phase: "start",
source: "tool",
messageID: "sub_demo_msg_tool",
partID: "sub_demo_tool_1",
tool: "read",
part,
},
toolCommit(part, "sub_demo_msg_tool", "start"),
{
kind: "assistant",
text: "Footer updates flow through stream.ts into RunFooter",
@@ -610,7 +592,6 @@ function emitQuestionTool(state: State): void {
],
})
doneTool(state, ref, {
title: "question",
output: "",
metadata: {
answers: [["Diff"], ["Usage", "custom-note"]],
@@ -635,7 +616,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
patterns: [command],
always: ["*"],
done: {
title: "git status --short",
output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`,
metadata: {
exit: 0,
@@ -658,7 +638,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
patterns: [target],
always: [target],
done: {
title: "read",
output: ["1: {", '2: "name": "opencode",', '3: "private": true', "4: }"].join("\n"),
metadata: {},
},
@@ -677,7 +656,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
patterns: ["explore"],
always: ["*"],
done: {
title: "Footer spacing checked",
output: "",
metadata: {
sessionID: "sub_demo_perm_1",
@@ -707,7 +685,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
},
always: [`${dir}/**`],
done: {
title: "read",
output: `1: # External demo\n2: Shared preview file\nPath: ${target}`,
metadata: {},
},
@@ -726,7 +703,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
patterns: ["*"],
always: ["*"],
done: {
title: "Retry allowed",
output: "Continuing after repeated failures.\n",
metadata: {},
},
@@ -744,7 +720,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
patterns: [file],
always: [file],
done: {
title: "edit",
output: "",
metadata: {
files: [{ file, status: "modified", patch: diff }],
@@ -955,8 +930,9 @@ export function createRunDemo(input: Input) {
const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise<boolean> => {
const text = line.text.trim()
const list = text.split(/\s+/)
const cmd = list[0] || ""
const head = parseSlashHead(text)
const list = head?.arguments.split(/\s+/).filter(Boolean) ?? []
const cmd = head ? `/${head.name}` : ""
clearSubagent(state.footer)
@@ -966,7 +942,7 @@ export function createRunDemo(input: Input) {
}
if (cmd === "/permission") {
const kind = permissionKind(list[1])
const kind = permissionKind(list[0])
if (!kind) {
note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`)
return true
@@ -977,7 +953,7 @@ export function createRunDemo(input: Input) {
}
if (cmd === "/form") {
const kind = formKind(list[1])
const kind = formKind(list[0])
if (!kind) {
note(state.footer, `Pick a form kind: ${FORMS.join(", ")}`)
return true
@@ -988,8 +964,8 @@ export function createRunDemo(input: Input) {
}
if (cmd === "/fmt") {
const kind = (list[1] || "").toLowerCase()
const body = list.slice(2).join(" ")
const kind = (list[0] || "").toLowerCase()
const body = list.slice(1).join(" ")
if (!kind) {
note(state.footer, `Pick a kind: ${KINDS.join(", ")}`)
return true
@@ -1032,7 +1008,6 @@ export function createRunDemo(input: Input) {
clearBlocker(state)
if (form.kind === "question") {
doneTool(state, form.ref, {
title: "question",
output: "",
metadata: {
answers: form.request.fields.map((field) => {
@@ -1045,7 +1020,6 @@ export function createRunDemo(input: Input) {
return true
}
doneTool(state, form.ref, {
title: form.request.title,
output: `Form submitted: ${Object.entries(input.answer)
.map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(", ") : String(value)}`)
.join("; ")}\n`,
+1 -38
View File
@@ -54,10 +54,6 @@ type SubagentEntry = PanelEntry & {
current: boolean
}
type QueuedEntry = PanelEntry & {
prompt: FooterQueuedPrompt
}
type SettingEntry = PanelEntry & {
key: keyof MiniSettings
}
@@ -92,18 +88,6 @@ function countLabel(count: number, total: number, query: string) {
return `${count}/${total}`
}
function categoryRank(category: string) {
if (category === "Project Commands") {
return 0
}
if (category === "MCP Commands") {
return 1
}
return 2
}
function subagentStatusLabel(status: FooterSubagentTab["status"]) {
if (status === "completed") {
return "done"
@@ -374,7 +358,6 @@ export function RunCommandMenuBody(props: {
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
const entries = createMemo<CommandEntry[]>(() => {
const builtins = ["compact", "editor", "new", "settings"]
const session: CommandEntry[] = [
{
action: "editor",
@@ -473,29 +456,10 @@ export function RunCommandMenuBody(props: {
]
: []),
]
const commands = (props.commands() ?? [])
.filter((item) => item.source !== "skill" && !builtins.includes(item.name))
.map(
(item) =>
({
action: "slash",
category: item.source === "mcp" ? "MCP Commands" : "Project Commands",
name: item.name,
display: item.name,
footer: `/${item.name}`,
keywords:
item.source === "mcp"
? `/${item.name} ${item.name} mcp ${item.description ?? ""}`
: `/${item.name} ${item.name} ${item.description ?? ""}`,
}) satisfies CommandEntry,
)
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display))
return [
...session,
...prompt,
...agent,
...commands,
{
action: "settings",
category: "System",
@@ -779,13 +743,12 @@ export function RunQueuedPromptSelectBody(props: {
onRows?: (rows: number) => void
mono?: boolean
}) {
const entries = createMemo<QueuedEntry[]>(() =>
const entries = createMemo(() =>
props.prompts().map((prompt) => ({
category: "",
display: prompt.prompt.text.replaceAll("\n", " "),
footer: prompt.delivery,
keywords: prompt.prompt.text,
prompt,
})),
)
const controller = createSearchablePanelController({
+5 -10
View File
@@ -31,13 +31,13 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit
import { monoTruncateMiddle } from "./mono"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } from "./types"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1
export const TEXTAREA_MIN_ROWS = 1
export const TEXTAREA_MAX_ROWS = 6
const TEXTAREA_MAX_ROWS = 6
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
@@ -65,7 +65,6 @@ type PromptInput = {
agents: Accessor<RunAgent[]>
references: Accessor<RunReference[]>
commands: Accessor<RunCommand[] | undefined>
tuiConfig: RunTuiConfig
state: Accessor<FooterState>
view: Accessor<string>
prompt: Accessor<boolean>
@@ -142,7 +141,7 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
}
}
export function selectedCommand(text: string, command: RunPrompt["command"], commands?: RunCommand[]) {
export function selectedCommand(text: string, command: RunPrompt["command"]) {
if (!command) {
return
}
@@ -152,14 +151,10 @@ export function selectedCommand(text: string, command: RunPrompt["command"], com
return
}
// Bound drafts (e.g. the skill picker) may predate or omit the catalog
// source; resolve it at submit time so routing never degrades to a plain
// command for a skill entry.
const source = command.source ?? commands?.find((item) => item.name === command.name)?.source
return {
name: command.name,
arguments: head.arguments,
...(source ? { source } : {}),
...(command.source ? { source: command.source } : {}),
}
}
@@ -1140,7 +1135,7 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command, input.commands())
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit()
return
-2
View File
@@ -74,7 +74,6 @@ type RunFooterOptions = {
agents: RunAgent[]
references: RunReference[]
wrote?: boolean
sessionID: () => string | undefined
agentLabel: string
modelLabel: string
model: RunInput["model"]
@@ -312,7 +311,6 @@ export class RunFooter implements FooterApi {
currentVariant: footer.currentVariant,
theme: footer.theme,
mono: options.mono,
tuiConfig: options.tuiConfig,
miniSettings: footer.miniSettings,
history: footer.history,
onSubmit: footer.handlePrompt,
-3
View File
@@ -50,7 +50,6 @@ import type {
RunPrompt,
RunProvider,
RunReference,
RunTuiConfig,
} from "./types"
import type { RunTheme } from "./theme"
@@ -88,7 +87,6 @@ type RunFooterViewProps = {
queuedPrompts?: () => FooterQueuedPrompt[]
theme: () => RunTheme
mono: boolean
tuiConfig: RunTuiConfig
miniSettings: () => MiniSettings
history?: () => RunPrompt[]
onSubmit: (input: RunPrompt) => boolean
@@ -359,7 +357,6 @@ export function RunFooterView(props: RunFooterViewProps) {
agents: props.agents,
references: props.references,
commands: props.commands,
tuiConfig: props.tuiConfig,
state: props.state,
view: promptView,
prompt,
+2 -2
View File
@@ -14,7 +14,7 @@ import {
import type { FormAnswerField } from "../util/form"
import type { FormReply, MiniFormRequest } from "./types"
export { formCustom, formLabel, formRows, formSelected, formTextual, formValidateValue }
export { formCustom, formLabel, formRows, formTextual, formValidateValue }
export type FormBodyState = {
formID: string
@@ -97,7 +97,7 @@ export function formSetSelected(state: FormBodyState, selected: number): FormBod
return { ...state, selected, error: "" }
}
export function formSetEditing(state: FormBodyState, editing: boolean): FormBodyState {
function formSetEditing(state: FormBodyState, editing: boolean): FormBodyState {
return { ...state, editing, error: "" }
}
+1 -1
View File
@@ -76,7 +76,7 @@ export function permissionLabel(option: PermissionOption): string {
export { permissionAlwaysLines }
export function permissionReply(
function permissionReply(
sessionID: string,
requestID: string,
reply: PermissionReply["reply"],
+9 -1
View File
@@ -82,6 +82,7 @@ export type Lifecycle = {
footer: FooterApi
onResize(fn: () => void): () => void
refreshTheme(): void
setTitle(title?: string): void
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
}
@@ -184,6 +185,12 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
consoleMode: "disabled",
clearOnShutdown: false,
})
const setTitle = (title?: string) => {
if (input.host.platform !== "linux") return
if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode")
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
}
setTitle(input.sessionTitle)
const theme = await resolveRunTheme(renderer, tuiConfig.theme, mono)
renderer.setBackgroundColor(theme.background)
const state: SplashState = {
@@ -225,7 +232,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
findFiles: input.findFiles,
agents: input.agents,
references: input.references,
sessionID: input.getSessionID ?? (() => input.sessionID),
...labels,
model: input.model,
variant: input.variant,
@@ -338,6 +344,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
footer.close()
await footer.idle().catch(() => {})
footer.destroy()
if (input.host.platform === "linux") renderer.setTerminalTitle("")
shutdown(renderer)
if (!wroteExit) {
input.host.stdout.write("\n")
@@ -350,6 +357,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
refreshTheme() {
footer.refreshTheme()
},
setTitle,
onResize(fn) {
let width = renderer.terminalWidth
let height = renderer.terminalHeight
+16 -7
View File
@@ -11,7 +11,7 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { LocationRef } from "@opencode-ai/client/promise"
import type { Config } from "../config"
import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
import {
resolveMiniSettings,
resolveModelInfo,
@@ -443,6 +443,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
if (footer.isClosed || runtimeController.signal.aborted) return
state.sessionID = next.sessionID
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
shell.setTitle(state.sessionTitle)
state.agent = next.agent
state.location = next.location
state.model = next.model
@@ -505,12 +506,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return
}
const model = await waitForDefaultModel({
sdk,
location: state.location,
active: () => currentModelLoad(generation, sdk),
signal,
})
const model = await sdk.model
.default(
{ location: { directory: state.location.directory, workspace: state.location.workspaceID } },
{ signal },
)
.then((result) =>
result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined,
)
.catch(() => undefined)
if (!currentModelLoad(generation, sdk)) return
const [fallbackSavedVariant, info] = await Promise.all([
input.host.preferences.resolveVariant(model),
@@ -760,6 +764,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
replayLimit: input.replayLimit,
footer,
onCommit: rememberLocal,
onSessionTitle: (title) => {
state.sessionTitle = title
shell.setTitle(title)
},
trace: log,
onCatalogRefresh: requestCatalogRefresh,
contextLimit: (model) =>
@@ -906,6 +914,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
state.shown = false
state.sessionID = created.sessionID
state.sessionTitle = created.sessionTitle
shell.setTitle(state.sessionTitle)
state.agent = created.agent ?? state.agent
state.location = created.location
state.model = created.model
+1 -1
View File
@@ -30,7 +30,7 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm
return Boolean(current && next && current === next)
}
export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
if (commit.kind === "tool") {
if (body.type === "structured" || body.type === "markdown") {
return "block"
+2 -3
View File
@@ -194,7 +194,6 @@ function tab(child: ChildState): FooterSubagentTab {
status: child.status,
background: child.background ? true : undefined,
title: child.title,
lastUpdatedAt: child.lastUpdatedAt,
}
}
@@ -1084,11 +1083,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
input.emit()
},
snapshot() {
const tabs = [...children.values()].map(tab).toSorted((a, b) => {
const tabs = [...children.values()].toSorted((a, b) => {
const active = Number(b.status === "running") - Number(a.status === "running")
if (active !== 0) return active
return b.lastUpdatedAt - a.lastUpdatedAt
})
}).map(tab)
const child = selected ? children.get(selected) : undefined
const details: Record<string, FooterSubagentDetail> =
child && !child.detailStale ? { [child.sessionID]: { commits: child.frames.map((item) => item.commit) } } : {}
@@ -46,6 +46,7 @@ type StreamInput = {
replayLimit?: number
footer: FooterApi
onCommit?: (commit: StreamCommit) => void
onSessionTitle?: (title: string) => void
trace?: Trace
signal?: AbortSignal
onCatalogRefresh?: (signal?: AbortSignal) => unknown | Promise<unknown>
@@ -828,6 +829,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
input.trace?.write("recv.event", event)
subagents.main(client, event, attempt.signal)
if (event.type === "session.renamed") {
input.onSessionTitle?.(event.data.title)
return
}
if (event.type === "session.input.admitted") {
if (event.data.input.type !== "user") return
mergePending({
+1 -1
View File
@@ -85,7 +85,7 @@ function traceCommit(commit: StreamCommit) {
}
}
export function traceSubagentState(state: FooterSubagentState) {
function traceSubagentState(state: FooterSubagentState) {
return {
tabs: state.tabs,
details: Object.fromEntries(
+2
View File
@@ -0,0 +1,2 @@
export { toolInlineInfo, toolOutputText } from "./tool"
export type { MiniToolPart } from "./types"
+10 -16
View File
@@ -27,18 +27,17 @@ import {
import { formatPath } from "../util/path-format"
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
export type { MiniToolPart } from "./types"
export { canonicalToolName } from "../util/tool-display"
export type ToolView = {
type ToolView = {
output: boolean
final: boolean
snap?: "code" | "diff" | "structured"
}
export type ToolPhase = "start" | "progress" | "final"
type ToolPhase = "start" | "progress" | "final"
export type ToolDict = Record<string, unknown>
type ToolDict = Record<string, unknown>
type PatchFile = {
status?: string
@@ -78,7 +77,7 @@ type ToolMetadata = ToolDict & {
exit?: number
}
export type ToolFrame = {
type ToolFrame = {
directory?: string
raw: string
name: string
@@ -94,7 +93,7 @@ export type ToolFrame = {
}
}
export type ToolInline = {
type ToolInline = {
icon: string
title: string
description?: string
@@ -102,7 +101,7 @@ export type ToolInline = {
body?: string
}
export type ToolProps = {
type ToolProps = {
input: ToolInput
metadata: ToolMetadata
frame: ToolFrame
@@ -166,7 +165,7 @@ export function toolOutputText(name: string, content: ReadonlyArray<{ type: stri
function normalizeInput(name: string, value: unknown) {
const input = dict(value)
const path = typeof input.path === "string" ? input.path : text(input.filePath) || text(input.filepath)
const path = typeof input.path === "string" ? input.path : text(input.filePath)
const agent = typeof input.agent === "string" ? input.agent : text(input.subagent_type)
return {
...input,
@@ -214,11 +213,6 @@ function normalizeStructured(name: string, value: unknown) {
...structured,
...(["edit", "patch"].includes(name) && Array.isArray(structured.files) ? { files } : {}),
...(name === "subagent" && sessionID ? { sessionID } : {}),
...(name === "shell" &&
finiteNumber(structured.exit) === undefined &&
finiteNumber(structured.exitCode) !== undefined
? { exit: finiteNumber(structured.exitCode) }
: {}),
}
}
@@ -674,7 +668,7 @@ function scrollShellFinal(p: ToolProps): string {
return fail(p.frame)
}
const code = p.metadata.exit ?? finiteNumber(p.frame.meta.exitCode) ?? finiteNumber(p.frame.meta.exit_code)
const code = p.metadata.exit
const time = span(p.frame)
if (code === undefined) {
if (!time) {
@@ -1113,7 +1107,7 @@ function frame(part: SessionMessageAssistantTool, directory?: string): ToolFrame
}
}
export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
const current = commit.part ? frame(commit.part, commit.directory) : undefined
return {
directory: commit.directory,
@@ -1198,7 +1192,7 @@ export function toolScroll(phase: ToolPhase, ctx: ToolFrame): string {
return fallbackFinal(ctx)
}
export function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined {
function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined {
const ctx = toolFrame(commit, raw)
const draw = rule(ctx.name)?.snap
if (!draw) {
+11 -20
View File
@@ -53,7 +53,7 @@ export type RunCommand = {
source?: string
}
export type RunProviderModel = {
type RunProviderModel = {
name?: string
cost?: {
input: number
@@ -159,7 +159,7 @@ export type MiniHost = {
export type EntryKind = "system" | "user" | "assistant" | "reasoning" | "tool" | "error"
// Whether the assistant is actively processing a turn.
export type FooterPhase = "idle" | "running"
type FooterPhase = "idle" | "running"
// Full snapshot of footer status bar state. Every update replaces the whole
// object in the SolidJS signal so the view re-renders atomically.
@@ -188,14 +188,14 @@ export type ScrollbackOptions = {
mono?: boolean
}
export type ToolCodeSnapshot = {
type ToolCodeSnapshot = {
kind: "code"
title: string
content: string
file?: string
}
export type ToolDiffSnapshot = {
type ToolDiffSnapshot = {
kind: "diff"
items: Array<{
title: string
@@ -205,14 +205,14 @@ export type ToolDiffSnapshot = {
}>
}
export type ToolTaskSnapshot = {
type ToolTaskSnapshot = {
kind: "task"
title: string
rows: string[]
tail: string
}
export type ToolQuestionSnapshot = {
type ToolQuestionSnapshot = {
kind: "question"
items: Array<{
question: string
@@ -223,15 +223,7 @@ export type ToolQuestionSnapshot = {
export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot
export type MiniToolState =
| { status: "pending"; input: Record<string, unknown>; raw?: string }
| {
status: "running"
input: Record<string, unknown>
title?: string
metadata?: Record<string, unknown>
time: { start: number }
}
type MiniToolState =
| {
status: "completed"
input: Record<string, unknown>
@@ -304,7 +296,6 @@ export type FooterSubagentTab = {
status: "running" | "completed" | "cancelled" | "error"
background?: boolean
title?: string
lastUpdatedAt: number
}
export type FooterSubagentDetail = {
@@ -392,7 +383,7 @@ export type FormCancel = {
location?: LocationRef
}
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "session" | "mini">
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini">
export type MiniSettings = {
thinking: "show" | "hide"
@@ -408,11 +399,11 @@ export type MiniSettingChange = {
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
// appends content (coalesced in the footer queue), "final" closes it.
export type StreamPhase = "start" | "progress" | "final"
type StreamPhase = "start" | "progress" | "final"
export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
type StreamSource = "assistant" | "reasoning" | "tool" | "system"
export type StreamToolState = "running" | "completed" | "error"
type StreamToolState = "running" | "completed" | "error"
// A single append-only commit to scrollback. The transport produces these from
// V2 events, and RunFooter.append() queues them for the next
+18 -3
View File
@@ -84,6 +84,7 @@ import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type
import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import { stringWidth } from "../../util/string-width"
import { useArgs } from "../../context/args"
addDefaultParsers(parsers.parsers)
@@ -120,6 +121,7 @@ export function Session() {
const { navigate } = useRoute()
const data = useData()
const local = useLocal()
const args = useArgs()
const paths = useTuiPaths()
const configState = useConfig()
const config = configState.data
@@ -216,6 +218,7 @@ export function Session() {
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
const [navigationMessage, setNavigationMessage] = createSignal<string>()
const [navigationSlack, setNavigationSlack] = createSignal(0)
const [synced, setSynced] = createSignal(false)
const clearMessageNavigation = () => {
setNavigationSlack(0)
@@ -242,6 +245,7 @@ export function Session() {
createEffect(() => {
if (client.connection.status() !== "connected") return
setSynced(false)
const sessionID = route.sessionID
void (async () => {
await Promise.all([
@@ -261,6 +265,7 @@ export function Session() {
}
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
setSynced(true)
})().catch((error) => {
if (route.sessionID !== sessionID) return
toast.show({
@@ -273,15 +278,25 @@ export function Session() {
})
let seeded = false
let sent = false
let scroll: ScrollBoxRenderable
let prompt: PromptRef | undefined
const [prompt, setPrompt] = createSignal<PromptRef>()
const bind = (r: PromptRef | undefined) => {
prompt = r
setPrompt(r)
promptRef.set(r)
if (seeded || !route.prompt || !r) return
seeded = true
r.set(route.prompt)
}
createEffect(() => {
const current = prompt()
if (sent || !current || !synced() || !local.model.ready) return
if (!local.agent.current() || !local.model.current()) return
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
sent = true
current.submit()
})
const dialog = useDialog()
const renderer = useRenderer()
const unavailable = (feature: string) => {
@@ -526,7 +541,7 @@ export function Session() {
void client.api.session.revert
.stage({ sessionID: route.sessionID, messageID: message.id })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
prompt?.set({
prompt()?.set({
...projectedPromptInput(message),
pasted: [],
})
+83
View File
@@ -69,6 +69,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
setTitle(title)
}
const events = createEventStream()
let promptRequests = 0
const calls = createFetch((url) => {
const session = {
id: "dummy",
@@ -88,6 +89,10 @@ test("session lifecycle updates the terminal title and prints the epilogue after
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === "/api/session/dummy/prompt") {
promptRequests++
return json({ data: {} })
}
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const originalWrite = process.stdout.write.bind(process.stdout)
@@ -124,6 +129,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
expect(stdout).toContain("Renamed session")
expect(stdout).toContain("opencode2 -s dummy")
expect(promptRequests).toBe(0)
} finally {
process.stdout.write = originalWrite
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
@@ -131,3 +137,80 @@ test("session lifecycle updates the terminal title and prints the epilogue after
mock.restore()
}
})
test("session startup prompt is submitted exactly once", 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 = createEventStream()
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } }
const session = {
id: "dummy",
title: "Demo session",
projectID: "project",
location: { directory: cwd },
agent: "build",
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const bodies: unknown[] = []
const promptSubmitted = Promise.withResolvers<void>()
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === "/api/agent")
return json({
location,
data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
})
if (url.pathname === "/api/model")
return json({
location,
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
})
if (url.pathname === "/api/session/dummy/prompt") {
bodies.push(await request.json())
promptSubmitted.resolve()
return json({ data: {} })
}
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: { sessionID: "dummy", prompt: "RESUME_READY" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await Promise.race([
promptSubmitted.promise,
Bun.sleep(2000).then(() => {
throw new Error("startup prompt was not submitted")
}),
])
await Bun.sleep(20)
setup.renderer.destroy()
await task
expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
}
})
+1 -21
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import { loadRunReferences, runProviders, waitForDefaultModel } from "../../src/mini/catalog.shared"
import { loadRunReferences, runProviders } from "../../src/mini/catalog.shared"
import { catalogModel, catalogProvider } from "./fixture/catalog"
afterEach(() => {
@@ -8,26 +8,6 @@ afterEach(() => {
})
describe("run catalog shared", () => {
test("resolves the catalog-selected model for the footer", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const selected = spyOn(client.model, "default").mockImplementation(
() =>
Promise.resolve({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: { id: "gpt-5", providerID: "openai" },
}) as never,
)
await expect(waitForDefaultModel({ sdk: client, location: { directory: "/tmp" } })).resolves.toEqual({
providerID: "openai",
modelID: "gpt-5",
})
expect(selected).toHaveBeenCalledWith(
{ location: { directory: "/tmp", workspace: undefined } },
{ signal: expect.any(AbortSignal) },
)
})
test("loads visible project references from the current reference catalog", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const list = spyOn(client.reference, "list").mockImplementation(
@@ -1,19 +0,0 @@
import { resolve, type Info, type Resolved } from "../../../src/config"
import { TuiKeybind } from "../../../src/config/keybind"
type ResolvedInput = Omit<Info, "attention" | "keybinds" | "leader"> & {
attention?: Partial<Resolved["attention"]>
keybinds?: Partial<TuiKeybind.Keybinds>
leader_timeout?: number
}
export function createTuiResolvedConfig(input: ResolvedInput = {}) {
const { leader_timeout, ...current } = input
return resolve(
{
...current,
leader: leader_timeout === undefined ? undefined : { timeout: leader_timeout },
},
{ terminalSuspend: process.platform !== "win32" },
)
}
@@ -26,7 +26,6 @@ test("down opens subagents from an empty prompt", async () => {
label: "Explore",
description: "Inspect the keymap",
status: "running",
lastUpdatedAt: 1,
},
],
details: {},
@@ -54,7 +53,6 @@ test("down opens subagents from an empty prompt", async () => {
view={view}
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false })}
mono={false}
onSubmit={() => true}
+18 -21
View File
@@ -35,7 +35,7 @@ import type {
} from "../../src/mini/types"
import { selectedCommand } from "../../src/mini/footer.prompt"
import { RejectField } from "../../src/mini/footer.permission"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
const tuiConfig = createTuiResolvedConfig()
@@ -87,7 +87,6 @@ function subagent(input: {
label: input.label,
description: input.description,
status: input.status ?? "running",
lastUpdatedAt: 1,
} satisfies FooterSubagentTab
}
@@ -152,7 +151,6 @@ async function renderFooter(
subagent={subagents}
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
mono={input.mono ?? false}
tuiConfig={config}
miniSettings={miniSettings}
onSubmit={input.onSubmit ?? (() => true)}
onPermissionReply={() => {}}
@@ -355,7 +353,7 @@ test("run entry content updates when live commit text changes", async () => {
}
})
test("direct command panel renders grouped command palette", async () => {
test("direct command panel renders grouped actions without catalog commands", async () => {
const [commands] = createSignal<RunCommand[] | undefined>([
command({ name: "review", description: "Review code" }),
command({ name: "deploy", description: "Deploy prompt", source: "mcp" }),
@@ -423,6 +421,16 @@ test("direct command panel renders grouped command palette", async () => {
expect(frame).not.toContain("Review code")
expect(frame).not.toContain("Commands 8")
await app.mockInput.typeText("review")
await app.renderOnce()
expect(app.captureCharFrame()).toContain("No results found")
app.mockInput.pressKey("u", { ctrl: true })
await app.mockInput.typeText("deploy")
await app.renderOnce()
expect(app.captureCharFrame()).toContain("No results found")
app.mockInput.pressKey("u", { ctrl: true })
await app.mockInput.typeText("status")
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Show status")
@@ -976,27 +984,17 @@ test("direct footer closes settings with ctrl-c instead of arming exit", async (
}
})
test("selectedCommand backfills the catalog source for bound drafts", () => {
const catalog = [command({ name: "opencode-ts", description: "TS skill", source: "skill" })]
// The skill picker binds `/name ` drafts; older drafts may lack source.
expect(selectedCommand("/opencode-ts fix it", { name: "opencode-ts", arguments: "" }, catalog)).toEqual({
name: "opencode-ts",
arguments: "fix it",
source: "skill",
})
// An explicit source wins without a catalog lookup.
test("selectedCommand validates the bound command and refreshes its arguments", () => {
expect(selectedCommand("/opencode-ts", { name: "opencode-ts", arguments: "", source: "skill" })).toEqual({
name: "opencode-ts",
arguments: "",
source: "skill",
})
// Plain commands stay untagged.
expect(
selectedCommand("/deploy prod", { name: "deploy", arguments: "" }, [
command({ name: "deploy", description: "Deploy" }),
]),
).toEqual({ name: "deploy", arguments: "prod" })
expect(selectedCommand("/deploy prod", { name: "deploy", arguments: "" })).toEqual({
name: "deploy",
arguments: "prod",
})
expect(selectedCommand("/other", { name: "deploy", arguments: "" })).toBeUndefined()
})
test("direct footer tags skill slash submissions with their catalog source", async () => {
@@ -1142,7 +1140,6 @@ test("direct footer shows authoritative pending work while running", async () =>
},
]}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={tuiConfig}
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false })}
mono={false}
onSubmit={() => true}
+3 -5
View File
@@ -3,7 +3,7 @@ import { OpenCode } from "@opencode-ai/client/promise"
import type { Resolved } from "../../src/config"
import { resolveMiniSettings, resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
import { catalogModel, catalogProvider } from "./fixture/catalog"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
function config(input?: {
leader?: string
@@ -21,7 +21,7 @@ function config(input?: {
}): Resolved {
const bind = input?.bindings
return createTuiResolvedConfig({
leader_timeout: input?.leaderTimeout,
leader: input?.leaderTimeout === undefined ? undefined : { timeout: input.leaderTimeout },
keybinds: {
...(input?.leader && { leader: input.leader }),
...(bind?.commandList && { command_list: bind.commandList }),
@@ -95,14 +95,12 @@ describe("run runtime boot", () => {
const result = await resolveRunTuiConfig(
createTuiResolvedConfig({
theme: { mode: "light" },
leader_timeout: 450,
session: { thinking: "show" },
leader: { timeout: 450 },
}),
)
expect(result.theme).toEqual({ mode: "light" })
expect(result.leader.timeout).toBe(450)
expect(result.session?.thinking).toBe("show")
expect(resolveMiniSettings(result)).toEqual({
thinking: "hide",
shell_output: "hide",
+7 -1
View File
@@ -5,7 +5,6 @@ import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
import type { FooterEvent, MiniHost } from "../../src/mini/types"
import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog"
import { createFooterApiFixture } from "./fixture/footer-api"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
function defer<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
@@ -84,6 +83,7 @@ describe("run interactive runtime", () => {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
setTitle: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
@@ -180,6 +180,7 @@ describe("run interactive runtime", () => {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
setTitle: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
@@ -199,6 +200,7 @@ describe("run interactive runtime", () => {
const lifecycleStarted = defer<void>()
const painted = defer<void>()
const events: FooterEvent[] = []
const titles: Array<string | undefined> = []
const api = footer(events)
api.idle = () => painted.promise
const event = api.event
@@ -265,6 +267,7 @@ describe("run interactive runtime", () => {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
setTitle: (title) => titles.push(title),
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
@@ -282,6 +285,7 @@ describe("run interactive runtime", () => {
history: [{ text: "previous prompt", parts: [] }],
})
expect(events).toContainEqual({ type: "agent", agent: "review" })
expect(titles).toEqual(["Resume"])
expect(events).toContainEqual({
type: "model",
model: "Little Frank · OpenAI · high",
@@ -344,6 +348,7 @@ describe("run interactive runtime", () => {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
setTitle: () => {},
resetForReplay: () => Promise.resolve(),
close: async (input) => {
closedTitle = input.sessionTitle
@@ -423,6 +428,7 @@ describe("run interactive runtime", () => {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
setTitle: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
@@ -200,6 +200,31 @@ afterEach(() => {
})
describe("V2 mini transport", () => {
test("reports session title changes", async () => {
const events = feed()
events.push(connected())
const titles: string[] = []
const transport = await createSessionTransport({
sdk: sdk({ streams: [events] }),
sessionID: "ses_1",
thinking: false,
footer: footer().api,
onSessionTitle: (title) => titles.push(title),
})
events.push({
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: durable("ses_1", 1),
data: { sessionID: "ses_1", title: "Greeting" },
})
while (titles.length === 0) await Bun.sleep(0)
expect(titles).toEqual(["Greeting"])
await transport.close()
})
test("formats footer usage with compact tokens and context percentage", async () => {
const events = feed()
events.push(connected())
@@ -2,6 +2,20 @@
Status: **Historical decision record.** Option B, replayable Location-scoped Catalog transforms, was selected and implemented. All interfaces and flows below are historical option sketches, not current API reference; current behavior is owned by Core.
## Implemented Readiness Contract
Catalog list endpoints return Location-scoped snapshots and do not wait for
plugin activation. Consumers that need an authoritative startup validation call
`POST /api/location/wait`, which waits up to five seconds for the initial plugin
generation to settle and returns the resolved Location. A timeout returns 503;
clients may call the endpoint again. After the barrier settles, absence from a
catalog or agent snapshot is authoritative until a later configuration or
integration update.
Ongoing catalog changes remain event-driven: `catalog.updated` tells frontends
to fetch a new snapshot. The wait endpoint is only an initial-settlement barrier
and does not turn snapshot reads into blocking operations.
The decision compared where provider/model inputs live and how visible catalog state changes after boot. The designs below preserve that comparison.
## Scenarios