Compare commits

...

4 Commits

Author SHA1 Message Date
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
30 changed files with 519 additions and 253 deletions
+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))))
+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() })
}),
)
+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)
-32
View File
@@ -92,18 +92,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 +362,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 +460,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",
@@ -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 = {
@@ -338,6 +345,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 +358,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
@@ -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({
+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(
+11 -1
View File
@@ -355,7 +355,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 +423,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")
+7
View File
@@ -84,6 +84,7 @@ describe("run interactive runtime", () => {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
setTitle: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
@@ -180,6 +181,7 @@ describe("run interactive runtime", () => {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
setTitle: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
@@ -199,6 +201,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 +268,7 @@ describe("run interactive runtime", () => {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
setTitle: (title) => titles.push(title),
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
@@ -282,6 +286,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 +349,7 @@ describe("run interactive runtime", () => {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
setTitle: () => {},
resetForReplay: () => Promise.resolve(),
close: async (input) => {
closedTitle = input.sessionTitle
@@ -423,6 +429,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