Skip to content

Commit 0d32d1f

Browse files
authored
cli: add --mini (#33353)
1 parent cf31029 commit 0d32d1f

11 files changed

Lines changed: 286 additions & 55 deletions

File tree

packages/opencode/src/cli/cmd/attach.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,31 @@ export const AttachCommand = cmd({
4141
alias: ["u"],
4242
type: "string",
4343
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
44+
})
45+
.option("mini", {
46+
type: "boolean",
47+
describe: "start the minimal interactive interface",
48+
default: false,
49+
})
50+
.option("replay", {
51+
type: "boolean",
52+
hidden: true,
53+
})
54+
.option("no-replay", {
55+
type: "boolean",
56+
describe: "disable mini session history replay on resume and after resize",
57+
})
58+
.option("replay-limit", {
59+
type: "number",
60+
describe: "cap visible mini replay to the newest N messages",
4461
}),
4562
handler: async (args) => {
46-
const { TuiConfig } = await import("@/config/tui")
47-
if (args.fork && !args.continue && !args.session) {
48-
UI.error("--fork requires --continue or --session")
63+
if (args.replay === true) {
64+
UI.error("--replay is not supported; replay is enabled by default")
4965
process.exitCode = 1
5066
return
5167
}
68+
const noReplay = args.replay === false || args.noReplay === true
5269

5370
const directory = (() => {
5471
if (!args.dir) return undefined
@@ -60,6 +77,40 @@ export const AttachCommand = cmd({
6077
return args.dir
6178
}
6279
})()
80+
81+
if (args.mini) {
82+
const { runMini } = await import("./run")
83+
await runMini({
84+
attach: args.url,
85+
directory,
86+
password: args.password,
87+
username: args.username,
88+
continue: args.continue,
89+
session: args.session,
90+
fork: args.fork,
91+
replay: noReplay ? false : undefined,
92+
replayLimit: args.replayLimit,
93+
})
94+
return
95+
}
96+
97+
const unsupported = [
98+
["--no-replay", noReplay],
99+
["--replay-limit", args.replayLimit !== undefined],
100+
].find((entry) => entry[1])?.[0]
101+
if (unsupported) {
102+
UI.error(`${unsupported} requires --mini`)
103+
process.exitCode = 1
104+
return
105+
}
106+
107+
const { TuiConfig } = await import("@/config/tui")
108+
if (args.fork && !args.continue && !args.session) {
109+
UI.error("--fork requires --continue or --session")
110+
process.exitCode = 1
111+
return
112+
}
113+
63114
const headers = ServerAuth.headers({ password: args.password, username: args.username })
64115
const config = await TuiConfig.get()
65116

packages/opencode/src/cli/cmd/cmd.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { CommandModule } from "yargs"
22

3-
export type WithDoubleDash<T> = T & { "--"?: string[] }
3+
export type WithDoubleDash<T> = T & { "--"?: string[]; _?: Array<string | number> }
44

55
export function cmd<T, U>(input: CommandModule<T, WithDoubleDash<U>>) {
66
return input

packages/opencode/src/cli/cmd/run.ts

Lines changed: 82 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
22
import { FSUtil } from "@opencode-ai/core/fs-util"
3-
// CLI entry point for `opencode run`.
3+
// CLI entry point for `opencode run` and `opencode --mini`.
44
//
55
// Handles three modes:
66
// 1. Non-interactive (default): sends a single prompt, streams events to
77
// stdout, and exits when the session goes idle.
8-
// 2. Interactive local (`--interactive`): boots the split-footer direct mode
8+
// 2. Interactive local (`opencode --mini`): boots the split-footer direct mode
99
// with an in-process server (no external HTTP).
10-
// 3. Interactive attach (`--interactive --attach`): connects to a running
10+
// 3. Interactive attach (`opencode --mini --attach`): connects to a running
1111
// opencode server and runs interactive mode against it.
1212
//
1313
// Also supports `--command` for slash-command execution, `--format json` for
@@ -217,21 +217,22 @@ export const RunCommand = effectCmd({
217217
type: "boolean",
218218
describe: "show thinking blocks",
219219
})
220+
.option("mini", {
221+
type: "boolean",
222+
hidden: true,
223+
default: false,
224+
})
220225
.option("replay", {
221226
type: "boolean",
222227
default: true,
228+
hidden: true,
223229
describe: "replay interactive session history on resume and after resize (use --no-replay to disable)",
224230
})
225231
.option("replay-limit", {
226232
type: "number",
233+
hidden: true,
227234
describe: "cap visible interactive replay to the newest N messages",
228235
})
229-
.option("interactive", {
230-
alias: ["i"],
231-
type: "boolean",
232-
describe: "run in direct interactive split-footer mode",
233-
default: false,
234-
})
235236
.option("dangerously-skip-permissions", {
236237
type: "boolean",
237238
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
@@ -240,6 +241,7 @@ export const RunCommand = effectCmd({
240241
.option("demo", {
241242
type: "boolean",
242243
default: false,
244+
hidden: true,
243245
describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately",
244246
}),
245247
handler: Effect.fn("Cli.run")(function* (args) {
@@ -252,7 +254,8 @@ export const RunCommand = effectCmd({
252254
const localInstance = yield* InstanceRef
253255
yield* Effect.promise(async () => {
254256
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
255-
const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
257+
const interactive = args.mini
258+
const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false)
256259
const die = (message: string): never => {
257260
UI.error(message)
258261
process.exit(1)
@@ -269,20 +272,24 @@ export const RunCommand = effectCmd({
269272
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
270273
.join(" ")
271274

272-
if (args.interactive && args.command) {
273-
die("--interactive cannot be used with --command")
275+
if (interactive && args.command) {
276+
die("--mini cannot be used with --command")
274277
}
275278

276-
if (args.demo && !args.interactive) {
277-
die("--demo requires --interactive")
279+
if (interactive && args._?.[0] !== "mini") {
280+
die("--mini must be used without the run subcommand")
278281
}
279282

280-
if (args.interactive && args.format === "json") {
281-
die("--interactive cannot be used with --format json")
283+
if (args.demo && !interactive) {
284+
die("--demo requires --mini")
282285
}
283286

284-
if (args["replay-limit"] !== undefined && !args.interactive) {
285-
die("--replay-limit requires --interactive")
287+
if (interactive && args.format === "json") {
288+
die("--mini cannot be used with --format json")
289+
}
290+
291+
if (args["replay-limit"] !== undefined && !interactive) {
292+
die("--replay-limit requires --mini")
286293
}
287294

288295
if (
@@ -292,19 +299,19 @@ export const RunCommand = effectCmd({
292299
die("--replay-limit must be a positive integer")
293300
}
294301

295-
if (args.interactive && !process.stdout.isTTY) {
296-
die("--interactive requires a TTY stdout")
302+
if (interactive && !process.stdout.isTTY) {
303+
die("--mini requires a TTY stdout")
297304
}
298305

299-
if (args.interactive) {
306+
if (interactive) {
300307
try {
301308
resolveInteractiveStdin().cleanup?.()
302309
} catch (error) {
303310
dieInteractive(error)
304311
}
305312
}
306313

307-
const replay = args.replay || args["replay-limit"] !== undefined
314+
const replay = args.replay === false ? false : args.replay || args["replay-limit"] !== undefined
308315

309316
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
310317
const directory = (() => {
@@ -393,7 +400,7 @@ export const RunCommand = effectCmd({
393400
message = resolveRunInput(message, piped) ?? ""
394401
const initialInput = resolveRunInput(rawMessage, piped)
395402

396-
if (message.trim().length === 0 && !args.command && !args.interactive) {
403+
if (message.trim().length === 0 && !args.command && !interactive) {
397404
UI.error("You must provide a message or a command")
398405
process.exit(1)
399406
}
@@ -403,7 +410,7 @@ export const RunCommand = effectCmd({
403410
process.exit(1)
404411
}
405412

406-
const rules: PermissionV1.Ruleset = args.interactive
413+
const rules: PermissionV1.Ruleset = interactive
407414
? []
408415
: [
409416
{
@@ -801,7 +808,7 @@ export const RunCommand = effectCmd({
801808

802809
await share(client, sessionID)
803810

804-
if (!args.interactive) {
811+
if (!interactive) {
805812
const events = await client.event.subscribe()
806813
const completed = loop(client, events).catch((e) => {
807814
console.error(e)
@@ -875,7 +882,7 @@ export const RunCommand = effectCmd({
875882
return
876883
}
877884

878-
if (args.interactive && !args.attach && !args.session && !args.continue) {
885+
if (interactive && !args.attach && !args.session && !args.continue) {
879886
const model = pick(args.model)
880887
const { runInteractiveLocalMode } = await import("./run/runtime")
881888
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -933,3 +940,52 @@ export const RunCommand = effectCmd({
933940
})
934941
}),
935942
})
943+
944+
type MiniCommandInput = {
945+
directory?: string
946+
attach?: string
947+
password?: string
948+
username?: string
949+
continue?: boolean
950+
session?: string
951+
fork?: boolean
952+
model?: string
953+
agent?: string
954+
prompt?: string
955+
replay?: boolean
956+
replayLimit?: number
957+
demo?: boolean
958+
}
959+
960+
export async function runMini(input: MiniCommandInput) {
961+
if (!RunCommand.handler) throw new Error("Mini command handler is unavailable")
962+
await RunCommand.handler({
963+
$0: "opencode",
964+
_: ["mini"],
965+
message: input.prompt ? [input.prompt] : [],
966+
command: undefined,
967+
continue: input.continue,
968+
session: input.session,
969+
fork: input.fork,
970+
share: undefined,
971+
model: input.model,
972+
agent: input.agent,
973+
format: "default",
974+
file: undefined,
975+
title: undefined,
976+
attach: input.attach,
977+
password: input.password,
978+
username: input.username,
979+
dir: input.directory,
980+
port: undefined,
981+
variant: undefined,
982+
thinking: undefined,
983+
mini: true,
984+
replay: input.replay ?? true,
985+
"replay-limit": input.replayLimit,
986+
replayLimit: input.replayLimit,
987+
"dangerously-skip-permissions": false,
988+
dangerouslySkipPermissions: false,
989+
demo: input.demo ?? false,
990+
})
991+
}

packages/opencode/src/cli/cmd/run/runtime.stdin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from "fs"
22
import * as tty from "node:tty"
33

4-
export const INTERACTIVE_INPUT_ERROR = "--interactive requires a controlling terminal for input"
4+
export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input"
55

66
type InteractiveStdin = {
77
stdin: NodeJS.ReadStream

packages/opencode/src/cli/cmd/run/runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Top-level orchestrator for `run --interactive`.
1+
// Top-level orchestrator for `opencode --mini`.
22
//
33
// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
44
// and prompt queue together into a single session loop. Two entry points:

packages/opencode/src/cli/cmd/run/splash.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
234234
lines,
235235
body_left + label.length,
236236
top + 1,
237-
`opencode run -i -s ${meta.session_id}`,
237+
`opencode --mini -s ${meta.session_id}`,
238238
right,
239239
undefined,
240240
TextAttributes.BOLD,

packages/opencode/src/cli/cmd/run/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Shared type vocabulary for the direct interactive mode (`run --interactive`).
1+
// Shared type vocabulary for the direct interactive mode (`opencode --mini`).
22
//
33
// Direct mode uses a split-footer terminal layout: immutable scrollback for the
44
// session transcript, and a mutable footer for prompt input, status, and

packages/opencode/src/cli/cmd/tui.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,73 @@ export const TuiThreadCommand = cmd({
103103
.option("agent", {
104104
type: "string",
105105
describe: "agent to use",
106+
})
107+
.option("mini", {
108+
type: "boolean",
109+
describe: "start the minimal interactive interface",
110+
default: false,
111+
})
112+
.option("replay", {
113+
type: "boolean",
114+
hidden: true,
115+
})
116+
.option("no-replay", {
117+
type: "boolean",
118+
describe: "disable mini session history replay on resume and after resize",
119+
})
120+
.option("replay-limit", {
121+
type: "number",
122+
describe: "cap visible mini replay to the newest N messages",
123+
})
124+
.option("demo", {
125+
type: "boolean",
126+
hidden: true,
106127
}),
107128
handler: async (args) => {
129+
if (args.replay === true) {
130+
UI.error("--replay is not supported; replay is enabled by default")
131+
process.exitCode = 1
132+
return
133+
}
134+
const noReplay = args.replay === false || args.noReplay === true
135+
136+
if (args.mini) {
137+
const network = ["--port", "--hostname", "--mdns", "--no-mdns", "--mdns-domain", "--cors"].find((option) =>
138+
process.argv.some((arg) => arg === option || arg.startsWith(option + "=")),
139+
)
140+
if (network) {
141+
UI.error(`${network} cannot be used with --mini`)
142+
process.exitCode = 1
143+
return
144+
}
145+
146+
const { runMini } = await import("./run")
147+
await runMini({
148+
directory: resolveThreadDirectory(args.project),
149+
continue: args.continue,
150+
session: args.session,
151+
fork: args.fork,
152+
model: args.model,
153+
agent: args.agent,
154+
prompt: args.prompt,
155+
replay: noReplay ? false : undefined,
156+
replayLimit: args.replayLimit,
157+
demo: args.demo,
158+
})
159+
return
160+
}
161+
162+
const unsupported = [
163+
["--no-replay", noReplay],
164+
["--replay-limit", args.replayLimit !== undefined],
165+
["--demo", args.demo !== undefined],
166+
].find((entry) => entry[1])?.[0]
167+
if (unsupported) {
168+
UI.error(`${unsupported} requires --mini`)
169+
process.exitCode = 1
170+
return
171+
}
172+
108173
const unguard = win32InstallCtrlCGuard()
109174
try {
110175
const { TuiConfig } = await import("@/config/tui")

0 commit comments

Comments
 (0)