mini migrate to v2 (#35526)

This commit is contained in:
Simon Klee 2026-07-06 11:03:30 +02:00 committed by GitHub
parent fb75ea2cf6
commit 32cf36de9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 2246 additions and 2335 deletions

View file

@ -101,16 +101,22 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"fuzzysort": "catalog:",
"jsonc-parser": "3.3.1",
"opentui-spinner": "catalog:",
"semver": "catalog:",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
@ -599,6 +605,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/cli": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@ -796,6 +803,7 @@
"name": "@opencode-ai/server",
"version": "1.17.13",
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*",

View file

@ -10,6 +10,20 @@
"files": [
"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": {
"build": "bun run script/build.ts",
"dev": "bun run src/index.ts",
@ -19,16 +33,22 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"fuzzysort": "catalog:",
"jsonc-parser": "3.3.1",
"opentui-spinner": "catalog:",
"semver": "catalog:",
"solid-js": "catalog:"
"solid-js": "catalog:",
"strip-ansi": "7.1.2"
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",

View file

@ -3,6 +3,31 @@ import { Spec } from "../framework/spec"
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", {
description: "OpenCode 2.0 preview command line interface",
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("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", {
description: "Manage the background server",
commands: [

View file

@ -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,
}),
)
}),
)

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,
}),
)
}),
)

View file

@ -1,154 +1,16 @@
import { 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 { 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 { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
import { Env } from "../../env"
import { ServiceConfig } from "../../services/service-config"
import { Updater } from "../../services/updater"
import { randomBytes, randomUUID } from "crypto"
import path from "path"
import { ServerProcess } from "../../server-process"
export default Runtime.handler(
Commands.commands.serve,
Effect.fn("cli.serve")(function* (input) {
if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home))
return yield* Effect.scoped(
Effect.gen(function* () {
const standalonePassword = yield* Env.password
// Keep the lease credential out of the environment inherited by any
// 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" })),
)
if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined"))
return yield* ServerProcess.run({
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
hostname: Option.getOrUndefined(input.hostname),
port: Option.getOrUndefined(input.port),
})
}),
)
// 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),
)
}

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"

View file

@ -31,6 +31,8 @@ const Handlers = Runtime.handlers(Commands, {
logout: () => import("./commands/handlers/mcp/logout"),
},
migrate: () => import("./commands/handlers/migrate"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
service: {
start: () => import("./commands/handlers/service/start"),
restart: () => import("./commands/handlers/service/restart"),

View file

@ -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"
type CurrentAgent = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number]
type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number]
type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number]
type CurrentProvider = NonNullable<
Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"]
>["data"][number]
type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["data"]>["data"][number]
type CurrentAgent = AgentListOutput["data"][number]
type CurrentCommand = CommandListOutput["data"][number]
type CurrentSkill = SkillListOutput["data"][number]
type CurrentProvider = ProviderListOutput["data"][number]
type CurrentModel = ModelListOutput["data"][number]
function location(directory: string) {
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
// admission. On timeout, return and let the real execution error surface.
export async function waitForCatalogReady(input: {
sdk: OpencodeClient
sdk: OpenCodeClient
directory: string
model: { providerID: string; modelID: string }
timeoutMs?: number
}) {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline) {
const models = await input.sdk.v2.model
.list(location(input.directory), { throwOnError: true })
.then((result) => result.data?.data ?? [])
const models = await input.sdk.model
.list(location(input.directory))
.then((result) => result.data)
.catch(() => undefined)
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
await new Promise((resolve) => setTimeout(resolve, 25))
@ -108,47 +113,47 @@ export async function waitForCatalogReady(input: {
}
export async function waitForDefaultModel(input: {
sdk: OpencodeClient
sdk: OpenCodeClient
directory: string
timeoutMs?: number
active?: () => boolean
}): Promise<{ providerID: string; modelID: string } | undefined> {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline && (input.active?.() ?? true)) {
const model = await input.sdk.v2.model
.default(location(input.directory), { throwOnError: true })
.then((result) => result.data?.data)
const model = await input.sdk.model
.default(location(input.directory))
.then((result) => result.data)
.catch(() => undefined)
if (model) return { providerID: model.providerID, modelID: model.id }
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise<RunAgent[]> {
const result = await sdk.v2.agent.list(location(directory), { throwOnError: true })
return (result.data?.data ?? []).map(runAgent)
export async function loadRunAgents(sdk: OpenCodeClient, directory: string): Promise<RunAgent[]> {
const result = await sdk.agent.list(location(directory))
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([
sdk.v2.command.list(location(directory), { throwOnError: true }),
sdk.v2.skill.list(location(directory), { throwOnError: true }),
sdk.command.list(location(directory)),
sdk.skill.list(location(directory)),
])
return [
...(commands.data?.data ?? []).map(runCommand),
...(skills.data?.data ?? []).filter((skill) => skill.slash !== false).map(runSkill),
...commands.data.map(runCommand),
...skills.data.filter((skill) => skill.slash !== false).map(runSkill),
]
}
export async function loadRunReferences(sdk: OpencodeClient, directory: string): Promise<RunReference[]> {
const result = await sdk.v2.reference.list(location(directory), { throwOnError: true })
return (result.data?.data ?? []).filter((reference) => !reference.hidden)
export async function loadRunReferences(sdk: OpenCodeClient, directory: string): Promise<RunReference[]> {
const result = await sdk.reference.list(location(directory))
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([
sdk.v2.provider.list(location(directory), { throwOnError: true }),
sdk.v2.model.list(location(directory), { throwOnError: true }),
sdk.provider.list(location(directory)),
sdk.model.list(location(directory)),
])
return runProviders(providers.data?.data ?? [], models.data?.data ?? [])
return runProviders([...providers.data], [...models.data])
}

View file

@ -678,7 +678,7 @@ function emitTask(state: State): void {
state: {
status: "running",
input: {
filePath: "packages/opencode/src/cli/cmd/run/stream.ts",
filePath: "packages/cli/src/mini/stream.ts",
offset: 1,
limit: 200,
},

View file

@ -3,7 +3,7 @@ import { TextAttributes, type ColorInput } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
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

View file

@ -12,7 +12,7 @@ import { normalizePromptContent } from "@opencode-ai/tui/prompt/content"
import fuzzysort from "fuzzysort"
import path from "path"
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 {
createPromptHistory,
displayCharAt,
@ -67,7 +67,7 @@ type PromptInput = {
prompt: Accessor<boolean>
width: Accessor<number>
theme: Accessor<RunFooterTheme>
history?: RunPrompt[]
history?: Accessor<RunPrompt[]>
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void
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"')])
})
let history = createPromptHistory(input.history)
let history = createPromptHistory(input.history?.())
createEffect(() => {
history = createPromptHistory(input.history?.())
})
let draft: RunPrompt = { text: "", parts: [] }
let stash: RunPrompt = { text: "", parts: [] }
let area: TextareaRenderable | undefined

View file

@ -203,6 +203,8 @@ export class RunFooter implements FooterApi {
private setSubagent: (next: FooterSubagentState) => void
private queuedPrompts: Accessor<FooterQueuedPrompt[]>
private setQueuedPrompts: Setter<FooterQueuedPrompt[]>
private history: Accessor<RunPrompt[]>
private setHistory: Setter<RunPrompt[]>
private promptRoute: FooterPromptRoute = { type: "composer" }
private subagentMenuRows = SUBAGENT_ROWS
private autocomplete = false
@ -289,6 +291,9 @@ export class RunFooter implements FooterApi {
const [queuedPrompts, setQueuedPrompts] = createSignal<FooterQueuedPrompt[]>([])
this.queuedPrompts = queuedPrompts
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.scrollback = this.createScrollback(options.wrote ?? false)
@ -322,7 +327,7 @@ export class RunFooter implements FooterApi {
diffStyle: options.diffStyle,
tuiConfig: options.tuiConfig,
backgroundSubagents: options.backgroundSubagents,
history: options.history,
history: footer.history,
agent: options.agentLabel,
onSubmit: footer.handlePrompt,
onPermissionReply: footer.handlePermissionReply,
@ -390,6 +395,11 @@ export class RunFooter implements FooterApi {
}
public event(next: FooterEvent): void {
if (next.type === "history") {
this.setHistory(next.history)
return
}
if (next.type === "model") {
this.setCurrentModel(next.selection)
}

View file

@ -88,7 +88,7 @@ type RunFooterViewProps = {
diffStyle?: RunDiffStyle
tuiConfig: RunTuiConfig
backgroundSubagents: boolean
history?: RunPrompt[]
history?: () => RunPrompt[]
agent: string
onSubmit: (input: RunPrompt) => boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void>

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"

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)
}

View file

@ -1,15 +1,17 @@
import type {
OpencodeClient,
EventSubscribeOutput,
OpenCodeClient,
} from "@opencode-ai/client/promise"
import type {
ReasoningPart,
StepFinishPart,
StepStartPart,
TextPart,
ToolPart,
V2Event,
} from "@opencode-ai/sdk/v2"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
import { MessageID } from "@/session/schema"
import { UI } from "../../ui"
import { UI } from "./ui"
type Model = {
providerID: string
@ -23,7 +25,7 @@ type File = {
}
type Input = {
client: OpencodeClient
client: OpenCodeClient
sessionID: string
message: string
files: File[]
@ -52,6 +54,7 @@ type ToolState = StartedPart & {
provider?: unknown
}
type V2Event = EventSubscribeOutput
type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
// 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) {
const controller = new AbortController()
const events = await input.client.v2.event.subscribe({
signal: controller.signal,
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const stream = events.stream[Symbol.asyncIterator]() as AsyncGenerator<V2Event>
const stream = input.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
const connected = await stream.next()
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 tools = new Map<string, ToolState>()
let submitted = false
@ -101,7 +99,7 @@ export async function runNonInteractivePrompt(input: Input) {
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) {
permissionRejected = true
UI.println(
@ -110,7 +108,7 @@ export async function runNonInteractivePrompt(input: Input) {
`permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`,
)
}
await input.client.v2.session.permission
await input.client.permission
.reply({
sessionID: input.sessionID,
requestID: request.id,
@ -118,18 +116,18 @@ export async function runNonInteractivePrompt(input: Input) {
})
.catch(() => {})
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 }) => {
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">) => {
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 () => {
@ -156,7 +154,7 @@ export async function runNonInteractivePrompt(input: Input) {
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.data.inputID === messageID) {
@ -367,34 +365,31 @@ export async function runNonInteractivePrompt(input: Input) {
interrupted = true
process.exitCode = 130
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)
let completed: Promise<void> | undefined
try {
if (input.agent) {
await input.client.v2.session.switchAgent(
{ sessionID: input.sessionID, agent: input.agent },
{ throwOnError: true },
)
await input.client.session.switchAgent({ sessionID: input.sessionID, agent: input.agent })
}
const selected = input.model
? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant }
: input.variant
? await input.client.v2.session
.get({ sessionID: input.sessionID }, { throwOnError: true })
.then((result) => result.data.data.model)
? await input.client.session
.get({ sessionID: input.sessionID })
.then((result) => result.model)
.then(async (model) => {
if (model) return { ...model, variant: input.variant }
const result = await input.client.v2.model.default(undefined, { throwOnError: true })
const fallback = result.data.data
const result = await input.client.model.default()
const fallback = result.data
return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined
})
: undefined
if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
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))
@ -402,7 +397,7 @@ export async function runNonInteractivePrompt(input: Input) {
submitted = true
completed = consume()
admission = new AbortController()
const response = await input.client.v2.session
const response = await input.client.session
.prompt(
{
sessionID: input.sessionID,
@ -413,11 +408,11 @@ export async function runNonInteractivePrompt(input: Input) {
},
delivery: "steer",
},
{ throwOnError: true, signal: admission.signal },
{ signal: admission.signal },
)
.catch(async (error) => {
if (interrupted) {
await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
controller.abort()
await completed?.catch(() => {})
@ -426,22 +421,21 @@ export async function runNonInteractivePrompt(input: Input) {
})
admission = undefined
if (!response) return
if (!response.data.data) throw new Error("Prompt was not admitted")
if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
const [permissions, questions, forms] = await Promise.all([
input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined),
Promise.all(
(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([
...(permissions?.data?.data ?? []).map(replyPermission),
...(questions?.data?.data ?? []).map(rejectQuestion),
...forms.flatMap((response) => response?.data?.data ?? []).map(cancelForm),
...(permissions ?? []).map(replyPermission),
...(questions ?? []).map(rejectQuestion),
...forms.flatMap((response) => response ?? []).map(cancelForm),
])
await completed
} finally {

View file

@ -7,7 +7,7 @@
// 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
// 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"
const HISTORY_LIMIT = 200

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)
}

View file

@ -7,12 +7,10 @@
// none block each other.
import { Context, Effect, Layer } from "effect"
import { resolve } from "@opencode-ai/tui/config"
import { TuiConfig } from "@/config/tui"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
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 { reusePendingTask } from "./runtime.shared"
import { resolveCurrentSession, sessionHistory } from "./session.shared"
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
import { pickVariant } from "./variant.shared"
@ -30,7 +28,6 @@ export type SessionInfo = {
variant: string | undefined
}
type Config = Awaited<ReturnType<typeof TuiConfig.get>>
type BootService = {
readonly resolveModelInfo: (
sdk: RunInput["sdk"],
@ -42,18 +39,10 @@ type BootService = {
sessionID: string,
model: RunInput["model"],
) => 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") {}
function loadConfig() {
return reusePendingTask(configTask, () => TuiConfig.get())
}
function emptyModelInfo(): ModelInfo {
return {
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(
Service,
Effect.gen(function* () {
const config = Effect.fn("RunBoot.config")(() => Effect.promise(() => loadConfig().catch(() => undefined)))
const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (
sdk: RunInput["sdk"],
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({
resolveModelInfo,
resolveSessionInfo,
resolveRunTuiConfig,
resolveDiffStyle,
})
}),
)
@ -190,10 +155,12 @@ export async function resolveSessionInfo(
}
// Reads TUI config once for direct mode keymap setup and display preferences.
export async function resolveRunTuiConfig(): Promise<RunTuiConfig> {
return runtime.runPromise((svc) => svc.resolveRunTuiConfig()).catch(() => defaultRunTuiConfig())
export async function resolveRunTuiConfig(
config?: RunTuiConfig | Promise<RunTuiConfig>,
): Promise<RunTuiConfig> {
return Promise.resolve(config).then((value) => value ?? defaultRunTuiConfig()).catch(() => defaultRunTuiConfig())
}
export async function resolveDiffStyle(): Promise<RunDiffStyle> {
return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto")
export async function resolveDiffStyle(config?: RunTuiConfig | Promise<RunTuiConfig>): Promise<RunDiffStyle> {
return resolveRunTuiConfig(config).then((value) => value.diff_style ?? "auto")
}

View file

@ -13,8 +13,8 @@ import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWr
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Global } from "@opencode-ai/core/global"
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { isDefaultTitle } from "@/session/title"
import * as Locale from "@/util/locale"
import { isDefaultTitle } from "@opencode-ai/tui/util/session"
import { Locale } from "@opencode-ai/tui/util/locale"
import { resolveInteractiveStdin } from "./runtime.stdin"
import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme"

View file

@ -8,8 +8,9 @@
// and tracks per-turn wall-clock duration for the footer status line.
//
// Resolves when the footer closes and all in-flight work finishes.
import * as Locale from "@/util/locale"
import { MessageID, PartID } from "@/session/schema"
import { ascending } from "@opencode-ai/schema/identifier"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Locale } from "@opencode-ai/tui/util/locale"
import { isExitCommand, isNewCommand } from "./prompt.shared"
import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types"
@ -167,7 +168,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
? prompt
: {
...prompt,
messageID: prompt.messageID ?? queued?.messageID ?? MessageID.ascending(),
messageID: prompt.messageID ?? queued?.messageID ?? SessionMessage.ID.create(),
}
state.active = sent
@ -285,8 +286,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
!isNewCommand(prompt.text)
) {
const queued: FooterQueuedPrompt = {
messageID: MessageID.ascending(),
partID: PartID.ascending(),
messageID: SessionMessage.ID.create(),
partID: "prt_" + ascending(),
prompt,
}
state.queued = [...state.queued, queued]

View file

@ -4,7 +4,7 @@
// and prompt queue together into a single session loop. Two entry points:
//
// 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:
// 1. resolves TUI config, model info, and session history,
@ -12,15 +12,22 @@
// 3. starts the stream transport (SDK event subscription), lazily for fresh
// local sessions,
// 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 { MessageID } from "@/session/schema"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { trace } from "./trace"
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 */
export { pickVariant, resolveVariant } from "./variant.shared"
@ -44,9 +51,7 @@ type CreateSession = (sdk: RunInput["sdk"], input: CreateSessionInput) => Promis
type RunRuntimeInput = {
boot: () => Promise<BootContext>
afterPaint?: (ctx: BootContext) => Promise<void> | void
resolveSession?: (
ctx: BootContext,
) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }>
resolveSession?: (ctx: BootContext) => Promise<ResolvedSession>
createSession?: (ctx: BootContext, input: CreateSessionInput) => Promise<ResolvedSession>
files: RunInput["files"]
initialInput?: string
@ -55,13 +60,14 @@ type RunRuntimeInput = {
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
}
type RunLocalInput = {
type RunDeferredInput = {
sdk: RunInput["sdk"]
directory: string
fetch: typeof globalThis.fetch
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
agent: RunInput["agent"]
model: RunInput["model"]
@ -73,6 +79,7 @@ type RunLocalInput = {
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
}
type StreamTransportModule = Pick<
@ -96,6 +103,7 @@ type ResolvedSession = {
sessionID: string
sessionTitle?: string
agent?: string | undefined
resume?: boolean
}
function createSessionResolver(fn?: CreateSession) {
@ -165,9 +173,9 @@ async function resolveExitTitle(
return undefined
}
return ctx.sdk.v2.session
return ctx.sdk.session
.get({ sessionID: state.sessionID })
.then((x) => x.data?.data.title)
.then((session) => session.title)
.catch(() => undefined)
}
@ -180,7 +188,7 @@ async function resolveExitTitle(
async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
const start = performance.now()
const log = trace()
const tuiConfigTask = resolveRunTuiConfig()
const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig)
const ctx = await input.boot()
const sessionTask =
ctx.resume === true
@ -247,9 +255,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
directory: ctx.directory,
findFiles: (query) =>
ctx.sdk.find
.files({ query, directory: ctx.directory })
.then((x) => x.data ?? [])
ctx.sdk.file
.find({ query, type: "file", location: { directory: ctx.directory } })
.then((result) => result.data.map((file) => file.path))
.catch(() => []),
agents: [],
references: [],
@ -257,7 +265,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
sessionTitle: state.sessionTitle,
getSessionID: () => state.sessionID,
first: session.first,
history: session.history,
history: state.history,
agent: state.agent,
model: state.model,
variant: state.activeVariant,
@ -269,17 +277,17 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
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) => {
if (state.demo?.questionReply(next)) {
return
}
await ctx.sdk.v2.session.question.reply({
await ctx.sdk.question.reply({
sessionID: state.sessionID,
requestID: next.requestID,
questionV2Reply: { answers: next.answers ?? [] },
answers: next.answers ?? [],
})
},
onQuestionReject: async (next) => {
@ -287,7 +295,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return
}
await ctx.sdk.v2.session.question.reject({ sessionID: state.sessionID, ...next })
await ctx.sdk.question.reject({ sessionID: state.sessionID, ...next })
},
onCycleVariant: () => {
if (!state.model || state.variants.length === 0) {
@ -369,7 +377,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
void (
state.stream
? state.stream.then((item) => item.handle.interruptActiveTurn())
: ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID })
: ctx.sdk.session.interrupt({ sessionID: state.sessionID })
)
.catch(() => {})
.finally(() => {
@ -383,11 +391,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
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) => {
log?.write("send.subagent.interrupt", { sessionID })
void ctx.sdk.v2.session.interrupt({ sessionID }).catch(() => {})
void ctx.sdk.session.interrupt({ sessionID }).catch(() => {})
},
onSubagentSelect: (sessionID) => {
state.selectSubagent?.(sessionID)
@ -398,7 +406,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
})
const footer = shell.footer
const firstPaint = footer.idle().catch(() => {})
const modelTask = firstPaint.then(() => (footer.isClosed ? undefined : loadModel()))
const ensureSession = () => {
if (!input.resolveSession || state.sessionID) {
return Promise.resolve()
@ -408,13 +415,33 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return state.session
}
state.session = input.resolveSession(ctx).then((next) => {
state.session = input.resolveSession(ctx).then(async (next) => {
state.sessionID = next.sessionID
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
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
}
const modelTask = firstPaint.then(async () => {
if (footer.isClosed) return
await ensureSession()
if (footer.isClosed) return
return loadModel()
})
const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => {
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 () => {
await firstPaint
if (footer.isClosed) return
await ensureSession()
if (footer.isClosed) return
await modelTask
if (footer.isClosed) return
let includeFiles = true
if (state.demo) {
await state.demo.start()
@ -728,7 +759,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
text: error instanceof Error ? error.message : String(error),
phase: "start",
source: "system",
messageID: MessageID.ascending(),
messageID: SessionMessage.ID.create(),
} as const
rememberLocal(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
// the in-process server, so no external HTTP server is needed.
export async function runInteractiveLocalMode(input: RunLocalInput): Promise<void> {
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
fetch: input.fetch,
directory: input.directory,
})
// Deferred mode paints before session resolution. The caller may back the
// generated client with a transport that is still acquiring a daemon.
export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: RunRuntimeDeps): Promise<void> {
const sdk = input.sdk
let session: Promise<ResolvedSession> | undefined
return runInteractiveRuntime({
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
resolveSession: () => {
if (session) {
return runInteractiveRuntime(
{
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
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
}
session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => {
if (!next?.id) {
throw new Error("Session not found")
}
},
createSession: createSessionResolver(input.createSession),
boot: async () => {
return {
sessionID: next.id,
sessionTitle: next.title,
agent,
sdk,
directory: input.directory,
sessionID: "",
sessionTitle: undefined,
resume: false,
agent: input.agent,
model: input.model,
variant: input.variant,
}
})
return session
},
},
createSession: createSessionResolver(input.createSession),
boot: async () => {
return {
sdk,
directory: input.directory,
sessionID: "",
sessionTitle: undefined,
resume: false,
agent: input.agent,
model: input.model,
variant: input.variant,
}
},
})
deps,
)
}
// Attach mode. Uses the caller-provided SDK client directly.
export async function runInteractiveMode(
input: RunInput & { createSession?: CreateSession },
input: RunInput & { createSession?: CreateSession; tuiConfig?: RunTuiConfig | Promise<RunTuiConfig> },
deps?: RunRuntimeDeps,
): Promise<void> {
return runInteractiveRuntime(
@ -900,6 +932,7 @@ export async function runInteractiveMode(
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
boot: async () => ({
sdk: input.sdk,
directory: input.directory,

View file

@ -25,7 +25,7 @@
// event arrives, the queue entry is removed and the footer falls back
// to the next pending request or to the prompt view.
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 type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"

View file

@ -4,11 +4,12 @@
// the prompt history ring. Also finds the most recently used variant for
// the current model so the footer can pre-select it.
import { promptCopy, promptSame } from "./prompt.shared"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import type { RunInput, RunPrompt } from "./types"
const LIMIT = 200
export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
export type SessionMessages = Array<{ info: Message; parts: Part[] }>
type Turn = {
prompt: RunPrompt
@ -160,10 +161,10 @@ export async function resolveCurrentSession(
limit = LIMIT,
): Promise<RunSession> {
const [response, session] = await Promise.all([
sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true }),
sdk.v2.session.get({ sessionID }, { throwOnError: true }),
sdk.message.list({ sessionID, limit, order: "desc" }),
sdk.session.get({ sessionID }),
])
const messages = response.data.data.toReversed()
const messages = response.data.toReversed()
return {
first: messages.length === 0,
turns: messages.flatMap((message) => {
@ -195,18 +196,18 @@ export async function resolveCurrentSession(
})),
],
},
provider: session.data.data.model?.providerID,
model: session.data.data.model?.id,
variant: session.data.data.model?.variant,
provider: session.model?.providerID,
model: session.model?.id,
variant: session.model?.variant,
},
]
}),
...(session.data.data.model && {
...(session.model && {
model: {
providerID: session.data.data.model.providerID,
modelID: session.data.data.model.id,
providerID: session.model.providerID,
modelID: session.model.id,
},
variant: session.data.data.model.variant,
variant: session.model.variant,
}),
}
}

View file

@ -17,8 +17,8 @@ import {
type ScrollbackSnapshot,
type ScrollbackWriter,
} from "@opentui/core"
import * as Locale from "@/util/locale"
import { go } from "@/cli/logo"
import { Locale } from "@opencode-ai/tui/util/locale"
import { go } from "@opencode-ai/tui/logo"
import type { RunSplashTheme } from "./theme"
export const SPLASH_TITLE_LIMIT = 50

View file

@ -15,14 +15,9 @@
// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
// backgrounding is intentionally absent: subagent jobs block the parent
// session, so only whole-session `v2.session.background(parentID)` exists.
import type {
OpencodeClient,
SessionMessage,
SessionMessageAssistantTool,
ToolPart,
V2Event,
} from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { SessionMessage, SessionMessageAssistantTool, ToolPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@opencode-ai/tui/util/locale"
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
const CHILD_MESSAGE_LIMIT = 80
@ -31,6 +26,8 @@ const CHILD_EVENT_BUFFER_LIMIT = 64
const FAMILY_LIST_LIMIT = 100
const FALLBACK_LABEL = "Subagent"
type V2Event = EventSubscribeOutput
export function outputText(content: ReadonlyArray<{ type: string; text?: string }>) {
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
}
@ -165,7 +162,7 @@ type ChildState = {
}
export type SubagentTrackerInput = {
sdk: OpencodeClient
sdk: OpenCodeClient
sessionID: string
thinking: boolean
emit: () => void
@ -390,8 +387,8 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const pendingPrompts = new Map(child.prompts)
const pendingTools = new Map(child.tools)
let retry = false
const task = input.sdk.v2.session
.messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true })
const task = input.sdk.message
.list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" })
.then((response) => {
const buffered = hydrationEvents.get(child.sessionID) ?? []
hydrationEvents.delete(child.sessionID)
@ -404,7 +401,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
for (const [id, prompt] of pendingPrompts) {
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) {
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
checked.add(sessionID)
if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, [])
void input.sdk.v2.session
.get({ sessionID }, { throwOnError: true })
.then((response) => {
const session = response.data.data
void input.sdk.session
.get({ sessionID })
.then((session) => {
const buffered = pendingEvents.get(sessionID) ?? []
pendingEvents.delete(sessionID)
if (session.parentID !== input.sessionID) return
@ -558,14 +554,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
})
childTool(
child,
{
structuredClone({
type: "tool",
id: event.data.callID,
name: event.data.tool,
provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.created, ran: event.created },
},
}) as SessionMessageAssistantTool,
event.data.assistantMessageID,
)
touch(child, event.created)
@ -578,7 +574,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const failed = event.type === "session.tool.failed"
childTool(
child,
{
structuredClone({
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
@ -605,7 +601,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
ran: current?.started,
completed: event.created,
},
},
}) as SessionMessageAssistantTool,
event.data.assistantMessageID,
)
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
// historical subagents beyond the projected message window still get tabs.
const family = await input.sdk.v2.session
.list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true })
.then((response) => response.data.data)
const family = await input.sdk.session
.list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" })
.then((response) => response.data)
.catch(() => [])
for (const session of family) {
const child = ensureChild(session.id)

View file

@ -1,13 +1,13 @@
import type {
OpencodeClient,
EventSubscribeOutput,
OpenCodeClient,
} from "@opencode-ai/client/promise"
import type {
PermissionRequest,
PermissionV2Request,
QuestionRequest,
QuestionV2Request,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantTool,
V2Event,
} from "@opencode-ai/sdk/v2"
import { Event } from "@opencode-ai/schema/event"
import { blockerStatus, pickBlockerView } from "./session-data"
@ -31,7 +31,7 @@ type Trace = {
}
type StreamInput = {
sdk: OpencodeClient
sdk: OpenCodeClient
directory?: string
sessionID: string
thinking: boolean
@ -90,7 +90,9 @@ type ShellWait = {
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 ToolState = {
@ -144,9 +146,9 @@ function permission(request: PermissionV2Request): PermissionRequest {
id: request.id,
sessionID: request.sessionID,
permission: request.action,
patterns: request.resources,
patterns: [...request.resources],
metadata: request.metadata ?? {},
always: request.save ?? [],
always: [...(request.save ?? [])],
tool: request.source?.type === "tool" ? request.source : undefined,
}
}
@ -155,7 +157,7 @@ function question(request: QuestionV2Request): QuestionRequest {
return {
id: request.id,
sessionID: request.sessionID,
questions: request.questions,
questions: request.questions.map((item) => ({ ...item, options: item.options.map((option) => ({ ...option })) })),
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.variant) return
const session = await input.sdk.v2.session
.get({ sessionID: input.sessionID }, { throwOnError: true, signal: next.signal })
.then((response) => response.data.data.model)
const session = await input.sdk.session
.get({ sessionID: input.sessionID }, { signal: next.signal })
.then((response) => response.model)
if (session) return { ...session, variant: next.variant }
const fallback = await input.sdk.v2.model
.default(undefined, { throwOnError: true, signal: next.signal })
.then((response) => response.data.data)
const fallback = await input.sdk.model
.default(undefined, { signal: next.signal })
.then((response) => response.data)
if (!fallback) return
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 [messages, permissions, questions, active] = await Promise.all([
input.sdk.v2.session.messages(
{ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" },
{ throwOnError: true },
),
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 }),
input.sdk.message.list({ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }),
input.sdk.permission.list({ sessionID: input.sessionID }),
input.sdk.question.list({ sessionID: input.sessionID }),
input.sdk.session.active(),
])
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)
state.permissions = permissions.data.data.map(permission)
state.questions = questions.data.data.map(question)
state.permissions = permissions.map(permission)
state.questions = questions.map(question)
syncBlockers()
await subagents.hydrate({ messages: projected, active: active.data.data })
const running = input.sessionID in active.data.data
await subagents.hydrate({ messages: [...projected], active: active.data })
const running = input.sessionID in active.data
write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" })
if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) {
const current = state.wait
@ -721,14 +720,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (event.type === "session.tool.called") {
if (state.finishedTools.has(event.data.callID)) return
const current = state.tools.get(event.data.callID)
const item: SessionMessageAssistantTool = {
const item = structuredClone({
type: "tool",
id: event.data.callID,
name: event.data.tool,
provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.created, ran: event.created },
}
}) as SessionMessageAssistantTool
renderTool(event.data.assistantMessageID, item)
return
}
@ -736,7 +735,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
const current = state.tools.get(event.data.callID)
const failed = event.type === "session.tool.failed"
const item: SessionMessageAssistantTool = {
const item = structuredClone({
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
@ -759,7 +758,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
result: event.data.result,
},
time: { created: current?.started ?? event.created, ran: current?.started, completed: event.created },
}
}) as SessionMessageAssistantTool
renderTool(event.data.assistantMessageID, item)
return
}
@ -838,12 +837,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const connection = new AbortController()
const abortConnection = () => connection.abort()
controller.signal.addEventListener("abort", abortConnection, { once: true })
const response = await input.sdk.v2.event.subscribe({
signal: connection.signal,
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator<RunV2Event>
const stream = input.sdk.event.subscribe({ signal: connection.signal })[Symbol.asyncIterator]()
try {
const first = await stream.next()
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 })
write([], { phase: "running", status: "running shell" })
try {
await input.sdk.v2.session.shell(
await input.sdk.session.shell(
{ 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)])
} catch (error) {
@ -950,7 +944,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.wait = active
const interrupt = () => {
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 })
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 })
await runTurnWait(next, messageID, {
send: () =>
input.sdk.v2.session.skill(
input.sdk.session.skill(
{ sessionID: input.sessionID, id: messageID, skill: command.name },
{ throwOnError: true, signal: next.signal },
{ signal: next.signal },
),
})
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 })
await runTurnWait(next, messageID, {
send: () =>
input.sdk.v2.session.command(
input.sdk.session.command(
{
sessionID: input.sessionID,
id: messageID,
@ -1013,24 +1007,24 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
agents: agents.length ? agents : undefined,
delivery: "steer",
},
{ throwOnError: true, signal: next.signal },
{ signal: next.signal },
),
})
return
}
if (next.agent) {
await input.sdk.v2.session.switchAgent(
await input.sdk.session.switchAgent(
{ sessionID: input.sessionID, agent: next.agent },
{ throwOnError: true, signal: next.signal },
{ signal: next.signal },
)
}
const selected = await resolveSelectedModel(input, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected)
await input.sdk.v2.session.switchModel(
await input.sdk.session.switchModel(
{ sessionID: input.sessionID, model: selected },
{ throwOnError: true, signal: next.signal },
{ signal: next.signal },
)
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 })
await runTurnWait(next, messageID, {
send: () =>
input.sdk.v2.session.prompt(
input.sdk.session.prompt(
{
sessionID: input.sessionID,
id: messageID,
@ -1053,7 +1047,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
},
delivery: "steer",
},
{ throwOnError: true, signal: next.signal },
{ signal: next.signal },
),
})
},
@ -1067,7 +1061,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
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) {
subagents.select(sessionID)

View file

@ -16,25 +16,8 @@ import os from "os"
import path from "path"
import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import type * as Tool from "@/tool/tool"
import type { ApplyPatchTool } from "@/tool/apply_patch"
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 { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype"
import { Locale } from "@opencode-ai/tui/util/locale"
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
export type ToolView = {
@ -47,6 +30,46 @@ export type ToolPhase = "start" | "progress" | "final"
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 = {
raw: string
name: string
@ -73,15 +96,15 @@ export type ToolPermissionInfo = {
file?: string
}
export type ToolProps<T = Tool.Info> = {
input: Partial<Tool.InferParameters<T>>
metadata: Partial<Tool.InferMetadata<T>>
export type ToolProps = {
input: ToolInput
metadata: ToolMetadata
frame: ToolFrame
}
type ToolPermissionProps<T = Tool.Info> = {
input: Partial<Tool.InferParameters<T>>
metadata: Partial<Tool.InferMetadata<T>>
type ToolPermissionProps = {
input: ToolInput
metadata: ToolMetadata
patterns: string[]
}
@ -91,40 +114,35 @@ type ToolPermissionCtx = {
patterns: string[]
}
type ToolDefs = {
invalid: typeof InvalidTool
bash: typeof BashTool
write: typeof WriteTool
edit: typeof EditTool
apply_patch: typeof ApplyPatchTool
batch: Tool.Info
task: typeof TaskTool
todowrite: typeof TodoWriteTool
question: typeof QuestionTool
read: typeof ReadTool
glob: typeof GlobTool
grep: typeof GrepTool
list: Tool.Info
lsp: typeof LspTool
webfetch: typeof WebFetchTool
websearch: typeof WebSearchTool
skill: typeof SkillTool
plan_exit: typeof PlanExitTool
}
type ToolName =
| "invalid"
| "bash"
| "write"
| "edit"
| "apply_patch"
| "batch"
| "task"
| "todowrite"
| "question"
| "read"
| "glob"
| "grep"
| "list"
| "lsp"
| "webfetch"
| "websearch"
| "skill"
| "plan_exit"
type ToolName = keyof ToolDefs
type ToolRule<T = Tool.Info> = {
type ToolRule = {
view: ToolView
run: (props: ToolProps<T>) => ToolInline
scroll?: Partial<Record<ToolPhase, (props: ToolProps<T>) => string>>
permission?: (props: ToolPermissionProps<T>) => ToolPermissionInfo
snap?: (props: ToolProps<T>) => ToolSnapshot | undefined
run: (props: ToolProps) => ToolInline
scroll?: Partial<Record<ToolPhase, (props: ToolProps) => string>>
permission?: (props: ToolPermissionProps) => ToolPermissionInfo
snap?: (props: ToolProps) => ToolSnapshot | undefined
}
type ToolRegistry = {
[K in ToolName]: ToolRule<ToolDefs[K]>
}
type ToolRegistry = Record<ToolName, ToolRule>
type AnyToolRule = ToolRule
@ -136,22 +154,28 @@ function dict(v: unknown): ToolDict {
return { ...v }
}
function props<T = Tool.Info>(frame: ToolFrame): ToolProps<T> {
function props(frame: ToolFrame): ToolProps {
return {
input: Object.assign(Object.create(null), frame.input),
metadata: Object.assign(Object.create(null), frame.meta),
input: frame.input,
metadata: frame.meta,
frame,
}
}
function permission<T = Tool.Info>(ctx: ToolPermissionCtx): ToolPermissionProps<T> {
function permission(ctx: ToolPermissionCtx): ToolPermissionProps {
return {
input: Object.assign(Object.create(null), ctx.input),
metadata: Object.assign(Object.create(null), ctx.meta),
input: ctx.input,
metadata: ctx.meta,
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 {
return typeof v === "string" ? v : ""
}
@ -285,7 +309,7 @@ function count(n: number, label: string): string {
return `${n} ${label}${n === 1 ? "" : "es"}`
}
function runGlob(p: ToolProps<typeof GlobTool>): ToolInline {
function runGlob(p: ToolProps): ToolInline {
const root = p.input.path ?? ""
const title = `Glob "${p.input.pattern ?? ""}"`
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 title = `Grep "${p.input.pattern ?? ""}"`
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 description = info(p.frame.input, ["filePath"]) || undefined
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 {
icon: "←",
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 ?? ""
return {
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 {
icon: "←",
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)
return {
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 desc = p.input.description
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 {
icon: "#",
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 {
icon: "→",
title: `Skill "${p.input.name ?? ""}"`,
}
}
function runPatch(p: ToolProps<typeof ApplyPatchTool>): ToolInline {
function runPatch(p: ToolProps): ToolInline {
const files = p.metadata.files?.length ?? 0
if (files === 0) {
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
return {
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 {
icon: "✗",
title: text(p.frame.state.title) || "Invalid Tool",
@ -463,14 +487,14 @@ function lspTitle(
return `LSP ${op} ${file}${pos}`
}
function runLsp(p: ToolProps<typeof LspTool>): ToolInline {
function runLsp(p: ToolProps): ToolInline {
return {
icon: "→",
title: text(p.frame.state.title) || lspTitle(p.input),
}
}
function runPlanExit(p: ToolProps<typeof PlanExitTool>): ToolInline {
function runPlanExit(p: ToolProps): ToolInline {
return {
icon: "→",
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 {
const rel = file.relativePath
const from = file.filePath
@ -497,7 +519,7 @@ function patchTitle(file: PatchFile): string {
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 content = p.input.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 diff = p.metadata.diff || ""
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)
if (files.length === 0) {
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 desc = p.input.description
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 content = typeof item?.content === "string" ? item.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 items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => {
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 wd = p.input.workdir ?? ""
const formatted = wd && wd !== "." ? toolPath(wd) : ""
@ -637,7 +659,7 @@ function scrollBashStart(p: ToolProps<typeof BashTool>): string {
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 cmd = (p.input.command ?? "").trim()
const fmt = (text: string) => {
@ -670,7 +692,7 @@ function scrollBashProgress(p: ToolProps<typeof BashTool>): string {
return fmt(out)
}
function scrollBashFinal(p: ToolProps<typeof BashTool>): string {
function scrollBashFinal(p: ToolProps): string {
if (p.frame.status === "error") {
return fail(p.frame)
}
@ -688,22 +710,22 @@ function scrollBashFinal(p: ToolProps<typeof BashTool>): string {
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 extra = info(p.frame.input, ["filePath"])
const tail = extra ? ` ${extra}` : ""
return `→ Read ${file}${tail}`.trim()
}
function scrollWriteStart(_: ToolProps<typeof WriteTool>): string {
function scrollWriteStart(_: ToolProps): string {
return ""
}
function scrollEditStart(_: ToolProps<typeof EditTool>): string {
function scrollEditStart(_: ToolProps): string {
return ""
}
function scrollPatchStart(_: ToolProps<typeof ApplyPatchTool>): string {
function scrollPatchStart(_: ToolProps): string {
return ""
}
@ -727,7 +749,7 @@ function patchLine(file: PatchFile): string {
return `~ Patched ${rel || toolPath(from)}`
}
function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
function scrollPatchFinal(p: ToolProps): string {
if (p.frame.status === "error") {
return fail(p.frame)
}
@ -756,7 +778,7 @@ function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
return patchLine(files[0]!)
}
function scrollTaskStart(_: ToolProps<typeof TaskTool>): string {
function scrollTaskStart(_: ToolProps): string {
return ""
}
@ -778,7 +800,7 @@ function taskResult(output: string): string | undefined {
return next || undefined
}
function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
function scrollTaskFinal(p: ToolProps): string {
if (p.frame.status === "error") {
return fail(p.frame)
}
@ -792,11 +814,11 @@ function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
return `# ${kind} Task\n${row}`
}
function scrollTodoStart(_: ToolProps<typeof TodoWriteTool>): string {
function scrollTodoStart(_: ToolProps): string {
return ""
}
function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {
function scrollTodoFinal(p: ToolProps): string {
const items = list<{ status?: string }>(p.input.todos)
const time = span(p.frame.state)
if (items.length === 0) {
@ -828,11 +850,11 @@ function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {
return tail.join(" · ")
}
function scrollQuestionStart(_: ToolProps<typeof QuestionTool>): string {
function scrollQuestionStart(_: ToolProps): string {
return ""
}
function scrollQuestionFinal(p: ToolProps<typeof QuestionTool>): string {
function scrollQuestionFinal(p: ToolProps): string {
const q = p.input.questions ?? []
const a = p.metadata.answers ?? []
const time = span(p.frame.state)
@ -859,15 +881,15 @@ function scrollQuestionFinal(p: ToolProps<typeof QuestionTool>): string {
return rows.join("\n")
}
function scrollLspStart(p: ToolProps<typeof LspTool>): string {
function scrollLspStart(p: ToolProps): string {
return `${lspTitle(p.input)}`
}
function scrollSkillStart(p: ToolProps<typeof SkillTool>): string {
function scrollSkillStart(p: ToolProps): string {
return `→ Skill "${p.input.name ?? ""}"`
}
function scrollGlobStart(p: ToolProps<typeof GlobTool>): string {
function scrollGlobStart(p: ToolProps): string {
const pattern = p.input.pattern ?? ""
const head = pattern ? `✱ Glob "${pattern}"` : "✱ Glob"
const dir = p.input.path ?? ""
@ -878,11 +900,11 @@ function scrollGlobStart(p: ToolProps<typeof GlobTool>): string {
return `${head} in ${toolPath(dir)}`
}
function scrollGlobFinal(p: ToolProps<typeof GlobTool>): string {
function scrollGlobFinal(p: ToolProps): string {
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 head = pattern ? `✱ Grep "${pattern}"` : "✱ Grep"
const dir = p.input.path ?? ""
@ -902,7 +924,7 @@ function scrollListStart(p: ToolProps): string {
return `→ List ${toolPath(dir)}`
}
function scrollWebfetchStart(p: ToolProps<typeof WebFetchTool>): string {
function scrollWebfetchStart(p: ToolProps): string {
const url = p.input.url ?? ""
if (!url) {
return "% WebFetch"
@ -911,7 +933,7 @@ function scrollWebfetchStart(p: ToolProps<typeof WebFetchTool>): string {
return `% WebFetch ${url}`
}
function scrollWebSearchStart(p: ToolProps<typeof WebSearchTool>): string {
function scrollWebSearchStart(p: ToolProps): string {
const title = webSearchProviderLabel(p.metadata.provider)
const query = p.input.query ?? ""
if (!query) {
@ -921,7 +943,7 @@ function scrollWebSearchStart(p: ToolProps<typeof WebSearchTool>): string {
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 file = input.filePath || input.filepath || p.patterns[0] || ""
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] || ""
return {
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] || ""
return {
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] || ""
return {
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 || ""
return {
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 desc = p.input.description
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 || ""
return {
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 title = webSearchProviderLabel(p.metadata.provider)
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 line = typeof p.input.line === "number" ? p.input.line : 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 {
icon: "$",
title: p.input.command || "",

View file

@ -11,7 +11,8 @@
// → stream.ts bridges to footer API
// → footer.ts queues commits and patches the footer view
// → 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"
export type RunFilePart = {
@ -21,10 +22,17 @@ export type RunFilePart = {
mime: string
}
type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
type PromptInput = Parameters<OpencodeClient["session"]["prompt"]>[0]
type PromptModel = { providerID: string; modelID: string }
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 = {
name: string
@ -111,12 +119,10 @@ export type RunAgent = {
hidden: boolean
}
export type RunReference = NonNullable<
Awaited<ReturnType<OpencodeClient["v2"]["reference"]["list"]>>["data"]
>["data"][number]
export type RunReference = ReferenceListOutput["data"][number]
export type RunInput = {
sdk: OpencodeClient
sdk: OpenCodeClient
directory: string
sessionID: string
sessionTitle?: string
@ -282,6 +288,10 @@ export type FooterOutput = {
// transport both emit these to update footer state without reaching into
// internal signals directly.
export type FooterEvent =
| {
type: "history"
history: RunPrompt[]
}
| {
type: "catalog"
agents: RunAgent[]
@ -342,11 +352,14 @@ export type FooterEvent =
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">

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"

View file

@ -12,9 +12,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-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 { isRecord } from "@/util/record"
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
import type { RunInput, RunProvider } from "./types"
@ -32,6 +31,10 @@ type VariantRuntime = {
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") {}
function modelKey(provider: string, model: string): string {

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()
})
})
}

View file

@ -9,11 +9,17 @@ import path from "node:path"
const Ready = Schema.Struct({ url: Schema.String })
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 entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
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(),
// Explicit entry wins over anything inherited, so a user-exported
// 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")(
function* () {
const makeTransport = Effect.fn("cli.standalone.transport")(
function* (options: Options) {
const password = randomBytes(32).toString("base64url")
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)
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
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)),
)
export function transport(options: Options = {}) {
return makeTransport(options)
}
export * as Standalone from "./standalone"

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")
}
})
})

View file

@ -31,11 +31,14 @@ export type Options = {
// Read-only lookup: registration file plus health check and version gate.
// Never spawns; escalation to start() is the caller's policy.
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)
if (info === undefined) return undefined
if (options.version !== undefined && info.version !== options.version) return undefined
const found = yield* probe(info)
return found?.transport
return yield* probe(info, options.version)
})
// 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"]
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
yield* Effect.try({
const child = yield* Effect.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 }),
})
return yield* discover(options).pipe(
return yield* discoverLocal(options).pipe(
Effect.flatMap((found) =>
found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found),
),
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")),
)
})
@ -90,6 +104,9 @@ export const Info = Schema.Struct({
export type Info = typeof Info.Type
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
// the same (the registering server self-evicts, clients rediscover).
@ -105,18 +122,23 @@ type LocalService = {
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 healthy = yield* Effect.tryPromise(() =>
const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/health", info.url), {
headers,
signal: AbortSignal.timeout(2_000),
}),
).pipe(
Effect.map((response) => response.ok),
Effect.orElseSucceed(() => false),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
if (response === undefined || !response.ok) return undefined
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
})

View file

@ -157,7 +157,7 @@ export type ProjectCopyError = {
export const isProjectCopyError = (value: unknown): value is 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 = {
readonly location?: {

View file

@ -85,6 +85,7 @@
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/cli": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",

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

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)
}
},
})

View file

@ -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"]
}

View file

@ -19,7 +19,7 @@ import { GithubCommand } from "./cli/cmd/github"
import { ExportCommand } from "./cli/cmd/export"
import { ImportCommand } from "./cli/cmd/import"
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 { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os"
@ -81,7 +81,7 @@ const cli = yargs(args)
.completion("completion", "generate shell completion script")
.command(AcpCommand)
.command(McpCommand)
.command(MiniCommand)
.command(V2ServeCommand)
.command(TuiThreadCommand)
.command(AttachCommand)
.command(RunCommand)

View file

@ -1,6 +1,6 @@
import yargs from "yargs"
import { MiniCommand } from "./cli/cmd/mini"
import { TuiThreadCommand } from "./cli/cmd/tui"
import { V2ServeCommand } from "./cli/cmd/v2-serve"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { hideBin } from "yargs/helpers"
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.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel
})
.command(MiniCommand)
.command(V2ServeCommand)
.command(TuiThreadCommand)
.parse()

View file

@ -41,34 +41,6 @@ Options:
--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`] = `
"opencode attach <url>
@ -100,33 +72,25 @@ Positionals:
message message to send [array] [default: []]
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]
--command the command to run, use message for args [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or --session) [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events)
-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]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or --session) [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events)
[string] [choices: "default", "json"] [default: "default"]
-f, --file file(s) to attach to message [array]
--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]
-p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string]
-u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')
[string]
--dir directory to run in, path on remote server if attaching [string]
--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!)
-f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value provided) [string]
--server connect to a running opencode server [string]
--dir directory to run in, or a path on the remote server [string]
--variant model variant [string]
--thinking show thinking blocks [boolean]
--auto auto-approve permissions that are not explicitly denied (dangerous!)
[boolean] [default: false]"
`;
@ -426,31 +390,6 @@ Options:
--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`] = `
"opencode mcp list

View file

@ -57,7 +57,6 @@ function normalize(text: string): string {
const TOP_LEVEL = [
"acp",
"mcp",
"mini",
"attach",
"run",
"debug",
@ -82,7 +81,6 @@ const TOP_LEVEL = [
// distinct argv shape, not every leaf. Add new entries when a subcommand
// gains user-visible flags that we want to lock in.
const SUBCOMMANDS = [
["mini", "attach"],
["mcp", "list"],
["mcp", "add"],
["mcp", "auth"],
@ -115,7 +113,7 @@ describe("opencode CLI help-text snapshots", () => {
const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV })
expect(topLevel.exitCode).toBe(0)
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("--thinking")
expect(topLevel.stderr).not.toContain("--variant")

View file

@ -1,6 +1,6 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import { loadRunReferences, runProviders, waitForDefaultModel } from "@/cli/cmd/run/catalog.shared"
import { OpenCode } from "@opencode-ai/client/promise"
import { loadRunReferences, runProviders, waitForDefaultModel } from "@opencode-ai/cli/mini/catalog.shared"
afterEach(() => {
mock.restore()
@ -8,20 +8,12 @@ afterEach(() => {
describe("run catalog shared", () => {
test("resolves the catalog-selected model for the footer", async () => {
const client = new OpencodeClient()
const selected = spyOn(client.v2.model, "default").mockImplementation(
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const selected = spyOn(client.model, "default").mockImplementation(
() =>
Promise.resolve({
data: {
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: {
id: "gpt-5",
providerID: "openai",
},
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: { id: "gpt-5", providerID: "openai" },
}) as never,
)
@ -29,17 +21,16 @@ describe("run catalog shared", () => {
providerID: "openai",
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 () => {
const client = new OpencodeClient()
const list = spyOn(client.v2.reference, "list").mockImplementation(
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const list = spyOn(client.reference, "list").mockImplementation(
() =>
Promise.resolve({
data: {
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: [
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: [
{
name: "effect",
path: "/repos/effect",
@ -52,17 +43,13 @@ describe("run catalog shared", () => {
hidden: true,
source: { type: "local", path: "/repos/secret" },
},
],
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
],
}) as never,
)
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" }])
})

View file

@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { entryBody, entryCanStream, entryDone } from "@/cli/cmd/run/entry.body"
import type { StreamCommit, ToolSnapshot } from "@/cli/cmd/run/types"
import { entryBody, entryCanStream, entryDone } from "@opencode-ai/cli/mini/entry.body"
import type { StreamCommit, ToolSnapshot } from "@opencode-ai/cli/mini/types"
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
return input

View file

@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
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) {
let dispose!: () => void

View file

@ -15,10 +15,10 @@ import {
RunSkillSelectBody,
RunSubagentSelectBody,
RunVariantSelectBody,
} from "@/cli/cmd/run/footer.command"
import { RunFooterView } from "@/cli/cmd/run/footer.view"
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme"
} from "@opencode-ai/cli/mini/footer.command"
import { RunFooterView } from "@opencode-ai/cli/mini/footer.view"
import { RunEntryContent } from "@opencode-ai/cli/mini/scrollback.writer"
import { RUN_THEME_FALLBACK, type RunTheme } from "@opencode-ai/cli/mini/theme"
import type {
FooterState,
FooterSubagentState,
@ -30,10 +30,10 @@ import type {
RunProvider,
RunTuiConfig,
StreamCommit,
} from "@/cli/cmd/run/types"
import { RunQuestionBody } from "@/cli/cmd/run/footer.question"
import { selectedCommand } from "@/cli/cmd/run/footer.prompt"
import { RejectField } from "@/cli/cmd/run/footer.permission"
} from "@opencode-ai/cli/mini/types"
import { RunQuestionBody } from "@opencode-ai/cli/mini/footer.question"
import { selectedCommand } from "@opencode-ai/cli/mini/footer.prompt"
import { RejectField } from "@opencode-ai/cli/mini/footer.permission"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
const tuiConfig = createTuiResolvedConfig()

View file

@ -1,5 +1,5 @@
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", () => {
test("preserves shared dialog and statusline breakpoints", () => {

View file

@ -1,16 +1,12 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2"
import { runNonInteractivePrompt } from "@/cli/cmd/run/noninteractive"
import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
import { runNonInteractivePrompt } from "@opencode-ai/cli/mini/noninteractive"
type V2Event = EventSubscribeOutput
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
function ok<T>(data: T) {
return Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
return Promise.resolve(data)
}
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
// live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: FormInfo[]; attached?: boolean }) {
const sdk = new OpencodeClient()
const values: V2Event[] = [{ id: "evt_connected", created: 0, type: "server.connected", data: {} }]
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
let wake: (() => void) | undefined
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
while (true) {
@ -58,22 +54,20 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?:
yield value
}
})()
spyOn(sdk.v2.event, "subscribe").mockImplementation(
() => Promise.resolve({ stream }) as ReturnType<typeof sdk.v2.event.subscribe>,
)
spyOn(sdk.v2.session.permission, "list").mockImplementation(() => ok({ data: [] }) as never)
spyOn(sdk.v2.session.question, "list").mockImplementation(() => ok({ data: [] }) as never)
spyOn(sdk.v2.session.form, "list").mockImplementation(
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.form, "list").mockImplementation(
(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.v2.session, "prompt").mockImplementation((request) => {
spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
values.push(...input.turn(messageID))
wake?.()
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({
client: sdk,
@ -102,9 +96,9 @@ describe("runNonInteractivePrompt", () => {
// which must not leave the consume loop waiting forever.
turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")],
})
expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
expect(sdk.v2.session.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_live" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
})
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")],
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.v2.session.form.list).not.toHaveBeenCalledWith({ sessionID: "global" })
expect(sdk.v2.session.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).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.list).not.toHaveBeenCalledWith({ sessionID: "global" })
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
})
})

View file

@ -8,7 +8,7 @@ import {
permissionInfo,
permissionReject,
permissionRun,
} from "@/cli/cmd/run/permission.shared"
} from "@opencode-ai/cli/mini/permission.shared"
function req(input: Partial<PermissionRequest> = {}): PermissionRequest {
return {

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { realignEditorPromptParts, resolveEditorSlashValue } from "@/cli/cmd/run/prompt.editor"
import type { RunPromptPart } from "@/cli/cmd/run/types"
import { realignEditorPromptParts, resolveEditorSlashValue } from "@opencode-ai/cli/mini/prompt.editor"
import type { RunPromptPart } from "@opencode-ai/cli/mini/types"
describe("run prompt editor helpers", () => {
test("strips the local /editor command from the initial editor text", () => {

View file

@ -5,8 +5,8 @@ import {
isNewCommand,
movePromptHistory,
pushPromptHistory,
} from "@/cli/cmd/run/prompt.shared"
import type { RunPrompt } from "@/cli/cmd/run/types"
} from "@opencode-ai/cli/mini/prompt.shared"
import type { RunPrompt } from "@opencode-ai/cli/mini/types"
function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt {
return { text, parts }

View file

@ -10,7 +10,7 @@ import {
questionStoreCustom,
questionSubmit,
questionSync,
} from "@/cli/cmd/run/question.shared"
} from "@opencode-ai/cli/mini/question.shared"
function req(input: Partial<QuestionRequest> = {}): QuestionRequest {
return {

View file

@ -363,11 +363,11 @@ describe("opencode run (non-interactive subprocess)", () => {
)
cliIt.concurrent(
"applies a variant to the configured default model",
"applies a variant to the selected model",
({ llm, opencode }) =>
Effect.gen(function* () {
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" },
})

View file

@ -1,17 +1,11 @@
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 { TuiConfig } from "@/config/tui"
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@opencode-ai/cli/mini/runtime.boot"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
function ok<T>(data: T) {
return Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
return Promise.resolve(data)
}
function provider(id: string, name: string) {
@ -108,23 +102,21 @@ describe("run runtime boot", () => {
})
test("reads footer keybinds from resolved keybind config", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(
config({
leader: "ctrl+g",
bindings: {
commandList: ["ctrl+p"],
variantCycle: ["ctrl+t", "alt+t"],
interrupt: ["ctrl+c"],
historyPrevious: ["k"],
historyNext: ["j"],
inputClear: ["ctrl+l"],
inputSubmit: ["ctrl+s"],
inputNewline: ["alt+return"],
},
}),
)
const input = config({
leader: "ctrl+g",
bindings: {
commandList: ["ctrl+p"],
variantCycle: ["ctrl+t", "alt+t"],
interrupt: ["ctrl+c"],
historyPrevious: ["k"],
historyNext: ["j"],
inputClear: ["ctrl+l"],
inputSubmit: ["ctrl+s"],
inputNewline: ["alt+return"],
},
})
const result = await resolveRunTuiConfig()
const result = await resolveRunTuiConfig(input)
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
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 () => {
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
const result = await resolveRunTuiConfig()
const result = await resolveRunTuiConfig(Promise.reject(new Error("boom")))
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x")
expect(result.leader_timeout).toBe(2000)
@ -157,31 +147,23 @@ describe("run runtime boot", () => {
})
test("preserves disabled leader from resolved tui config", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(config({ leader: "none" }))
const result = await resolveRunTuiConfig()
const result = await resolveRunTuiConfig(config({ leader: "none" }))
expect(result.keybinds.get("leader")).toEqual([])
})
test("reads diff style and falls back to auto", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(config({ diff_style: "stacked" }))
await expect(resolveDiffStyle()).resolves.toBe("stacked")
await expect(resolveDiffStyle(config({ diff_style: "stacked" }))).resolves.toBe("stacked")
mock.restore()
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
await expect(resolveDiffStyle()).resolves.toBe("auto")
await expect(resolveDiffStyle(Promise.reject(new Error("boom")))).resolves.toBe("auto")
})
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 models = [model("gpt-5", "openai", 128000, ["high", "minimal"])]
// The generated methods have conditional return types for throwOnError; these mocks represent the successful branch.
// @ts-expect-error successful SDK response is valid for both modes at runtime
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 }))
const providerList = spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: [
@ -230,19 +212,15 @@ describe("run runtime boot", () => {
directory: "/workspace",
},
},
{ throwOnError: true },
)
})
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 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.
// @ts-expect-error successful SDK response is valid for both modes at runtime
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 }))
spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: [

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { runPromptQueue } from "@/cli/cmd/run/runtime.queue"
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/run/types"
import { runPromptQueue } from "@opencode-ai/cli/mini/runtime.queue"
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@opencode-ai/cli/mini/types"
function footer() {
const prompts = new Set<(input: RunPrompt) => void>()

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
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) {
return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream

View file

@ -1,7 +1,7 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2"
import { runInteractiveMode } from "@/cli/cmd/run/runtime"
import type { FooterApi, FooterEvent, RunProvider } from "@/cli/cmd/run/types"
import { OpenCode } from "@opencode-ai/client/promise"
import { runInteractiveDeferredMode, runInteractiveMode } from "@opencode-ai/cli/mini/runtime"
import type { FooterApi, FooterEvent, RunProvider } from "@opencode-ai/cli/mini/types"
const provider: RunProvider = {
id: "openai",
@ -45,12 +45,7 @@ function defer<T>() {
}
function ok<T>(data: T) {
return Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
return Promise.resolve(data)
}
function footer(events: FooterEvent[] = []): FooterApi {
@ -110,16 +105,173 @@ afterEach(() => {
})
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 () => {
const providersStarted = defer<void>()
const providers = defer<void>()
const lifecycleModels: unknown[] = []
const sdk = new OpencodeClient()
const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused"))
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 () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(sdk.provider, "list").mockImplementation(async () => {
providersStarted.resolve()
await providers.promise
return ok({
@ -142,55 +294,56 @@ describe("run interactive runtime", () => {
],
}) as never
})
spyOn(sdk.v2.model, "list").mockImplementation(() =>
ok({
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,
},
spyOn(sdk.model, "list").mockImplementation(
() =>
ok({
location: {
directory: "/tmp",
},
],
}) as never,
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,
},
},
],
}) as never,
)
spyOn(sdk.v2.session, "messages").mockImplementation(() =>
spyOn(sdk.message, "list").mockImplementation(() =>
ok({
data: [
{
@ -205,9 +358,9 @@ describe("run interactive runtime", () => {
cursor: {},
}),
)
spyOn(sdk.v2.session, "get").mockImplementation(() =>
ok({
data: {
spyOn(sdk.session, "get").mockImplementation(
() =>
ok({
id: "ses-1",
projectID: "pro-1",
title: "Session",
@ -232,13 +385,12 @@ describe("run interactive runtime", () => {
providerID: "openai",
id: "gpt-5",
},
},
}),
}) 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)
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 = runInteractiveMode(
{
@ -296,13 +448,10 @@ describe("run interactive runtime", () => {
expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }])
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 () => {
const sdk = new OpencodeClient()
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const defaultStarted = defer<void>()
const releaseDefault = defer<void>()
const lifecycleStarted = defer<void>()
@ -320,7 +469,7 @@ describe("run interactive runtime", () => {
api.close()
}
spyOn(sdk.v2.model, "default").mockImplementation(async () => {
spyOn(sdk.model, "default").mockImplementation(async () => {
defaultRequested = true
defaultStarted.resolve()
await releaseDefault.promise
@ -329,24 +478,12 @@ describe("run interactive runtime", () => {
data: { id: "gpt-5", providerID: "openai" },
}) as never
})
spyOn(sdk.v2.provider, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.model, "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,
)
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 = runInteractiveMode(
{
@ -402,12 +539,12 @@ describe("run interactive runtime", () => {
})
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 painted = defer<void>()
const api = footer()
api.idle = () => painted.promise
const defaultModel = spyOn(sdk.v2.model, "default")
const defaultModel = spyOn(sdk.model, "default")
const task = runInteractiveMode(
{
@ -444,8 +581,50 @@ describe("run interactive runtime", () => {
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 () => {
const sdk = new OpencodeClient()
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const refreshGate = defer<void>()
let providerCalls = 0
let modelCalls = 0
@ -453,7 +632,7 @@ describe("run interactive runtime", () => {
let referenceCalls = 0
const events: FooterEvent[] = []
const api = footer(events)
spyOn(sdk.v2.provider, "list").mockImplementation(async () => {
spyOn(sdk.provider, "list").mockImplementation(async () => {
providerCalls++
if (providerCalls === 2) {
await refreshGate.promise
@ -471,7 +650,7 @@ describe("run interactive runtime", () => {
],
}) as never
})
spyOn(sdk.v2.model, "list").mockImplementation(() => {
spyOn(sdk.model, "list").mockImplementation(() => {
modelCalls++
return ok({
location: { directory: "/tmp" },
@ -484,9 +663,7 @@ describe("run interactive runtime", () => {
capabilities: { tools: true, input: ["text"], output: ["text"] },
request: { headers: {}, body: {} },
variants:
modelCalls >= 4
? []
: [{ id: modelCalls >= 3 ? "high" : "low", settings: {}, headers: {}, body: {} }],
modelCalls >= 4 ? [] : [{ id: modelCalls >= 3 ? "high" : "low", settings: {}, headers: {}, body: {} }],
time: { released: 1 },
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
status: "active",
@ -496,7 +673,7 @@ describe("run interactive runtime", () => {
],
}) as never
})
spyOn(sdk.v2.agent, "list").mockImplementation(async () => {
spyOn(sdk.agent, "list").mockImplementation(async () => {
agentCalls++
if (agentCalls === 2) throw new Error("agent refresh failed")
return ok({
@ -504,7 +681,7 @@ describe("run interactive runtime", () => {
data: [{ id: "build", description: agentCalls >= 3 ? "Refreshed agent" : "Agent", mode: "primary" }],
}) as never
})
spyOn(sdk.v2.reference, "list").mockImplementation(() => {
spyOn(sdk.reference, "list").mockImplementation(() => {
referenceCalls++
return ok({
location: { directory: "/tmp" },
@ -513,12 +690,10 @@ describe("run interactive runtime", () => {
],
}) as never
})
spyOn(sdk.v2.command, "list").mockImplementation(() =>
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.command, "list").mockImplementation(
() => ok({ location: { directory: "/tmp" }, data: [{ name: "check", description: "Check" }] }) as never,
)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
let finalProviders: RunProvider[] = []
let finalLimits: Record<string, number> = {}
let retainedProviders: RunProvider[] = []

View file

@ -2,9 +2,9 @@ import { afterEach, expect, test } from "bun:test"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { RGBA, SyntaxStyle } from "@opentui/core"
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface"
import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme"
import type { StreamCommit } from "@/cli/cmd/run/types"
import { RunScrollbackStream } from "@opencode-ai/cli/mini/scrollback.surface"
import { RUN_THEME_FALLBACK, type RunTheme } from "@opencode-ai/cli/mini/theme"
import type { StreamCommit } from "@opencode-ai/cli/mini/types"
type ClaimedCommit = {
snapshot: {

View file

@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { createSessionData, reduceSessionData } from "@/cli/cmd/run/session-data"
import type { StreamCommit } from "@/cli/cmd/run/types"
import { createSessionData, reduceSessionData } from "@opencode-ai/cli/mini/session-data"
import type { StreamCommit } from "@opencode-ai/cli/mini/types"
function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
return reduceSessionData({

View file

@ -1,5 +1,5 @@
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 {
createSession,
resolveCurrentSession,
@ -7,7 +7,7 @@ import {
sessionVariant,
type RunSession,
type SessionMessages,
} from "@/cli/cmd/run/session.shared"
} from "@opencode-ai/cli/mini/session.shared"
type Message = SessionMessages[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 () => {
const client = new OpencodeClient()
spyOn(client.v2.session, "messages").mockImplementation(() =>
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.message, "list").mockImplementation(() =>
Promise.resolve({
data: {
data: [
data: [
{
id: "msg_prompt",
type: "user",
@ -272,32 +271,20 @@ describe("run session shared", () => {
agents: [],
time: { created: 1 },
},
],
cursor: {},
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
],
cursor: {},
}),
)
spyOn(client.v2.session, "get").mockImplementation(() =>
spyOn(client.session, "get").mockImplementation(() =>
Promise.resolve({
data: {
data: {
id: "ses_1",
title: "Session",
version: "dev",
projectID: "proj_1",
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
time: { created: 1, updated: 1 },
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(),
id: "ses_1",
title: "Session",
projectID: "proj_1",
location: { directory: "/tmp" },
time: { created: 1, updated: 1 },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
model: { providerID: "openai", id: "gpt-5", variant: "high" },
}),
)

View file

@ -2,12 +2,12 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "node:url"
import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2"
import { createSessionTransport } from "@/cli/cmd/run/stream-v2.transport"
import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types"
import { OpenCode, type EventSubscribeOutput, type MessageListOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
import { createSessionTransport } from "@opencode-ai/cli/mini/stream-v2.transport"
import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types"
import { tmpdir } from "../../fixture/fixture"
type RunV2Event = V2Event
type RunV2Event = EventSubscribeOutput
function feed() {
const values: RunV2Event[] = []
@ -41,16 +41,11 @@ function feed() {
}
function ok<T>(data: T) {
return Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
})
return Promise.resolve(data)
}
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) {
@ -85,9 +80,7 @@ function footer() {
return { api, commits, events }
}
type SessionMessages = NonNullable<
Awaited<ReturnType<OpencodeClient["v2"]["session"]["messages"]>>["data"]
>["data"][number][]
type SessionMessages = MessageListOutput["data"]
function sdk(input: {
streams: ReturnType<typeof feed>[]
@ -95,15 +88,10 @@ function sdk(input: {
messages?: Record<string, SessionMessages>
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
spyOn(client.v2.event, "subscribe").mockImplementation(
() =>
Promise.resolve({ stream: input.streams[subscription++]?.stream ?? feed().stream }) as ReturnType<
typeof client.v2.event.subscribe
>,
)
spyOn(client.v2.session, "messages").mockImplementation((request) =>
spyOn(client.event, "subscribe").mockImplementation(() => input.streams[subscription++]?.stream ?? feed().stream)
spyOn(client.message, "list").mockImplementation((request) =>
ok({
data: input.messages?.[request.sessionID] ?? [
{
@ -118,14 +106,14 @@ function sdk(input: {
cursor: {},
}),
)
spyOn(client.v2.session.permission, "list").mockImplementation(() => ok({ data: [] }))
spyOn(client.v2.session.question, "list").mockImplementation(() => ok({ data: [] }))
spyOn(client.v2.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} }))
spyOn(client.v2.session, "switchAgent").mockImplementation(() => ok(undefined))
spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined))
spyOn(client.permission, "list").mockImplementation(() => ok([]))
spyOn(client.question, "list").mockImplementation(() => ok([]))
spyOn(client.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} }))
spyOn(client.session, "switchAgent").mockImplementation(() => ok(undefined))
spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined))
// The generated methods have conditional return types for throwOnError; the
// 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
return ok({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
@ -139,7 +127,7 @@ function sdk(input: {
) ?? [],
}) as never
})
spyOn(client.v2.model, "default").mockImplementation(
spyOn(client.model, "default").mockImplementation(
() =>
ok({
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"])
let admitted = false
// 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
spyOn(client.v2.session, "prompt").mockImplementation((request) => {
spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" }
admitted = true
@ -185,7 +171,7 @@ describe("V2 mini transport", () => {
delivery: "steer" as const,
timeCreated: 2,
},
})
}) as never
})
const turn = transport.runPromptTurn({
@ -250,10 +236,8 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["prompt"]>[0] | undefined
// 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
spyOn(client.v2.session, "prompt").mockImplementation((input) => {
let request: Parameters<OpenCodeClient["session"]["prompt"]>[0] | undefined
spyOn(client.session, "prompt").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
@ -282,7 +266,7 @@ describe("V2 mini transport", () => {
delivery: "steer" as const,
timeCreated: 2,
},
})
}) as never
})
await transport.runPromptTurn({
@ -344,10 +328,10 @@ describe("V2 mini transport", () => {
limits: () => ({}),
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.
// @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
queueMicrotask(() => {
events.push({
@ -441,10 +425,10 @@ describe("V2 mini transport", () => {
limits: () => ({}),
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.
// @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
queueMicrotask(() => {
events.push({
@ -561,7 +545,7 @@ describe("V2 mini transport", () => {
},
})
let projected = false
spyOn(client.v2.session, "messages").mockImplementation(() =>
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: projected
? [
@ -589,7 +573,7 @@ describe("V2 mini transport", () => {
let admitted = false
// 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
spyOn(client.v2.session, "prompt").mockImplementation((request) => {
spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" }
admitted = true
@ -632,7 +616,7 @@ describe("V2 mini transport", () => {
return active
},
})
spyOn(client.v2.session, "messages").mockImplementation(() =>
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: projected
? [
@ -661,7 +645,7 @@ describe("V2 mini transport", () => {
let admitted = false
// 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
spyOn(client.v2.session, "prompt").mockImplementation((request) => {
spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" }
admitted = true
@ -701,7 +685,7 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
spyOn(client.v2.session, "messages").mockImplementation(() =>
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: [
{
@ -745,7 +729,7 @@ describe("V2 mini transport", () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
spyOn(client.v2.session, "messages").mockImplementation(() =>
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: [
{
@ -842,7 +826,7 @@ describe("V2 mini transport", () => {
let admitted = false
// 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
spyOn(client.v2.session, "prompt").mockImplementation((request) => {
spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" }
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 },
})
})
const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined))
const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
const turn = transport.runPromptTurn({
agent: undefined,
@ -886,21 +870,19 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
// The generated method has conditional return types for throwOnError; the test only needs the nested model field.
// @ts-expect-error minimal session shape is enough for this lookup
spyOn(client.v2.session, "get").mockImplementation(() => ok({ data: { model: undefined } }))
spyOn(client.v2.model, "default").mockImplementation(
spyOn(client.session, "get").mockImplementation(() => ok({ model: undefined }) as never)
spyOn(client.model, "default").mockImplementation(
() =>
ok({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: { id: "gpt-5", providerID: "openai" },
}) as never,
)
const switched = spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined))
const switched = spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined))
let admitted = false
// 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
spyOn(client.v2.session, "prompt").mockImplementation((request) => {
spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" }
admitted = true
@ -938,7 +920,7 @@ describe("V2 mini transport", () => {
expect(switched).toHaveBeenCalledWith(
{ sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } },
expect.objectContaining({ throwOnError: true }),
{ signal: undefined },
)
await transport.close()
})
@ -958,7 +940,7 @@ describe("V2 mini transport", () => {
let admitted = false
// 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
spyOn(client.v2.session, "prompt").mockImplementation((request) => {
spyOn(client.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
const prompt = request.prompt ?? { text: "" }
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 },
})
})
const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined))
const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
const controller = new AbortController()
const turn = transport.runPromptTurn({
agent: undefined,
@ -1014,8 +996,8 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["shell"]>[0] | undefined
spyOn(client.v2.session, "shell").mockImplementation((input) => {
let request: Parameters<OpenCodeClient["session"]["shell"]>[0] | undefined
spyOn(client.session, "shell").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
@ -1094,7 +1076,7 @@ describe("V2 mini transport", () => {
})
let started = false
let aborted = false
spyOn(client.v2.session, "shell").mockImplementation(
spyOn(client.session, "shell").mockImplementation(
(_input, options) =>
new Promise((_, reject) => {
started = true
@ -1104,7 +1086,7 @@ describe("V2 mini transport", () => {
})
}) 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({
agent: undefined,
@ -1135,9 +1117,9 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["shell"]>[0] | undefined
let request: Parameters<OpenCodeClient["session"]["shell"]>[0] | undefined
let complete!: () => void
spyOn(client.v2.session, "shell").mockImplementation((input) => {
spyOn(client.session, "shell").mockImplementation((input) => {
request = input
return new Promise<void>((resolve) => {
complete = resolve
@ -1414,8 +1396,8 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["command"]>[0] | undefined
spyOn(client.v2.session, "command").mockImplementation((input) => {
let request: Parameters<OpenCodeClient["session"]["command"]>[0] | undefined
spyOn(client.session, "command").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
@ -1436,14 +1418,12 @@ describe("V2 mini transport", () => {
})
})
return ok({
data: {
admittedSeq: 1,
id: input.id ?? "msg_cmd",
sessionID: "ses_1",
prompt: { text: "evaluated template" },
delivery: "steer" as const,
timeCreated: 2,
},
admittedSeq: 1,
id: input.id ?? "msg_cmd",
sessionID: "ses_1",
prompt: { text: "evaluated template" },
delivery: "steer" as const,
timeCreated: 2,
})
})
@ -1471,8 +1451,8 @@ describe("V2 mini transport", () => {
delivery: "steer",
})
// Selection rides the command payload; no separate client-side switch.
expect(client.v2.session.switchAgent).not.toHaveBeenCalled()
expect(client.v2.session.switchModel).not.toHaveBeenCalled()
expect(client.session.switchAgent).not.toHaveBeenCalled()
expect(client.session.switchModel).not.toHaveBeenCalled()
await transport.close()
})
@ -1488,10 +1468,10 @@ describe("V2 mini transport", () => {
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["skill"]>[0] | undefined
const command = spyOn(client.v2.session, "command")
const prompt = spyOn(client.v2.session, "prompt")
spyOn(client.v2.session, "skill").mockImplementation((input) => {
let request: Parameters<OpenCodeClient["session"]["skill"]>[0] | undefined
const command = spyOn(client.session, "command")
const prompt = spyOn(client.session, "prompt")
spyOn(client.session, "skill").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
@ -1551,7 +1531,7 @@ describe("V2 mini transport", () => {
footer: ui.api,
})
let sent = false
spyOn(client.v2.session, "skill").mockImplementation(() => {
spyOn(client.session, "skill").mockImplementation(() => {
sent = true
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({
data: {
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
},
}),
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp" },
}) as never,
)
const ui = footer()
const transport = await createSessionTransport({
@ -1857,7 +1835,7 @@ describe("V2 mini transport", () => {
const hydration = new Promise<void>((resolve) => {
releaseHydration = resolve
})
spyOn(client.v2.session, "messages").mockImplementation(async (request) => {
spyOn(client.message, "list").mockImplementation(async (request) => {
if (request.sessionID === "ses_child") {
childHydrating = true
await hydration
@ -1927,7 +1905,7 @@ describe("V2 mini transport", () => {
const retry = new Promise<void>((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: {} })
childRequests++
if (childRequests === 1) {
@ -2006,7 +1984,7 @@ describe("V2 mini transport", () => {
const hydration = new Promise<void>((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: {} })
childHydrating = true
await hydration
@ -2119,21 +2097,19 @@ describe("V2 mini transport", () => {
const gate = new Promise<void>((resolve) => {
resolveGet = resolve
})
spyOn(client.v2.session, "get").mockImplementation(async () => {
spyOn(client.session, "get").mockImplementation(async () => {
await gate
return ok({
data: {
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
},
})
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp" },
}) as never
})
const ui = footer()
const transport = await createSessionTransport({
@ -2178,21 +2154,19 @@ describe("V2 mini transport", () => {
const gate = new Promise<void>((resolve) => {
resolveGet = resolve
})
spyOn(client.v2.session, "get").mockImplementation(async () => {
spyOn(client.session, "get").mockImplementation(async () => {
await gate
return ok({
data: {
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
},
})
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp" },
}) as never
})
const ui = footer()
const transport = await createSessionTransport({
@ -2286,10 +2260,7 @@ describe("V2 mini transport", () => {
footer: ui.api,
})
const states = ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : []))
expect(client.v2.session.list).toHaveBeenCalledWith(
{ parentID: "ses_1", limit: 100, order: "desc" },
{ throwOnError: true },
)
expect(client.session.list).toHaveBeenCalledWith({ parentID: "ses_1", limit: 100, order: "desc" })
expect(states.at(-1)?.tabs).toMatchObject([
{
sessionID: "ses_child_old",

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { writeSessionOutput } from "@/cli/cmd/run/stream"
import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types"
import { writeSessionOutput } from "@opencode-ai/cli/mini/stream"
import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types"
function footer() {
const events: FooterEvent[] = []

View file

@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
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

View file

@ -10,9 +10,9 @@ import {
formatModelLabel,
pickVariant,
resolveVariant,
} from "@/cli/cmd/run/variant.shared"
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
import type { RunProvider } from "@/cli/cmd/run/types"
} from "@opencode-ai/cli/mini/variant.shared"
import type { SessionMessages } from "@opencode-ai/cli/mini/session.shared"
import type { RunProvider } from "@opencode-ai/cli/mini/types"
import { testEffect } from "../../lib/effect"
const model = {

View file

@ -4,7 +4,6 @@ import fs from "fs/promises"
import path from "path"
import yargs from "yargs"
import { tmpdir } from "../../fixture/fixture"
import { MiniLocalCommand } from "../../../src/cli/cmd/mini"
import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui"
import { cliIt } from "../../lib/cli-process"
@ -38,7 +37,7 @@ describe("tui thread", () => {
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 cwd = await tmpdir({ git: true })
@ -46,18 +45,6 @@ describe("tui thread", () => {
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 () => {
const args = await yargs([])
.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 }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"])

View file

@ -4,7 +4,11 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const HealthGroup = HttpApiGroup.make("server.health")
.add(
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(
OpenApi.annotations({
identifier: "v2.health.get",

View file

@ -15546,6 +15546,8 @@ export type V2HealthGetResponses = {
*/
200: {
healthy: true
version: string
pid: number
}
}

View file

@ -12,6 +12,7 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*",

View file

@ -1,7 +1,10 @@
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
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 }),
),
)

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),
)
}

View file

@ -38,9 +38,11 @@
"./plugin/command-shim": "./src/plugin/command-shim.ts",
"./parsers-config": "./src/parsers-config.ts",
"./util/error": "./src/util/error.ts",
"./util/filetype": "./src/util/filetype.ts",
"./util/locale": "./src/util/locale.ts",
"./util/persistence": "./src/util/persistence.ts",
"./util/record": "./src/util/record.ts",
"./util/session": "./src/util/session.ts",
"./logo": "./src/logo.ts",
"./ui/dialog": "./src/ui/dialog.tsx",
"./ui/spinner": "./src/ui/spinner.ts",