mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 05:08:32 +00:00
mini migrate to v2 (#35526)
This commit is contained in:
parent
fb75ea2cf6
commit
32cf36de9d
93 changed files with 2246 additions and 2335 deletions
8
bun.lock
8
bun.lock
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
35
packages/cli/src/commands/handlers/mini.ts
Normal file
35
packages/cli/src/commands/handlers/mini.ts
Normal 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,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
31
packages/cli/src/commands/handlers/run.ts
Normal file
31
packages/cli/src/commands/handlers/run.ts
Normal 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,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
70
packages/cli/src/daemon.ts
Normal file
70
packages/cli/src/daemon.ts
Normal 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"
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
7
packages/cli/src/mini/index.ts
Normal file
7
packages/cli/src/mini/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { runMini, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
|
||||
export {
|
||||
runNonInteractive,
|
||||
mergeInput as mergeNonInteractiveInput,
|
||||
pickRunModel,
|
||||
type RunCommandInput,
|
||||
} from "./run"
|
||||
234
packages/cli/src/mini/mini.ts
Normal file
234
packages/cli/src/mini/mini.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { truthy } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import { Daemon } from "../daemon"
|
||||
import { waitForCatalogReady } from "./catalog.shared"
|
||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
|
||||
import type { RunInput, RunTuiConfig } from "./types"
|
||||
|
||||
export type MiniCommandInput = {
|
||||
directory?: string
|
||||
attach?: string
|
||||
password?: string
|
||||
username?: string
|
||||
continue?: boolean
|
||||
session?: string
|
||||
fork?: boolean
|
||||
model?: string
|
||||
agent?: string
|
||||
prompt?: string
|
||||
replay?: boolean
|
||||
replayLimit?: number
|
||||
demo?: boolean
|
||||
serverCommand?: ReadonlyArray<string>
|
||||
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
|
||||
}
|
||||
|
||||
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
|
||||
type Transport = { readonly url: string; readonly headers?: HeadersInit }
|
||||
|
||||
export async function runMini(input: MiniCommandInput) {
|
||||
validate(input)
|
||||
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
|
||||
const runtimeTask = import("./runtime")
|
||||
const directory = input.attach ? input.directory : localDirectory(input.directory)
|
||||
const transportTask = startTransport(input)
|
||||
void transportTask.catch(() => {})
|
||||
|
||||
try {
|
||||
if (input.attach) await transportTask
|
||||
const sdk = OpenCode.make({
|
||||
baseUrl: "http://opencode.pending",
|
||||
fetch: deferredFetch(transportTask),
|
||||
})
|
||||
const attachedSession =
|
||||
input.attach && input.session && !input.directory
|
||||
? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found"))
|
||||
: undefined
|
||||
const resolvedDirectory =
|
||||
directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await transportTask, sdk))
|
||||
const model = parseModel(input.model)
|
||||
let agentTask: Promise<string | undefined> | undefined
|
||||
const resolveAgent = () => {
|
||||
agentTask ??= validateAgent(sdk, resolvedDirectory, input.agent, input.attach)
|
||||
return agentTask
|
||||
}
|
||||
const resolveSession = async () => {
|
||||
const [agent, selected] = await Promise.all([
|
||||
resolveAgent(),
|
||||
selectSession(sdk, resolvedDirectory, input, attachedSession),
|
||||
])
|
||||
const readyModel =
|
||||
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
|
||||
if (readyModel) await waitForCatalogReady({ sdk, directory: resolvedDirectory, model: readyModel })
|
||||
const session = selected ?? (await createSession(sdk, resolvedDirectory, agent, model))
|
||||
return { id: session.id, title: session.title, resume: selected !== undefined }
|
||||
}
|
||||
const create = (
|
||||
_sdk: OpenCodeClient,
|
||||
next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined },
|
||||
) => createSession(sdk, resolvedDirectory, next.agent, next.model, next.variant)
|
||||
const runtime = await runtimeTask
|
||||
await runtime.runInteractiveDeferredMode({
|
||||
sdk,
|
||||
directory: resolvedDirectory,
|
||||
resolveAgent,
|
||||
session: resolveSession,
|
||||
createSession: create,
|
||||
agent: input.agent,
|
||||
model,
|
||||
variant: undefined,
|
||||
files: [],
|
||||
initialInput,
|
||||
thinking: true,
|
||||
backgroundSubagents:
|
||||
truthy("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS") ||
|
||||
(process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === undefined && truthy("OPENCODE_EXPERIMENTAL")),
|
||||
replay: input.replay ?? true,
|
||||
replayLimit: input.replayLimit,
|
||||
demo: input.demo,
|
||||
tuiConfig: input.tuiConfig,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) fail(error.message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export function mergeInput(piped: string | undefined, prompt: string | undefined) {
|
||||
if (!prompt) return piped || undefined
|
||||
if (!piped) return prompt
|
||||
return piped + "\n" + prompt
|
||||
}
|
||||
|
||||
function validate(input: MiniCommandInput) {
|
||||
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
|
||||
if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) {
|
||||
fail("--replay-limit must be a positive integer")
|
||||
}
|
||||
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
||||
resolveInteractiveStdin().cleanup?.()
|
||||
}
|
||||
|
||||
function localDirectory(directory?: string): string {
|
||||
const root = process.env.PWD ?? process.cwd()
|
||||
try {
|
||||
process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
fail(`Failed to change directory to ${directory}`)
|
||||
}
|
||||
}
|
||||
|
||||
function startTransport(input: MiniCommandInput): Promise<Transport> {
|
||||
if (input.attach) {
|
||||
return Effect.runPromise(
|
||||
Daemon.transport({
|
||||
mode: "attach",
|
||||
url: input.attach,
|
||||
password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD,
|
||||
username: input.username ?? process.env.OPENCODE_SERVER_USERNAME,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Effect.runPromise(
|
||||
Daemon.transport({ mode: "shared", command: input.serverCommand }).pipe(
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
Effect.provide(Global.layerWith({})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function deferredFetch(transportTask: Promise<{ url: string; headers?: HeadersInit }>): typeof globalThis.fetch {
|
||||
const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const transport = await transportTask
|
||||
const request = new Request(input, init)
|
||||
const source = new URL(request.url)
|
||||
const headers = new Headers(request.headers)
|
||||
for (const [key, value] of new Headers(transport.headers)) headers.set(key, value)
|
||||
return globalThis.fetch(new Request(new URL(source.pathname + source.search, transport.url), request), { headers })
|
||||
}
|
||||
return fetch as typeof globalThis.fetch
|
||||
}
|
||||
|
||||
async function remoteDirectory(
|
||||
transport: { url: string; headers?: HeadersInit },
|
||||
sdk: OpenCodeClient,
|
||||
): Promise<string> {
|
||||
const location = await sdk.location.get()
|
||||
if (!location.directory) throw new Error(`Failed to resolve remote directory from ${transport.url}`)
|
||||
return location.directory
|
||||
}
|
||||
|
||||
function parseModel(value?: string): RunInput["model"] {
|
||||
if (!value) return
|
||||
const [providerID, ...rest] = value.split("/")
|
||||
const modelID = rest.join("/")
|
||||
if (!providerID || !modelID) fail("--model must use the format provider/model")
|
||||
return { providerID, modelID }
|
||||
}
|
||||
|
||||
async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string, attach?: string) {
|
||||
if (!name) return
|
||||
const deadline = Date.now() + 5_000
|
||||
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
|
||||
while (Date.now() < deadline) {
|
||||
agents = await sdk.agent.list({ location: { directory } }).catch(() => undefined)
|
||||
const agent = agents?.data.find((item) => item.id === name)
|
||||
if (agent?.mode === "subagent") {
|
||||
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
if (agent) return name
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
if (!agents) {
|
||||
warning(`failed to list agents${attach ? ` from ${attach}` : ""}. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
||||
}
|
||||
|
||||
async function selectSession(sdk: OpenCodeClient, directory: string, input: MiniCommandInput, preselected?: Session) {
|
||||
const selected =
|
||||
preselected ??
|
||||
(input.session
|
||||
? await sdk.session.get({ sessionID: input.session }).catch(() => undefined)
|
||||
: input.continue
|
||||
? await sdk.session
|
||||
.list({ directory, parentID: null, limit: 1, order: "desc" })
|
||||
.then((result) => result.data[0])
|
||||
: undefined)
|
||||
if (input.session && !selected) fail("Session not found")
|
||||
if (!selected) return
|
||||
if (!input.fork) return selected
|
||||
return sdk.session.fork({ sessionID: selected.id })
|
||||
}
|
||||
|
||||
async function createSession(
|
||||
sdk: OpenCodeClient,
|
||||
directory: string,
|
||||
agent: string | undefined,
|
||||
model: RunInput["model"],
|
||||
variant?: string,
|
||||
): Promise<Session> {
|
||||
if (model) await waitForCatalogReady({ sdk, directory, model })
|
||||
return sdk.session.create({
|
||||
agent,
|
||||
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
|
||||
location: { directory },
|
||||
})
|
||||
}
|
||||
|
||||
function warning(message: string) {
|
||||
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`\x1b[91m\x1b[1mError: \x1b[0m${message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -1,15 +1,17 @@
|
|||
import type {
|
||||
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 {
|
||||
|
|
@ -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
|
||||
308
packages/cli/src/mini/run.ts
Normal file
308
packages/cli/src/mini/run.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { Effect } from "effect"
|
||||
import { open } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Daemon } from "../daemon"
|
||||
import { Standalone } from "../services/standalone"
|
||||
import { loadRunAgents, waitForCatalogReady, waitForDefaultModel } from "./catalog.shared"
|
||||
import { runNonInteractivePrompt } from "./noninteractive"
|
||||
import { toolInlineInfo } from "./tool"
|
||||
import { UI } from "./ui"
|
||||
|
||||
export type RunCommandInput = {
|
||||
message: string[]
|
||||
continue?: boolean
|
||||
session?: string
|
||||
fork?: boolean
|
||||
model?: string
|
||||
agent?: string
|
||||
format: "default" | "json"
|
||||
file: string[]
|
||||
title?: string
|
||||
server?: string
|
||||
password?: string
|
||||
username?: string
|
||||
directory?: string
|
||||
variant?: string
|
||||
thinking?: boolean
|
||||
dangerouslySkipPermissions?: boolean
|
||||
standaloneCommand?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
type FilePart = {
|
||||
url: string
|
||||
filename: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
type Transport = { readonly url: string; readonly headers?: HeadersInit }
|
||||
|
||||
type Prepared = {
|
||||
directory?: string
|
||||
message: string
|
||||
files: FilePart[]
|
||||
}
|
||||
|
||||
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export async function runNonInteractive(input: RunCommandInput) {
|
||||
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
||||
const root = process.env.PWD ?? process.cwd()
|
||||
const directory = input.server ? input.directory : localDirectory(input.directory, root)
|
||||
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await Bun.stdin.text())
|
||||
if (!message?.trim()) fail("You must provide a message")
|
||||
const files = await Promise.all(
|
||||
input.file.map((file) => prepareFile(file, input.server ? root : (directory ?? root), input.server !== undefined)),
|
||||
)
|
||||
const prepared = { directory, message, files }
|
||||
if (input.standaloneCommand)
|
||||
return Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* Standalone.transport({ command: input.standaloneCommand })
|
||||
yield* Effect.promise(() => execute(input, prepared, transport))
|
||||
}),
|
||||
),
|
||||
)
|
||||
const transport = await startTransport(input)
|
||||
return execute(input, prepared, transport)
|
||||
}
|
||||
|
||||
async function execute(input: RunCommandInput, prepared: Prepared, transport: Transport) {
|
||||
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
|
||||
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
|
||||
if (!requestedDirectory) fail("Failed to resolve server directory")
|
||||
const session = await selectSession(client, requestedDirectory, input)
|
||||
const cwd = session?.location.directory ?? requestedDirectory
|
||||
const explicitModel = parseModel(input.model)
|
||||
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
|
||||
const defaultModel =
|
||||
input.variant && !explicitModel && !sessionModel
|
||||
? await waitForDefaultModel({ sdk: client, directory: cwd })
|
||||
: undefined
|
||||
const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel)
|
||||
if (input.variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
|
||||
if (model) {
|
||||
await waitForCatalogReady({ sdk: client, directory: cwd, model })
|
||||
const available = await client.model.list({ location: { directory: cwd } })
|
||||
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
|
||||
return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
|
||||
}
|
||||
const agent = await validateAgent(client, cwd, input.agent, input.server)
|
||||
const selected =
|
||||
session ??
|
||||
(await client.session.create({
|
||||
agent,
|
||||
model: model ? { providerID: model.providerID, id: model.modelID, variant: input.variant } : undefined,
|
||||
location: { directory: cwd },
|
||||
}))
|
||||
if (!session && input.title !== undefined) {
|
||||
await client.session.rename({
|
||||
sessionID: selected.id,
|
||||
title:
|
||||
input.title ||
|
||||
prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await runNonInteractivePrompt({
|
||||
client,
|
||||
sessionID: selected.id,
|
||||
message: prepared.message,
|
||||
files: prepared.files,
|
||||
agent,
|
||||
model,
|
||||
variant: input.variant,
|
||||
thinking: input.thinking ?? false,
|
||||
format: input.format,
|
||||
dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false,
|
||||
attached: !input.standaloneCommand,
|
||||
renderTool,
|
||||
renderToolError,
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeInput(message: string | undefined, piped: string | undefined) {
|
||||
if (!message) return piped || undefined
|
||||
if (!piped) return message
|
||||
return message + "\n" + piped
|
||||
}
|
||||
|
||||
export function pickRunModel(
|
||||
explicit: { providerID: string; modelID: string } | undefined,
|
||||
variant: string | undefined,
|
||||
session: { providerID: string; modelID: string } | undefined,
|
||||
fallback: { providerID: string; modelID: string } | undefined,
|
||||
) {
|
||||
if (explicit) return explicit
|
||||
if (!variant) return
|
||||
return session ?? fallback
|
||||
}
|
||||
|
||||
function formatMessage(message: string[]) {
|
||||
const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ")
|
||||
return value || undefined
|
||||
}
|
||||
|
||||
function localDirectory(directory: string | undefined, root: string) {
|
||||
try {
|
||||
process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
fail(`Failed to change directory to ${directory}`)
|
||||
}
|
||||
}
|
||||
|
||||
function startTransport(input: RunCommandInput) {
|
||||
if (input.server) {
|
||||
return Effect.runPromise(
|
||||
Daemon.transport({
|
||||
mode: "attach",
|
||||
url: input.server,
|
||||
password: input.password,
|
||||
username: input.username,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Effect.runPromise(
|
||||
Daemon.transport({ mode: "shared" }).pipe(
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
Effect.provide(Global.layerWith({})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function parseModel(value?: string) {
|
||||
if (!value) return
|
||||
const [providerID, ...rest] = value.split("/")
|
||||
const modelID = rest.join("/")
|
||||
if (!providerID || !modelID) fail("--model must use the format provider/model")
|
||||
return { providerID, modelID }
|
||||
}
|
||||
|
||||
async function validateAgent(client: OpenCodeClient, directory: string, name?: string, server?: string) {
|
||||
if (!name) return
|
||||
const agents = await loadRunAgents(client, directory).catch(() => undefined)
|
||||
if (!agents) {
|
||||
warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
const agent = agents.find((item) => item.name === name)
|
||||
if (!agent) {
|
||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
if (agent.mode === "subagent") {
|
||||
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
async function selectSession(client: OpenCodeClient, directory: string, input: RunCommandInput) {
|
||||
const selected = input.session
|
||||
? await client.session.get({ sessionID: input.session }).catch(() => undefined)
|
||||
: input.continue
|
||||
? await client.session
|
||||
.list({ directory, parentID: null, limit: 1, order: "desc" })
|
||||
.then((result) => result.data[0])
|
||||
: undefined
|
||||
if (input.session && !selected) fail("Session not found")
|
||||
if (!selected || !input.fork) return selected
|
||||
return client.session.fork({ sessionID: selected.id })
|
||||
}
|
||||
|
||||
async function prepareFile(input: string, directory: string, remote: boolean): Promise<FilePart> {
|
||||
const file = path.resolve(directory, input)
|
||||
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
|
||||
try {
|
||||
const stat = await handle.stat()
|
||||
if (remote && stat.isDirectory()) fail(`Cannot attach local directory without a shared filesystem: ${input}`)
|
||||
if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES)
|
||||
fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`)
|
||||
const content = Buffer.alloc(Number(stat.size))
|
||||
let offset = 0
|
||||
while (offset < content.length) {
|
||||
const read = await handle.read(content, offset, content.length - offset, offset)
|
||||
if (read.bytesRead === 0) break
|
||||
offset += read.bytesRead
|
||||
}
|
||||
const bytes = content.subarray(0, offset)
|
||||
const detected = FSUtil.mimeType(file)
|
||||
const text = bytes.toString("utf8")
|
||||
const mime =
|
||||
detected.startsWith("image/") || detected === "application/pdf"
|
||||
? detected
|
||||
: !isBinaryContent(bytes) && Buffer.from(text, "utf8").equals(bytes)
|
||||
? "text/plain"
|
||||
: detected
|
||||
return {
|
||||
url: `data:${mime};base64,${bytes.toString("base64")}`,
|
||||
filename: path.basename(file),
|
||||
mime,
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function isBinaryContent(bytes: Uint8Array) {
|
||||
if (bytes.length === 0) return false
|
||||
if (bytes.includes(0)) return true
|
||||
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
|
||||
}
|
||||
|
||||
async function renderTool(part: ToolPart) {
|
||||
const info = toolInlineInfo(part)
|
||||
if (info.mode === "block") {
|
||||
UI.empty()
|
||||
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title)
|
||||
if (info.body?.trim()) UI.println(info.body)
|
||||
UI.empty()
|
||||
return
|
||||
}
|
||||
UI.println(
|
||||
UI.Style.TEXT_NORMAL + info.icon,
|
||||
UI.Style.TEXT_NORMAL + info.title,
|
||||
info.description ? UI.Style.TEXT_DIM + info.description + UI.Style.TEXT_NORMAL : "",
|
||||
)
|
||||
}
|
||||
|
||||
async function renderToolError(part: ToolPart) {
|
||||
const info = toolInlineInfo(part)
|
||||
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
||||
}
|
||||
|
||||
function warning(message: string) {
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
|
||||
}
|
||||
|
||||
function reportError(input: RunCommandInput, message: string, sessionID?: string) {
|
||||
process.exitCode = 1
|
||||
if (input.format === "json") {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
timestamp: Date.now(),
|
||||
sessionID: sessionID ?? "",
|
||||
error: { type: "unknown", message },
|
||||
}) + "\n",
|
||||
)
|
||||
return
|
||||
}
|
||||
UI.error(message)
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
UI.error(message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -7,12 +7,10 @@
|
|||
// none block each other.
|
||||
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")
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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]
|
||||
|
|
@ -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,
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 || "",
|
||||
|
|
@ -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">
|
||||
|
||||
27
packages/cli/src/mini/ui.ts
Normal file
27
packages/cli/src/mini/ui.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { EOL } from "node:os"
|
||||
|
||||
export const Style = {
|
||||
TEXT_DIM: "\x1b[90m",
|
||||
TEXT_NORMAL: "\x1b[0m",
|
||||
TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
|
||||
TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
|
||||
}
|
||||
|
||||
export function println(...message: string[]) {
|
||||
process.stderr.write(message.join(" ") + EOL)
|
||||
}
|
||||
|
||||
let blank = false
|
||||
|
||||
export function empty() {
|
||||
if (blank) return
|
||||
println(Style.TEXT_NORMAL)
|
||||
blank = true
|
||||
}
|
||||
|
||||
export function error(message: string) {
|
||||
if (message.startsWith("Error: ")) message = message.slice("Error: ".length)
|
||||
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
||||
}
|
||||
|
||||
export * as UI from "./ui"
|
||||
|
|
@ -12,9 +12,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import { 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 {
|
||||
134
packages/cli/src/server-process.ts
Normal file
134
packages/cli/src/server-process.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
80
packages/cli/test/mini.test.ts
Normal file
80
packages/cli/test/mini.test.ts
Normal 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")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -1,172 +0,0 @@
|
|||
import type { Argv } from "yargs"
|
||||
import { cmd } from "./cmd"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { resolveThreadDirectory } from "./tui"
|
||||
|
||||
type ReplayArgs = {
|
||||
replay?: boolean
|
||||
noReplay?: boolean
|
||||
}
|
||||
|
||||
type MiniArgs = ReplayArgs & {
|
||||
continue?: boolean
|
||||
session?: string
|
||||
fork?: boolean
|
||||
replayLimit?: number
|
||||
}
|
||||
|
||||
type MiniLocalArgs = MiniArgs & {
|
||||
project?: string
|
||||
model?: string
|
||||
agent?: string
|
||||
prompt?: string
|
||||
demo?: boolean
|
||||
}
|
||||
|
||||
type MiniAttachArgs = MiniArgs & {
|
||||
url: string
|
||||
dir?: string
|
||||
password?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
function replay(args: ReplayArgs) {
|
||||
if (args.replay === true) {
|
||||
UI.error("--replay is not supported; replay is enabled by default")
|
||||
process.exitCode = 1
|
||||
return "invalid" as const
|
||||
}
|
||||
return args.replay === false || args.noReplay === true ? false : undefined
|
||||
}
|
||||
|
||||
function miniOptions<T>(yargs: Argv<T>) {
|
||||
return yargs
|
||||
.option("continue", {
|
||||
alias: ["c"],
|
||||
describe: "continue the last session",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("session", {
|
||||
alias: ["s"],
|
||||
describe: "session id to continue",
|
||||
type: "string",
|
||||
})
|
||||
.option("fork", {
|
||||
type: "boolean",
|
||||
describe: "fork the session when continuing (use with --continue or --session)",
|
||||
})
|
||||
.option("replay", {
|
||||
type: "boolean",
|
||||
hidden: true,
|
||||
})
|
||||
.option("no-replay", {
|
||||
type: "boolean",
|
||||
describe: "disable session history replay on resume and after resize",
|
||||
})
|
||||
.option("replay-limit", {
|
||||
type: "number",
|
||||
describe: "cap visible replay to the newest N messages",
|
||||
})
|
||||
}
|
||||
|
||||
/** @internal Exported for CLI parser tests. */
|
||||
export const MiniLocalCommand = cmd<{}, MiniLocalArgs>({
|
||||
command: "$0 [project]",
|
||||
describe: "start the minimal interactive interface",
|
||||
builder: (yargs) =>
|
||||
miniOptions(
|
||||
yargs
|
||||
.positional("project", {
|
||||
type: "string",
|
||||
describe: "path to start opencode in",
|
||||
})
|
||||
.option("model", {
|
||||
type: "string",
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
})
|
||||
.option("agent", {
|
||||
type: "string",
|
||||
describe: "agent to use",
|
||||
})
|
||||
.option("prompt", {
|
||||
type: "string",
|
||||
describe: "prompt to use",
|
||||
})
|
||||
.option("demo", {
|
||||
type: "boolean",
|
||||
hidden: true,
|
||||
}),
|
||||
),
|
||||
handler: async (args) => {
|
||||
const shouldReplay = replay(args)
|
||||
if (shouldReplay === "invalid") return
|
||||
|
||||
const { runMini } = await import("./run")
|
||||
await runMini({
|
||||
directory: resolveThreadDirectory(args.project),
|
||||
continue: args.continue,
|
||||
session: args.session,
|
||||
fork: args.fork,
|
||||
model: args.model,
|
||||
agent: args.agent,
|
||||
prompt: args.prompt,
|
||||
replay: shouldReplay,
|
||||
replayLimit: args.replayLimit,
|
||||
demo: args.demo,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
/** @internal Exported for CLI parser tests. */
|
||||
export const MiniAttachCommand = cmd<{}, MiniAttachArgs>({
|
||||
command: "attach <url>",
|
||||
describe: "attach to a running opencode server with the minimal interface",
|
||||
builder: (yargs) =>
|
||||
miniOptions(
|
||||
yargs
|
||||
.positional("url", {
|
||||
type: "string",
|
||||
describe: "http://localhost:4096",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("dir", {
|
||||
type: "string",
|
||||
describe: "directory on the remote server",
|
||||
})
|
||||
.option("password", {
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
|
||||
})
|
||||
.option("username", {
|
||||
alias: ["u"],
|
||||
type: "string",
|
||||
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
|
||||
}),
|
||||
),
|
||||
handler: async (args) => {
|
||||
const shouldReplay = replay(args)
|
||||
if (shouldReplay === "invalid") return
|
||||
|
||||
const { runMini } = await import("./run")
|
||||
await runMini({
|
||||
attach: args.url,
|
||||
directory: args.dir,
|
||||
password: args.password,
|
||||
username: args.username,
|
||||
continue: args.continue,
|
||||
session: args.session,
|
||||
fork: args.fork,
|
||||
replay: shouldReplay,
|
||||
replayLimit: args.replayLimit,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const MiniCommand = cmd({
|
||||
command: "mini",
|
||||
describe: "start the minimal interactive interface",
|
||||
builder: (yargs) => yargs.command(MiniLocalCommand).command(MiniAttachCommand).demandCommand(),
|
||||
handler: async () => {},
|
||||
})
|
||||
File diff suppressed because it is too large
Load diff
31
packages/opencode/src/cli/cmd/v2-serve.ts
Normal file
31
packages/opencode/src/cli/cmd/v2-serve.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
})
|
||||
8
packages/opencode/src/cli/cmd/v2-server-command.ts
Normal file
8
packages/opencode/src/cli/cmd/v2-server-command.ts
Normal 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"]
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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" }])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -15546,6 +15546,8 @@ export type V2HealthGetResponses = {
|
|||
*/
|
||||
200: {
|
||||
healthy: true
|
||||
version: string
|
||||
pid: number
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
64
packages/server/src/process.ts
Normal file
64
packages/server/src/process.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue