From 0d32d1f2935cc18bb9567763fb16128647012b21 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 22 Jun 2026 16:27:56 +0200 Subject: [PATCH] cli: add --mini (#33353) --- packages/opencode/src/cli/cmd/attach.ts | 57 ++++++++- packages/opencode/src/cli/cmd/cmd.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 108 +++++++++++++----- .../opencode/src/cli/cmd/run/runtime.stdin.ts | 2 +- packages/opencode/src/cli/cmd/run/runtime.ts | 2 +- packages/opencode/src/cli/cmd/run/splash.ts | 2 +- packages/opencode/src/cli/cmd/run/types.ts | 2 +- packages/opencode/src/cli/cmd/tui.ts | 65 +++++++++++ .../__snapshots__/help-snapshots.test.ts.snap | 36 +++--- .../test/cli/help/help-snapshots.test.ts | 4 + packages/opencode/test/cli/tui/thread.test.ts | 61 +++++++++- 11 files changed, 286 insertions(+), 55 deletions(-) diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 278cee8a70..6f5aea6a12 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -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() diff --git a/packages/opencode/src/cli/cmd/cmd.ts b/packages/opencode/src/cli/cmd/cmd.ts index 05af009b88..910787f940 100644 --- a/packages/opencode/src/cli/cmd/cmd.ts +++ b/packages/opencode/src/cli/cmd/cmd.ts @@ -1,6 +1,6 @@ import type { CommandModule } from "yargs" -export type WithDoubleDash = T & { "--"?: string[] } +export type WithDoubleDash = T & { "--"?: string[]; _?: Array } export function cmd(input: CommandModule>) { return input diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 958632776b..fad09c3a7a 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -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, + }) +} diff --git a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts index dad46a7fb0..d236fb02c2 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts @@ -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 diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 65cd15f1ad..90cddffa22 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -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: diff --git a/packages/opencode/src/cli/cmd/run/splash.ts b/packages/opencode/src/cli/cmd/run/splash.ts index 20194b95ce..141ff6fc55 100644 --- a/packages/opencode/src/cli/cmd/run/splash.ts +++ b/packages/opencode/src/cli/cmd/run/splash.ts @@ -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, diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index 62e1a2d8a4..a914922e48 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -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 diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 68941e976a..329874791d 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -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") diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index a672d2acae..25a4c38f90 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -50,17 +50,21 @@ 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 to run in [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] - -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] - -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')[string]" + -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 to run in [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] + -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] + -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') + [string] + --mini start the minimal interactive interface [boolean] [default: false] + --no-replay disable mini session history replay on resume and after resize [boolean] + --replay-limit cap visible mini replay to the newest N messages [number]" `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = ` @@ -103,16 +107,8 @@ Options: --variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string] --thinking show thinking blocks [boolean] - --replay replay interactive session history on resume and after resize - (use --no-replay to disable) [boolean] [default: true] - --replay-limit cap visible interactive replay to the newest N messages - [number] - -i, --interactive run in direct interactive split-footer mode - [boolean] [default: false] --dangerously-skip-permissions auto-approve permissions that are not explicitly denied - (dangerous!) [boolean] [default: false] - --demo enable direct interactive demo slash commands; pass one as the - message to run it immediately [boolean] [default: false]" + (dangerous!) [boolean] [default: false]" `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode debug --help 1`] = ` diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index edd92120ad..a2626b113d 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -102,6 +102,10 @@ describe("opencode CLI help-text snapshots", () => { const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV }) expect(topLevel.exitCode).toBe(0) expect(topLevel.stderr.endsWith(EOL)).toBe(true) + expect(topLevel.stderr).toContain("--mini") + expect(topLevel.stderr).not.toContain("--thinking") + expect(topLevel.stderr).not.toContain("--variant") + expect(topLevel.stderr).not.toContain("--demo") const argvs: Array = [...TOP_LEVEL.map((c) => [c] as const), ...SUBCOMMANDS] diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index f79fd40da7..73f87d904b 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test" +import { Effect } from "effect" import fs from "fs/promises" import path from "path" +import yargs from "yargs" import { tmpdir } from "../../fixture/fixture" -import { resolveThreadDirectory } from "../../../src/cli/cmd/tui" +import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui" +import { cliIt } from "../../lib/cli-process" describe("tui thread", () => { test("loads the TUI integration lazily", async () => { @@ -33,4 +36,60 @@ describe("tui thread", () => { test("uses the real cwd after resolving a relative project from PWD", async () => { await check(".") }) + + test("resolves a relative mini project from PWD when cwd differs", async () => { + await using pwd = await tmpdir({ git: true }) + await using cwd = await tmpdir({ git: true }) + + expect(resolveThreadDirectory(".", pwd.path, cwd.path)).toBe(pwd.path) + expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path) + }) + + test("parses supported --no-replay forms", async () => { + for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) { + const args = await yargs([]) + .command({ ...TuiThreadCommand, handler: () => {} }) + .exitProcess(false) + .parse(["--mini", 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: () => {} }) + .exitProcess(false) + .parse(["--mdns", "--no-mdns"]) + + expect(args.mdns).toBe(false) + }) + + cliIt.live("rejects mini-only options without --mini", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["--replay-limit", "10"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--replay-limit requires --mini") + }), + ) + + cliIt.live("routes attached sessions to mini mode", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--mini requires a TTY stdout") + }), + ) + + cliIt.live("rejects network options in mini mode", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["--mini", "--port", "4096"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--port cannot be used with --mini") + }), + ) })