cli: add --mini (#33353)

This commit is contained in:
Simon Klee
2026-06-22 16:27:56 +02:00
committed by GitHub
parent cf31029350
commit 0d32d1f293
11 changed files with 286 additions and 55 deletions
+54 -3
View File
@@ -41,14 +41,31 @@ export const AttachCommand = cmd({
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
})
.option("mini", {
type: "boolean",
describe: "start the minimal interactive interface",
default: false,
})
.option("replay", {
type: "boolean",
hidden: true,
})
.option("no-replay", {
type: "boolean",
describe: "disable mini session history replay on resume and after resize",
})
.option("replay-limit", {
type: "number",
describe: "cap visible mini replay to the newest N messages",
}),
handler: async (args) => {
const { TuiConfig } = await import("@/config/tui")
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
if (args.replay === true) {
UI.error("--replay is not supported; replay is enabled by default")
process.exitCode = 1
return
}
const noReplay = args.replay === false || args.noReplay === true
const directory = (() => {
if (!args.dir) return undefined
@@ -60,6 +77,40 @@ export const AttachCommand = cmd({
return args.dir
}
})()
if (args.mini) {
const { runMini } = await import("./run")
await runMini({
attach: args.url,
directory,
password: args.password,
username: args.username,
continue: args.continue,
session: args.session,
fork: args.fork,
replay: noReplay ? false : undefined,
replayLimit: args.replayLimit,
})
return
}
const unsupported = [
["--no-replay", noReplay],
["--replay-limit", args.replayLimit !== undefined],
].find((entry) => entry[1])?.[0]
if (unsupported) {
UI.error(`${unsupported} requires --mini`)
process.exitCode = 1
return
}
const { TuiConfig } = await import("@/config/tui")
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exitCode = 1
return
}
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await TuiConfig.get()
+1 -1
View File
@@ -1,6 +1,6 @@
import type { CommandModule } from "yargs"
export type WithDoubleDash<T> = T & { "--"?: string[] }
export type WithDoubleDash<T> = T & { "--"?: string[]; _?: Array<string | number> }
export function cmd<T, U>(input: CommandModule<T, WithDoubleDash<U>>) {
return input
+82 -26
View File
@@ -1,13 +1,13 @@
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { FSUtil } from "@opencode-ai/core/fs-util"
// CLI entry point for `opencode run`.
// CLI entry point for `opencode run` and `opencode --mini`.
//
// Handles three modes:
// 1. Non-interactive (default): sends a single prompt, streams events to
// stdout, and exits when the session goes idle.
// 2. Interactive local (`--interactive`): boots the split-footer direct mode
// 2. Interactive local (`opencode --mini`): boots the split-footer direct mode
// with an in-process server (no external HTTP).
// 3. Interactive attach (`--interactive --attach`): connects to a running
// 3. Interactive attach (`opencode --mini --attach`): connects to a running
// opencode server and runs interactive mode against it.
//
// Also supports `--command` for slash-command execution, `--format json` for
@@ -217,21 +217,22 @@ export const RunCommand = effectCmd({
type: "boolean",
describe: "show thinking blocks",
})
.option("mini", {
type: "boolean",
hidden: true,
default: false,
})
.option("replay", {
type: "boolean",
default: true,
hidden: true,
describe: "replay interactive session history on resume and after resize (use --no-replay to disable)",
})
.option("replay-limit", {
type: "number",
hidden: true,
describe: "cap visible interactive replay to the newest N messages",
})
.option("interactive", {
alias: ["i"],
type: "boolean",
describe: "run in direct interactive split-footer mode",
default: false,
})
.option("dangerously-skip-permissions", {
type: "boolean",
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
@@ -240,6 +241,7 @@ export const RunCommand = effectCmd({
.option("demo", {
type: "boolean",
default: false,
hidden: true,
describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately",
}),
handler: Effect.fn("Cli.run")(function* (args) {
@@ -252,7 +254,8 @@ export const RunCommand = effectCmd({
const localInstance = yield* InstanceRef
yield* Effect.promise(async () => {
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
const interactive = args.mini
const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false)
const die = (message: string): never => {
UI.error(message)
process.exit(1)
@@ -269,20 +272,24 @@ export const RunCommand = effectCmd({
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ")
if (args.interactive && args.command) {
die("--interactive cannot be used with --command")
if (interactive && args.command) {
die("--mini cannot be used with --command")
}
if (args.demo && !args.interactive) {
die("--demo requires --interactive")
if (interactive && args._?.[0] !== "mini") {
die("--mini must be used without the run subcommand")
}
if (args.interactive && args.format === "json") {
die("--interactive cannot be used with --format json")
if (args.demo && !interactive) {
die("--demo requires --mini")
}
if (args["replay-limit"] !== undefined && !args.interactive) {
die("--replay-limit requires --interactive")
if (interactive && args.format === "json") {
die("--mini cannot be used with --format json")
}
if (args["replay-limit"] !== undefined && !interactive) {
die("--replay-limit requires --mini")
}
if (
@@ -292,11 +299,11 @@ export const RunCommand = effectCmd({
die("--replay-limit must be a positive integer")
}
if (args.interactive && !process.stdout.isTTY) {
die("--interactive requires a TTY stdout")
if (interactive && !process.stdout.isTTY) {
die("--mini requires a TTY stdout")
}
if (args.interactive) {
if (interactive) {
try {
resolveInteractiveStdin().cleanup?.()
} catch (error) {
@@ -304,7 +311,7 @@ export const RunCommand = effectCmd({
}
}
const replay = args.replay || args["replay-limit"] !== undefined
const replay = args.replay === false ? false : args.replay || args["replay-limit"] !== undefined
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const directory = (() => {
@@ -393,7 +400,7 @@ export const RunCommand = effectCmd({
message = resolveRunInput(message, piped) ?? ""
const initialInput = resolveRunInput(rawMessage, piped)
if (message.trim().length === 0 && !args.command && !args.interactive) {
if (message.trim().length === 0 && !args.command && !interactive) {
UI.error("You must provide a message or a command")
process.exit(1)
}
@@ -403,7 +410,7 @@ export const RunCommand = effectCmd({
process.exit(1)
}
const rules: PermissionV1.Ruleset = args.interactive
const rules: PermissionV1.Ruleset = interactive
? []
: [
{
@@ -801,7 +808,7 @@ export const RunCommand = effectCmd({
await share(client, sessionID)
if (!args.interactive) {
if (!interactive) {
const events = await client.event.subscribe()
const completed = loop(client, events).catch((e) => {
console.error(e)
@@ -875,7 +882,7 @@ export const RunCommand = effectCmd({
return
}
if (args.interactive && !args.attach && !args.session && !args.continue) {
if (interactive && !args.attach && !args.session && !args.continue) {
const model = pick(args.model)
const { runInteractiveLocalMode } = await import("./run/runtime")
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -933,3 +940,52 @@ export const RunCommand = effectCmd({
})
}),
})
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
}
export async function runMini(input: MiniCommandInput) {
if (!RunCommand.handler) throw new Error("Mini command handler is unavailable")
await RunCommand.handler({
$0: "opencode",
_: ["mini"],
message: input.prompt ? [input.prompt] : [],
command: undefined,
continue: input.continue,
session: input.session,
fork: input.fork,
share: undefined,
model: input.model,
agent: input.agent,
format: "default",
file: undefined,
title: undefined,
attach: input.attach,
password: input.password,
username: input.username,
dir: input.directory,
port: undefined,
variant: undefined,
thinking: undefined,
mini: true,
replay: input.replay ?? true,
"replay-limit": input.replayLimit,
replayLimit: input.replayLimit,
"dangerously-skip-permissions": false,
dangerouslySkipPermissions: false,
demo: input.demo ?? false,
})
}
@@ -1,7 +1,7 @@
import fs from "fs"
import * as tty from "node:tty"
export const INTERACTIVE_INPUT_ERROR = "--interactive requires a controlling terminal for input"
export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input"
type InteractiveStdin = {
stdin: NodeJS.ReadStream
+1 -1
View File
@@ -1,4 +1,4 @@
// Top-level orchestrator for `run --interactive`.
// Top-level orchestrator for `opencode --mini`.
//
// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
// and prompt queue together into a single session loop. Two entry points:
+1 -1
View File
@@ -234,7 +234,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
lines,
body_left + label.length,
top + 1,
`opencode run -i -s ${meta.session_id}`,
`opencode --mini -s ${meta.session_id}`,
right,
undefined,
TextAttributes.BOLD,
+1 -1
View File
@@ -1,4 +1,4 @@
// Shared type vocabulary for the direct interactive mode (`run --interactive`).
// Shared type vocabulary for the direct interactive mode (`opencode --mini`).
//
// Direct mode uses a split-footer terminal layout: immutable scrollback for the
// session transcript, and a mutable footer for prompt input, status, and
+65
View File
@@ -103,8 +103,73 @@ export const TuiThreadCommand = cmd({
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("mini", {
type: "boolean",
describe: "start the minimal interactive interface",
default: false,
})
.option("replay", {
type: "boolean",
hidden: true,
})
.option("no-replay", {
type: "boolean",
describe: "disable mini session history replay on resume and after resize",
})
.option("replay-limit", {
type: "number",
describe: "cap visible mini replay to the newest N messages",
})
.option("demo", {
type: "boolean",
hidden: true,
}),
handler: async (args) => {
if (args.replay === true) {
UI.error("--replay is not supported; replay is enabled by default")
process.exitCode = 1
return
}
const noReplay = args.replay === false || args.noReplay === true
if (args.mini) {
const network = ["--port", "--hostname", "--mdns", "--no-mdns", "--mdns-domain", "--cors"].find((option) =>
process.argv.some((arg) => arg === option || arg.startsWith(option + "=")),
)
if (network) {
UI.error(`${network} cannot be used with --mini`)
process.exitCode = 1
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: noReplay ? false : undefined,
replayLimit: args.replayLimit,
demo: args.demo,
})
return
}
const unsupported = [
["--no-replay", noReplay],
["--replay-limit", args.replayLimit !== undefined],
["--demo", args.demo !== undefined],
].find((entry) => entry[1])?.[0]
if (unsupported) {
UI.error(`${unsupported} requires --mini`)
process.exitCode = 1
return
}
const unguard = win32InstallCtrlCGuard()
try {
const { TuiConfig } = await import("@/config/tui")