mini migrate to v2 (#35526)

This commit is contained in:
Simon Klee
2026-07-06 11:03:30 +02:00
committed by GitHub
parent fbcbf3c6ef
commit b2cf7a2953
93 changed files with 2246 additions and 2335 deletions
+8
View File
@@ -101,16 +101,22 @@
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*", "@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"opentui-spinner": "catalog:",
"semver": "catalog:", "semver": "catalog:",
"solid-js": "catalog:", "solid-js": "catalog:",
"strip-ansi": "7.1.2",
}, },
"devDependencies": { "devDependencies": {
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
@@ -599,6 +605,7 @@
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
"@opencode-ai/cli": "workspace:*",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
@@ -796,6 +803,7 @@
"name": "@opencode-ai/server", "name": "@opencode-ai/server",
"version": "1.17.13", "version": "1.17.13",
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*", "@opencode-ai/simulation": "workspace:*",
+21 -1
View File
@@ -10,6 +10,20 @@
"files": [ "files": [
"bin" "bin"
], ],
"exports": {
"./daemon": "./src/daemon.ts",
"./mini": "./src/mini/index.ts",
"./mini/footer.command": "./src/mini/footer.command.tsx",
"./mini/footer.menu": "./src/mini/footer.menu.tsx",
"./mini/footer.permission": "./src/mini/footer.permission.tsx",
"./mini/footer.prompt": "./src/mini/footer.prompt.tsx",
"./mini/footer.question": "./src/mini/footer.question.tsx",
"./mini/footer.subagent": "./src/mini/footer.subagent.tsx",
"./mini/footer.view": "./src/mini/footer.view.tsx",
"./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
"./mini/*": "./src/mini/*.ts",
"./server-process": "./src/server-process.ts"
},
"scripts": { "scripts": {
"build": "bun run script/build.ts", "build": "bun run script/build.ts",
"dev": "bun run src/index.ts", "dev": "bun run src/index.ts",
@@ -19,16 +33,22 @@
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*", "@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"opentui-spinner": "catalog:",
"semver": "catalog:", "semver": "catalog:",
"solid-js": "catalog:" "solid-js": "catalog:",
"strip-ansi": "7.1.2"
}, },
"devDependencies": { "devDependencies": {
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
+105
View File
@@ -3,6 +3,31 @@ import { Spec } from "../framework/spec"
declare const OPENCODE_CLI_NAME: string | undefined declare const OPENCODE_CLI_NAME: string | undefined
const MiniParams = {
continue: Flag.boolean("continue").pipe(
Flag.withAlias("c"),
Flag.withDescription("Continue the last session"),
Flag.withDefault(false),
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue"),
Flag.optional,
),
fork: Flag.boolean("fork").pipe(
Flag.withDescription("Fork the session when continuing"),
Flag.withDefault(false),
),
replay: Flag.boolean("replay").pipe(
Flag.withDescription("Replay session history on resume and after resize"),
Flag.withDefault(true),
),
replayLimit: Flag.integer("replay-limit").pipe(
Flag.withDescription("Cap visible replay to the newest N messages"),
Flag.optional,
),
}
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface", description: "OpenCode 2.0 preview command line interface",
params: { params: {
@@ -88,6 +113,86 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
], ],
}), }),
Spec.make("migrate", { description: "Migrate v1 data to v2" }), Spec.make("migrate", { description: "Migrate v1 data to v2" }),
Spec.make("mini", {
description: "Start the minimal interactive interface",
params: {
...MiniParams,
project: Argument.string("project").pipe(
Argument.withDescription("Path to start OpenCode in"),
Argument.optional,
),
model: Flag.string("model").pipe(
Flag.withAlias("m"),
Flag.withDescription("Model to use in the format provider/model"),
Flag.optional,
),
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
server: Flag.string("server").pipe(
Flag.withDescription("Connect to a server URL instead of the background service"),
Flag.optional,
),
demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
},
}),
Spec.make("run", {
description: "Run OpenCode with a message",
params: {
message: Argument.string("message").pipe(
Argument.withDescription("Message to send"),
Argument.variadic({ min: 0 }),
),
continue: Flag.boolean("continue").pipe(
Flag.withAlias("c"),
Flag.withDescription("Continue the last session"),
Flag.withDefault(false),
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue"),
Flag.optional,
),
fork: Flag.boolean("fork").pipe(
Flag.withDescription("Fork the session before continuing"),
Flag.withDefault(false),
),
model: Flag.string("model").pipe(
Flag.withAlias("m"),
Flag.withDescription("Model to use in the format provider/model"),
Flag.optional,
),
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
format: Flag.choice("format", ["default", "json"]).pipe(
Flag.withDescription("Output format"),
Flag.withDefault("default"),
),
file: Flag.string("file").pipe(
Flag.withAlias("f"),
Flag.withDescription("File to attach to the message"),
Flag.atMost(100),
),
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
server: Flag.string("server").pipe(
Flag.withDescription("Connect to a server URL instead of the background service"),
Flag.optional,
),
dir: Flag.string("dir").pipe(Flag.withDescription("Directory to run in"), Flag.optional),
variant: Flag.string("variant").pipe(Flag.withDescription("Model variant"), Flag.optional),
thinking: Flag.boolean("thinking").pipe(
Flag.withDescription("Show thinking blocks"),
Flag.withDefault(false),
),
auto: Flag.boolean("auto").pipe(
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
Flag.withDefault(false),
),
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe(
Flag.withDefault(false),
Flag.withHidden,
),
},
}),
Spec.make("service", { Spec.make("service", {
description: "Manage the background server", description: "Manage the background server",
commands: [ commands: [
@@ -0,0 +1,35 @@
import { Effect, Option, Redacted } from "effect"
import path from "node:path"
import { Commands } from "../commands"
import { Env } from "../../env"
import { Runtime } from "../../framework/runtime"
export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () {
const { runMini } = yield* Effect.promise(() => import("../../mini"))
const project = Option.getOrUndefined(input.project)
const server = Option.getOrUndefined(input.server)
const password = yield* Env.password
yield* Effect.promise(() =>
runMini({
attach: server,
password: password ? Redacted.value(password) : undefined,
directory:
server !== undefined
? project
: project === undefined
? process.cwd()
: path.resolve(process.env.PWD ?? process.cwd(), project),
continue: input.continue,
session: Option.getOrUndefined(input.session),
fork: input.fork,
model: Option.getOrUndefined(input.model),
agent: Option.getOrUndefined(input.agent),
prompt: Option.getOrUndefined(input.prompt),
replay: input.replay,
replayLimit: Option.getOrUndefined(input.replayLimit),
demo: input.demo,
}),
)
}),
)
+31
View File
@@ -0,0 +1,31 @@
import { Effect, Option, Redacted } from "effect"
import { Commands } from "../commands"
import { Env } from "../../env"
import { Runtime } from "../../framework/runtime"
export default Runtime.handler(Commands.commands.run, (input) =>
Effect.gen(function* () {
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
const password = yield* Env.password
const separator = process.argv.indexOf("--", 2)
yield* Effect.promise(() =>
runNonInteractive({
message: [...input.message, ...(separator === -1 ? [] : process.argv.slice(separator + 1))],
continue: input.continue,
session: Option.getOrUndefined(input.session),
fork: input.fork,
model: Option.getOrUndefined(input.model),
agent: Option.getOrUndefined(input.agent),
format: input.format,
file: [...input.file],
title: Option.getOrUndefined(input.title),
server: Option.getOrUndefined(input.server),
password: password ? Redacted.value(password) : undefined,
directory: Option.getOrUndefined(input.dir),
variant: Option.getOrUndefined(input.variant),
thinking: input.thinking,
dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions,
}),
)
}),
)
+8 -146
View File
@@ -1,154 +1,16 @@
import { NodeHttpServer } from "@effect/platform-node" import { Effect, Option } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Project } from "@opencode-ai/core/project"
import { Global } from "@opencode-ai/core/global"
import { Context, Effect, FileSystem, Layer, Option, Redacted, Schedule, Schema } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes"
import { ServerAuth } from "@opencode-ai/server/auth"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { ServerProcess } from "../../server-process"
import { Env } from "../../env"
import { ServiceConfig } from "../../services/service-config"
import { Updater } from "../../services/updater"
import { randomBytes, randomUUID } from "crypto"
import path from "path"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.serve, Commands.commands.serve,
Effect.fn("cli.serve")(function* (input) { Effect.fn("cli.serve")(function* (input) {
if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home)) if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined"))
return yield* Effect.scoped( return yield* ServerProcess.run({
Effect.gen(function* () { mode: input.service ? "service" : input.stdio ? "stdio" : "default",
const standalonePassword = yield* Env.password hostname: Option.getOrUndefined(input.hostname),
// Keep the lease credential out of the environment inherited by any port: Option.getOrUndefined(input.port),
// process this server spawns. })
if (input.stdio) {
delete process.env.OPENCODE_PASSWORD
delete process.env.OPENCODE_SERVER_PASSWORD
}
const config = input.service ? yield* ServiceConfig.read() : {}
const password = input.service
? yield* ServiceConfig.password()
: standalonePassword
? Redacted.value(standalonePassword)
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
const port = Option.isSome(input.port)
? input.port
: config.port === undefined
? Option.none<number>()
: Option.some(config.port)
const address = yield* listen(hostname, port, password)
yield* Effect.tryPromise(() =>
createOpencodeClient({
baseUrl: HttpServer.formatAddress(address),
headers: ServerAuth.headers({ password }),
}).v2.health.get({}),
)
if (input.service) yield* register(address)
const url = HttpServer.formatAddress(address)
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* input.stdio ? waitForStdinClose() : Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })),
)
}), }),
) )
// Server-side half of the registration protocol. The registration embeds the
// password so the file alone is enough for any client to discover and
// authenticate. The file arbitrates ownership after concurrent starts; it is
// not a startup lock: the atomic rename elects the latest writer, the watcher
// self-evicts losers, and the finalizer id-guard keeps an exiting server from
// deleting its successor's registration.
// Written and read through Service.Info so the file the server registers is
// provably the contract clients discover with.
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
const register = Effect.fnUntraced(function* (address: HttpServer.Address) {
const fs = yield* FileSystem.FileSystem
const { file } = yield* ServiceConfig.options()
const id = randomUUID()
const secret = yield* ServiceConfig.password()
const temp = file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
const encoded = yield* encodeInfo({
id,
version: InstallationVersion,
url: HttpServer.formatAddress(address),
pid: process.pid,
password: secret,
})
yield* fs.writeFileString(temp, encoded, { mode: 0o600 })
yield* fs.rename(temp, file)
const currentID = fs.readFileString(file).pipe(
Effect.flatMap(decodeInfo),
Effect.map((info) => info.id),
Effect.orElseSucceed(() => undefined),
)
yield* currentID.pipe(
Effect.flatMap((current) =>
current === id
? Effect.void
: Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore),
),
Effect.repeat(Schedule.spaced("10 seconds")),
Effect.forkScoped,
)
yield* Effect.addFinalizer(() =>
currentID.pipe(
Effect.flatMap((current) => (current === id ? fs.remove(file) : Effect.void)),
Effect.ignore,
),
)
})
function waitForStdinClose() {
return Effect.callback<void>((resume) => {
const close = () => resume(Effect.void)
process.stdin.once("end", close)
process.stdin.once("close", close)
process.stdin.resume()
if (process.stdin.readableEnded || process.stdin.destroyed) close()
return Effect.sync(() => {
process.stdin.off("end", close)
process.stdin.off("close", close)
process.stdin.pause()
})
})
}
function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password)
const next = (port: number): ReturnType<typeof bind> =>
bind(hostname, port, password).pipe(
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
)
return next(4096)
}
function bind(hostname: string, port: number, password: string) {
const server = createServer()
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
),
).pipe(
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
)
}
+70
View File
@@ -0,0 +1,70 @@
import { Service } from "@opencode-ai/client/effect"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ServerAuth } from "@opencode-ai/server/auth"
import { Effect } from "effect"
import { ServiceConfig } from "./services/service-config"
export type SharedOptions = {
readonly mode: "shared"
readonly command?: ReadonlyArray<string>
}
export type AttachOptions = {
readonly mode: "attach"
readonly url: string
readonly username?: string
readonly password?: string
}
export type Options = SharedOptions | AttachOptions
const attach = Effect.fn("cli.daemon.attach")(function* (options: AttachOptions) {
const transport = {
url: options.url,
headers:
options.password === undefined
? undefined
: ServerAuth.headers({ password: options.password, username: options.username }),
} satisfies Service.Transport
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
const health = yield* Effect.tryPromise({
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => attachError(options, cause),
})
if (health.version !== InstallationVersion)
return yield* Effect.fail(
new Error(`Server at ${options.url} has version ${health.version}; this client requires ${InstallationVersion}`),
)
return transport
})
const shared = Effect.fn("cli.daemon.shared")(function* (options: SharedOptions) {
const config = yield* ServiceConfig.options()
const service = options.command === undefined ? config : { ...config, command: options.command }
const found = yield* Service.discover(service)
if (found) return found
return yield* Service.start(service)
})
export function transport(options: AttachOptions): ReturnType<typeof attach>
export function transport(options: SharedOptions): ReturnType<typeof shared>
export function transport(options: Options): ReturnType<typeof attach> | ReturnType<typeof shared>
export function transport(options: Options) {
if (options.mode === "attach") return attach(options)
return shared(options)
}
function attachError(options: AttachOptions, cause: unknown) {
if (isUnauthorizedError(cause)) {
return new Error(
options.password === undefined
? `Server at ${options.url} requires authentication; provide a password`
: `Server at ${options.url} rejected the supplied credentials`,
{ cause },
)
}
if (cause instanceof ClientError && cause.reason === "Transport")
return new Error(`Could not reach server at ${options.url}`, { cause })
return new Error(`Server at ${options.url} did not provide a compatible V2 health response`, { cause })
}
export * as Daemon from "./daemon"
+2
View File
@@ -31,6 +31,8 @@ const Handlers = Runtime.handlers(Commands, {
logout: () => import("./commands/handlers/mcp/logout"), logout: () => import("./commands/handlers/mcp/logout"),
}, },
migrate: () => import("./commands/handlers/migrate"), migrate: () => import("./commands/handlers/migrate"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
service: { service: {
start: () => import("./commands/handlers/service/start"), start: () => import("./commands/handlers/service/start"),
restart: () => import("./commands/handlers/service/restart"), restart: () => import("./commands/handlers/service/restart"),
@@ -1,13 +1,18 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2" import type {
AgentListOutput,
CommandListOutput,
ModelListOutput,
OpenCodeClient,
ProviderListOutput,
SkillListOutput,
} from "@opencode-ai/client/promise"
import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types" import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
type CurrentAgent = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number] type CurrentAgent = AgentListOutput["data"][number]
type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number] type CurrentCommand = CommandListOutput["data"][number]
type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number] type CurrentSkill = SkillListOutput["data"][number]
type CurrentProvider = NonNullable< type CurrentProvider = ProviderListOutput["data"][number]
Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"] type CurrentModel = ModelListOutput["data"][number]
>["data"][number]
type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["data"]>["data"][number]
function location(directory: string) { function location(directory: string) {
return { return {
@@ -91,16 +96,16 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[
// For explicit --model flows, wait for that exact ref to appear before prompt // For explicit --model flows, wait for that exact ref to appear before prompt
// admission. On timeout, return and let the real execution error surface. // admission. On timeout, return and let the real execution error surface.
export async function waitForCatalogReady(input: { export async function waitForCatalogReady(input: {
sdk: OpencodeClient sdk: OpenCodeClient
directory: string directory: string
model: { providerID: string; modelID: string } model: { providerID: string; modelID: string }
timeoutMs?: number timeoutMs?: number
}) { }) {
const deadline = Date.now() + (input.timeoutMs ?? 5_000) const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline) { while (Date.now() < deadline) {
const models = await input.sdk.v2.model const models = await input.sdk.model
.list(location(input.directory), { throwOnError: true }) .list(location(input.directory))
.then((result) => result.data?.data ?? []) .then((result) => result.data)
.catch(() => undefined) .catch(() => undefined)
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
await new Promise((resolve) => setTimeout(resolve, 25)) await new Promise((resolve) => setTimeout(resolve, 25))
@@ -108,47 +113,47 @@ export async function waitForCatalogReady(input: {
} }
export async function waitForDefaultModel(input: { export async function waitForDefaultModel(input: {
sdk: OpencodeClient sdk: OpenCodeClient
directory: string directory: string
timeoutMs?: number timeoutMs?: number
active?: () => boolean active?: () => boolean
}): Promise<{ providerID: string; modelID: string } | undefined> { }): Promise<{ providerID: string; modelID: string } | undefined> {
const deadline = Date.now() + (input.timeoutMs ?? 5_000) const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline && (input.active?.() ?? true)) { while (Date.now() < deadline && (input.active?.() ?? true)) {
const model = await input.sdk.v2.model const model = await input.sdk.model
.default(location(input.directory), { throwOnError: true }) .default(location(input.directory))
.then((result) => result.data?.data) .then((result) => result.data)
.catch(() => undefined) .catch(() => undefined)
if (model) return { providerID: model.providerID, modelID: model.id } if (model) return { providerID: model.providerID, modelID: model.id }
await new Promise((resolve) => setTimeout(resolve, 25)) await new Promise((resolve) => setTimeout(resolve, 25))
} }
} }
export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise<RunAgent[]> { export async function loadRunAgents(sdk: OpenCodeClient, directory: string): Promise<RunAgent[]> {
const result = await sdk.v2.agent.list(location(directory), { throwOnError: true }) const result = await sdk.agent.list(location(directory))
return (result.data?.data ?? []).map(runAgent) return result.data.map(runAgent)
} }
export async function loadRunCommands(sdk: OpencodeClient, directory: string): Promise<RunCommand[]> { export async function loadRunCommands(sdk: OpenCodeClient, directory: string): Promise<RunCommand[]> {
const [commands, skills] = await Promise.all([ const [commands, skills] = await Promise.all([
sdk.v2.command.list(location(directory), { throwOnError: true }), sdk.command.list(location(directory)),
sdk.v2.skill.list(location(directory), { throwOnError: true }), sdk.skill.list(location(directory)),
]) ])
return [ return [
...(commands.data?.data ?? []).map(runCommand), ...commands.data.map(runCommand),
...(skills.data?.data ?? []).filter((skill) => skill.slash !== false).map(runSkill), ...skills.data.filter((skill) => skill.slash !== false).map(runSkill),
] ]
} }
export async function loadRunReferences(sdk: OpencodeClient, directory: string): Promise<RunReference[]> { export async function loadRunReferences(sdk: OpenCodeClient, directory: string): Promise<RunReference[]> {
const result = await sdk.v2.reference.list(location(directory), { throwOnError: true }) const result = await sdk.reference.list(location(directory))
return (result.data?.data ?? []).filter((reference) => !reference.hidden) return result.data.filter((reference) => !reference.hidden)
} }
export async function loadRunProviders(sdk: OpencodeClient, directory: string): Promise<RunProvider[]> { export async function loadRunProviders(sdk: OpenCodeClient, directory: string): Promise<RunProvider[]> {
const [providers, models] = await Promise.all([ const [providers, models] = await Promise.all([
sdk.v2.provider.list(location(directory), { throwOnError: true }), sdk.provider.list(location(directory)),
sdk.v2.model.list(location(directory), { throwOnError: true }), sdk.model.list(location(directory)),
]) ])
return runProviders(providers.data?.data ?? [], models.data?.data ?? []) return runProviders([...providers.data], [...models.data])
} }
@@ -678,7 +678,7 @@ function emitTask(state: State): void {
state: { state: {
status: "running", status: "running",
input: { input: {
filePath: "packages/opencode/src/cli/cmd/run/stream.ts", filePath: "packages/cli/src/mini/stream.ts",
offset: 1, offset: 1,
limit: 200, limit: 200,
}, },
@@ -3,7 +3,7 @@ import { TextAttributes, type ColorInput } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { transparent, type RunFooterTheme } from "./theme" import { transparent, type RunFooterTheme } from "./theme"
import * as Locale from "@/util/locale" import { Locale } from "@opencode-ai/tui/util/locale"
export const FOOTER_MENU_ROWS = 8 export const FOOTER_MENU_ROWS = 8
@@ -12,7 +12,7 @@ import { normalizePromptContent } from "@opencode-ai/tui/prompt/content"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import path from "path" import path from "path"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js" import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import * as Locale from "@/util/locale" import { Locale } from "@opencode-ai/tui/util/locale"
import { import {
createPromptHistory, createPromptHistory,
displayCharAt, displayCharAt,
@@ -67,7 +67,7 @@ type PromptInput = {
prompt: Accessor<boolean> prompt: Accessor<boolean>
width: Accessor<number> width: Accessor<number>
theme: Accessor<RunFooterTheme> theme: Accessor<RunFooterTheme>
history?: RunPrompt[] history?: Accessor<RunPrompt[]>
onSubmit: (input: RunPrompt) => boolean | Promise<boolean> onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void onCycle: () => void
onInterrupt: () => boolean onInterrupt: () => boolean
@@ -302,7 +302,10 @@ export function createPromptState(input: PromptInput): PromptState {
return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')]) return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
}) })
let history = createPromptHistory(input.history) let history = createPromptHistory(input.history?.())
createEffect(() => {
history = createPromptHistory(input.history?.())
})
let draft: RunPrompt = { text: "", parts: [] } let draft: RunPrompt = { text: "", parts: [] }
let stash: RunPrompt = { text: "", parts: [] } let stash: RunPrompt = { text: "", parts: [] }
let area: TextareaRenderable | undefined let area: TextareaRenderable | undefined
@@ -203,6 +203,8 @@ export class RunFooter implements FooterApi {
private setSubagent: (next: FooterSubagentState) => void private setSubagent: (next: FooterSubagentState) => void
private queuedPrompts: Accessor<FooterQueuedPrompt[]> private queuedPrompts: Accessor<FooterQueuedPrompt[]>
private setQueuedPrompts: Setter<FooterQueuedPrompt[]> private setQueuedPrompts: Setter<FooterQueuedPrompt[]>
private history: Accessor<RunPrompt[]>
private setHistory: Setter<RunPrompt[]>
private promptRoute: FooterPromptRoute = { type: "composer" } private promptRoute: FooterPromptRoute = { type: "composer" }
private subagentMenuRows = SUBAGENT_ROWS private subagentMenuRows = SUBAGENT_ROWS
private autocomplete = false private autocomplete = false
@@ -289,6 +291,9 @@ export class RunFooter implements FooterApi {
const [queuedPrompts, setQueuedPrompts] = createSignal<FooterQueuedPrompt[]>([]) const [queuedPrompts, setQueuedPrompts] = createSignal<FooterQueuedPrompt[]>([])
this.queuedPrompts = queuedPrompts this.queuedPrompts = queuedPrompts
this.setQueuedPrompts = setQueuedPrompts this.setQueuedPrompts = setQueuedPrompts
const [history, setHistory] = createSignal(options.history ?? [])
this.history = history
this.setHistory = setHistory
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS) this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
this.scrollback = this.createScrollback(options.wrote ?? false) this.scrollback = this.createScrollback(options.wrote ?? false)
@@ -322,7 +327,7 @@ export class RunFooter implements FooterApi {
diffStyle: options.diffStyle, diffStyle: options.diffStyle,
tuiConfig: options.tuiConfig, tuiConfig: options.tuiConfig,
backgroundSubagents: options.backgroundSubagents, backgroundSubagents: options.backgroundSubagents,
history: options.history, history: footer.history,
agent: options.agentLabel, agent: options.agentLabel,
onSubmit: footer.handlePrompt, onSubmit: footer.handlePrompt,
onPermissionReply: footer.handlePermissionReply, onPermissionReply: footer.handlePermissionReply,
@@ -390,6 +395,11 @@ export class RunFooter implements FooterApi {
} }
public event(next: FooterEvent): void { public event(next: FooterEvent): void {
if (next.type === "history") {
this.setHistory(next.history)
return
}
if (next.type === "model") { if (next.type === "model") {
this.setCurrentModel(next.selection) this.setCurrentModel(next.selection)
} }
@@ -88,7 +88,7 @@ type RunFooterViewProps = {
diffStyle?: RunDiffStyle diffStyle?: RunDiffStyle
tuiConfig: RunTuiConfig tuiConfig: RunTuiConfig
backgroundSubagents: boolean backgroundSubagents: boolean
history?: RunPrompt[] history?: () => RunPrompt[]
agent: string agent: string
onSubmit: (input: RunPrompt) => boolean onSubmit: (input: RunPrompt) => boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void> onPermissionReply: (input: PermissionReply) => void | Promise<void>
+7
View File
@@ -0,0 +1,7 @@
export { runMini, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
export {
runNonInteractive,
mergeInput as mergeNonInteractiveInput,
pickRunModel,
type RunCommandInput,
} from "./run"
+234
View File
@@ -0,0 +1,234 @@
import { NodeFileSystem } from "@effect/platform-node"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { truthy } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Effect } from "effect"
import path from "node:path"
import { Daemon } from "../daemon"
import { waitForCatalogReady } from "./catalog.shared"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
import type { RunInput, RunTuiConfig } from "./types"
export type MiniCommandInput = {
directory?: string
attach?: string
password?: string
username?: string
continue?: boolean
session?: string
fork?: boolean
model?: string
agent?: string
prompt?: string
replay?: boolean
replayLimit?: number
demo?: boolean
serverCommand?: ReadonlyArray<string>
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
}
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
type Transport = { readonly url: string; readonly headers?: HeadersInit }
export async function runMini(input: MiniCommandInput) {
validate(input)
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
const runtimeTask = import("./runtime")
const directory = input.attach ? input.directory : localDirectory(input.directory)
const transportTask = startTransport(input)
void transportTask.catch(() => {})
try {
if (input.attach) await transportTask
const sdk = OpenCode.make({
baseUrl: "http://opencode.pending",
fetch: deferredFetch(transportTask),
})
const attachedSession =
input.attach && input.session && !input.directory
? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found"))
: undefined
const resolvedDirectory =
directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await transportTask, sdk))
const model = parseModel(input.model)
let agentTask: Promise<string | undefined> | undefined
const resolveAgent = () => {
agentTask ??= validateAgent(sdk, resolvedDirectory, input.agent, input.attach)
return agentTask
}
const resolveSession = async () => {
const [agent, selected] = await Promise.all([
resolveAgent(),
selectSession(sdk, resolvedDirectory, input, attachedSession),
])
const readyModel =
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
if (readyModel) await waitForCatalogReady({ sdk, directory: resolvedDirectory, model: readyModel })
const session = selected ?? (await createSession(sdk, resolvedDirectory, agent, model))
return { id: session.id, title: session.title, resume: selected !== undefined }
}
const create = (
_sdk: OpenCodeClient,
next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined },
) => createSession(sdk, resolvedDirectory, next.agent, next.model, next.variant)
const runtime = await runtimeTask
await runtime.runInteractiveDeferredMode({
sdk,
directory: resolvedDirectory,
resolveAgent,
session: resolveSession,
createSession: create,
agent: input.agent,
model,
variant: undefined,
files: [],
initialInput,
thinking: true,
backgroundSubagents:
truthy("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS") ||
(process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === undefined && truthy("OPENCODE_EXPERIMENTAL")),
replay: input.replay ?? true,
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
})
} catch (error) {
if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) fail(error.message)
throw error
}
}
/** @internal Exported for testing. */
export function mergeInput(piped: string | undefined, prompt: string | undefined) {
if (!prompt) return piped || undefined
if (!piped) return prompt
return piped + "\n" + prompt
}
function validate(input: MiniCommandInput) {
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) {
fail("--replay-limit must be a positive integer")
}
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
resolveInteractiveStdin().cleanup?.()
}
function localDirectory(directory?: string): string {
const root = process.env.PWD ?? process.cwd()
try {
process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root)
return process.cwd()
} catch {
fail(`Failed to change directory to ${directory}`)
}
}
function startTransport(input: MiniCommandInput): Promise<Transport> {
if (input.attach) {
return Effect.runPromise(
Daemon.transport({
mode: "attach",
url: input.attach,
password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD,
username: input.username ?? process.env.OPENCODE_SERVER_USERNAME,
}),
)
}
return Effect.runPromise(
Daemon.transport({ mode: "shared", command: input.serverCommand }).pipe(
Effect.provide(NodeFileSystem.layer),
Effect.provide(Global.layerWith({})),
),
)
}
function deferredFetch(transportTask: Promise<{ url: string; headers?: HeadersInit }>): typeof globalThis.fetch {
const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const transport = await transportTask
const request = new Request(input, init)
const source = new URL(request.url)
const headers = new Headers(request.headers)
for (const [key, value] of new Headers(transport.headers)) headers.set(key, value)
return globalThis.fetch(new Request(new URL(source.pathname + source.search, transport.url), request), { headers })
}
return fetch as typeof globalThis.fetch
}
async function remoteDirectory(
transport: { url: string; headers?: HeadersInit },
sdk: OpenCodeClient,
): Promise<string> {
const location = await sdk.location.get()
if (!location.directory) throw new Error(`Failed to resolve remote directory from ${transport.url}`)
return location.directory
}
function parseModel(value?: string): RunInput["model"] {
if (!value) return
const [providerID, ...rest] = value.split("/")
const modelID = rest.join("/")
if (!providerID || !modelID) fail("--model must use the format provider/model")
return { providerID, modelID }
}
async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string, attach?: string) {
if (!name) return
const deadline = Date.now() + 5_000
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
while (Date.now() < deadline) {
agents = await sdk.agent.list({ location: { directory } }).catch(() => 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 Bun.sleep(25)
}
if (!agents) {
warning(`failed to list agents${attach ? ` from ${attach}` : ""}. Falling back to default agent`)
return
}
warning(`agent "${name}" not found. Falling back to default agent`)
}
async function selectSession(sdk: OpenCodeClient, directory: string, input: MiniCommandInput, preselected?: Session) {
const selected =
preselected ??
(input.session
? await sdk.session.get({ sessionID: input.session }).catch(() => undefined)
: input.continue
? await sdk.session
.list({ directory, parentID: null, limit: 1, order: "desc" })
.then((result) => result.data[0])
: undefined)
if (input.session && !selected) fail("Session not found")
if (!selected) return
if (!input.fork) return selected
return sdk.session.fork({ sessionID: selected.id })
}
async function createSession(
sdk: OpenCodeClient,
directory: string,
agent: string | undefined,
model: RunInput["model"],
variant?: string,
): Promise<Session> {
if (model) await waitForCatalogReady({ sdk, directory, model })
return sdk.session.create({
agent,
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
location: { directory },
})
}
function warning(message: string) {
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
}
function fail(message: string): never {
process.stderr.write(`\x1b[91m\x1b[1mError: \x1b[0m${message}\n`)
process.exit(1)
}
@@ -1,15 +1,17 @@
import type { import type {
OpencodeClient, EventSubscribeOutput,
OpenCodeClient,
} from "@opencode-ai/client/promise"
import type {
ReasoningPart, ReasoningPart,
StepFinishPart, StepFinishPart,
StepStartPart, StepStartPart,
TextPart, TextPart,
ToolPart, ToolPart,
V2Event,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os" import { EOL } from "node:os"
import { MessageID } from "@/session/schema" import { UI } from "./ui"
import { UI } from "../../ui"
type Model = { type Model = {
providerID: string providerID: string
@@ -23,7 +25,7 @@ type File = {
} }
type Input = { type Input = {
client: OpencodeClient client: OpenCodeClient
sessionID: string sessionID: string
message: string message: string
files: File[] files: File[]
@@ -52,6 +54,7 @@ type ToolState = StartedPart & {
provider?: unknown provider?: unknown
} }
type V2Event = EventSubscribeOutput
type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"] type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
// MCP elicitations are temporarily owned by the "global" sentinel instead of a real // MCP elicitations are temporarily owned by the "global" sentinel instead of a real
@@ -61,16 +64,11 @@ const GLOBAL_FORM_SESSION_ID = "global"
export async function runNonInteractivePrompt(input: Input) { export async function runNonInteractivePrompt(input: Input) {
const controller = new AbortController() const controller = new AbortController()
const events = await input.client.v2.event.subscribe({ const stream = input.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
signal: controller.signal,
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const stream = events.stream[Symbol.asyncIterator]() as AsyncGenerator<V2Event>
const connected = await stream.next() const connected = await stream.next()
if (connected.done) throw new Error("Event stream disconnected before prompt admission") if (connected.done) throw new Error("Event stream disconnected before prompt admission")
const messageID = MessageID.ascending() const messageID = SessionMessage.ID.create()
const starts = new Map<string, StartedPart>() const starts = new Map<string, StartedPart>()
const tools = new Map<string, ToolState>() const tools = new Map<string, ToolState>()
let submitted = false let submitted = false
@@ -101,7 +99,7 @@ export async function runNonInteractivePrompt(input: Input) {
UI.empty() UI.empty()
} }
const replyPermission = async (request: { id: string; action: string; resources: string[] }) => { const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
if (!input.dangerouslySkipPermissions) { if (!input.dangerouslySkipPermissions) {
permissionRejected = true permissionRejected = true
UI.println( UI.println(
@@ -110,7 +108,7 @@ export async function runNonInteractivePrompt(input: Input) {
`permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`, `permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`,
) )
} }
await input.client.v2.session.permission await input.client.permission
.reply({ .reply({
sessionID: input.sessionID, sessionID: input.sessionID,
requestID: request.id, requestID: request.id,
@@ -118,18 +116,18 @@ export async function runNonInteractivePrompt(input: Input) {
}) })
.catch(() => {}) .catch(() => {})
if (!input.dangerouslySkipPermissions) { if (!input.dangerouslySkipPermissions) {
await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
} }
} }
const rejectQuestion = async (request: { id: string }) => { const rejectQuestion = async (request: { id: string }) => {
questionRejected = true questionRejected = true
await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {}) await input.client.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
} }
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => { const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
formCancelled = true formCancelled = true
await input.client.v2.session.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {}) await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
} }
const consume = async () => { const consume = async () => {
@@ -156,7 +154,7 @@ export async function runNonInteractivePrompt(input: Input) {
continue continue
} }
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
const time = toMillis(event.created) const time = toMillis("created" in event ? event.created : undefined)
if (event.type === "session.prompt.promoted") { if (event.type === "session.prompt.promoted") {
if (event.data.inputID === messageID) { if (event.data.inputID === messageID) {
@@ -367,34 +365,31 @@ export async function runNonInteractivePrompt(input: Input) {
interrupted = true interrupted = true
process.exitCode = 130 process.exitCode = 130
admission?.abort() admission?.abort()
void input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) void input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
} }
process.on("SIGINT", interrupt) process.on("SIGINT", interrupt)
let completed: Promise<void> | undefined let completed: Promise<void> | undefined
try { try {
if (input.agent) { if (input.agent) {
await input.client.v2.session.switchAgent( await input.client.session.switchAgent({ sessionID: input.sessionID, agent: input.agent })
{ sessionID: input.sessionID, agent: input.agent },
{ throwOnError: true },
)
} }
const selected = input.model const selected = input.model
? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant } ? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant }
: input.variant : input.variant
? await input.client.v2.session ? await input.client.session
.get({ sessionID: input.sessionID }, { throwOnError: true }) .get({ sessionID: input.sessionID })
.then((result) => result.data.data.model) .then((result) => result.model)
.then(async (model) => { .then(async (model) => {
if (model) return { ...model, variant: input.variant } if (model) return { ...model, variant: input.variant }
const result = await input.client.v2.model.default(undefined, { throwOnError: true }) const result = await input.client.model.default()
const fallback = result.data.data const fallback = result.data
return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined
}) })
: undefined : undefined
if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model") if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected) { if (selected) {
await input.client.v2.session.switchModel({ sessionID: input.sessionID, model: selected }, { throwOnError: true }) await input.client.session.switchModel({ sessionID: input.sessionID, model: selected })
} }
const prepared = await Promise.all(input.files.map(prepareFile)) const prepared = await Promise.all(input.files.map(prepareFile))
@@ -402,7 +397,7 @@ export async function runNonInteractivePrompt(input: Input) {
submitted = true submitted = true
completed = consume() completed = consume()
admission = new AbortController() admission = new AbortController()
const response = await input.client.v2.session const response = await input.client.session
.prompt( .prompt(
{ {
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -413,11 +408,11 @@ export async function runNonInteractivePrompt(input: Input) {
}, },
delivery: "steer", delivery: "steer",
}, },
{ throwOnError: true, signal: admission.signal }, { signal: admission.signal },
) )
.catch(async (error) => { .catch(async (error) => {
if (interrupted) { if (interrupted) {
await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
} }
controller.abort() controller.abort()
await completed?.catch(() => {}) await completed?.catch(() => {})
@@ -426,22 +421,21 @@ export async function runNonInteractivePrompt(input: Input) {
}) })
admission = undefined admission = undefined
if (!response) return if (!response) return
if (!response.data.data) throw new Error("Prompt was not admitted") if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
const [permissions, questions, forms] = await Promise.all([ const [permissions, questions, forms] = await Promise.all([
input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined), input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined), input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined),
Promise.all( Promise.all(
(input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) => (input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) =>
input.client.v2.session.form.list({ sessionID }).catch(() => undefined), input.client.form.list({ sessionID }).catch(() => undefined),
), ),
), ),
]) ])
await Promise.all([ await Promise.all([
...(permissions?.data?.data ?? []).map(replyPermission), ...(permissions ?? []).map(replyPermission),
...(questions?.data?.data ?? []).map(rejectQuestion), ...(questions ?? []).map(rejectQuestion),
...forms.flatMap((response) => response?.data?.data ?? []).map(cancelForm), ...forms.flatMap((response) => response ?? []).map(cancelForm),
]) ])
await completed await completed
} finally { } finally {
@@ -7,7 +7,7 @@
// the current browse position. When the user arrows up at cursor offset 0, // the current browse position. When the user arrows up at cursor offset 0,
// the current draft is saved and history begins. Arrowing past the end // the current draft is saved and history begins. Arrowing past the end
// restores the draft. // restores the draft.
export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display" export { displayCharAt, displaySlice, mentionTriggerIndex } from "@opencode-ai/tui/prompt/display"
import type { RunPrompt } from "./types" import type { RunPrompt } from "./types"
const HISTORY_LIMIT = 200 const HISTORY_LIMIT = 200
+308
View File
@@ -0,0 +1,308 @@
import { NodeFileSystem } from "@effect/platform-node"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { Global } from "@opencode-ai/core/global"
import { FSUtil } from "@opencode-ai/core/fs-util"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { open } from "node:fs/promises"
import path from "node:path"
import { Daemon } from "../daemon"
import { Standalone } from "../services/standalone"
import { loadRunAgents, waitForCatalogReady, waitForDefaultModel } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive"
import { toolInlineInfo } from "./tool"
import { UI } from "./ui"
export type RunCommandInput = {
message: string[]
continue?: boolean
session?: string
fork?: boolean
model?: string
agent?: string
format: "default" | "json"
file: string[]
title?: string
server?: string
password?: string
username?: string
directory?: string
variant?: string
thinking?: boolean
dangerouslySkipPermissions?: boolean
standaloneCommand?: ReadonlyArray<string>
}
type FilePart = {
url: string
filename: string
mime: string
}
type Transport = { readonly url: string; readonly headers?: HeadersInit }
type Prepared = {
directory?: string
message: string
files: FilePart[]
}
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
export async function runNonInteractive(input: RunCommandInput) {
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
const root = process.env.PWD ?? process.cwd()
const directory = input.server ? input.directory : localDirectory(input.directory, root)
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await Bun.stdin.text())
if (!message?.trim()) fail("You must provide a message")
const files = await Promise.all(
input.file.map((file) => prepareFile(file, input.server ? root : (directory ?? root), input.server !== undefined)),
)
const prepared = { directory, message, files }
if (input.standaloneCommand)
return Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* Standalone.transport({ command: input.standaloneCommand })
yield* Effect.promise(() => execute(input, prepared, transport))
}),
),
)
const transport = await startTransport(input)
return execute(input, prepared, transport)
}
async function execute(input: RunCommandInput, prepared: Prepared, transport: Transport) {
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
if (!requestedDirectory) fail("Failed to resolve server directory")
const session = await selectSession(client, requestedDirectory, input)
const cwd = session?.location.directory ?? requestedDirectory
const explicitModel = parseModel(input.model)
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
const defaultModel =
input.variant && !explicitModel && !sessionModel
? await waitForDefaultModel({ sdk: client, directory: cwd })
: undefined
const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel)
if (input.variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (model) {
await waitForCatalogReady({ sdk: client, directory: cwd, model })
const available = await client.model.list({ location: { directory: cwd } })
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
}
const agent = await validateAgent(client, cwd, input.agent, input.server)
const selected =
session ??
(await client.session.create({
agent,
model: model ? { providerID: model.providerID, id: model.modelID, variant: input.variant } : undefined,
location: { directory: cwd },
}))
if (!session && input.title !== undefined) {
await client.session.rename({
sessionID: selected.id,
title:
input.title ||
prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
})
}
try {
await runNonInteractivePrompt({
client,
sessionID: selected.id,
message: prepared.message,
files: prepared.files,
agent,
model,
variant: input.variant,
thinking: input.thinking ?? false,
format: input.format,
dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false,
attached: !input.standaloneCommand,
renderTool,
renderToolError,
})
} catch (error) {
UI.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
}
export function mergeInput(message: string | undefined, piped: string | undefined) {
if (!message) return piped || undefined
if (!piped) return message
return message + "\n" + piped
}
export function pickRunModel(
explicit: { providerID: string; modelID: string } | undefined,
variant: string | undefined,
session: { providerID: string; modelID: string } | undefined,
fallback: { providerID: string; modelID: string } | undefined,
) {
if (explicit) return explicit
if (!variant) return
return session ?? fallback
}
function formatMessage(message: string[]) {
const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ")
return value || undefined
}
function localDirectory(directory: string | undefined, root: string) {
try {
process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root)
return process.cwd()
} catch {
fail(`Failed to change directory to ${directory}`)
}
}
function startTransport(input: RunCommandInput) {
if (input.server) {
return Effect.runPromise(
Daemon.transport({
mode: "attach",
url: input.server,
password: input.password,
username: input.username,
}),
)
}
return Effect.runPromise(
Daemon.transport({ mode: "shared" }).pipe(
Effect.provide(NodeFileSystem.layer),
Effect.provide(Global.layerWith({})),
),
)
}
function parseModel(value?: string) {
if (!value) return
const [providerID, ...rest] = value.split("/")
const modelID = rest.join("/")
if (!providerID || !modelID) fail("--model must use the format provider/model")
return { providerID, modelID }
}
async function validateAgent(client: OpenCodeClient, directory: string, name?: string, server?: string) {
if (!name) return
const agents = await loadRunAgents(client, directory).catch(() => undefined)
if (!agents) {
warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`)
return
}
const agent = agents.find((item) => item.name === name)
if (!agent) {
warning(`agent "${name}" not found. Falling back to default agent`)
return
}
if (agent.mode === "subagent") {
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
return
}
return name
}
async function selectSession(client: OpenCodeClient, directory: string, input: RunCommandInput) {
const selected = input.session
? await client.session.get({ sessionID: input.session }).catch(() => undefined)
: input.continue
? await client.session
.list({ directory, parentID: null, limit: 1, order: "desc" })
.then((result) => result.data[0])
: undefined
if (input.session && !selected) fail("Session not found")
if (!selected || !input.fork) return selected
return client.session.fork({ sessionID: selected.id })
}
async function prepareFile(input: string, directory: string, remote: boolean): Promise<FilePart> {
const file = path.resolve(directory, input)
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
try {
const stat = await handle.stat()
if (remote && stat.isDirectory()) fail(`Cannot attach local directory without a shared filesystem: ${input}`)
if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES)
fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`)
const content = Buffer.alloc(Number(stat.size))
let offset = 0
while (offset < content.length) {
const read = await handle.read(content, offset, content.length - offset, offset)
if (read.bytesRead === 0) break
offset += read.bytesRead
}
const bytes = content.subarray(0, offset)
const detected = FSUtil.mimeType(file)
const text = bytes.toString("utf8")
const mime =
detected.startsWith("image/") || detected === "application/pdf"
? detected
: !isBinaryContent(bytes) && Buffer.from(text, "utf8").equals(bytes)
? "text/plain"
: detected
return {
url: `data:${mime};base64,${bytes.toString("base64")}`,
filename: path.basename(file),
mime,
}
} finally {
await handle.close()
}
}
function isBinaryContent(bytes: Uint8Array) {
if (bytes.length === 0) return false
if (bytes.includes(0)) return true
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
}
async function renderTool(part: ToolPart) {
const info = toolInlineInfo(part)
if (info.mode === "block") {
UI.empty()
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title)
if (info.body?.trim()) UI.println(info.body)
UI.empty()
return
}
UI.println(
UI.Style.TEXT_NORMAL + info.icon,
UI.Style.TEXT_NORMAL + info.title,
info.description ? UI.Style.TEXT_DIM + info.description + UI.Style.TEXT_NORMAL : "",
)
}
async function renderToolError(part: ToolPart) {
const info = toolInlineInfo(part)
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}
function warning(message: string) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
}
function reportError(input: RunCommandInput, message: string, sessionID?: string) {
process.exitCode = 1
if (input.format === "json") {
process.stdout.write(
JSON.stringify({
type: "error",
timestamp: Date.now(),
sessionID: sessionID ?? "",
error: { type: "unknown", message },
}) + "\n",
)
return
}
UI.error(message)
}
function fail(message: string): never {
UI.error(message)
process.exit(1)
}
@@ -7,12 +7,10 @@
// none block each other. // none block each other.
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { resolve } from "@opencode-ai/tui/config" import { resolve } from "@opencode-ai/tui/config"
import { TuiConfig } from "@/config/tui"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { loadRunProviders } from "./catalog.shared" import { loadRunProviders } from "./catalog.shared"
import { reusePendingTask } from "./runtime.shared"
import { resolveCurrentSession, sessionHistory } from "./session.shared" import { resolveCurrentSession, sessionHistory } from "./session.shared"
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types" import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
import { pickVariant } from "./variant.shared" import { pickVariant } from "./variant.shared"
@@ -30,7 +28,6 @@ export type SessionInfo = {
variant: string | undefined variant: string | undefined
} }
type Config = Awaited<ReturnType<typeof TuiConfig.get>>
type BootService = { type BootService = {
readonly resolveModelInfo: ( readonly resolveModelInfo: (
sdk: RunInput["sdk"], sdk: RunInput["sdk"],
@@ -42,18 +39,10 @@ type BootService = {
sessionID: string, sessionID: string,
model: RunInput["model"], model: RunInput["model"],
) => Effect.Effect<SessionInfo> ) => Effect.Effect<SessionInfo>
readonly resolveRunTuiConfig: () => Effect.Effect<RunTuiConfig>
readonly resolveDiffStyle: () => Effect.Effect<RunDiffStyle>
} }
const configTask: { current?: Promise<Config> } = {}
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {} class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
function loadConfig() {
return reusePendingTask(configTask, () => TuiConfig.get())
}
function emptyModelInfo(): ModelInfo { function emptyModelInfo(): ModelInfo {
return { return {
providers: [], providers: [],
@@ -77,23 +66,9 @@ function defaultRunTuiConfig(): RunTuiConfig {
} }
} }
function runTuiConfig(config: Config | undefined): RunTuiConfig {
if (!config) {
return defaultRunTuiConfig()
}
return {
keybinds: config.keybinds,
leader_timeout: config.leader_timeout,
diff_style: config.diff_style ?? "auto",
}
}
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const config = Effect.fn("RunBoot.config")(() => Effect.promise(() => loadConfig().catch(() => undefined)))
const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* ( const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (
sdk: RunInput["sdk"], sdk: RunInput["sdk"],
directory: string, directory: string,
@@ -147,19 +122,9 @@ const layer = Layer.effect(
} }
}) })
const resolveRunTuiConfig = Effect.fn("RunBoot.resolveRunTuiConfig")(function* () {
return runTuiConfig(yield* config())
})
const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () {
return runTuiConfig(yield* config()).diff_style ?? "auto"
})
return Service.of({ return Service.of({
resolveModelInfo, resolveModelInfo,
resolveSessionInfo, resolveSessionInfo,
resolveRunTuiConfig,
resolveDiffStyle,
}) })
}), }),
) )
@@ -190,10 +155,12 @@ export async function resolveSessionInfo(
} }
// Reads TUI config once for direct mode keymap setup and display preferences. // Reads TUI config once for direct mode keymap setup and display preferences.
export async function resolveRunTuiConfig(): Promise<RunTuiConfig> { export async function resolveRunTuiConfig(
return runtime.runPromise((svc) => svc.resolveRunTuiConfig()).catch(() => defaultRunTuiConfig()) config?: RunTuiConfig | Promise<RunTuiConfig>,
): Promise<RunTuiConfig> {
return Promise.resolve(config).then((value) => value ?? defaultRunTuiConfig()).catch(() => defaultRunTuiConfig())
} }
export async function resolveDiffStyle(): Promise<RunDiffStyle> { export async function resolveDiffStyle(config?: RunTuiConfig | Promise<RunTuiConfig>): Promise<RunDiffStyle> {
return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto") return resolveRunTuiConfig(config).then((value) => value.diff_style ?? "auto")
} }
@@ -13,8 +13,8 @@ import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWr
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap" import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { isDefaultTitle } from "@/session/title" import { isDefaultTitle } from "@opencode-ai/tui/util/session"
import * as Locale from "@/util/locale" import { Locale } from "@opencode-ai/tui/util/locale"
import { resolveInteractiveStdin } from "./runtime.stdin" import { resolveInteractiveStdin } from "./runtime.stdin"
import { entrySplash, exitSplash, splashMeta } from "./splash" import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme" import { resolveRunTheme } from "./theme"
@@ -8,8 +8,9 @@
// and tracks per-turn wall-clock duration for the footer status line. // and tracks per-turn wall-clock duration for the footer status line.
// //
// Resolves when the footer closes and all in-flight work finishes. // Resolves when the footer closes and all in-flight work finishes.
import * as Locale from "@/util/locale" import { ascending } from "@opencode-ai/schema/identifier"
import { MessageID, PartID } from "@/session/schema" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Locale } from "@opencode-ai/tui/util/locale"
import { isExitCommand, isNewCommand } from "./prompt.shared" import { isExitCommand, isNewCommand } from "./prompt.shared"
import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types" import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types"
@@ -167,7 +168,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
? prompt ? prompt
: { : {
...prompt, ...prompt,
messageID: prompt.messageID ?? queued?.messageID ?? MessageID.ascending(), messageID: prompt.messageID ?? queued?.messageID ?? SessionMessage.ID.create(),
} }
state.active = sent state.active = sent
@@ -285,8 +286,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
!isNewCommand(prompt.text) !isNewCommand(prompt.text)
) { ) {
const queued: FooterQueuedPrompt = { const queued: FooterQueuedPrompt = {
messageID: MessageID.ascending(), messageID: SessionMessage.ID.create(),
partID: PartID.ascending(), partID: "prt_" + ascending(),
prompt, prompt,
} }
state.queued = [...state.queued, queued] state.queued = [...state.queued, queued]
@@ -4,7 +4,7 @@
// and prompt queue together into a single session loop. Two entry points: // and prompt queue together into a single session loop. Two entry points:
// //
// runInteractiveMode -- used when an SDK client already exists (attach mode) // runInteractiveMode -- used when an SDK client already exists (attach mode)
// runInteractiveLocalMode -- used for local in-process mode (no server) // runInteractiveDeferredMode -- paints before resolving its session
// //
// Both delegate to runInteractiveRuntime, which: // Both delegate to runInteractiveRuntime, which:
// 1. resolves TUI config, model info, and session history, // 1. resolves TUI config, model info, and session history,
@@ -12,15 +12,22 @@
// 3. starts the stream transport (SDK event subscription), lazily for fresh // 3. starts the stream transport (SDK event subscription), lazily for fresh
// local sessions, // local sessions,
// 4. runs the prompt queue until the footer closes. // 4. runs the prompt queue until the footer closes.
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { MessageID } from "@/session/schema" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared" import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle" import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { trace } from "./trace" import { trace } from "./trace"
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared" import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types" import type {
LocalReplayAnchor,
LocalReplayRow,
RunInput,
RunPrompt,
RunProvider,
RunTuiConfig,
StreamCommit,
} from "./types"
/** @internal Exported for testing */ /** @internal Exported for testing */
export { pickVariant, resolveVariant } from "./variant.shared" export { pickVariant, resolveVariant } from "./variant.shared"
@@ -44,9 +51,7 @@ type CreateSession = (sdk: RunInput["sdk"], input: CreateSessionInput) => Promis
type RunRuntimeInput = { type RunRuntimeInput = {
boot: () => Promise<BootContext> boot: () => Promise<BootContext>
afterPaint?: (ctx: BootContext) => Promise<void> | void afterPaint?: (ctx: BootContext) => Promise<void> | void
resolveSession?: ( resolveSession?: (ctx: BootContext) => Promise<ResolvedSession>
ctx: BootContext,
) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }>
createSession?: (ctx: BootContext, input: CreateSessionInput) => Promise<ResolvedSession> createSession?: (ctx: BootContext, input: CreateSessionInput) => Promise<ResolvedSession>
files: RunInput["files"] files: RunInput["files"]
initialInput?: string initialInput?: string
@@ -55,13 +60,14 @@ type RunRuntimeInput = {
replay?: boolean replay?: boolean
replayLimit?: number replayLimit?: number
demo?: RunInput["demo"] demo?: RunInput["demo"]
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
} }
type RunLocalInput = { type RunDeferredInput = {
sdk: RunInput["sdk"]
directory: string directory: string
fetch: typeof globalThis.fetch
resolveAgent: () => Promise<string | undefined> resolveAgent: () => Promise<string | undefined>
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined> session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string; resume?: boolean } | undefined>
createSession?: CreateSession createSession?: CreateSession
agent: RunInput["agent"] agent: RunInput["agent"]
model: RunInput["model"] model: RunInput["model"]
@@ -73,6 +79,7 @@ type RunLocalInput = {
replay?: boolean replay?: boolean
replayLimit?: number replayLimit?: number
demo?: RunInput["demo"] demo?: RunInput["demo"]
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
} }
type StreamTransportModule = Pick< type StreamTransportModule = Pick<
@@ -96,6 +103,7 @@ type ResolvedSession = {
sessionID: string sessionID: string
sessionTitle?: string sessionTitle?: string
agent?: string | undefined agent?: string | undefined
resume?: boolean
} }
function createSessionResolver(fn?: CreateSession) { function createSessionResolver(fn?: CreateSession) {
@@ -165,9 +173,9 @@ async function resolveExitTitle(
return undefined return undefined
} }
return ctx.sdk.v2.session return ctx.sdk.session
.get({ sessionID: state.sessionID }) .get({ sessionID: state.sessionID })
.then((x) => x.data?.data.title) .then((session) => session.title)
.catch(() => undefined) .catch(() => undefined)
} }
@@ -180,7 +188,7 @@ async function resolveExitTitle(
async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> { async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
const start = performance.now() const start = performance.now()
const log = trace() const log = trace()
const tuiConfigTask = resolveRunTuiConfig() const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig)
const ctx = await input.boot() const ctx = await input.boot()
const sessionTask = const sessionTask =
ctx.resume === true ctx.resume === true
@@ -247,9 +255,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({ const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
directory: ctx.directory, directory: ctx.directory,
findFiles: (query) => findFiles: (query) =>
ctx.sdk.find ctx.sdk.file
.files({ query, directory: ctx.directory }) .find({ query, type: "file", location: { directory: ctx.directory } })
.then((x) => x.data ?? []) .then((result) => result.data.map((file) => file.path))
.catch(() => []), .catch(() => []),
agents: [], agents: [],
references: [], references: [],
@@ -257,7 +265,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
sessionTitle: state.sessionTitle, sessionTitle: state.sessionTitle,
getSessionID: () => state.sessionID, getSessionID: () => state.sessionID,
first: session.first, first: session.first,
history: session.history, history: state.history,
agent: state.agent, agent: state.agent,
model: state.model, model: state.model,
variant: state.activeVariant, variant: state.activeVariant,
@@ -269,17 +277,17 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
} }
log?.write("send.permission.reply", next) log?.write("send.permission.reply", next)
await ctx.sdk.v2.session.permission.reply({ sessionID: state.sessionID, ...next }) await ctx.sdk.permission.reply({ sessionID: state.sessionID, ...next })
}, },
onQuestionReply: async (next) => { onQuestionReply: async (next) => {
if (state.demo?.questionReply(next)) { if (state.demo?.questionReply(next)) {
return return
} }
await ctx.sdk.v2.session.question.reply({ await ctx.sdk.question.reply({
sessionID: state.sessionID, sessionID: state.sessionID,
requestID: next.requestID, requestID: next.requestID,
questionV2Reply: { answers: next.answers ?? [] }, answers: next.answers ?? [],
}) })
}, },
onQuestionReject: async (next) => { onQuestionReject: async (next) => {
@@ -287,7 +295,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return return
} }
await ctx.sdk.v2.session.question.reject({ sessionID: state.sessionID, ...next }) await ctx.sdk.question.reject({ sessionID: state.sessionID, ...next })
}, },
onCycleVariant: () => { onCycleVariant: () => {
if (!state.model || state.variants.length === 0) { if (!state.model || state.variants.length === 0) {
@@ -369,7 +377,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
void ( void (
state.stream state.stream
? state.stream.then((item) => item.handle.interruptActiveTurn()) ? state.stream.then((item) => item.handle.interruptActiveTurn())
: ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID }) : ctx.sdk.session.interrupt({ sessionID: state.sessionID })
) )
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
@@ -383,11 +391,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
} }
log?.write("send.background", { sessionID: state.sessionID }) log?.write("send.background", { sessionID: state.sessionID })
void ctx.sdk.v2.session.background({ sessionID: state.sessionID }).catch(() => {}) void ctx.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
}, },
onSubagentInterrupt: (sessionID) => { onSubagentInterrupt: (sessionID) => {
log?.write("send.subagent.interrupt", { sessionID }) log?.write("send.subagent.interrupt", { sessionID })
void ctx.sdk.v2.session.interrupt({ sessionID }).catch(() => {}) void ctx.sdk.session.interrupt({ sessionID }).catch(() => {})
}, },
onSubagentSelect: (sessionID) => { onSubagentSelect: (sessionID) => {
state.selectSubagent?.(sessionID) state.selectSubagent?.(sessionID)
@@ -398,7 +406,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}) })
const footer = shell.footer const footer = shell.footer
const firstPaint = footer.idle().catch(() => {}) const firstPaint = footer.idle().catch(() => {})
const modelTask = firstPaint.then(() => (footer.isClosed ? undefined : loadModel()))
const ensureSession = () => { const ensureSession = () => {
if (!input.resolveSession || state.sessionID) { if (!input.resolveSession || state.sessionID) {
return Promise.resolve() return Promise.resolve()
@@ -408,13 +415,33 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return state.session return state.session
} }
state.session = input.resolveSession(ctx).then((next) => { state.session = input.resolveSession(ctx).then(async (next) => {
state.sessionID = next.sessionID state.sessionID = next.sessionID
state.sessionTitle = next.sessionTitle ?? state.sessionTitle state.sessionTitle = next.sessionTitle ?? state.sessionTitle
state.agent = next.agent state.agent = next.agent
if (!next.resume) return
const resumed = await resolveSessionInfo(ctx.sdk, next.sessionID, ctx.model)
session.first = resumed.first
session.history = resumed.history
session.model = resumed.model
session.variant = resumed.variant
state.shown = !resumed.first
state.history = [...resumed.history]
state.model = ctx.model ?? resumed.model
const resumedSavedVariant = state.model ? await resolveSavedVariant(state.model) : undefined
state.activeVariant = resolveVariant(ctx.variant, resumed.variant, resumedSavedVariant, [])
session.variant = state.activeVariant
footer.event({ type: "history", history: resumed.history })
footer.event({ type: "first", first: resumed.first })
}) })
return state.session return state.session
} }
const modelTask = firstPaint.then(async () => {
if (footer.isClosed) return
await ensureSession()
if (footer.isClosed) return
return loadModel()
})
const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => { const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => {
state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT) state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT)
} }
@@ -640,6 +667,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
const runQueue = async () => { const runQueue = async () => {
await firstPaint await firstPaint
if (footer.isClosed) return if (footer.isClosed) return
await ensureSession()
if (footer.isClosed) return
await modelTask
if (footer.isClosed) return
let includeFiles = true let includeFiles = true
if (state.demo) { if (state.demo) {
await state.demo.start() await state.demo.start()
@@ -728,7 +759,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
text: error instanceof Error ? error.message : String(error), text: error instanceof Error ? error.message : String(error),
phase: "start", phase: "start",
source: "system", source: "system",
messageID: MessageID.ascending(), messageID: SessionMessage.ID.create(),
} as const } as const
rememberLocal(commit) rememberLocal(commit)
footer.append(commit) footer.append(commit)
@@ -834,61 +865,62 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
} }
} }
// Local in-process mode. Creates an SDK client backed by a direct fetch to // Deferred mode paints before session resolution. The caller may back the
// the in-process server, so no external HTTP server is needed. // generated client with a transport that is still acquiring a daemon.
export async function runInteractiveLocalMode(input: RunLocalInput): Promise<void> { export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: RunRuntimeDeps): Promise<void> {
const sdk = createOpencodeClient({ const sdk = input.sdk
baseUrl: "http://opencode.internal",
fetch: input.fetch,
directory: input.directory,
})
let session: Promise<ResolvedSession> | undefined let session: Promise<ResolvedSession> | undefined
return runInteractiveRuntime({ return runInteractiveRuntime(
files: input.files, {
initialInput: input.initialInput, files: input.files,
thinking: input.thinking, initialInput: input.initialInput,
backgroundSubagents: input.backgroundSubagents, thinking: input.thinking,
replay: input.replay, backgroundSubagents: input.backgroundSubagents,
replayLimit: input.replayLimit, replay: input.replay,
demo: input.demo, replayLimit: input.replayLimit,
resolveSession: () => { demo: input.demo,
if (session) { tuiConfig: input.tuiConfig,
resolveSession: () => {
if (session) {
return session
}
session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => {
if (!next?.id) {
throw new Error("Session not found")
}
return {
sessionID: next.id,
sessionTitle: next.title,
agent,
resume: next.resume,
}
})
return session return session
} },
createSession: createSessionResolver(input.createSession),
session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => { boot: async () => {
if (!next?.id) {
throw new Error("Session not found")
}
return { return {
sessionID: next.id, sdk,
sessionTitle: next.title, directory: input.directory,
agent, sessionID: "",
sessionTitle: undefined,
resume: false,
agent: input.agent,
model: input.model,
variant: input.variant,
} }
}) },
return session
}, },
createSession: createSessionResolver(input.createSession), deps,
boot: async () => { )
return {
sdk,
directory: input.directory,
sessionID: "",
sessionTitle: undefined,
resume: false,
agent: input.agent,
model: input.model,
variant: input.variant,
}
},
})
} }
// Attach mode. Uses the caller-provided SDK client directly. // Attach mode. Uses the caller-provided SDK client directly.
export async function runInteractiveMode( export async function runInteractiveMode(
input: RunInput & { createSession?: CreateSession }, input: RunInput & { createSession?: CreateSession; tuiConfig?: RunTuiConfig | Promise<RunTuiConfig> },
deps?: RunRuntimeDeps, deps?: RunRuntimeDeps,
): Promise<void> { ): Promise<void> {
return runInteractiveRuntime( return runInteractiveRuntime(
@@ -900,6 +932,7 @@ export async function runInteractiveMode(
replay: input.replay, replay: input.replay,
replayLimit: input.replayLimit, replayLimit: input.replayLimit,
demo: input.demo, demo: input.demo,
tuiConfig: input.tuiConfig,
boot: async () => ({ boot: async () => ({
sdk: input.sdk, sdk: input.sdk,
directory: input.directory, directory: input.directory,
@@ -25,7 +25,7 @@
// event arrives, the queue entry is removed and the footer falls back // event arrives, the queue entry is removed and the footer falls back
// to the next pending request or to the prompt view. // to the next pending request or to the prompt view.
import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import * as Locale from "@/util/locale" import { Locale } from "@opencode-ai/tui/util/locale"
import { toolView } from "./tool" import { toolView } from "./tool"
import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types" import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
@@ -4,11 +4,12 @@
// the prompt history ring. Also finds the most recently used variant for // the prompt history ring. Also finds the most recently used variant for
// the current model so the footer can pre-select it. // the current model so the footer can pre-select it.
import { promptCopy, promptSame } from "./prompt.shared" import { promptCopy, promptSame } from "./prompt.shared"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import type { RunInput, RunPrompt } from "./types" import type { RunInput, RunPrompt } from "./types"
const LIMIT = 200 const LIMIT = 200
export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]> export type SessionMessages = Array<{ info: Message; parts: Part[] }>
type Turn = { type Turn = {
prompt: RunPrompt prompt: RunPrompt
@@ -160,10 +161,10 @@ export async function resolveCurrentSession(
limit = LIMIT, limit = LIMIT,
): Promise<RunSession> { ): Promise<RunSession> {
const [response, session] = await Promise.all([ const [response, session] = await Promise.all([
sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true }), sdk.message.list({ sessionID, limit, order: "desc" }),
sdk.v2.session.get({ sessionID }, { throwOnError: true }), sdk.session.get({ sessionID }),
]) ])
const messages = response.data.data.toReversed() const messages = response.data.toReversed()
return { return {
first: messages.length === 0, first: messages.length === 0,
turns: messages.flatMap((message) => { turns: messages.flatMap((message) => {
@@ -195,18 +196,18 @@ export async function resolveCurrentSession(
})), })),
], ],
}, },
provider: session.data.data.model?.providerID, provider: session.model?.providerID,
model: session.data.data.model?.id, model: session.model?.id,
variant: session.data.data.model?.variant, variant: session.model?.variant,
}, },
] ]
}), }),
...(session.data.data.model && { ...(session.model && {
model: { model: {
providerID: session.data.data.model.providerID, providerID: session.model.providerID,
modelID: session.data.data.model.id, modelID: session.model.id,
}, },
variant: session.data.data.model.variant, variant: session.model.variant,
}), }),
} }
} }
@@ -17,8 +17,8 @@ import {
type ScrollbackSnapshot, type ScrollbackSnapshot,
type ScrollbackWriter, type ScrollbackWriter,
} from "@opentui/core" } from "@opentui/core"
import * as Locale from "@/util/locale" import { Locale } from "@opencode-ai/tui/util/locale"
import { go } from "@/cli/logo" import { go } from "@opencode-ai/tui/logo"
import type { RunSplashTheme } from "./theme" import type { RunSplashTheme } from "./theme"
export const SPLASH_TITLE_LIMIT = 50 export const SPLASH_TITLE_LIMIT = 50
@@ -15,14 +15,9 @@
// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child // Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
// backgrounding is intentionally absent: subagent jobs block the parent // backgrounding is intentionally absent: subagent jobs block the parent
// session, so only whole-session `v2.session.background(parentID)` exists. // session, so only whole-session `v2.session.background(parentID)` exists.
import type { import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
OpencodeClient, import type { SessionMessage, SessionMessageAssistantTool, ToolPart } from "@opencode-ai/sdk/v2"
SessionMessage, import { Locale } from "@opencode-ai/tui/util/locale"
SessionMessageAssistantTool,
ToolPart,
V2Event,
} from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types" import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
const CHILD_MESSAGE_LIMIT = 80 const CHILD_MESSAGE_LIMIT = 80
@@ -31,6 +26,8 @@ const CHILD_EVENT_BUFFER_LIMIT = 64
const FAMILY_LIST_LIMIT = 100 const FAMILY_LIST_LIMIT = 100
const FALLBACK_LABEL = "Subagent" const FALLBACK_LABEL = "Subagent"
type V2Event = EventSubscribeOutput
export function outputText(content: ReadonlyArray<{ type: string; text?: string }>) { export function outputText(content: ReadonlyArray<{ type: string; text?: string }>) {
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n") return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
} }
@@ -165,7 +162,7 @@ type ChildState = {
} }
export type SubagentTrackerInput = { export type SubagentTrackerInput = {
sdk: OpencodeClient sdk: OpenCodeClient
sessionID: string sessionID: string
thinking: boolean thinking: boolean
emit: () => void emit: () => void
@@ -390,8 +387,8 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const pendingPrompts = new Map(child.prompts) const pendingPrompts = new Map(child.prompts)
const pendingTools = new Map(child.tools) const pendingTools = new Map(child.tools)
let retry = false let retry = false
const task = input.sdk.v2.session const task = input.sdk.message
.messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true }) .list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" })
.then((response) => { .then((response) => {
const buffered = hydrationEvents.get(child.sessionID) ?? [] const buffered = hydrationEvents.get(child.sessionID) ?? []
hydrationEvents.delete(child.sessionID) hydrationEvents.delete(child.sessionID)
@@ -404,7 +401,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
for (const [id, prompt] of pendingPrompts) { for (const [id, prompt] of pendingPrompts) {
if (!child.prompts.has(id)) child.prompts.set(id, prompt) if (!child.prompts.has(id)) child.prompts.set(id, prompt)
} }
rebuild(child, response.data.data.toReversed()) rebuild(child, structuredClone(response.data).toReversed() as SessionMessage[])
for (const [id, tool] of pendingTools) { for (const [id, tool] of pendingTools) {
if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool) if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool)
} }
@@ -428,10 +425,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return
checked.add(sessionID) checked.add(sessionID)
if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, []) if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, [])
void input.sdk.v2.session void input.sdk.session
.get({ sessionID }, { throwOnError: true }) .get({ sessionID })
.then((response) => { .then((session) => {
const session = response.data.data
const buffered = pendingEvents.get(sessionID) ?? [] const buffered = pendingEvents.get(sessionID) ?? []
pendingEvents.delete(sessionID) pendingEvents.delete(sessionID)
if (session.parentID !== input.sessionID) return if (session.parentID !== input.sessionID) return
@@ -558,14 +554,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
}) })
childTool( childTool(
child, child,
{ structuredClone({
type: "tool", type: "tool",
id: event.data.callID, id: event.data.callID,
name: event.data.tool, name: event.data.tool,
provider: event.data.provider, provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] }, state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.created, ran: event.created }, time: { created: current?.started ?? event.created, ran: event.created },
}, }) as SessionMessageAssistantTool,
event.data.assistantMessageID, event.data.assistantMessageID,
) )
touch(child, event.created) touch(child, event.created)
@@ -578,7 +574,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const failed = event.type === "session.tool.failed" const failed = event.type === "session.tool.failed"
childTool( childTool(
child, child,
{ structuredClone({
type: "tool", type: "tool",
id: event.data.callID, id: event.data.callID,
name: current?.name ?? "tool", name: current?.name ?? "tool",
@@ -605,7 +601,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
ran: current?.started, ran: current?.started,
completed: event.created, completed: event.created,
}, },
}, }) as SessionMessageAssistantTool,
event.data.assistantMessageID, event.data.assistantMessageID,
) )
touch(child, event.created) touch(child, event.created)
@@ -697,9 +693,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
} }
// Family index: adopt children directly from the current session list so // Family index: adopt children directly from the current session list so
// historical subagents beyond the projected message window still get tabs. // historical subagents beyond the projected message window still get tabs.
const family = await input.sdk.v2.session const family = await input.sdk.session
.list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true }) .list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" })
.then((response) => response.data.data) .then((response) => response.data)
.catch(() => []) .catch(() => [])
for (const session of family) { for (const session of family) {
const child = ensureChild(session.id) const child = ensureChild(session.id)
@@ -1,13 +1,13 @@
import type { import type {
OpencodeClient, EventSubscribeOutput,
OpenCodeClient,
} from "@opencode-ai/client/promise"
import type {
PermissionRequest, PermissionRequest,
PermissionV2Request,
QuestionRequest, QuestionRequest,
QuestionV2Request,
SessionMessage, SessionMessage,
SessionMessageAssistant, SessionMessageAssistant,
SessionMessageAssistantTool, SessionMessageAssistantTool,
V2Event,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { blockerStatus, pickBlockerView } from "./session-data" import { blockerStatus, pickBlockerView } from "./session-data"
@@ -31,7 +31,7 @@ type Trace = {
} }
type StreamInput = { type StreamInput = {
sdk: OpencodeClient sdk: OpenCodeClient
directory?: string directory?: string
sessionID: string sessionID: string
thinking: boolean thinking: boolean
@@ -90,7 +90,9 @@ type ShellWait = {
abort: () => void abort: () => void
} }
type RunV2Event = V2Event type RunV2Event = EventSubscribeOutput
type PermissionV2Request = Extract<RunV2Event, { type: "permission.v2.asked" }>["data"]
type QuestionV2Request = Extract<RunV2Event, { type: "question.v2.asked" }>["data"]
type PromptFilePart = Extract<RunPromptPart, { type: "file" }> type PromptFilePart = Extract<RunPromptPart, { type: "file" }>
type ToolState = { type ToolState = {
@@ -144,9 +146,9 @@ function permission(request: PermissionV2Request): PermissionRequest {
id: request.id, id: request.id,
sessionID: request.sessionID, sessionID: request.sessionID,
permission: request.action, permission: request.action,
patterns: request.resources, patterns: [...request.resources],
metadata: request.metadata ?? {}, metadata: request.metadata ?? {},
always: request.save ?? [], always: [...(request.save ?? [])],
tool: request.source?.type === "tool" ? request.source : undefined, tool: request.source?.type === "tool" ? request.source : undefined,
} }
} }
@@ -155,7 +157,7 @@ function question(request: QuestionV2Request): QuestionRequest {
return { return {
id: request.id, id: request.id,
sessionID: request.sessionID, sessionID: request.sessionID,
questions: request.questions, questions: request.questions.map((item) => ({ ...item, options: item.options.map((option) => ({ ...option })) })),
tool: request.tool, tool: request.tool,
} }
} }
@@ -303,14 +305,14 @@ async function resolveSelectedModel(input: StreamInput, next: Pick<SessionTurnIn
if (next.model) return { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant } if (next.model) return { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
if (!next.variant) return if (!next.variant) return
const session = await input.sdk.v2.session const session = await input.sdk.session
.get({ sessionID: input.sessionID }, { throwOnError: true, signal: next.signal }) .get({ sessionID: input.sessionID }, { signal: next.signal })
.then((response) => response.data.data.model) .then((response) => response.model)
if (session) return { ...session, variant: next.variant } if (session) return { ...session, variant: next.variant }
const fallback = await input.sdk.v2.model const fallback = await input.sdk.model
.default(undefined, { throwOnError: true, signal: next.signal }) .default(undefined, { signal: next.signal })
.then((response) => response.data.data) .then((response) => response.data)
if (!fallback) return if (!fallback) return
return { providerID: fallback.providerID, id: fallback.id, variant: next.variant } return { providerID: fallback.providerID, id: fallback.id, variant: next.variant }
} }
@@ -532,21 +534,18 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const hydrate = async (next: { render: boolean; reuseVisibleWait: boolean }) => { const hydrate = async (next: { render: boolean; reuseVisibleWait: boolean }) => {
const [messages, permissions, questions, active] = await Promise.all([ const [messages, permissions, questions, active] = await Promise.all([
input.sdk.v2.session.messages( input.sdk.message.list({ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }),
{ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }, input.sdk.permission.list({ sessionID: input.sessionID }),
{ throwOnError: true }, input.sdk.question.list({ sessionID: input.sessionID }),
), input.sdk.session.active(),
input.sdk.v2.session.permission.list({ sessionID: input.sessionID }, { throwOnError: true }),
input.sdk.v2.session.question.list({ sessionID: input.sessionID }, { throwOnError: true }),
input.sdk.v2.session.active({ throwOnError: true }),
]) ])
const projected = messages.data.data.toReversed() const projected = structuredClone(messages.data).toReversed() as SessionMessage[]
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait) for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
state.permissions = permissions.data.data.map(permission) state.permissions = permissions.map(permission)
state.questions = questions.data.data.map(question) state.questions = questions.map(question)
syncBlockers() syncBlockers()
await subagents.hydrate({ messages: projected, active: active.data.data }) await subagents.hydrate({ messages: [...projected], active: active.data })
const running = input.sessionID in active.data.data const running = input.sessionID in active.data
write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" }) write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" })
if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) { if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) {
const current = state.wait const current = state.wait
@@ -721,14 +720,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (event.type === "session.tool.called") { if (event.type === "session.tool.called") {
if (state.finishedTools.has(event.data.callID)) return if (state.finishedTools.has(event.data.callID)) return
const current = state.tools.get(event.data.callID) const current = state.tools.get(event.data.callID)
const item: SessionMessageAssistantTool = { const item = structuredClone({
type: "tool", type: "tool",
id: event.data.callID, id: event.data.callID,
name: event.data.tool, name: event.data.tool,
provider: event.data.provider, provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] }, state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.created, ran: event.created }, time: { created: current?.started ?? event.created, ran: event.created },
} }) as SessionMessageAssistantTool
renderTool(event.data.assistantMessageID, item) renderTool(event.data.assistantMessageID, item)
return return
} }
@@ -736,7 +735,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (event.type === "session.tool.success" || event.type === "session.tool.failed") { if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
const current = state.tools.get(event.data.callID) const current = state.tools.get(event.data.callID)
const failed = event.type === "session.tool.failed" const failed = event.type === "session.tool.failed"
const item: SessionMessageAssistantTool = { const item = structuredClone({
type: "tool", type: "tool",
id: event.data.callID, id: event.data.callID,
name: current?.name ?? "tool", name: current?.name ?? "tool",
@@ -759,7 +758,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
result: event.data.result, result: event.data.result,
}, },
time: { created: current?.started ?? event.created, ran: current?.started, completed: event.created }, time: { created: current?.started ?? event.created, ran: current?.started, completed: event.created },
} }) as SessionMessageAssistantTool
renderTool(event.data.assistantMessageID, item) renderTool(event.data.assistantMessageID, item)
return return
} }
@@ -838,12 +837,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const connection = new AbortController() const connection = new AbortController()
const abortConnection = () => connection.abort() const abortConnection = () => connection.abort()
controller.signal.addEventListener("abort", abortConnection, { once: true }) controller.signal.addEventListener("abort", abortConnection, { once: true })
const response = await input.sdk.v2.event.subscribe({ const stream = input.sdk.event.subscribe({ signal: connection.signal })[Symbol.asyncIterator]()
signal: connection.signal,
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator<RunV2Event>
try { try {
const first = await stream.next() const first = await stream.next()
if (first.done || first.value.type !== "server.connected") throw new Error("Event stream disconnected") if (first.done || first.value.type !== "server.connected") throw new Error("Event stream disconnected")
@@ -910,9 +904,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input.trace?.write("send.shell", { sessionID: input.sessionID, id: eventID, command: next.prompt.text }) input.trace?.write("send.shell", { sessionID: input.sessionID, id: eventID, command: next.prompt.text })
write([], { phase: "running", status: "running shell" }) write([], { phase: "running", status: "running shell" })
try { try {
await input.sdk.v2.session.shell( await input.sdk.session.shell(
{ sessionID: input.sessionID, id: eventID, command: next.prompt.text }, { sessionID: input.sessionID, id: eventID, command: next.prompt.text },
{ throwOnError: true, signal: abort.signal }, { signal: abort.signal },
) )
await Promise.race([output, wait(SHELL_OUTPUT_GRACE_MS, abort.signal)]) await Promise.race([output, wait(SHELL_OUTPUT_GRACE_MS, abort.signal)])
} catch (error) { } catch (error) {
@@ -950,7 +944,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.wait = active state.wait = active
const interrupt = () => { const interrupt = () => {
active.interrupted = true active.interrupted = true
void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) void input.sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
} }
next.signal?.addEventListener("abort", interrupt, { once: true }) next.signal?.addEventListener("abort", interrupt, { once: true })
try { try {
@@ -981,9 +975,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name }) input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name })
await runTurnWait(next, messageID, { await runTurnWait(next, messageID, {
send: () => send: () =>
input.sdk.v2.session.skill( input.sdk.session.skill(
{ sessionID: input.sessionID, id: messageID, skill: command.name }, { sessionID: input.sessionID, id: messageID, skill: command.name },
{ throwOnError: true, signal: next.signal }, { signal: next.signal },
), ),
}) })
return return
@@ -1001,7 +995,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name }) input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name })
await runTurnWait(next, messageID, { await runTurnWait(next, messageID, {
send: () => send: () =>
input.sdk.v2.session.command( input.sdk.session.command(
{ {
sessionID: input.sessionID, sessionID: input.sessionID,
id: messageID, id: messageID,
@@ -1013,24 +1007,24 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
agents: agents.length ? agents : undefined, agents: agents.length ? agents : undefined,
delivery: "steer", delivery: "steer",
}, },
{ throwOnError: true, signal: next.signal }, { signal: next.signal },
), ),
}) })
return return
} }
if (next.agent) { if (next.agent) {
await input.sdk.v2.session.switchAgent( await input.sdk.session.switchAgent(
{ sessionID: input.sessionID, agent: next.agent }, { sessionID: input.sessionID, agent: next.agent },
{ throwOnError: true, signal: next.signal }, { signal: next.signal },
) )
} }
const selected = await resolveSelectedModel(input, next) const selected = await resolveSelectedModel(input, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model") if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected) if (selected)
await input.sdk.v2.session.switchModel( await input.sdk.session.switchModel(
{ sessionID: input.sessionID, model: selected }, { sessionID: input.sessionID, model: selected },
{ throwOnError: true, signal: next.signal }, { signal: next.signal },
) )
const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile)) const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile))
@@ -1042,7 +1036,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID }) input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
await runTurnWait(next, messageID, { await runTurnWait(next, messageID, {
send: () => send: () =>
input.sdk.v2.session.prompt( input.sdk.session.prompt(
{ {
sessionID: input.sessionID, sessionID: input.sessionID,
id: messageID, id: messageID,
@@ -1053,7 +1047,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}, },
delivery: "steer", delivery: "steer",
}, },
{ throwOnError: true, signal: next.signal }, { signal: next.signal },
), ),
}) })
}, },
@@ -1067,7 +1061,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return return
} }
if (state.wait) state.wait.interrupted = true if (state.wait) state.wait.interrupted = true
await input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) await input.sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}, },
selectSubagent(sessionID) { selectSubagent(sessionID) {
subagents.select(sessionID) subagents.select(sessionID)
@@ -16,25 +16,8 @@ import os from "os"
import path from "path" import path from "path"
import stripAnsi from "strip-ansi" import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2" import type { ToolPart } from "@opencode-ai/sdk/v2"
import type * as Tool from "@/tool/tool" import { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype"
import type { ApplyPatchTool } from "@/tool/apply_patch" import { Locale } from "@opencode-ai/tui/util/locale"
import type { ShellTool as BashTool } from "@/tool/shell"
import type { EditTool } from "@/tool/edit"
import type { GlobTool } from "@/tool/glob"
import type { GrepTool } from "@/tool/grep"
import type { InvalidTool } from "@/tool/invalid"
import type { LspTool } from "@/tool/lsp"
import type { PlanExitTool } from "@/tool/plan"
import type { QuestionTool } from "@/tool/question"
import type { ReadTool } from "@/tool/read"
import type { SkillTool } from "@/tool/skill"
import type { TaskTool } from "@/tool/task"
import type { TodoWriteTool } from "@/tool/todo"
import type { WebFetchTool } from "@/tool/webfetch"
import { webSearchProviderLabel, type WebSearchTool } from "@/tool/websearch"
import type { WriteTool } from "@/tool/write"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import * as Locale from "@/util/locale"
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types" import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
export type ToolView = { export type ToolView = {
@@ -47,6 +30,46 @@ export type ToolPhase = "start" | "progress" | "final"
export type ToolDict = Record<string, unknown> export type ToolDict = Record<string, unknown>
type PatchFile = {
type?: string
relativePath?: string
filePath?: string
movePath?: string
patch?: string
deletions?: number
}
type ToolInput = ToolDict & {
path?: string
pattern?: string
filePath?: string
filepath?: string
url?: string
query?: string
subagent_type?: string
description?: string
name?: string
operation?: string
line?: number
character?: number
content?: string
command?: string
workdir?: string
todos?: Array<{ status?: string; content?: string }>
questions?: Array<{ question?: string }>
diff?: string
}
type ToolMetadata = ToolDict & {
count?: number
matches?: number
diff?: string
provider?: unknown
files?: PatchFile[]
answers?: string[][]
exit?: number
}
export type ToolFrame = { export type ToolFrame = {
raw: string raw: string
name: string name: string
@@ -73,15 +96,15 @@ export type ToolPermissionInfo = {
file?: string file?: string
} }
export type ToolProps<T = Tool.Info> = { export type ToolProps = {
input: Partial<Tool.InferParameters<T>> input: ToolInput
metadata: Partial<Tool.InferMetadata<T>> metadata: ToolMetadata
frame: ToolFrame frame: ToolFrame
} }
type ToolPermissionProps<T = Tool.Info> = { type ToolPermissionProps = {
input: Partial<Tool.InferParameters<T>> input: ToolInput
metadata: Partial<Tool.InferMetadata<T>> metadata: ToolMetadata
patterns: string[] patterns: string[]
} }
@@ -91,40 +114,35 @@ type ToolPermissionCtx = {
patterns: string[] patterns: string[]
} }
type ToolDefs = { type ToolName =
invalid: typeof InvalidTool | "invalid"
bash: typeof BashTool | "bash"
write: typeof WriteTool | "write"
edit: typeof EditTool | "edit"
apply_patch: typeof ApplyPatchTool | "apply_patch"
batch: Tool.Info | "batch"
task: typeof TaskTool | "task"
todowrite: typeof TodoWriteTool | "todowrite"
question: typeof QuestionTool | "question"
read: typeof ReadTool | "read"
glob: typeof GlobTool | "glob"
grep: typeof GrepTool | "grep"
list: Tool.Info | "list"
lsp: typeof LspTool | "lsp"
webfetch: typeof WebFetchTool | "webfetch"
websearch: typeof WebSearchTool | "websearch"
skill: typeof SkillTool | "skill"
plan_exit: typeof PlanExitTool | "plan_exit"
}
type ToolName = keyof ToolDefs type ToolRule = {
type ToolRule<T = Tool.Info> = {
view: ToolView view: ToolView
run: (props: ToolProps<T>) => ToolInline run: (props: ToolProps) => ToolInline
scroll?: Partial<Record<ToolPhase, (props: ToolProps<T>) => string>> scroll?: Partial<Record<ToolPhase, (props: ToolProps) => string>>
permission?: (props: ToolPermissionProps<T>) => ToolPermissionInfo permission?: (props: ToolPermissionProps) => ToolPermissionInfo
snap?: (props: ToolProps<T>) => ToolSnapshot | undefined snap?: (props: ToolProps) => ToolSnapshot | undefined
} }
type ToolRegistry = { type ToolRegistry = Record<ToolName, ToolRule>
[K in ToolName]: ToolRule<ToolDefs[K]>
}
type AnyToolRule = ToolRule type AnyToolRule = ToolRule
@@ -136,22 +154,28 @@ function dict(v: unknown): ToolDict {
return { ...v } return { ...v }
} }
function props<T = Tool.Info>(frame: ToolFrame): ToolProps<T> { function props(frame: ToolFrame): ToolProps {
return { return {
input: Object.assign(Object.create(null), frame.input), input: frame.input,
metadata: Object.assign(Object.create(null), frame.meta), metadata: frame.meta,
frame, frame,
} }
} }
function permission<T = Tool.Info>(ctx: ToolPermissionCtx): ToolPermissionProps<T> { function permission(ctx: ToolPermissionCtx): ToolPermissionProps {
return { return {
input: Object.assign(Object.create(null), ctx.input), input: ctx.input,
metadata: Object.assign(Object.create(null), ctx.meta), metadata: ctx.meta,
patterns: ctx.patterns, patterns: ctx.patterns,
} }
} }
function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search"
return "Web Search"
}
function text(v: unknown): string { function text(v: unknown): string {
return typeof v === "string" ? v : "" return typeof v === "string" ? v : ""
} }
@@ -285,7 +309,7 @@ function count(n: number, label: string): string {
return `${n} ${label}${n === 1 ? "" : "es"}` return `${n} ${label}${n === 1 ? "" : "es"}`
} }
function runGlob(p: ToolProps<typeof GlobTool>): ToolInline { function runGlob(p: ToolProps): ToolInline {
const root = p.input.path ?? "" const root = p.input.path ?? ""
const title = `Glob "${p.input.pattern ?? ""}"` const title = `Glob "${p.input.pattern ?? ""}"`
const suffix = root ? `in ${toolPath(root)}` : "" const suffix = root ? `in ${toolPath(root)}` : ""
@@ -298,7 +322,7 @@ function runGlob(p: ToolProps<typeof GlobTool>): ToolInline {
} }
} }
function runGrep(p: ToolProps<typeof GrepTool>): ToolInline { function runGrep(p: ToolProps): ToolInline {
const root = p.input.path ?? "" const root = p.input.path ?? ""
const title = `Grep "${p.input.pattern ?? ""}"` const title = `Grep "${p.input.pattern ?? ""}"`
const suffix = root ? `in ${toolPath(root)}` : "" const suffix = root ? `in ${toolPath(root)}` : ""
@@ -319,7 +343,7 @@ function runList(p: ToolProps): ToolInline {
} }
} }
function runRead(p: ToolProps<typeof ReadTool>): ToolInline { function runRead(p: ToolProps): ToolInline {
const file = toolPath(p.input.filePath) const file = toolPath(p.input.filePath)
const description = info(p.frame.input, ["filePath"]) || undefined const description = info(p.frame.input, ["filePath"]) || undefined
return { return {
@@ -329,7 +353,7 @@ function runRead(p: ToolProps<typeof ReadTool>): ToolInline {
} }
} }
function runWrite(p: ToolProps<typeof WriteTool>): ToolInline { function runWrite(p: ToolProps): ToolInline {
return { return {
icon: "←", icon: "←",
title: `Write ${toolPath(p.input.filePath)}`, title: `Write ${toolPath(p.input.filePath)}`,
@@ -338,7 +362,7 @@ function runWrite(p: ToolProps<typeof WriteTool>): ToolInline {
} }
} }
function runWebfetch(p: ToolProps<typeof WebFetchTool>): ToolInline { function runWebfetch(p: ToolProps): ToolInline {
const url = p.input.url ?? "" const url = p.input.url ?? ""
return { return {
icon: "%", icon: "%",
@@ -346,7 +370,7 @@ function runWebfetch(p: ToolProps<typeof WebFetchTool>): ToolInline {
} }
} }
function runEdit(p: ToolProps<typeof EditTool>): ToolInline { function runEdit(p: ToolProps): ToolInline {
return { return {
icon: "←", icon: "←",
title: `Edit ${toolPath(p.input.filePath)}`, title: `Edit ${toolPath(p.input.filePath)}`,
@@ -355,7 +379,7 @@ function runEdit(p: ToolProps<typeof EditTool>): ToolInline {
} }
} }
function runWebSearch(p: ToolProps<typeof WebSearchTool>): ToolInline { function runWebSearch(p: ToolProps): ToolInline {
const title = webSearchProviderLabel(p.metadata.provider) const title = webSearchProviderLabel(p.metadata.provider)
return { return {
icon: "◈", icon: "◈",
@@ -363,7 +387,7 @@ function runWebSearch(p: ToolProps<typeof WebSearchTool>): ToolInline {
} }
} }
function runTask(p: ToolProps<typeof TaskTool>): ToolInline { function runTask(p: ToolProps): ToolInline {
const kind = Locale.titlecase(p.input.subagent_type || "unknown") const kind = Locale.titlecase(p.input.subagent_type || "unknown")
const desc = p.input.description const desc = p.input.description
const icon = p.frame.status === "error" ? "✗" : p.frame.status === "running" ? "•" : "✓" const icon = p.frame.status === "error" ? "✗" : p.frame.status === "running" ? "•" : "✓"
@@ -374,7 +398,7 @@ function runTask(p: ToolProps<typeof TaskTool>): ToolInline {
} }
} }
function runTodo(p: ToolProps<typeof TodoWriteTool>): ToolInline { function runTodo(p: ToolProps): ToolInline {
return { return {
icon: "#", icon: "#",
title: "Todos", title: "Todos",
@@ -393,14 +417,14 @@ function runTodo(p: ToolProps<typeof TodoWriteTool>): ToolInline {
} }
} }
function runSkill(p: ToolProps<typeof SkillTool>): ToolInline { function runSkill(p: ToolProps): ToolInline {
return { return {
icon: "→", icon: "→",
title: `Skill "${p.input.name ?? ""}"`, title: `Skill "${p.input.name ?? ""}"`,
} }
} }
function runPatch(p: ToolProps<typeof ApplyPatchTool>): ToolInline { function runPatch(p: ToolProps): ToolInline {
const files = p.metadata.files?.length ?? 0 const files = p.metadata.files?.length ?? 0
if (files === 0) { if (files === 0) {
return { return {
@@ -415,7 +439,7 @@ function runPatch(p: ToolProps<typeof ApplyPatchTool>): ToolInline {
} }
} }
function runQuestion(p: ToolProps<typeof QuestionTool>): ToolInline { function runQuestion(p: ToolProps): ToolInline {
const total = list(p.frame.input.questions).length const total = list(p.frame.input.questions).length
return { return {
icon: "→", icon: "→",
@@ -423,7 +447,7 @@ function runQuestion(p: ToolProps<typeof QuestionTool>): ToolInline {
} }
} }
function runInvalid(p: ToolProps<typeof InvalidTool>): ToolInline { function runInvalid(p: ToolProps): ToolInline {
return { return {
icon: "✗", icon: "✗",
title: text(p.frame.state.title) || "Invalid Tool", title: text(p.frame.state.title) || "Invalid Tool",
@@ -463,14 +487,14 @@ function lspTitle(
return `LSP ${op} ${file}${pos}` return `LSP ${op} ${file}${pos}`
} }
function runLsp(p: ToolProps<typeof LspTool>): ToolInline { function runLsp(p: ToolProps): ToolInline {
return { return {
icon: "→", icon: "→",
title: text(p.frame.state.title) || lspTitle(p.input), title: text(p.frame.state.title) || lspTitle(p.input),
} }
} }
function runPlanExit(p: ToolProps<typeof PlanExitTool>): ToolInline { function runPlanExit(p: ToolProps): ToolInline {
return { return {
icon: "→", icon: "→",
title: text(p.frame.state.title) || "Switching to build agent", title: text(p.frame.state.title) || "Switching to build agent",
@@ -479,8 +503,6 @@ function runPlanExit(p: ToolProps<typeof PlanExitTool>): ToolInline {
} }
} }
type PatchFile = Tool.InferMetadata<typeof ApplyPatchTool>["files"][number]
function patchTitle(file: PatchFile): string { function patchTitle(file: PatchFile): string {
const rel = file.relativePath const rel = file.relativePath
const from = file.filePath const from = file.filePath
@@ -497,7 +519,7 @@ function patchTitle(file: PatchFile): string {
return `# Patched ${rel || toolPath(from)}` return `# Patched ${rel || toolPath(from)}`
} }
function snapWrite(p: ToolProps<typeof WriteTool>): ToolSnapshot | undefined { function snapWrite(p: ToolProps): ToolSnapshot | undefined {
const file = p.input.filePath || "" const file = p.input.filePath || ""
const content = p.input.content || "" const content = p.input.content || ""
if (!file && !content) { if (!file && !content) {
@@ -512,7 +534,7 @@ function snapWrite(p: ToolProps<typeof WriteTool>): ToolSnapshot | undefined {
} }
} }
function snapEdit(p: ToolProps<typeof EditTool>): ToolSnapshot | undefined { function snapEdit(p: ToolProps): ToolSnapshot | undefined {
const file = p.input.filePath || "" const file = p.input.filePath || ""
const diff = p.metadata.diff || "" const diff = p.metadata.diff || ""
if (!file || !diff.trim()) { if (!file || !diff.trim()) {
@@ -531,7 +553,7 @@ function snapEdit(p: ToolProps<typeof EditTool>): ToolSnapshot | undefined {
} }
} }
function snapPatch(p: ToolProps<typeof ApplyPatchTool>): ToolSnapshot | undefined { function snapPatch(p: ToolProps): ToolSnapshot | undefined {
const files = list<PatchFile>(p.frame.meta.files) const files = list<PatchFile>(p.frame.meta.files)
if (files.length === 0) { if (files.length === 0) {
return undefined return undefined
@@ -568,7 +590,7 @@ function snapPatch(p: ToolProps<typeof ApplyPatchTool>): ToolSnapshot | undefine
} }
} }
function snapTask(p: ToolProps<typeof TaskTool>): ToolSnapshot { function snapTask(p: ToolProps): ToolSnapshot {
const kind = Locale.titlecase(p.input.subagent_type || "general") const kind = Locale.titlecase(p.input.subagent_type || "general")
const desc = p.input.description const desc = p.input.description
const title = text(p.frame.state.title) const title = text(p.frame.state.title)
@@ -582,7 +604,7 @@ function snapTask(p: ToolProps<typeof TaskTool>): ToolSnapshot {
} }
} }
function snapTodo(p: ToolProps<typeof TodoWriteTool>): ToolSnapshot { function snapTodo(p: ToolProps): ToolSnapshot {
const items = list<{ status?: string; content?: string }>(p.frame.input.todos).flatMap((item) => { const items = list<{ status?: string; content?: string }>(p.frame.input.todos).flatMap((item) => {
const content = typeof item?.content === "string" ? item.content : "" const content = typeof item?.content === "string" ? item.content : ""
if (!content) { if (!content) {
@@ -604,7 +626,7 @@ function snapTodo(p: ToolProps<typeof TodoWriteTool>): ToolSnapshot {
} }
} }
function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot { function snapQuestion(p: ToolProps): ToolSnapshot {
const answers = list<unknown[]>(p.frame.meta.answers) const answers = list<unknown[]>(p.frame.meta.answers)
const items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => { const items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => {
const answer = list<string>(answers[i]).filter((entry) => typeof entry === "string") const answer = list<string>(answers[i]).filter((entry) => typeof entry === "string")
@@ -621,7 +643,7 @@ function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
} }
} }
function scrollBashStart(p: ToolProps<typeof BashTool>): string { function scrollBashStart(p: ToolProps): string {
const cmd = p.input.command ?? "" const cmd = p.input.command ?? ""
const wd = p.input.workdir ?? "" const wd = p.input.workdir ?? ""
const formatted = wd && wd !== "." ? toolPath(wd) : "" const formatted = wd && wd !== "." ? toolPath(wd) : ""
@@ -637,7 +659,7 @@ function scrollBashStart(p: ToolProps<typeof BashTool>): string {
return `# Running in ${dir}\n$ ${cmd}` return `# Running in ${dir}\n$ ${cmd}`
} }
function scrollBashProgress(p: ToolProps<typeof BashTool>): string { function scrollBashProgress(p: ToolProps): string {
const out = stripAnsi(p.frame.raw) const out = stripAnsi(p.frame.raw)
const cmd = (p.input.command ?? "").trim() const cmd = (p.input.command ?? "").trim()
const fmt = (text: string) => { const fmt = (text: string) => {
@@ -670,7 +692,7 @@ function scrollBashProgress(p: ToolProps<typeof BashTool>): string {
return fmt(out) return fmt(out)
} }
function scrollBashFinal(p: ToolProps<typeof BashTool>): string { function scrollBashFinal(p: ToolProps): string {
if (p.frame.status === "error") { if (p.frame.status === "error") {
return fail(p.frame) return fail(p.frame)
} }
@@ -688,22 +710,22 @@ function scrollBashFinal(p: ToolProps<typeof BashTool>): string {
return `bash completed (exit ${code})${time ? ` · ${time}` : ""}` return `bash completed (exit ${code})${time ? ` · ${time}` : ""}`
} }
function scrollReadStart(p: ToolProps<typeof ReadTool>): string { function scrollReadStart(p: ToolProps): string {
const file = toolPath(p.input.filePath) const file = toolPath(p.input.filePath)
const extra = info(p.frame.input, ["filePath"]) const extra = info(p.frame.input, ["filePath"])
const tail = extra ? ` ${extra}` : "" const tail = extra ? ` ${extra}` : ""
return `→ Read ${file}${tail}`.trim() return `→ Read ${file}${tail}`.trim()
} }
function scrollWriteStart(_: ToolProps<typeof WriteTool>): string { function scrollWriteStart(_: ToolProps): string {
return "" return ""
} }
function scrollEditStart(_: ToolProps<typeof EditTool>): string { function scrollEditStart(_: ToolProps): string {
return "" return ""
} }
function scrollPatchStart(_: ToolProps<typeof ApplyPatchTool>): string { function scrollPatchStart(_: ToolProps): string {
return "" return ""
} }
@@ -727,7 +749,7 @@ function patchLine(file: PatchFile): string {
return `~ Patched ${rel || toolPath(from)}` return `~ Patched ${rel || toolPath(from)}`
} }
function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string { function scrollPatchFinal(p: ToolProps): string {
if (p.frame.status === "error") { if (p.frame.status === "error") {
return fail(p.frame) return fail(p.frame)
} }
@@ -756,7 +778,7 @@ function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
return patchLine(files[0]!) return patchLine(files[0]!)
} }
function scrollTaskStart(_: ToolProps<typeof TaskTool>): string { function scrollTaskStart(_: ToolProps): string {
return "" return ""
} }
@@ -778,7 +800,7 @@ function taskResult(output: string): string | undefined {
return next || undefined return next || undefined
} }
function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string { function scrollTaskFinal(p: ToolProps): string {
if (p.frame.status === "error") { if (p.frame.status === "error") {
return fail(p.frame) return fail(p.frame)
} }
@@ -792,11 +814,11 @@ function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
return `# ${kind} Task\n${row}` return `# ${kind} Task\n${row}`
} }
function scrollTodoStart(_: ToolProps<typeof TodoWriteTool>): string { function scrollTodoStart(_: ToolProps): string {
return "" return ""
} }
function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string { function scrollTodoFinal(p: ToolProps): string {
const items = list<{ status?: string }>(p.input.todos) const items = list<{ status?: string }>(p.input.todos)
const time = span(p.frame.state) const time = span(p.frame.state)
if (items.length === 0) { if (items.length === 0) {
@@ -828,11 +850,11 @@ function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {
return tail.join(" · ") return tail.join(" · ")
} }
function scrollQuestionStart(_: ToolProps<typeof QuestionTool>): string { function scrollQuestionStart(_: ToolProps): string {
return "" return ""
} }
function scrollQuestionFinal(p: ToolProps<typeof QuestionTool>): string { function scrollQuestionFinal(p: ToolProps): string {
const q = p.input.questions ?? [] const q = p.input.questions ?? []
const a = p.metadata.answers ?? [] const a = p.metadata.answers ?? []
const time = span(p.frame.state) const time = span(p.frame.state)
@@ -859,15 +881,15 @@ function scrollQuestionFinal(p: ToolProps<typeof QuestionTool>): string {
return rows.join("\n") return rows.join("\n")
} }
function scrollLspStart(p: ToolProps<typeof LspTool>): string { function scrollLspStart(p: ToolProps): string {
return `${lspTitle(p.input)}` return `${lspTitle(p.input)}`
} }
function scrollSkillStart(p: ToolProps<typeof SkillTool>): string { function scrollSkillStart(p: ToolProps): string {
return `→ Skill "${p.input.name ?? ""}"` return `→ Skill "${p.input.name ?? ""}"`
} }
function scrollGlobStart(p: ToolProps<typeof GlobTool>): string { function scrollGlobStart(p: ToolProps): string {
const pattern = p.input.pattern ?? "" const pattern = p.input.pattern ?? ""
const head = pattern ? `✱ Glob "${pattern}"` : "✱ Glob" const head = pattern ? `✱ Glob "${pattern}"` : "✱ Glob"
const dir = p.input.path ?? "" const dir = p.input.path ?? ""
@@ -878,11 +900,11 @@ function scrollGlobStart(p: ToolProps<typeof GlobTool>): string {
return `${head} in ${toolPath(dir)}` return `${head} in ${toolPath(dir)}`
} }
function scrollGlobFinal(p: ToolProps<typeof GlobTool>): string { function scrollGlobFinal(p: ToolProps): string {
return toolError(p.frame) || fail(p.frame) return toolError(p.frame) || fail(p.frame)
} }
function scrollGrepStart(p: ToolProps<typeof GrepTool>): string { function scrollGrepStart(p: ToolProps): string {
const pattern = p.input.pattern ?? "" const pattern = p.input.pattern ?? ""
const head = pattern ? `✱ Grep "${pattern}"` : "✱ Grep" const head = pattern ? `✱ Grep "${pattern}"` : "✱ Grep"
const dir = p.input.path ?? "" const dir = p.input.path ?? ""
@@ -902,7 +924,7 @@ function scrollListStart(p: ToolProps): string {
return `→ List ${toolPath(dir)}` return `→ List ${toolPath(dir)}`
} }
function scrollWebfetchStart(p: ToolProps<typeof WebFetchTool>): string { function scrollWebfetchStart(p: ToolProps): string {
const url = p.input.url ?? "" const url = p.input.url ?? ""
if (!url) { if (!url) {
return "% WebFetch" return "% WebFetch"
@@ -911,7 +933,7 @@ function scrollWebfetchStart(p: ToolProps<typeof WebFetchTool>): string {
return `% WebFetch ${url}` return `% WebFetch ${url}`
} }
function scrollWebSearchStart(p: ToolProps<typeof WebSearchTool>): string { function scrollWebSearchStart(p: ToolProps): string {
const title = webSearchProviderLabel(p.metadata.provider) const title = webSearchProviderLabel(p.metadata.provider)
const query = p.input.query ?? "" const query = p.input.query ?? ""
if (!query) { if (!query) {
@@ -921,7 +943,7 @@ function scrollWebSearchStart(p: ToolProps<typeof WebSearchTool>): string {
return `${title} "${query}"` return `${title} "${query}"`
} }
function permEdit(p: ToolPermissionProps<typeof EditTool>): ToolPermissionInfo { function permEdit(p: ToolPermissionProps): ToolPermissionInfo {
const input = p.input as { filePath?: string; filepath?: string; diff?: string } const input = p.input as { filePath?: string; filepath?: string; diff?: string }
const file = input.filePath || input.filepath || p.patterns[0] || "" const file = input.filePath || input.filepath || p.patterns[0] || ""
return { return {
@@ -933,7 +955,7 @@ function permEdit(p: ToolPermissionProps<typeof EditTool>): ToolPermissionInfo {
} }
} }
function permRead(p: ToolPermissionProps<typeof ReadTool>): ToolPermissionInfo { function permRead(p: ToolPermissionProps): ToolPermissionInfo {
const file = p.input.filePath || p.patterns[0] || "" const file = p.input.filePath || p.patterns[0] || ""
return { return {
icon: "→", icon: "→",
@@ -942,7 +964,7 @@ function permRead(p: ToolPermissionProps<typeof ReadTool>): ToolPermissionInfo {
} }
} }
function permGlob(p: ToolPermissionProps<typeof GlobTool>): ToolPermissionInfo { function permGlob(p: ToolPermissionProps): ToolPermissionInfo {
const pattern = p.input.pattern || p.patterns[0] || "" const pattern = p.input.pattern || p.patterns[0] || ""
return { return {
icon: "✱", icon: "✱",
@@ -951,7 +973,7 @@ function permGlob(p: ToolPermissionProps<typeof GlobTool>): ToolPermissionInfo {
} }
} }
function permGrep(p: ToolPermissionProps<typeof GrepTool>): ToolPermissionInfo { function permGrep(p: ToolPermissionProps): ToolPermissionInfo {
const pattern = p.input.pattern || p.patterns[0] || "" const pattern = p.input.pattern || p.patterns[0] || ""
return { return {
icon: "✱", icon: "✱",
@@ -969,7 +991,7 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo {
} }
} }
function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo { function permBash(p: ToolPermissionProps): ToolPermissionInfo {
const cmd = p.input.command || "" const cmd = p.input.command || ""
return { return {
icon: "#", icon: "#",
@@ -978,7 +1000,7 @@ function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo {
} }
} }
function permTask(p: ToolPermissionProps<typeof TaskTool>): ToolPermissionInfo { function permTask(p: ToolPermissionProps): ToolPermissionInfo {
const type = p.input.subagent_type || "general" const type = p.input.subagent_type || "general"
const desc = p.input.description const desc = p.input.description
return { return {
@@ -988,7 +1010,7 @@ function permTask(p: ToolPermissionProps<typeof TaskTool>): ToolPermissionInfo {
} }
} }
function permWebfetch(p: ToolPermissionProps<typeof WebFetchTool>): ToolPermissionInfo { function permWebfetch(p: ToolPermissionProps): ToolPermissionInfo {
const url = p.input.url || "" const url = p.input.url || ""
return { return {
icon: "%", icon: "%",
@@ -997,7 +1019,7 @@ function permWebfetch(p: ToolPermissionProps<typeof WebFetchTool>): ToolPermissi
} }
} }
function permWebSearch(p: ToolPermissionProps<typeof WebSearchTool>): ToolPermissionInfo { function permWebSearch(p: ToolPermissionProps): ToolPermissionInfo {
const query = p.input.query || "" const query = p.input.query || ""
const title = webSearchProviderLabel(p.metadata.provider) const title = webSearchProviderLabel(p.metadata.provider)
return { return {
@@ -1007,7 +1029,7 @@ function permWebSearch(p: ToolPermissionProps<typeof WebSearchTool>): ToolPermis
} }
} }
function permLsp(p: ToolPermissionProps<typeof LspTool>): ToolPermissionInfo { function permLsp(p: ToolPermissionProps): ToolPermissionInfo {
const file = p.input.filePath || "" const file = p.input.filePath || ""
const line = typeof p.input.line === "number" ? p.input.line : undefined const line = typeof p.input.line === "number" ? p.input.line : undefined
const char = typeof p.input.character === "number" ? p.input.character : undefined const char = typeof p.input.character === "number" ? p.input.character : undefined
@@ -1273,7 +1295,7 @@ export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
} }
} }
function runBash(p: ToolProps<typeof BashTool>): ToolInline { function runBash(p: ToolProps): ToolInline {
return { return {
icon: "$", icon: "$",
title: p.input.command || "", title: p.input.command || "",
@@ -11,7 +11,8 @@
// → stream.ts bridges to footer API // → stream.ts bridges to footer API
// → footer.ts queues commits and patches the footer view // → footer.ts queues commits and patches the footer view
// → OpenTUI split-footer renderer writes to terminal // → OpenTUI split-footer renderer writes to terminal
import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise"
import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import type { TuiConfig } from "@opencode-ai/tui/config" import type { TuiConfig } from "@opencode-ai/tui/config"
export type RunFilePart = { export type RunFilePart = {
@@ -21,10 +22,17 @@ export type RunFilePart = {
mime: string mime: string
} }
type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"] type PromptModel = { providerID: string; modelID: string }
type PromptInput = Parameters<OpencodeClient["session"]["prompt"]>[0]
export type RunPromptPart = NonNullable<PromptInput["parts"]>[number] export type RunPromptPart =
| {
type: "file"
url: string
filename?: string
mime?: string
source?: FilePart["source"]
}
| { type: "agent"; name: string; source?: { start: number; end: number; value: string } }
export type RunCommand = { export type RunCommand = {
name: string name: string
@@ -111,12 +119,10 @@ export type RunAgent = {
hidden: boolean hidden: boolean
} }
export type RunReference = NonNullable< export type RunReference = ReferenceListOutput["data"][number]
Awaited<ReturnType<OpencodeClient["v2"]["reference"]["list"]>>["data"]
>["data"][number]
export type RunInput = { export type RunInput = {
sdk: OpencodeClient sdk: OpenCodeClient
directory: string directory: string
sessionID: string sessionID: string
sessionTitle?: string sessionTitle?: string
@@ -282,6 +288,10 @@ export type FooterOutput = {
// transport both emit these to update footer state without reaching into // transport both emit these to update footer state without reaching into
// internal signals directly. // internal signals directly.
export type FooterEvent = export type FooterEvent =
| {
type: "history"
history: RunPrompt[]
}
| { | {
type: "catalog" type: "catalog"
agents: RunAgent[] agents: RunAgent[]
@@ -342,11 +352,14 @@ export type FooterEvent =
state: FooterSubagentState state: FooterSubagentState
} }
export type PermissionReply = Parameters<OpencodeClient["permission"]["reply"]>[0] export type PermissionReply = Omit<Parameters<OpenCodeClient["permission"]["reply"]>[0], "sessionID">
export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0] export type QuestionReply = {
requestID: string
answers: string[][]
}
export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0] export type QuestionReject = Omit<Parameters<OpenCodeClient["question"]["reject"]>[0], "sessionID">
export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style"> export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style">
+27
View File
@@ -0,0 +1,27 @@
import { EOL } from "node:os"
export const Style = {
TEXT_DIM: "\x1b[90m",
TEXT_NORMAL: "\x1b[0m",
TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
}
export function println(...message: string[]) {
process.stderr.write(message.join(" ") + EOL)
}
let blank = false
export function empty() {
if (blank) return
println(Style.TEXT_NORMAL)
blank = true
}
export function error(message: string) {
if (message.startsWith("Error: ")) message = message.slice("Error: ".length)
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}
export * as UI from "./ui"
@@ -12,9 +12,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { isRecord } from "@/util/record"
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared" import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
import type { RunInput, RunProvider } from "./types" import type { RunInput, RunProvider } from "./types"
@@ -32,6 +31,10 @@ type VariantRuntime = {
saveVariant(model: RunInput["model"], variant: string | undefined): Promise<void> saveVariant(model: RunInput["model"], variant: string | undefined): Promise<void>
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {} class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {}
function modelKey(provider: string, model: string): string { function modelKey(provider: string, model: string): string {
+134
View File
@@ -0,0 +1,134 @@
export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AppProcess } from "@opencode-ai/core/process"
import { Flock } from "@opencode-ai/core/util/flock"
import { start } from "@opencode-ai/server/process"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { Updater } from "./services/updater"
export type Mode = "default" | "service" | "stdio"
export type Options = {
readonly mode: Mode
readonly hostname?: string
readonly port?: number
}
export const run = Effect.fn("cli.server-process.run")((options: Options) =>
processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(NodeServices.layer),
),
)
const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
return yield* Effect.scoped(
Effect.gen(function* () {
if (options.mode === "service") {
const service = yield* ServiceConfig.options()
yield* Flock.effect("service-process", {
dir: path.dirname(service.file),
staleMs: 3_000,
timeoutMs: 15_000,
})
}
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
if (options.mode === "stdio") {
delete process.env.OPENCODE_PASSWORD
delete process.env.OPENCODE_SERVER_PASSWORD
}
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const password =
options.mode === "service"
? yield* ServiceConfig.password()
: environmentPassword
? Redacted.value(environmentPassword)
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const address = yield* start({
hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
port: Option.fromNullishOr(options.port ?? config.port),
password,
}).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
if (options.mode === "service") yield* register(address, password)
const url = HttpServer.formatAddress(address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* options.mode === "stdio" ? waitForStdinClose() : Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })),
)
})
// The latest atomic registration wins. A displaced process notices the new id,
// exits, and cannot remove its successor's registration from its finalizer.
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
const register = Effect.fnUntraced(function* (address: HttpServer.Address, password: string) {
const fs = yield* FileSystem.FileSystem
const options = yield* ServiceConfig.options()
const id = randomUUID()
const temp = options.file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(options.file), { recursive: true })
const encoded = yield* encodeInfo({
id,
version: InstallationVersion,
url: HttpServer.formatAddress(address),
pid: process.pid,
password,
})
yield* fs.writeFileString(temp, encoded, { mode: 0o600 })
yield* fs.rename(temp, options.file)
const currentID = fs.readFileString(options.file).pipe(
Effect.flatMap(decodeInfo),
Effect.map((info) => info.id),
Effect.orElseSucceed(() => undefined),
)
yield* currentID.pipe(
Effect.flatMap((current) =>
current === id
? Effect.void
: Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore),
),
Effect.repeat(Schedule.spaced("10 seconds")),
Effect.forkScoped,
)
yield* Effect.addFinalizer(() =>
currentID.pipe(
Effect.flatMap((current) => (current === id ? fs.remove(options.file) : Effect.void)),
Effect.ignore,
),
)
})
function waitForStdinClose() {
return Effect.callback<void>((resume) => {
const close = () => resume(Effect.void)
process.stdin.once("end", close)
process.stdin.once("close", close)
process.stdin.resume()
if (process.stdin.readableEnded || process.stdin.destroyed) close()
return Effect.sync(() => {
process.stdin.off("end", close)
process.stdin.off("close", close)
process.stdin.pause()
})
})
}
+15 -5
View File
@@ -9,11 +9,17 @@ import path from "node:path"
const Ready = Schema.Struct({ url: Schema.String }) const Ready = Schema.Struct({ url: Schema.String })
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready)) const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
function command(password: string) { type Options = {
readonly command?: ReadonlyArray<string>
}
function command(password: string, options: Options) {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : [] const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint") if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], { const [executable, ...args] = options.command ?? [process.execPath, ...entrypoint, "serve"]
if (!executable) throw new Error("Failed to resolve standalone server command")
return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], {
cwd: process.cwd(), cwd: process.cwd(),
// Explicit entry wins over anything inherited, so a user-exported // Explicit entry wins over anything inherited, so a user-exported
// OPENCODE_PASSWORD cannot shadow the child's lease credential. // OPENCODE_PASSWORD cannot shadow the child's lease credential.
@@ -28,11 +34,11 @@ function command(password: string) {
}) })
} }
export const transport = Effect.fn("cli.standalone.transport")( const makeTransport = Effect.fn("cli.standalone.transport")(
function* () { function* (options: Options) {
const password = randomBytes(32).toString("base64url") const password = randomBytes(32).toString("base64url")
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const proc = yield* spawner.spawn(command(password)) const proc = yield* spawner.spawn(command(password, options))
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString) const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness")) if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
const ready = yield* Effect.tryPromise(() => decodeReady(output)) const ready = yield* Effect.tryPromise(() => decodeReady(output))
@@ -41,4 +47,8 @@ export const transport = Effect.fn("cli.standalone.transport")(
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
) )
export function transport(options: Options = {}) {
return makeTransport(options)
}
export * as Standalone from "./standalone" export * as Standalone from "./standalone"
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, test } from "bun:test"
import { mergeInteractiveInput, mergeNonInteractiveInput, pickRunModel } from "../src/mini"
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: new URL("..", import.meta.url).pathname,
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { stdout, stderr, exitCode }
}
describe("mini command", () => {
test("uses piped stdin as the initial prompt", () => {
expect(mergeInteractiveInput("from stdin", undefined)).toBe("from stdin")
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
})
test("keeps run as mini's non-interactive input mode", () => {
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
})
test("applies a variant to a resumed session's model", () => {
expect(
pickRunModel(
undefined,
"high",
{ providerID: "session-provider", modelID: "session-model" },
{ providerID: "default-provider", modelID: "default-model" },
),
).toEqual({ providerID: "session-provider", modelID: "session-model" })
})
test("is registered in the preview CLI", async () => {
const result = await cli(["--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("mini Start the minimal interactive interface")
expect(result.stdout).toContain("run Run OpenCode with a message")
})
test("exposes run without legacy attach or command modes", async () => {
const result = await cli(["run", "--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("--server string")
expect(result.stdout).not.toContain("--attach")
expect(result.stdout).not.toContain("--command")
})
test("keeps option-like prompt text after the argument separator", async () => {
const result = await cli(["run", "--server", "http://127.0.0.1:1", "--", "--foo"])
expect(result.exitCode).toBe(1)
expect(result.stderr).not.toContain("You must provide a message")
})
test("uses the shared V2 server option instead of an attach command", async () => {
const result = await cli(["mini", "--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("--server string")
expect(result.stdout).not.toContain("SUBCOMMANDS")
})
test("routes local and explicit-server invocations into mini", async () => {
for (const args of [["mini"], ["mini", "--server", "http://127.0.0.1:1"]]) {
const result = await cli(args)
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("opencode mini requires a TTY stdout")
}
})
})
+33 -11
View File
@@ -31,11 +31,14 @@ export type Options = {
// Read-only lookup: registration file plus health check and version gate. // Read-only lookup: registration file plus health check and version gate.
// Never spawns; escalation to start() is the caller's policy. // Never spawns; escalation to start() is the caller's policy.
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) { export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
return (yield* discoverLocal(options))?.transport
})
const discoverLocal = Effect.fnUntraced(function* (options: Options) {
const info = yield* read(options.file) const info = yield* read(options.file)
if (info === undefined) return undefined if (info === undefined) return undefined
if (options.version !== undefined && info.version !== options.version) return undefined if (options.version !== undefined && info.version !== options.version) return undefined
const found = yield* probe(info) return yield* probe(info, options.version)
return found?.transport
}) })
// Idempotent ensure-running: reuses a healthy compatible server, replaces a // Idempotent ensure-running: reuses a healthy compatible server, replaces a
@@ -48,18 +51,29 @@ export const start = Effect.fn("service.start")(function* (options: Options = {}
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) return yield* Effect.fail(new Error("Missing service command")) if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
yield* Effect.try({ const child = yield* Effect.try({
try: () => { try: () => {
spawn(command, args, { detached: true, stdio: "ignore" }).unref() const child = spawn(command, args, { detached: true, stdio: "ignore" })
child.unref()
return child
}, },
catch: (cause) => new Error("Failed to start server", { cause }), catch: (cause) => new Error("Failed to start server", { cause }),
}) })
return yield* discover(options).pipe( return yield* discoverLocal(options).pipe(
Effect.flatMap((found) => Effect.flatMap((found) =>
found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found), found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found),
), ),
Effect.retry(poll), Effect.retry(poll),
Effect.tap((found) =>
found.info.pid === child.pid
? Effect.void
: Effect.sync(() => {
child.kill("SIGTERM")
}),
),
Effect.map((found) => found.transport),
Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)),
Effect.mapError(() => new Error("Failed to start server")), Effect.mapError(() => new Error("Failed to start server")),
) )
}) })
@@ -90,6 +104,9 @@ export const Info = Schema.Struct({
export type Info = typeof Info.Type export type Info = typeof Info.Type
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeHealth = Schema.decodeUnknownEffect(
Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, pid: Schema.Int }),
)
// A missing or corrupt file means no valid info; callers treat both // A missing or corrupt file means no valid info; callers treat both
// the same (the registering server self-evicts, clients rediscover). // the same (the registering server self-evicts, clients rediscover).
@@ -105,18 +122,23 @@ type LocalService = {
readonly transport: Transport readonly transport: Transport
} }
const probe = Effect.fnUntraced(function* (info: Info) { const probe = Effect.fnUntraced(function* (info: Info, version?: string) {
const headers = info.password === undefined ? undefined : auth(info.password) const headers = info.password === undefined ? undefined : auth(info.password)
const healthy = yield* Effect.tryPromise(() => const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/health", info.url), { fetch(new URL("/api/health", info.url), {
headers, headers,
signal: AbortSignal.timeout(2_000), signal: AbortSignal.timeout(2_000),
}), }),
).pipe( ).pipe(Effect.option, Effect.map(Option.getOrUndefined))
Effect.map((response) => response.ok), if (response === undefined || !response.ok) return undefined
Effect.orElseSucceed(() => false), const health = yield* Effect.tryPromise(() => response.json()).pipe(
Effect.flatMap(decodeHealth),
Effect.option,
Effect.map(Option.getOrUndefined),
) )
if (!healthy) return undefined if (health?.pid !== info.pid) return undefined
if (info.version !== undefined && health.version !== info.version) return undefined
if (version !== undefined && health.version !== version) return undefined
return { info, transport: { url: info.url, headers } } satisfies LocalService return { info, transport: { url: info.url, headers } } satisfies LocalService
}) })
@@ -157,7 +157,7 @@ export type ProjectCopyError = {
export const isProjectCopyError = (value: unknown): value is ProjectCopyError => export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError" typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError"
export type HealthGetOutput = { readonly healthy: true } export type HealthGetOutput = { readonly healthy: true; readonly version: string; readonly pid: number }
export type LocationGetInput = { export type LocationGetInput = {
readonly location?: { readonly location?: {
+1
View File
@@ -85,6 +85,7 @@
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/cli": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
-172
View File
@@ -1,172 +0,0 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { UI } from "@/cli/ui"
import { resolveThreadDirectory } from "./tui"
type ReplayArgs = {
replay?: boolean
noReplay?: boolean
}
type MiniArgs = ReplayArgs & {
continue?: boolean
session?: string
fork?: boolean
replayLimit?: number
}
type MiniLocalArgs = MiniArgs & {
project?: string
model?: string
agent?: string
prompt?: string
demo?: boolean
}
type MiniAttachArgs = MiniArgs & {
url: string
dir?: string
password?: string
username?: string
}
function replay(args: ReplayArgs) {
if (args.replay === true) {
UI.error("--replay is not supported; replay is enabled by default")
process.exitCode = 1
return "invalid" as const
}
return args.replay === false || args.noReplay === true ? false : undefined
}
function miniOptions<T>(yargs: Argv<T>) {
return yargs
.option("continue", {
alias: ["c"],
describe: "continue the last session",
type: "boolean",
})
.option("session", {
alias: ["s"],
describe: "session id to continue",
type: "string",
})
.option("fork", {
type: "boolean",
describe: "fork the session when continuing (use with --continue or --session)",
})
.option("replay", {
type: "boolean",
hidden: true,
})
.option("no-replay", {
type: "boolean",
describe: "disable session history replay on resume and after resize",
})
.option("replay-limit", {
type: "number",
describe: "cap visible replay to the newest N messages",
})
}
/** @internal Exported for CLI parser tests. */
export const MiniLocalCommand = cmd<{}, MiniLocalArgs>({
command: "$0 [project]",
describe: "start the minimal interactive interface",
builder: (yargs) =>
miniOptions(
yargs
.positional("project", {
type: "string",
describe: "path to start opencode in",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "model to use in the format of provider/model",
})
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("prompt", {
type: "string",
describe: "prompt to use",
})
.option("demo", {
type: "boolean",
hidden: true,
}),
),
handler: async (args) => {
const shouldReplay = replay(args)
if (shouldReplay === "invalid") return
const { runMini } = await import("./run")
await runMini({
directory: resolveThreadDirectory(args.project),
continue: args.continue,
session: args.session,
fork: args.fork,
model: args.model,
agent: args.agent,
prompt: args.prompt,
replay: shouldReplay,
replayLimit: args.replayLimit,
demo: args.demo,
})
},
})
/** @internal Exported for CLI parser tests. */
export const MiniAttachCommand = cmd<{}, MiniAttachArgs>({
command: "attach <url>",
describe: "attach to a running opencode server with the minimal interface",
builder: (yargs) =>
miniOptions(
yargs
.positional("url", {
type: "string",
describe: "http://localhost:4096",
demandOption: true,
})
.option("dir", {
type: "string",
describe: "directory on the remote server",
})
.option("password", {
alias: ["p"],
type: "string",
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
})
.option("username", {
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
}),
),
handler: async (args) => {
const shouldReplay = replay(args)
if (shouldReplay === "invalid") return
const { runMini } = await import("./run")
await runMini({
attach: args.url,
directory: args.dir,
password: args.password,
username: args.username,
continue: args.continue,
session: args.session,
fork: args.fork,
replay: shouldReplay,
replayLimit: args.replayLimit,
})
},
})
export const MiniCommand = cmd({
command: "mini",
describe: "start the minimal interactive interface",
builder: (yargs) => yargs.command(MiniLocalCommand).command(MiniAttachCommand).demandCommand(),
handler: async () => {},
})
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
import { ServerProcess } from "@opencode-ai/cli/server-process"
import { Effect } from "effect"
import { cmd } from "./cmd"
export const V2ServeCommand = cmd({
command: "__v2-serve",
describe: false,
builder: (yargs) =>
yargs
.option("stdio", { type: "boolean", hidden: true })
.option("port", { type: "number", hidden: true }),
handler: async (args) => {
const controller = new AbortController()
const interrupt = () => controller.abort()
process.once("SIGINT", interrupt)
process.once("SIGTERM", interrupt)
process.once("SIGHUP", interrupt)
try {
await Effect.runPromise(
ServerProcess.run({ mode: args.stdio ? "stdio" : "service", port: args.port }),
{ signal: controller.signal },
)
} catch (error) {
if (!controller.signal.aborted) throw error
} finally {
process.off("SIGINT", interrupt)
process.off("SIGTERM", interrupt)
process.off("SIGHUP", interrupt)
}
},
})
@@ -0,0 +1,8 @@
import path from "node:path"
export function v2ServerCommand() {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
return [process.execPath, ...entrypoint, "__v2-serve"]
}
+2 -2
View File
@@ -19,7 +19,7 @@ import { GithubCommand } from "./cli/cmd/github"
import { ExportCommand } from "./cli/cmd/export" import { ExportCommand } from "./cli/cmd/export"
import { ImportCommand } from "./cli/cmd/import" import { ImportCommand } from "./cli/cmd/import"
import { AttachCommand } from "./cli/cmd/attach" import { AttachCommand } from "./cli/cmd/attach"
import { MiniCommand } from "./cli/cmd/mini" import { V2ServeCommand } from "./cli/cmd/v2-serve"
import { TuiThreadCommand } from "./cli/cmd/tui" import { TuiThreadCommand } from "./cli/cmd/tui"
import { AcpCommand } from "./cli/cmd/acp" import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os" import { EOL } from "os"
@@ -81,7 +81,7 @@ const cli = yargs(args)
.completion("completion", "generate shell completion script") .completion("completion", "generate shell completion script")
.command(AcpCommand) .command(AcpCommand)
.command(McpCommand) .command(McpCommand)
.command(MiniCommand) .command(V2ServeCommand)
.command(TuiThreadCommand) .command(TuiThreadCommand)
.command(AttachCommand) .command(AttachCommand)
.command(RunCommand) .command(RunCommand)
+2 -2
View File
@@ -1,6 +1,6 @@
import yargs from "yargs" import yargs from "yargs"
import { MiniCommand } from "./cli/cmd/mini"
import { TuiThreadCommand } from "./cli/cmd/tui" import { TuiThreadCommand } from "./cli/cmd/tui"
import { V2ServeCommand } from "./cli/cmd/v2-serve"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { hideBin } from "yargs/helpers" import { hideBin } from "yargs/helpers"
const cli = yargs(hideBin(process.argv)) const cli = yargs(hideBin(process.argv))
@@ -28,6 +28,6 @@ const cli = yargs(hideBin(process.argv))
if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1" if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1"
if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel
}) })
.command(MiniCommand) .command(V2ServeCommand)
.command(TuiThreadCommand) .command(TuiThreadCommand)
.parse() .parse()
@@ -41,34 +41,6 @@ Options:
--pure run without external plugins [boolean]" --pure run without external plugins [boolean]"
`; `;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini --help 1`] = `
"opencode mini
start the minimal interactive interface
Commands:
opencode mini [project] start the minimal interactive interface [default]
opencode mini attach <url> attach to a running opencode server with the minimal interface
Positionals:
project path to start opencode in [string]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--prompt prompt to use [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
--no-replay disable session history replay on resume and after resize [boolean]
--replay-limit cap visible replay to the newest N messages [number]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = ` exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = `
"opencode attach <url> "opencode attach <url>
@@ -100,33 +72,25 @@ Positionals:
message message to send [array] [default: []] message message to send [array] [default: []]
Options: Options:
-h, --help show help [boolean] -h, --help show help [boolean]
-v, --version show version number [boolean] -v, --version show version number [boolean]
--print-logs print logs to stderr [boolean] --print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean] --pure run without external plugins [boolean]
--command the command to run, use message for args [string] -c, --continue continue the last session [boolean]
-c, --continue continue the last session [boolean] -s, --session session id to continue [string]
-s, --session session id to continue [string] --fork fork the session before continuing (requires --continue or --session) [boolean]
--fork fork the session before continuing (requires --continue or --session) [boolean] -m, --model model to use in the format of provider/model [string]
-m, --model model to use in the format of provider/model [string] --agent agent to use [string]
--agent agent to use [string] --format format: default (formatted) or json (raw JSON events)
--format format: default (formatted) or json (raw JSON events)
[string] [choices: "default", "json"] [default: "default"] [string] [choices: "default", "json"] [default: "default"]
-f, --file file(s) to attach to message [array] -f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value provided) [string] --title title for the session (uses truncated prompt if no value provided) [string]
--attach attach to a running opencode server (e.g., http://localhost:4096) [string] --server connect to a running opencode server [string]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] --dir directory to run in, or a path on the remote server [string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') --variant model variant [string]
[string] --thinking show thinking blocks [boolean]
--dir directory to run in, path on remote server if attaching [string] --auto auto-approve permissions that are not explicitly denied (dangerous!)
--port port for the local server (defaults to random port if no value provided)
[number]
--variant model variant (provider-specific reasoning effort, e.g., high, max, minimal)
[string]
--thinking show thinking blocks [boolean]
-i, --interactive run in direct interactive split-footer mode [boolean] [default: false]
--auto auto-approve permissions that are not explicitly denied (dangerous!)
[boolean] [default: false]" [boolean] [default: false]"
`; `;
@@ -426,31 +390,6 @@ Options:
--format Output format [string] [choices: "json", "tsv"] [default: "tsv"]" --format Output format [string] [choices: "json", "tsv"] [default: "tsv"]"
`; `;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini attach --help 1`] = `
"opencode mini attach <url>
attach to a running opencode server with the minimal interface
Positionals:
url http://localhost:4096 [string] [required]
Options:
-h, --help show help [boolean]
-v, --version show version number [boolean]
--print-logs print logs to stderr [boolean]
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
--dir directory on the remote server [string]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')
[string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
--no-replay disable session history replay on resume and after resize [boolean]
--replay-limit cap visible replay to the newest N messages [number]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = ` exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = `
"opencode mcp list "opencode mcp list
@@ -57,7 +57,6 @@ function normalize(text: string): string {
const TOP_LEVEL = [ const TOP_LEVEL = [
"acp", "acp",
"mcp", "mcp",
"mini",
"attach", "attach",
"run", "run",
"debug", "debug",
@@ -82,7 +81,6 @@ const TOP_LEVEL = [
// distinct argv shape, not every leaf. Add new entries when a subcommand // distinct argv shape, not every leaf. Add new entries when a subcommand
// gains user-visible flags that we want to lock in. // gains user-visible flags that we want to lock in.
const SUBCOMMANDS = [ const SUBCOMMANDS = [
["mini", "attach"],
["mcp", "list"], ["mcp", "list"],
["mcp", "add"], ["mcp", "add"],
["mcp", "auth"], ["mcp", "auth"],
@@ -115,7 +113,7 @@ describe("opencode CLI help-text snapshots", () => {
const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV }) const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV })
expect(topLevel.exitCode).toBe(0) expect(topLevel.exitCode).toBe(0)
expect(topLevel.stderr.endsWith("\n")).toBe(true) expect(topLevel.stderr.endsWith("\n")).toBe(true)
expect(topLevel.stderr).toContain("opencode mini") expect(topLevel.stderr).not.toContain("opencode mini")
expect(topLevel.stderr).not.toContain("--mini") expect(topLevel.stderr).not.toContain("--mini")
expect(topLevel.stderr).not.toContain("--thinking") expect(topLevel.stderr).not.toContain("--thinking")
expect(topLevel.stderr).not.toContain("--variant") expect(topLevel.stderr).not.toContain("--variant")
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2" import { OpenCode } from "@opencode-ai/client/promise"
import { loadRunReferences, runProviders, waitForDefaultModel } from "@/cli/cmd/run/catalog.shared" import { loadRunReferences, runProviders, waitForDefaultModel } from "@opencode-ai/cli/mini/catalog.shared"
afterEach(() => { afterEach(() => {
mock.restore() mock.restore()
@@ -8,20 +8,12 @@ afterEach(() => {
describe("run catalog shared", () => { describe("run catalog shared", () => {
test("resolves the catalog-selected model for the footer", async () => { test("resolves the catalog-selected model for the footer", async () => {
const client = new OpencodeClient() const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const selected = spyOn(client.v2.model, "default").mockImplementation( const selected = spyOn(client.model, "default").mockImplementation(
() => () =>
Promise.resolve({ Promise.resolve({
data: { location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, data: { id: "gpt-5", providerID: "openai" },
data: {
id: "gpt-5",
providerID: "openai",
},
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}) as never, }) as never,
) )
@@ -29,17 +21,16 @@ describe("run catalog shared", () => {
providerID: "openai", providerID: "openai",
modelID: "gpt-5", modelID: "gpt-5",
}) })
expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true }) expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } })
}) })
test("loads visible project references from the current reference catalog", async () => { test("loads visible project references from the current reference catalog", async () => {
const client = new OpencodeClient() const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const list = spyOn(client.v2.reference, "list").mockImplementation( const list = spyOn(client.reference, "list").mockImplementation(
() => () =>
Promise.resolve({ Promise.resolve({
data: { location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, data: [
data: [
{ {
name: "effect", name: "effect",
path: "/repos/effect", path: "/repos/effect",
@@ -52,17 +43,13 @@ describe("run catalog shared", () => {
hidden: true, hidden: true,
source: { type: "local", path: "/repos/secret" }, source: { type: "local", path: "/repos/secret" },
}, },
], ],
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}) as never, }) as never,
) )
const references = await loadRunReferences(client, "/tmp") const references = await loadRunReferences(client, "/tmp")
expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true }) expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } })
expect(references).toMatchObject([{ name: "effect", path: "/repos/effect", description: "Effect v4 sources" }]) expect(references).toMatchObject([{ name: "effect", path: "/repos/effect", description: "Effect v4 sources" }])
}) })
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ToolPart } from "@opencode-ai/sdk/v2" import type { ToolPart } from "@opencode-ai/sdk/v2"
import { entryBody, entryCanStream, entryDone } from "@/cli/cmd/run/entry.body" import { entryBody, entryCanStream, entryDone } from "@opencode-ai/cli/mini/entry.body"
import type { StreamCommit, ToolSnapshot } from "@/cli/cmd/run/types" import type { StreamCommit, ToolSnapshot } from "@opencode-ai/cli/mini/types"
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit { function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
return input return input
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { createRoot } from "solid-js" import { createRoot } from "solid-js"
import { FOOTER_MENU_ROWS, createFooterMenuState } from "@/cli/cmd/run/footer.menu" import { FOOTER_MENU_ROWS, createFooterMenuState } from "@opencode-ai/cli/mini/footer.menu"
function mount(count: number, limit = FOOTER_MENU_ROWS) { function mount(count: number, limit = FOOTER_MENU_ROWS) {
let dispose!: () => void let dispose!: () => void
@@ -15,10 +15,10 @@ import {
RunSkillSelectBody, RunSkillSelectBody,
RunSubagentSelectBody, RunSubagentSelectBody,
RunVariantSelectBody, RunVariantSelectBody,
} from "@/cli/cmd/run/footer.command" } from "@opencode-ai/cli/mini/footer.command"
import { RunFooterView } from "@/cli/cmd/run/footer.view" import { RunFooterView } from "@opencode-ai/cli/mini/footer.view"
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer" import { RunEntryContent } from "@opencode-ai/cli/mini/scrollback.writer"
import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme" import { RUN_THEME_FALLBACK, type RunTheme } from "@opencode-ai/cli/mini/theme"
import type { import type {
FooterState, FooterState,
FooterSubagentState, FooterSubagentState,
@@ -30,10 +30,10 @@ import type {
RunProvider, RunProvider,
RunTuiConfig, RunTuiConfig,
StreamCommit, StreamCommit,
} from "@/cli/cmd/run/types" } from "@opencode-ai/cli/mini/types"
import { RunQuestionBody } from "@/cli/cmd/run/footer.question" import { RunQuestionBody } from "@opencode-ai/cli/mini/footer.question"
import { selectedCommand } from "@/cli/cmd/run/footer.prompt" import { selectedCommand } from "@opencode-ai/cli/mini/footer.prompt"
import { RejectField } from "@/cli/cmd/run/footer.permission" import { RejectField } from "@opencode-ai/cli/mini/footer.permission"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
const tuiConfig = createTuiResolvedConfig() const tuiConfig = createTuiResolvedConfig()
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { footerWidthPolicy } from "@/cli/cmd/run/footer.width" import { footerWidthPolicy } from "@opencode-ai/cli/mini/footer.width"
describe("run footer width", () => { describe("run footer width", () => {
test("preserves shared dialog and statusline breakpoints", () => { test("preserves shared dialog and statusline breakpoints", () => {
@@ -1,16 +1,12 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2" import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
import { runNonInteractivePrompt } from "@/cli/cmd/run/noninteractive" import { runNonInteractivePrompt } from "@opencode-ai/cli/mini/noninteractive"
type V2Event = EventSubscribeOutput
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"] type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
function ok<T>(data: T) { function ok<T>(data: T) {
return Promise.resolve({ return Promise.resolve(data)
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
} }
function form(id: string, sessionID: string): FormInfo { function form(id: string, sessionID: string): FormInfo {
@@ -43,8 +39,8 @@ function settled(outcome: "success" | "interrupted" = "success"): V2Event {
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the // Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// live events the prompt admission triggers, keyed by the generated message ID. // live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: FormInfo[]; attached?: boolean }) { async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: FormInfo[]; attached?: boolean }) {
const sdk = new OpencodeClient() const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const values: V2Event[] = [{ id: "evt_connected", created: 0, type: "server.connected", data: {} }] const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
let wake: (() => void) | undefined let wake: (() => void) | undefined
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> { const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
while (true) { while (true) {
@@ -58,22 +54,20 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?:
yield value yield value
} }
})() })()
spyOn(sdk.v2.event, "subscribe").mockImplementation( spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
() => Promise.resolve({ stream }) as ReturnType<typeof sdk.v2.event.subscribe>, spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
) spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.v2.session.permission, "list").mockImplementation(() => ok({ data: [] }) as never) spyOn(sdk.form, "list").mockImplementation(
spyOn(sdk.v2.session.question, "list").mockImplementation(() => ok({ data: [] }) as never)
spyOn(sdk.v2.session.form, "list").mockImplementation(
(request) => (request) =>
ok({ data: input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? [] }) as never, ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
) )
spyOn(sdk.v2.session.form, "cancel").mockImplementation(() => ok(undefined) as never) spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.v2.session, "prompt").mockImplementation((request) => { spyOn(sdk.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt" const messageID = request.id ?? "msg_prompt"
values.push(...input.turn(messageID)) values.push(...input.turn(messageID))
wake?.() wake?.()
wake = undefined wake = undefined
return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 } }) as never return ok({ admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never
}) })
await runNonInteractivePrompt({ await runNonInteractivePrompt({
client: sdk, client: sdk,
@@ -102,9 +96,9 @@ describe("runNonInteractivePrompt", () => {
// which must not leave the consume loop waiting forever. // which must not leave the consume loop waiting forever.
turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")], turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")],
}) })
expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }) expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }) expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
}) })
test("attach mode cancels only session-owned forms", async () => { test("attach mode cancels only session-owned forms", async () => {
@@ -113,9 +107,9 @@ describe("runNonInteractivePrompt", () => {
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")], pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()], turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
}) })
expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.v2.session.form.list).not.toHaveBeenCalledWith({ sessionID: "global" }) expect(sdk.form.list).not.toHaveBeenCalledWith({ sessionID: "global" })
expect(sdk.v2.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }) expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
expect(sdk.v2.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }) expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
}) })
}) })
@@ -8,7 +8,7 @@ import {
permissionInfo, permissionInfo,
permissionReject, permissionReject,
permissionRun, permissionRun,
} from "@/cli/cmd/run/permission.shared" } from "@opencode-ai/cli/mini/permission.shared"
function req(input: Partial<PermissionRequest> = {}): PermissionRequest { function req(input: Partial<PermissionRequest> = {}): PermissionRequest {
return { return {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { realignEditorPromptParts, resolveEditorSlashValue } from "@/cli/cmd/run/prompt.editor" import { realignEditorPromptParts, resolveEditorSlashValue } from "@opencode-ai/cli/mini/prompt.editor"
import type { RunPromptPart } from "@/cli/cmd/run/types" import type { RunPromptPart } from "@opencode-ai/cli/mini/types"
describe("run prompt editor helpers", () => { describe("run prompt editor helpers", () => {
test("strips the local /editor command from the initial editor text", () => { test("strips the local /editor command from the initial editor text", () => {
@@ -5,8 +5,8 @@ import {
isNewCommand, isNewCommand,
movePromptHistory, movePromptHistory,
pushPromptHistory, pushPromptHistory,
} from "@/cli/cmd/run/prompt.shared" } from "@opencode-ai/cli/mini/prompt.shared"
import type { RunPrompt } from "@/cli/cmd/run/types" import type { RunPrompt } from "@opencode-ai/cli/mini/types"
function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt { function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt {
return { text, parts } return { text, parts }
@@ -10,7 +10,7 @@ import {
questionStoreCustom, questionStoreCustom,
questionSubmit, questionSubmit,
questionSync, questionSync,
} from "@/cli/cmd/run/question.shared" } from "@opencode-ai/cli/mini/question.shared"
function req(input: Partial<QuestionRequest> = {}): QuestionRequest { function req(input: Partial<QuestionRequest> = {}): QuestionRequest {
return { return {
@@ -363,11 +363,11 @@ describe("opencode run (non-interactive subprocess)", () => {
) )
cliIt.concurrent( cliIt.concurrent(
"applies a variant to the configured default model", "applies a variant to the selected model",
({ llm, opencode }) => ({ llm, opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* llm.text("variant response") yield* llm.text("variant response")
const result = yield* opencode.spawn(["run", "--variant", "default", "use the default model"], { const result = yield* opencode.spawn(["run", "--model", "test/test-model", "--variant", "default", "use the model"], {
config: { ...testProviderConfig(llm.url), model: "test/test-model" }, config: { ...testProviderConfig(llm.url), model: "test/test-model" },
}) })
@@ -1,17 +1,11 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2" import { OpenCode } from "@opencode-ai/client/promise"
import type { Resolved } from "@opencode-ai/tui/config" import type { Resolved } from "@opencode-ai/tui/config"
import { TuiConfig } from "@/config/tui" import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@opencode-ai/cli/mini/runtime.boot"
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
function ok<T>(data: T) { function ok<T>(data: T) {
return Promise.resolve({ return Promise.resolve(data)
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
} }
function provider(id: string, name: string) { function provider(id: string, name: string) {
@@ -108,23 +102,21 @@ describe("run runtime boot", () => {
}) })
test("reads footer keybinds from resolved keybind config", async () => { test("reads footer keybinds from resolved keybind config", async () => {
spyOn(TuiConfig, "get").mockResolvedValue( const input = config({
config({ leader: "ctrl+g",
leader: "ctrl+g", bindings: {
bindings: { commandList: ["ctrl+p"],
commandList: ["ctrl+p"], variantCycle: ["ctrl+t", "alt+t"],
variantCycle: ["ctrl+t", "alt+t"], interrupt: ["ctrl+c"],
interrupt: ["ctrl+c"], historyPrevious: ["k"],
historyPrevious: ["k"], historyNext: ["j"],
historyNext: ["j"], inputClear: ["ctrl+l"],
inputClear: ["ctrl+l"], inputSubmit: ["ctrl+s"],
inputSubmit: ["ctrl+s"], inputNewline: ["alt+return"],
inputNewline: ["alt+return"], },
}, })
}),
)
const result = await resolveRunTuiConfig() const result = await resolveRunTuiConfig(input)
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g") expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
expect(result.leader_timeout).toBe(2000) expect(result.leader_timeout).toBe(2000)
@@ -139,9 +131,7 @@ describe("run runtime boot", () => {
}) })
test("falls back to default tui keymap config when config load fails", async () => { test("falls back to default tui keymap config when config load fails", async () => {
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom")) const result = await resolveRunTuiConfig(Promise.reject(new Error("boom")))
const result = await resolveRunTuiConfig()
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x") expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x")
expect(result.leader_timeout).toBe(2000) expect(result.leader_timeout).toBe(2000)
@@ -157,31 +147,23 @@ describe("run runtime boot", () => {
}) })
test("preserves disabled leader from resolved tui config", async () => { test("preserves disabled leader from resolved tui config", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(config({ leader: "none" })) const result = await resolveRunTuiConfig(config({ leader: "none" }))
const result = await resolveRunTuiConfig()
expect(result.keybinds.get("leader")).toEqual([]) expect(result.keybinds.get("leader")).toEqual([])
}) })
test("reads diff style and falls back to auto", async () => { test("reads diff style and falls back to auto", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(config({ diff_style: "stacked" })) await expect(resolveDiffStyle(config({ diff_style: "stacked" }))).resolves.toBe("stacked")
await expect(resolveDiffStyle()).resolves.toBe("stacked")
mock.restore() await expect(resolveDiffStyle(Promise.reject(new Error("boom")))).resolves.toBe("auto")
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
await expect(resolveDiffStyle()).resolves.toBe("auto")
}) })
test("loads v2 providers and models for model selector data", async () => { test("loads v2 providers and models for model selector data", async () => {
const sdk = new OpencodeClient() const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const providers = [provider("openai", "OpenAI")] const providers = [provider("openai", "OpenAI")]
const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])] const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])]
// The generated methods have conditional return types for throwOnError; these mocks represent the successful branch. const providerList = spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
// @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
const providerList = spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers }))
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models }))
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: [ providers: [
@@ -230,19 +212,15 @@ describe("run runtime boot", () => {
directory: "/workspace", directory: "/workspace",
}, },
}, },
{ throwOnError: true },
) )
}) })
test("loads context limits across v2 providers", async () => { test("loads context limits across v2 providers", async () => {
const sdk = new OpencodeClient() const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const providers = [provider("openai", "OpenAI"), provider("anthropic", "Anthropic")] const providers = [provider("openai", "OpenAI"), provider("anthropic", "Anthropic")]
const models = [model("gpt-5", "openai", 128000, ["high", "minimal"]), model("sonnet", "anthropic", 200000)] const models = [model("gpt-5", "openai", 128000, ["high", "minimal"]), model("sonnet", "anthropic", 200000)]
// The generated methods have conditional return types for throwOnError; these mocks represent the successful branch. spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
// @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers }))
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models }))
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: [ providers: [
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { runPromptQueue } from "@/cli/cmd/run/runtime.queue" import { runPromptQueue } from "@opencode-ai/cli/mini/runtime.queue"
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/run/types" import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@opencode-ai/cli/mini/types"
function footer() { function footer() {
const prompts = new Set<(input: RunPrompt) => void>() const prompts = new Set<(input: RunPrompt) => void>()
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Readable } from "node:stream" import { Readable } from "node:stream"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@/cli/cmd/run/runtime.stdin" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@opencode-ai/cli/mini/runtime.stdin"
function stream(isTTY: boolean) { function stream(isTTY: boolean) {
return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream
+283 -108
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2" import { OpenCode } from "@opencode-ai/client/promise"
import { runInteractiveMode } from "@/cli/cmd/run/runtime" import { runInteractiveDeferredMode, runInteractiveMode } from "@opencode-ai/cli/mini/runtime"
import type { FooterApi, FooterEvent, RunProvider } from "@/cli/cmd/run/types" import type { FooterApi, FooterEvent, RunProvider } from "@opencode-ai/cli/mini/types"
const provider: RunProvider = { const provider: RunProvider = {
id: "openai", id: "openai",
@@ -45,12 +45,7 @@ function defer<T>() {
} }
function ok<T>(data: T) { function ok<T>(data: T) {
return Promise.resolve({ return Promise.resolve(data)
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
} }
function footer(events: FooterEvent[] = []): FooterApi { function footer(events: FooterEvent[] = []): FooterApi {
@@ -110,16 +105,173 @@ afterEach(() => {
}) })
describe("run interactive runtime", () => { describe("run interactive runtime", () => {
test("resolves the deferred session only after first paint", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const lifecycleStarted = defer<void>()
const painted = defer<void>()
const api = footer()
let resolved = 0
api.idle = () => painted.promise
spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
const task = runInteractiveDeferredMode(
{
sdk,
directory: "/tmp",
resolveAgent: async () => "build",
session: async () => {
resolved++
api.close()
return { id: "ses-deferred", title: "Deferred" }
},
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
variant: undefined,
files: [],
thinking: false,
backgroundSubagents: false,
},
{
createRuntimeLifecycle: async () => {
lifecycleStarted.resolve()
return {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
},
},
)
await lifecycleStarted.promise
expect(resolved).toBe(0)
painted.resolve()
await task
expect(resolved).toBe(1)
})
test("restores deferred session history and model after first paint", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const lifecycleStarted = defer<void>()
const painted = defer<void>()
const events: FooterEvent[] = []
const api = footer(events)
api.idle = () => painted.promise
const event = api.event
api.event = (value) => {
event(value)
if (value.type === "model") api.close()
}
spyOn(sdk.session, "get").mockImplementation(
() =>
ok({
id: "ses-resume",
projectID: "pro-1",
title: "Resume",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
location: { directory: "/tmp" },
model: { providerID: "openai", id: "gpt-5", variant: "high" },
}) as never,
)
spyOn(sdk.message, "list").mockImplementation(
() =>
ok({
data: [{ id: "msg-user", type: "user", text: "previous prompt", time: { created: 1 } }],
cursor: {},
}) as never,
)
spyOn(sdk.provider, "list").mockImplementation(
() =>
ok({
location: { directory: "/tmp" },
data: [{ id: "openai", name: "OpenAI", request: { headers: {}, body: {} } }],
}) as never,
)
spyOn(sdk.model, "list").mockImplementation(
() =>
ok({
location: { directory: "/tmp" },
data: [
{
id: "gpt-5",
providerID: "openai",
name: "Little Frank",
capabilities: { tools: true, input: ["text"], output: ["text"] },
request: { headers: {}, body: {} },
variants: [{ id: "high", settings: {}, headers: {}, body: {} }],
time: { released: 1 },
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
status: "active",
enabled: true,
limit: { context: 128000, output: 8192 },
},
],
}) as never,
)
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
const task = runInteractiveDeferredMode(
{
sdk,
directory: "/tmp",
resolveAgent: async () => "build",
session: async () => ({ id: "ses-resume", title: "Resume", resume: true }),
agent: "build",
model: undefined,
variant: undefined,
files: [],
thinking: false,
backgroundSubagents: false,
},
{
createRuntimeLifecycle: async () => {
lifecycleStarted.resolve()
return {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
},
},
)
await lifecycleStarted.promise
expect(sdk.session.get).not.toHaveBeenCalled()
painted.resolve()
await task
expect(events).toContainEqual({
type: "history",
history: [{ text: "previous prompt", parts: [] }],
})
expect(events).toContainEqual({
type: "model",
model: "Little Frank · OpenAI · high",
selection: { providerID: "openai", modelID: "gpt-5" },
})
})
test("waits for provider metadata before eager replay transport bootstrap", async () => { test("waits for provider metadata before eager replay transport bootstrap", async () => {
const providersStarted = defer<void>() const providersStarted = defer<void>()
const providers = defer<void>() const providers = defer<void>()
const lifecycleModels: unknown[] = [] const lifecycleModels: unknown[] = []
const sdk = new OpencodeClient() const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused")) spyOn(sdk.provider, "list").mockImplementation(async () => {
const legacyAgents = spyOn(sdk.app, "agents").mockRejectedValue(new Error("legacy agents should stay unused"))
const legacyCommands = spyOn(sdk.command, "list").mockRejectedValue(new Error("legacy commands should stay unused"))
spyOn(sdk.v2.provider, "list").mockImplementation(async () => {
providersStarted.resolve() providersStarted.resolve()
await providers.promise await providers.promise
return ok({ return ok({
@@ -142,55 +294,56 @@ describe("run interactive runtime", () => {
], ],
}) as never }) as never
}) })
spyOn(sdk.v2.model, "list").mockImplementation(() => spyOn(sdk.model, "list").mockImplementation(
ok({ () =>
location: { ok({
directory: "/tmp", location: {
}, directory: "/tmp",
data: [
{
id: "gpt-5",
providerID: "openai",
name: "Little Frank",
api: {
id: "openai",
type: "native",
settings: {},
},
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
},
request: {
headers: {},
body: {},
},
variants: [],
time: {
released: 1,
},
cost: [
{
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
],
status: "active",
enabled: true,
limit: {
context: 128000,
output: 8192,
},
}, },
], data: [
}) as never, {
id: "gpt-5",
providerID: "openai",
name: "Little Frank",
api: {
id: "openai",
type: "native",
settings: {},
},
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
},
request: {
headers: {},
body: {},
},
variants: [],
time: {
released: 1,
},
cost: [
{
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
],
status: "active",
enabled: true,
limit: {
context: 128000,
output: 8192,
},
},
],
}) as never,
) )
spyOn(sdk.v2.session, "messages").mockImplementation(() => spyOn(sdk.message, "list").mockImplementation(() =>
ok({ ok({
data: [ data: [
{ {
@@ -205,9 +358,9 @@ describe("run interactive runtime", () => {
cursor: {}, cursor: {},
}), }),
) )
spyOn(sdk.v2.session, "get").mockImplementation(() => spyOn(sdk.session, "get").mockImplementation(
ok({ () =>
data: { ok({
id: "ses-1", id: "ses-1",
projectID: "pro-1", projectID: "pro-1",
title: "Session", title: "Session",
@@ -232,13 +385,12 @@ describe("run interactive runtime", () => {
providerID: "openai", providerID: "openai",
id: "gpt-5", id: "gpt-5",
}, },
}, }) as never,
}),
) )
spyOn(sdk.v2.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.v2.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.v2.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.v2.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
const task = runInteractiveMode( const task = runInteractiveMode(
{ {
@@ -296,13 +448,10 @@ describe("run interactive runtime", () => {
expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }]) expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }])
expect(transportProviders).toEqual([[provider]]) expect(transportProviders).toEqual([[provider]])
expect(legacyProviders).not.toHaveBeenCalled()
expect(legacyAgents).not.toHaveBeenCalled()
expect(legacyCommands).not.toHaveBeenCalled()
}) })
test("defers catalog-selected model resolution until after first paint", async () => { test("defers catalog-selected model resolution until after first paint", async () => {
const sdk = new OpencodeClient() const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const defaultStarted = defer<void>() const defaultStarted = defer<void>()
const releaseDefault = defer<void>() const releaseDefault = defer<void>()
const lifecycleStarted = defer<void>() const lifecycleStarted = defer<void>()
@@ -320,7 +469,7 @@ describe("run interactive runtime", () => {
api.close() api.close()
} }
spyOn(sdk.v2.model, "default").mockImplementation(async () => { spyOn(sdk.model, "default").mockImplementation(async () => {
defaultRequested = true defaultRequested = true
defaultStarted.resolve() defaultStarted.resolve()
await releaseDefault.promise await releaseDefault.promise
@@ -329,24 +478,12 @@ describe("run interactive runtime", () => {
data: { id: "gpt-5", providerID: "openai" }, data: { id: "gpt-5", providerID: "openai" },
}) as never }) as never
}) })
spyOn(sdk.v2.provider, "list").mockImplementation(() => spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
ok({ location: { directory: "/tmp" }, data: [] }) as never, spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
) spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.v2.model, "list").mockImplementation(() => spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
ok({ location: { directory: "/tmp" }, data: [] }) as never, spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
) spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.v2.agent, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.reference, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.command, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.skill, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
const task = runInteractiveMode( const task = runInteractiveMode(
{ {
@@ -402,12 +539,12 @@ describe("run interactive runtime", () => {
}) })
test("does not start deferred work after the footer closes", async () => { test("does not start deferred work after the footer closes", async () => {
const sdk = new OpencodeClient() const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const lifecycleStarted = defer<void>() const lifecycleStarted = defer<void>()
const painted = defer<void>() const painted = defer<void>()
const api = footer() const api = footer()
api.idle = () => painted.promise api.idle = () => painted.promise
const defaultModel = spyOn(sdk.v2.model, "default") const defaultModel = spyOn(sdk.model, "default")
const task = runInteractiveMode( const task = runInteractiveMode(
{ {
@@ -444,8 +581,50 @@ describe("run interactive runtime", () => {
expect(defaultModel).not.toHaveBeenCalled() expect(defaultModel).not.toHaveBeenCalled()
}) })
test("searches files through the V2 file API", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const api = footer()
const find = spyOn(sdk.file, "find").mockImplementation(
() =>
ok({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: [{ path: "src/index.ts", type: "file" }],
}) as never,
)
await runInteractiveMode(
{
sdk,
directory: "/tmp",
sessionID: "ses-files",
resume: false,
agent: "build",
model: undefined,
variant: undefined,
files: [],
thinking: false,
backgroundSubagents: false,
},
{
createRuntimeLifecycle: async (input) => {
await expect(input.findFiles("index")).resolves.toEqual(["src/index.ts"])
api.close()
return {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
},
},
)
expect(find).toHaveBeenCalledWith({ query: "index", type: "file", location: { directory: "/tmp" } })
})
test("retains last-known-good state across failed coalesced refreshes and retries later", async () => { test("retains last-known-good state across failed coalesced refreshes and retries later", async () => {
const sdk = new OpencodeClient() const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const refreshGate = defer<void>() const refreshGate = defer<void>()
let providerCalls = 0 let providerCalls = 0
let modelCalls = 0 let modelCalls = 0
@@ -453,7 +632,7 @@ describe("run interactive runtime", () => {
let referenceCalls = 0 let referenceCalls = 0
const events: FooterEvent[] = [] const events: FooterEvent[] = []
const api = footer(events) const api = footer(events)
spyOn(sdk.v2.provider, "list").mockImplementation(async () => { spyOn(sdk.provider, "list").mockImplementation(async () => {
providerCalls++ providerCalls++
if (providerCalls === 2) { if (providerCalls === 2) {
await refreshGate.promise await refreshGate.promise
@@ -471,7 +650,7 @@ describe("run interactive runtime", () => {
], ],
}) as never }) as never
}) })
spyOn(sdk.v2.model, "list").mockImplementation(() => { spyOn(sdk.model, "list").mockImplementation(() => {
modelCalls++ modelCalls++
return ok({ return ok({
location: { directory: "/tmp" }, location: { directory: "/tmp" },
@@ -484,9 +663,7 @@ describe("run interactive runtime", () => {
capabilities: { tools: true, input: ["text"], output: ["text"] }, capabilities: { tools: true, input: ["text"], output: ["text"] },
request: { headers: {}, body: {} }, request: { headers: {}, body: {} },
variants: variants:
modelCalls >= 4 modelCalls >= 4 ? [] : [{ id: modelCalls >= 3 ? "high" : "low", settings: {}, headers: {}, body: {} }],
? []
: [{ id: modelCalls >= 3 ? "high" : "low", settings: {}, headers: {}, body: {} }],
time: { released: 1 }, time: { released: 1 },
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
status: "active", status: "active",
@@ -496,7 +673,7 @@ describe("run interactive runtime", () => {
], ],
}) as never }) as never
}) })
spyOn(sdk.v2.agent, "list").mockImplementation(async () => { spyOn(sdk.agent, "list").mockImplementation(async () => {
agentCalls++ agentCalls++
if (agentCalls === 2) throw new Error("agent refresh failed") if (agentCalls === 2) throw new Error("agent refresh failed")
return ok({ return ok({
@@ -504,7 +681,7 @@ describe("run interactive runtime", () => {
data: [{ id: "build", description: agentCalls >= 3 ? "Refreshed agent" : "Agent", mode: "primary" }], data: [{ id: "build", description: agentCalls >= 3 ? "Refreshed agent" : "Agent", mode: "primary" }],
}) as never }) as never
}) })
spyOn(sdk.v2.reference, "list").mockImplementation(() => { spyOn(sdk.reference, "list").mockImplementation(() => {
referenceCalls++ referenceCalls++
return ok({ return ok({
location: { directory: "/tmp" }, location: { directory: "/tmp" },
@@ -513,12 +690,10 @@ describe("run interactive runtime", () => {
], ],
}) as never }) as never
}) })
spyOn(sdk.v2.command, "list").mockImplementation(() => spyOn(sdk.command, "list").mockImplementation(
ok({ location: { directory: "/tmp" }, data: [{ name: "check", description: "Check" }] }) as never, () => ok({ location: { directory: "/tmp" }, data: [{ name: "check", description: "Check" }] }) as never,
)
spyOn(sdk.v2.skill, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
) )
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
let finalProviders: RunProvider[] = [] let finalProviders: RunProvider[] = []
let finalLimits: Record<string, number> = {} let finalLimits: Record<string, number> = {}
let retainedProviders: RunProvider[] = [] let retainedProviders: RunProvider[] = []
@@ -2,9 +2,9 @@ import { afterEach, expect, test } from "bun:test"
import type { ToolPart } from "@opencode-ai/sdk/v2" import type { ToolPart } from "@opencode-ai/sdk/v2"
import { RGBA, SyntaxStyle } from "@opentui/core" import { RGBA, SyntaxStyle } from "@opentui/core"
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing" import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface" import { RunScrollbackStream } from "@opencode-ai/cli/mini/scrollback.surface"
import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme" import { RUN_THEME_FALLBACK, type RunTheme } from "@opencode-ai/cli/mini/theme"
import type { StreamCommit } from "@/cli/cmd/run/types" import type { StreamCommit } from "@opencode-ai/cli/mini/types"
type ClaimedCommit = { type ClaimedCommit = {
snapshot: { snapshot: {
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2" import type { Event } from "@opencode-ai/sdk/v2"
import { createSessionData, reduceSessionData } from "@/cli/cmd/run/session-data" import { createSessionData, reduceSessionData } from "@opencode-ai/cli/mini/session-data"
import type { StreamCommit } from "@/cli/cmd/run/types" import type { StreamCommit } from "@opencode-ai/cli/mini/types"
function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) { function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
return reduceSessionData({ return reduceSessionData({
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2" import { OpenCode } from "@opencode-ai/client/promise"
import { import {
createSession, createSession,
resolveCurrentSession, resolveCurrentSession,
@@ -7,7 +7,7 @@ import {
sessionVariant, sessionVariant,
type RunSession, type RunSession,
type SessionMessages, type SessionMessages,
} from "@/cli/cmd/run/session.shared" } from "@opencode-ai/cli/mini/session.shared"
type Message = SessionMessages[number] type Message = SessionMessages[number]
type Part = Message["parts"][number] type Part = Message["parts"][number]
@@ -252,11 +252,10 @@ describe("run session shared", () => {
}) })
test("restores current prompt history from stored text and file references", async () => { test("restores current prompt history from stored text and file references", async () => {
const client = new OpencodeClient() const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.v2.session, "messages").mockImplementation(() => spyOn(client.message, "list").mockImplementation(() =>
Promise.resolve({ Promise.resolve({
data: { data: [
data: [
{ {
id: "msg_prompt", id: "msg_prompt",
type: "user", type: "user",
@@ -272,32 +271,20 @@ describe("run session shared", () => {
agents: [], agents: [],
time: { created: 1 }, time: { created: 1 },
}, },
], ],
cursor: {}, cursor: {},
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}), }),
) )
spyOn(client.v2.session, "get").mockImplementation(() => spyOn(client.session, "get").mockImplementation(() =>
Promise.resolve({ Promise.resolve({
data: { id: "ses_1",
data: { title: "Session",
id: "ses_1", projectID: "proj_1",
title: "Session", location: { directory: "/tmp" },
version: "dev", time: { created: 1, updated: 1 },
projectID: "proj_1", cost: 0,
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 }, model: { providerID: "openai", id: "gpt-5", variant: "high" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
model: { providerID: "openai", id: "gpt-5", variant: "high" },
},
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}), }),
) )
@@ -2,12 +2,12 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { pathToFileURL } from "node:url" import { pathToFileURL } from "node:url"
import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2" import { OpenCode, type EventSubscribeOutput, type MessageListOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
import { createSessionTransport } from "@/cli/cmd/run/stream-v2.transport" import { createSessionTransport } from "@opencode-ai/cli/mini/stream-v2.transport"
import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types" import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
type RunV2Event = V2Event type RunV2Event = EventSubscribeOutput
function feed() { function feed() {
const values: RunV2Event[] = [] const values: RunV2Event[] = []
@@ -41,16 +41,11 @@ function feed() {
} }
function ok<T>(data: T) { function ok<T>(data: T) {
return Promise.resolve({ return Promise.resolve(data)
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
} }
function connected(id = "evt_connected") { function connected(id = "evt_connected") {
return { id, created: 0, type: "server.connected", data: {} } satisfies RunV2Event return { id, type: "server.connected", data: {} } satisfies RunV2Event
} }
function durable(sessionID: string, seq = 0, version = 1) { function durable(sessionID: string, seq = 0, version = 1) {
@@ -85,9 +80,7 @@ function footer() {
return { api, commits, events } return { api, commits, events }
} }
type SessionMessages = NonNullable< type SessionMessages = MessageListOutput["data"]
Awaited<ReturnType<OpencodeClient["v2"]["session"]["messages"]>>["data"]
>["data"][number][]
function sdk(input: { function sdk(input: {
streams: ReturnType<typeof feed>[] streams: ReturnType<typeof feed>[]
@@ -95,15 +88,10 @@ function sdk(input: {
messages?: Record<string, SessionMessages> messages?: Record<string, SessionMessages>
sessions?: Array<{ id: string; parentID?: string; title?: string; agent?: string; time: { updated: number } }> sessions?: Array<{ id: string; parentID?: string; title?: string; agent?: string; time: { updated: number } }>
}) { }) {
const client = new OpencodeClient() const client = OpenCode.make({ baseUrl: "https://opencode.test" })
let subscription = 0 let subscription = 0
spyOn(client.v2.event, "subscribe").mockImplementation( spyOn(client.event, "subscribe").mockImplementation(() => input.streams[subscription++]?.stream ?? feed().stream)
() => spyOn(client.message, "list").mockImplementation((request) =>
Promise.resolve({ stream: input.streams[subscription++]?.stream ?? feed().stream }) as ReturnType<
typeof client.v2.event.subscribe
>,
)
spyOn(client.v2.session, "messages").mockImplementation((request) =>
ok({ ok({
data: input.messages?.[request.sessionID] ?? [ data: input.messages?.[request.sessionID] ?? [
{ {
@@ -118,14 +106,14 @@ function sdk(input: {
cursor: {}, cursor: {},
}), }),
) )
spyOn(client.v2.session.permission, "list").mockImplementation(() => ok({ data: [] })) spyOn(client.permission, "list").mockImplementation(() => ok([]))
spyOn(client.v2.session.question, "list").mockImplementation(() => ok({ data: [] })) spyOn(client.question, "list").mockImplementation(() => ok([]))
spyOn(client.v2.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} })) spyOn(client.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} }))
spyOn(client.v2.session, "switchAgent").mockImplementation(() => ok(undefined)) spyOn(client.session, "switchAgent").mockImplementation(() => ok(undefined))
spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined)) spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined))
// The generated methods have conditional return types for throwOnError; the // The generated methods have conditional return types for throwOnError; the
// minimal shapes below are enough for family discovery and model fallback. // minimal shapes below are enough for family discovery and model fallback.
spyOn(client.v2.session, "list").mockImplementation((request) => { spyOn(client.session, "list").mockImplementation((request) => {
const parentID = request?.parentID const parentID = request?.parentID
return ok({ return ok({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
@@ -139,7 +127,7 @@ function sdk(input: {
) ?? [], ) ?? [],
}) as never }) as never
}) })
spyOn(client.v2.model, "default").mockImplementation( spyOn(client.model, "default").mockImplementation(
() => () =>
ok({ ok({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
@@ -170,9 +158,7 @@ describe("V2 mini transport", () => {
expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt"]) expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt"])
let admitted = false let admitted = false
// The generated method has conditional return types for throwOnError; this mock represents the successful branch. spyOn(client.session, "prompt").mockImplementation((request) => {
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt" const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" } const prompt = request.prompt ?? { text: "" }
admitted = true admitted = true
@@ -185,7 +171,7 @@ describe("V2 mini transport", () => {
delivery: "steer" as const, delivery: "steer" as const,
timeCreated: 2, timeCreated: 2,
}, },
}) }) as never
}) })
const turn = transport.runPromptTurn({ const turn = transport.runPromptTurn({
@@ -250,10 +236,8 @@ describe("V2 mini transport", () => {
limits: () => ({}), limits: () => ({}),
footer: ui.api, footer: ui.api,
}) })
let request: Parameters<OpencodeClient["v2"]["session"]["prompt"]>[0] | undefined let request: Parameters<OpenCodeClient["session"]["prompt"]>[0] | undefined
// The generated method has conditional return types for throwOnError; this mock represents the successful branch. spyOn(client.session, "prompt").mockImplementation((input) => {
// @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((input) => {
request = input request = input
queueMicrotask(() => { queueMicrotask(() => {
events.push({ events.push({
@@ -282,7 +266,7 @@ describe("V2 mini transport", () => {
delivery: "steer" as const, delivery: "steer" as const,
timeCreated: 2, timeCreated: 2,
}, },
}) }) as never
}) })
await transport.runPromptTurn({ await transport.runPromptTurn({
@@ -344,10 +328,10 @@ describe("V2 mini transport", () => {
limits: () => ({}), limits: () => ({}),
footer: ui.api, footer: ui.api,
}) })
let request: Parameters<OpencodeClient["v2"]["session"]["prompt"]>[0] | undefined let request: Parameters<OpenCodeClient["session"]["prompt"]>[0] | undefined
// The generated method has conditional return types for throwOnError; this mock represents the successful branch. // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime // @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((input) => { spyOn(client.session, "prompt").mockImplementation((input) => {
request = input request = input
queueMicrotask(() => { queueMicrotask(() => {
events.push({ events.push({
@@ -441,10 +425,10 @@ describe("V2 mini transport", () => {
limits: () => ({}), limits: () => ({}),
footer: ui.api, footer: ui.api,
}) })
let request: Parameters<OpencodeClient["v2"]["session"]["prompt"]>[0] | undefined let request: Parameters<OpenCodeClient["session"]["prompt"]>[0] | undefined
// The generated method has conditional return types for throwOnError; this mock represents the successful branch. // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime // @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((input) => { spyOn(client.session, "prompt").mockImplementation((input) => {
request = input request = input
queueMicrotask(() => { queueMicrotask(() => {
events.push({ events.push({
@@ -561,7 +545,7 @@ describe("V2 mini transport", () => {
}, },
}) })
let projected = false let projected = false
spyOn(client.v2.session, "messages").mockImplementation(() => spyOn(client.message, "list").mockImplementation(() =>
ok({ ok({
data: projected data: projected
? [ ? [
@@ -589,7 +573,7 @@ describe("V2 mini transport", () => {
let admitted = false let admitted = false
// The generated method has conditional return types for throwOnError; this mock represents the successful branch. // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime // @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((request) => { spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt" const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" } const prompt = request.prompt ?? { text: "" }
admitted = true admitted = true
@@ -632,7 +616,7 @@ describe("V2 mini transport", () => {
return active return active
}, },
}) })
spyOn(client.v2.session, "messages").mockImplementation(() => spyOn(client.message, "list").mockImplementation(() =>
ok({ ok({
data: projected data: projected
? [ ? [
@@ -661,7 +645,7 @@ describe("V2 mini transport", () => {
let admitted = false let admitted = false
// The generated method has conditional return types for throwOnError; this mock represents the successful branch. // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime // @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((request) => { spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt" const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" } const prompt = request.prompt ?? { text: "" }
admitted = true admitted = true
@@ -701,7 +685,7 @@ describe("V2 mini transport", () => {
limits: () => ({}), limits: () => ({}),
footer: ui.api, footer: ui.api,
}) })
spyOn(client.v2.session, "messages").mockImplementation(() => spyOn(client.message, "list").mockImplementation(() =>
ok({ ok({
data: [ data: [
{ {
@@ -745,7 +729,7 @@ describe("V2 mini transport", () => {
const events = feed() const events = feed()
events.push(connected()) events.push(connected())
const client = sdk({ streams: [events] }) const client = sdk({ streams: [events] })
spyOn(client.v2.session, "messages").mockImplementation(() => spyOn(client.message, "list").mockImplementation(() =>
ok({ ok({
data: [ data: [
{ {
@@ -842,7 +826,7 @@ describe("V2 mini transport", () => {
let admitted = false let admitted = false
// The generated method has conditional return types for throwOnError; this mock represents the successful branch. // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime // @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((request) => { spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt" const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" } const prompt = request.prompt ?? { text: "" }
admitted = true admitted = true
@@ -850,7 +834,7 @@ describe("V2 mini transport", () => {
data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 },
}) })
}) })
const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
const turn = transport.runPromptTurn({ const turn = transport.runPromptTurn({
agent: undefined, agent: undefined,
@@ -886,21 +870,19 @@ describe("V2 mini transport", () => {
limits: () => ({}), limits: () => ({}),
footer: ui.api, footer: ui.api,
}) })
// The generated method has conditional return types for throwOnError; the test only needs the nested model field. spyOn(client.session, "get").mockImplementation(() => ok({ model: undefined }) as never)
// @ts-expect-error minimal session shape is enough for this lookup spyOn(client.model, "default").mockImplementation(
spyOn(client.v2.session, "get").mockImplementation(() => ok({ data: { model: undefined } }))
spyOn(client.v2.model, "default").mockImplementation(
() => () =>
ok({ ok({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: { id: "gpt-5", providerID: "openai" }, data: { id: "gpt-5", providerID: "openai" },
}) as never, }) as never,
) )
const switched = spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined)) const switched = spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined))
let admitted = false let admitted = false
// The generated method has conditional return types for throwOnError; this mock represents the successful branch. // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime // @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((request) => { spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt" const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" } const prompt = request.prompt ?? { text: "" }
admitted = true admitted = true
@@ -938,7 +920,7 @@ describe("V2 mini transport", () => {
expect(switched).toHaveBeenCalledWith( expect(switched).toHaveBeenCalledWith(
{ sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } }, { sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } },
expect.objectContaining({ throwOnError: true }), { signal: undefined },
) )
await transport.close() await transport.close()
}) })
@@ -958,7 +940,7 @@ describe("V2 mini transport", () => {
let admitted = false let admitted = false
// The generated method has conditional return types for throwOnError; this mock represents the successful branch. // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime // @ts-expect-error successful SDK response is valid for both modes at runtime
spyOn(client.v2.session, "prompt").mockImplementation((request) => { spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt" const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" } const prompt = request.prompt ?? { text: "" }
admitted = true admitted = true
@@ -966,7 +948,7 @@ describe("V2 mini transport", () => {
data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 },
}) })
}) })
const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
const controller = new AbortController() const controller = new AbortController()
const turn = transport.runPromptTurn({ const turn = transport.runPromptTurn({
agent: undefined, agent: undefined,
@@ -1014,8 +996,8 @@ describe("V2 mini transport", () => {
limits: () => ({}), limits: () => ({}),
footer: ui.api, footer: ui.api,
}) })
let request: Parameters<OpencodeClient["v2"]["session"]["shell"]>[0] | undefined let request: Parameters<OpenCodeClient["session"]["shell"]>[0] | undefined
spyOn(client.v2.session, "shell").mockImplementation((input) => { spyOn(client.session, "shell").mockImplementation((input) => {
request = input request = input
queueMicrotask(() => { queueMicrotask(() => {
events.push({ events.push({
@@ -1094,7 +1076,7 @@ describe("V2 mini transport", () => {
}) })
let started = false let started = false
let aborted = false let aborted = false
spyOn(client.v2.session, "shell").mockImplementation( spyOn(client.session, "shell").mockImplementation(
(_input, options) => (_input, options) =>
new Promise((_, reject) => { new Promise((_, reject) => {
started = true started = true
@@ -1104,7 +1086,7 @@ describe("V2 mini transport", () => {
}) })
}) as never, }) as never,
) )
const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
const turn = transport.runPromptTurn({ const turn = transport.runPromptTurn({
agent: undefined, agent: undefined,
@@ -1135,9 +1117,9 @@ describe("V2 mini transport", () => {
limits: () => ({}), limits: () => ({}),
footer: ui.api, footer: ui.api,
}) })
let request: Parameters<OpencodeClient["v2"]["session"]["shell"]>[0] | undefined let request: Parameters<OpenCodeClient["session"]["shell"]>[0] | undefined
let complete!: () => void let complete!: () => void
spyOn(client.v2.session, "shell").mockImplementation((input) => { spyOn(client.session, "shell").mockImplementation((input) => {
request = input request = input
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
complete = resolve complete = resolve
@@ -1414,8 +1396,8 @@ describe("V2 mini transport", () => {
limits: () => ({}), limits: () => ({}),
footer: ui.api, footer: ui.api,
}) })
let request: Parameters<OpencodeClient["v2"]["session"]["command"]>[0] | undefined let request: Parameters<OpenCodeClient["session"]["command"]>[0] | undefined
spyOn(client.v2.session, "command").mockImplementation((input) => { spyOn(client.session, "command").mockImplementation((input) => {
request = input request = input
queueMicrotask(() => { queueMicrotask(() => {
events.push({ events.push({
@@ -1436,14 +1418,12 @@ describe("V2 mini transport", () => {
}) })
}) })
return ok({ return ok({
data: { admittedSeq: 1,
admittedSeq: 1, id: input.id ?? "msg_cmd",
id: input.id ?? "msg_cmd", sessionID: "ses_1",
sessionID: "ses_1", prompt: { text: "evaluated template" },
prompt: { text: "evaluated template" }, delivery: "steer" as const,
delivery: "steer" as const, timeCreated: 2,
timeCreated: 2,
},
}) })
}) })
@@ -1471,8 +1451,8 @@ describe("V2 mini transport", () => {
delivery: "steer", delivery: "steer",
}) })
// Selection rides the command payload; no separate client-side switch. // Selection rides the command payload; no separate client-side switch.
expect(client.v2.session.switchAgent).not.toHaveBeenCalled() expect(client.session.switchAgent).not.toHaveBeenCalled()
expect(client.v2.session.switchModel).not.toHaveBeenCalled() expect(client.session.switchModel).not.toHaveBeenCalled()
await transport.close() await transport.close()
}) })
@@ -1488,10 +1468,10 @@ describe("V2 mini transport", () => {
limits: () => ({}), limits: () => ({}),
footer: ui.api, footer: ui.api,
}) })
let request: Parameters<OpencodeClient["v2"]["session"]["skill"]>[0] | undefined let request: Parameters<OpenCodeClient["session"]["skill"]>[0] | undefined
const command = spyOn(client.v2.session, "command") const command = spyOn(client.session, "command")
const prompt = spyOn(client.v2.session, "prompt") const prompt = spyOn(client.session, "prompt")
spyOn(client.v2.session, "skill").mockImplementation((input) => { spyOn(client.session, "skill").mockImplementation((input) => {
request = input request = input
queueMicrotask(() => { queueMicrotask(() => {
events.push({ events.push({
@@ -1551,7 +1531,7 @@ describe("V2 mini transport", () => {
footer: ui.api, footer: ui.api,
}) })
let sent = false let sent = false
spyOn(client.v2.session, "skill").mockImplementation(() => { spyOn(client.session, "skill").mockImplementation(() => {
sent = true sent = true
return ok(undefined) as never return ok(undefined) as never
}) })
@@ -1721,20 +1701,18 @@ describe("V2 mini transport", () => {
], ],
}, },
}) })
spyOn(client.v2.session, "get").mockImplementation(() => spyOn(client.session, "get").mockImplementation(() =>
ok({ ok({
data: { id: "ses_child",
id: "ses_child", parentID: "ses_1",
parentID: "ses_1", projectID: "proj_1",
projectID: "proj_1", agent: "explore",
agent: "explore", cost: 0,
cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 },
time: { created: 1, updated: 1 }, title: "Find files",
title: "Find files", location: { directory: "/tmp" },
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, }) as never,
},
}),
) )
const ui = footer() const ui = footer()
const transport = await createSessionTransport({ const transport = await createSessionTransport({
@@ -1857,7 +1835,7 @@ describe("V2 mini transport", () => {
const hydration = new Promise<void>((resolve) => { const hydration = new Promise<void>((resolve) => {
releaseHydration = resolve releaseHydration = resolve
}) })
spyOn(client.v2.session, "messages").mockImplementation(async (request) => { spyOn(client.message, "list").mockImplementation(async (request) => {
if (request.sessionID === "ses_child") { if (request.sessionID === "ses_child") {
childHydrating = true childHydrating = true
await hydration await hydration
@@ -1927,7 +1905,7 @@ describe("V2 mini transport", () => {
const retry = new Promise<void>((resolve) => { const retry = new Promise<void>((resolve) => {
releaseRetry = resolve releaseRetry = resolve
}) })
spyOn(client.v2.session, "messages").mockImplementation(async (request) => { spyOn(client.message, "list").mockImplementation(async (request) => {
if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} }) if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} })
childRequests++ childRequests++
if (childRequests === 1) { if (childRequests === 1) {
@@ -2006,7 +1984,7 @@ describe("V2 mini transport", () => {
const hydration = new Promise<void>((resolve) => { const hydration = new Promise<void>((resolve) => {
releaseHydration = resolve releaseHydration = resolve
}) })
spyOn(client.v2.session, "messages").mockImplementation(async (request) => { spyOn(client.message, "list").mockImplementation(async (request) => {
if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} }) if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} })
childHydrating = true childHydrating = true
await hydration await hydration
@@ -2119,21 +2097,19 @@ describe("V2 mini transport", () => {
const gate = new Promise<void>((resolve) => { const gate = new Promise<void>((resolve) => {
resolveGet = resolve resolveGet = resolve
}) })
spyOn(client.v2.session, "get").mockImplementation(async () => { spyOn(client.session, "get").mockImplementation(async () => {
await gate await gate
return ok({ return ok({
data: { id: "ses_child",
id: "ses_child", parentID: "ses_1",
parentID: "ses_1", projectID: "proj_1",
projectID: "proj_1", agent: "explore",
agent: "explore", cost: 0,
cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 },
time: { created: 1, updated: 1 }, title: "Find files",
title: "Find files", location: { directory: "/tmp" },
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, }) as never
},
})
}) })
const ui = footer() const ui = footer()
const transport = await createSessionTransport({ const transport = await createSessionTransport({
@@ -2178,21 +2154,19 @@ describe("V2 mini transport", () => {
const gate = new Promise<void>((resolve) => { const gate = new Promise<void>((resolve) => {
resolveGet = resolve resolveGet = resolve
}) })
spyOn(client.v2.session, "get").mockImplementation(async () => { spyOn(client.session, "get").mockImplementation(async () => {
await gate await gate
return ok({ return ok({
data: { id: "ses_child",
id: "ses_child", parentID: "ses_1",
parentID: "ses_1", projectID: "proj_1",
projectID: "proj_1", agent: "explore",
agent: "explore", cost: 0,
cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 },
time: { created: 1, updated: 1 }, title: "Find files",
title: "Find files", location: { directory: "/tmp" },
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, }) as never
},
})
}) })
const ui = footer() const ui = footer()
const transport = await createSessionTransport({ const transport = await createSessionTransport({
@@ -2286,10 +2260,7 @@ describe("V2 mini transport", () => {
footer: ui.api, footer: ui.api,
}) })
const states = ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) const states = ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : []))
expect(client.v2.session.list).toHaveBeenCalledWith( expect(client.session.list).toHaveBeenCalledWith({ parentID: "ses_1", limit: 100, order: "desc" })
{ parentID: "ses_1", limit: 100, order: "desc" },
{ throwOnError: true },
)
expect(states.at(-1)?.tabs).toMatchObject([ expect(states.at(-1)?.tabs).toMatchObject([
{ {
sessionID: "ses_child_old", sessionID: "ses_child_old",
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { writeSessionOutput } from "@/cli/cmd/run/stream" import { writeSessionOutput } from "@opencode-ai/cli/mini/stream"
import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types" import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types"
function footer() { function footer() {
const events: FooterEvent[] = [] const events: FooterEvent[] = []
+1 -1
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core" import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@/cli/cmd/run/theme" import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@opencode-ai/cli/mini/theme"
const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
@@ -10,9 +10,9 @@ import {
formatModelLabel, formatModelLabel,
pickVariant, pickVariant,
resolveVariant, resolveVariant,
} from "@/cli/cmd/run/variant.shared" } from "@opencode-ai/cli/mini/variant.shared"
import type { SessionMessages } from "@/cli/cmd/run/session.shared" import type { SessionMessages } from "@opencode-ai/cli/mini/session.shared"
import type { RunProvider } from "@/cli/cmd/run/types" import type { RunProvider } from "@opencode-ai/cli/mini/types"
import { testEffect } from "../../lib/effect" import { testEffect } from "../../lib/effect"
const model = { const model = {
+1 -32
View File
@@ -4,7 +4,6 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import yargs from "yargs" import yargs from "yargs"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { MiniLocalCommand } from "../../../src/cli/cmd/mini"
import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui" import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui"
import { cliIt } from "../../lib/cli-process" import { cliIt } from "../../lib/cli-process"
@@ -38,7 +37,7 @@ describe("tui thread", () => {
await check(".") await check(".")
}) })
test("resolves a relative mini project from PWD when cwd differs", async () => { test("resolves a relative project from PWD when cwd differs", async () => {
await using pwd = await tmpdir({ git: true }) await using pwd = await tmpdir({ git: true })
await using cwd = await tmpdir({ git: true }) await using cwd = await tmpdir({ git: true })
@@ -46,18 +45,6 @@ describe("tui thread", () => {
expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path) expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path)
}) })
test("parses supported mini --no-replay forms", async () => {
for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) {
const args = await yargs([])
.command({ ...MiniLocalCommand, handler: () => {} })
.exitProcess(false)
.parse([option, "--replay-limit", "10"])
expect(args.replay === false || args.noReplay === true).toBe(true)
expect(args.replayLimit).toBe(10)
}
})
test("preserves boolean negation for existing options", async () => { test("preserves boolean negation for existing options", async () => {
const args = await yargs([]) const args = await yargs([])
.command({ ...TuiThreadCommand, handler: () => {} }) .command({ ...TuiThreadCommand, handler: () => {} })
@@ -85,24 +72,6 @@ describe("tui thread", () => {
}), }),
) )
cliIt.live("routes local sessions through mini", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["mini"])
opencode.expectExit(result, 1)
expect(result.stderr).toContain("opencode mini requires a TTY stdout")
}),
)
cliIt.live("routes attached sessions through mini attach", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["mini", "attach", "http://127.0.0.1:1"])
opencode.expectExit(result, 1)
expect(result.stderr).toContain("opencode mini requires a TTY stdout")
}),
)
cliIt.live("rejects removed attach mini alias", ({ opencode }) => cliIt.live("rejects removed attach mini alias", ({ opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"]) const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"])
+5 -1
View File
@@ -4,7 +4,11 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const HealthGroup = HttpApiGroup.make("server.health") export const HealthGroup = HttpApiGroup.make("server.health")
.add( .add(
HttpApiEndpoint.get("health.get", "/api/health", { HttpApiEndpoint.get("health.get", "/api/health", {
success: Schema.Struct({ healthy: Schema.Literal(true) }), success: Schema.Struct({
healthy: Schema.Literal(true),
version: Schema.String,
pid: Schema.Int.check(Schema.isGreaterThan(0)),
}),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.health.get", identifier: "v2.health.get",
+2
View File
@@ -15546,6 +15546,8 @@ export type V2HealthGetResponses = {
*/ */
200: { 200: {
healthy: true healthy: true
version: string
pid: number
} }
} }
+1
View File
@@ -12,6 +12,7 @@
"typecheck": "tsgo --noEmit" "typecheck": "tsgo --noEmit"
}, },
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*", "@opencode-ai/simulation": "workspace:*",
+4 -1
View File
@@ -1,7 +1,10 @@
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api" import { Api } from "../api"
export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) => export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
handlers.handle("health.get", () => Effect.succeed({ healthy: true as const })), handlers.handle("health.get", () =>
Effect.succeed({ healthy: true as const, version: InstallationVersion, pid: process.pid }),
),
) )
+64
View File
@@ -0,0 +1,64 @@
export * as ServerProcess from "./process"
import { NodeHttpClient, NodeHttpServer } from "@effect/platform-node"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Project } from "@opencode-ai/core/project"
import { HealthGroup } from "@opencode-ai/protocol/groups/health"
import { Context, Effect, Layer, Option } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { createRoutes } from "./routes"
export type Options = {
readonly hostname: string
readonly port: Option.Option<number>
readonly password: string
}
const ReadinessApi = HttpApi.make("readiness").add(HealthGroup)
export const start = Effect.fn("ServerProcess.start")(function* (options: Options) {
if (!options.password) return yield* Effect.fail(new Error("Missing server password"))
const address = yield* listen(options)
yield* Effect.gen(function* () {
const client = yield* HttpApiClient.make(ReadinessApi, {
baseUrl: HttpServer.formatAddress(address),
transformClient: HttpClient.mapRequest((request) =>
HttpClientRequest.setHeader(
request,
"authorization",
ServerAuth.header({ username: "opencode", password: options.password }) ?? "",
),
),
})
yield* client["server.health"]["health.get"]({})
}).pipe(Effect.provide(NodeHttpClient.layerNodeHttp))
return address
})
function listen(options: Options) {
if (Option.isSome(options.port)) return bind(options.hostname, options.port.value, options.password)
const next = (port: number): ReturnType<typeof bind> =>
bind(options.hostname, port, options.password).pipe(
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
)
return next(4096)
}
function bind(hostname: string, port: number, password: string) {
const server = createServer()
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
),
).pipe(
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
)
}
+2
View File
@@ -38,9 +38,11 @@
"./plugin/command-shim": "./src/plugin/command-shim.ts", "./plugin/command-shim": "./src/plugin/command-shim.ts",
"./parsers-config": "./src/parsers-config.ts", "./parsers-config": "./src/parsers-config.ts",
"./util/error": "./src/util/error.ts", "./util/error": "./src/util/error.ts",
"./util/filetype": "./src/util/filetype.ts",
"./util/locale": "./src/util/locale.ts", "./util/locale": "./src/util/locale.ts",
"./util/persistence": "./src/util/persistence.ts", "./util/persistence": "./src/util/persistence.ts",
"./util/record": "./src/util/record.ts", "./util/record": "./src/util/record.ts",
"./util/session": "./src/util/session.ts",
"./logo": "./src/logo.ts", "./logo": "./src/logo.ts",
"./ui/dialog": "./src/ui/dialog.tsx", "./ui/dialog": "./src/ui/dialog.tsx",
"./ui/spinner": "./src/ui/spinner.ts", "./ui/spinner": "./src/ui/spinner.ts",