1
0
Simon Klee 1 сар өмнө
parent
commit
0d32d1f293

+ 54 - 3
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()
 

+ 1 - 1
packages/opencode/src/cli/cmd/cmd.ts

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

+ 1 - 1
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

+ 1 - 1
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:

+ 1 - 1
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,

+ 1 - 1
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

+ 65 - 0
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")

+ 16 - 20
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`] = `

+ 4 - 0
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<readonly string[]> = [...TOP_LEVEL.map((c) => [c] as const), ...SUBCOMMANDS]
 

+ 60 - 1
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")
+    }),
+  )
 })