diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 23937d8292b0..4696a1b23537 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -53,6 +53,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Flag.withDescription("Session ID to continue"), Flag.optional, ), + fork: Flag.boolean("fork").pipe( + Flag.withDescription("Fork the session before continuing"), + Flag.withDefault(false), + ), prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional), }, commands: [ diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 35c5635a09f2..1576038477b6 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -16,6 +16,9 @@ export default Runtime.handler(Commands, (input) => Effect.gen(function* () { const requestedDirectory = Option.getOrUndefined(input.directory) const requestedServer = Option.getOrUndefined(input.server) + const requestedSession = Option.getOrUndefined(input.session) + if (input.fork && !input.continue && !requestedSession) + yield* Effect.fail(new Error("--fork requires --continue or --session")) if (requestedDirectory !== undefined) process.chdir(requestedDirectory) const preflight = UpdatePreflight.make() yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close())) @@ -74,7 +77,8 @@ export default Runtime.handler(Commands, (input) => }, args: { continue: input.continue, - sessionID: Option.getOrUndefined(input.session), + sessionID: requestedSession, + fork: input.fork, prompt: Option.getOrUndefined(input.prompt), auto: input.auto || input.yolo || input.dangerouslySkipPermissions, }, diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index e9304d194fa8..8bedf44e024a 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -121,6 +121,13 @@ describe("mini command", () => { expect(result.stdout).toContain("run Run OpenCode with a message") }) + test("requires a session target when forking from the full TUI", async () => { + const result = await cli(["--fork"]) + + expect(result.exitCode).toBe(1) + expect(result.stdout).toContain("--fork requires --continue or --session") + }) + test("exposes run without legacy interactive, attach, or command modes", async () => { const result = await cli(["run", "--help"]) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c1bdf5cfd74b..b99cc8565c1d 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -365,7 +365,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { { - if (continued || !args.continue) return + if (continued || !args.continue || args.sessionID) return continued = true const location = data.location.default() void client.api.session diff --git a/packages/tui/src/context/args.tsx b/packages/tui/src/context/args.tsx index 9a528f2f0ece..76a403acd71b 100644 --- a/packages/tui/src/context/args.tsx +++ b/packages/tui/src/context/args.tsx @@ -1,4 +1,5 @@ import { createSimpleContext } from "./helper" +import { createSignal } from "solid-js" export interface Args { model?: string @@ -12,5 +13,23 @@ export interface Args { export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({ name: "Args", - init: (props: Args) => props, + init: (props: Args) => { + const [prompt, setPrompt] = createSignal(props.prompt) + return { + model: props.model, + agent: props.agent, + continue: props.continue, + sessionID: props.sessionID, + fork: props.fork, + auto: props.auto, + get prompt() { + return prompt() + }, + consumePrompt() { + const value = prompt() + setPrompt(undefined) + return value + }, + } + }, }) diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index 0180d9a5ffac..41d4b4d08667 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -69,6 +69,7 @@ export function Home() { if (!args.prompt) return if (r.current.text !== args.prompt) return sent = true + args.consumePrompt() r.submit() }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index ff05284da176..5eaeeddd76ab 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -558,6 +558,7 @@ export function Session(props: { if (!local.agent.current() || !local.model.current()) return if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return sent = true + args.consumePrompt() current.submit() }) const dialog = useDialog() diff --git a/packages/tui/test/context/args.test.tsx b/packages/tui/test/context/args.test.tsx new file mode 100644 index 000000000000..d81ab748dc70 --- /dev/null +++ b/packages/tui/test/context/args.test.tsx @@ -0,0 +1,26 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { ArgsProvider, useArgs } from "../../src/context/args" + +test("consumes the startup prompt once", async () => { + let args: ReturnType + function Probe() { + args = useArgs() + return + } + const app = await testRender(() => ( + + + + )) + + try { + expect(args!.prompt).toBe("FOLLOW_UP") + expect(args!.consumePrompt()).toBe("FOLLOW_UP") + expect(args!.prompt).toBeUndefined() + expect(args!.consumePrompt()).toBeUndefined() + } finally { + app.renderer.destroy() + } +})