diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index a287c3c9ec..c0b914fc62 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -32,6 +32,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Flag.withDescription("Session ID to continue"), Flag.optional, ), + prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional), }, commands: [ Spec.make("acp", { description: "Start an Agent Client Protocol server" }), diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 66f7469aa5..f83a0ef7cb 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -55,7 +55,11 @@ export default Runtime.handler(Commands, (input) => } : undefined, }, - args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, + args: { + continue: input.continue, + sessionID: Option.getOrUndefined(input.session), + prompt: Option.getOrUndefined(input.prompt), + }, config: { path: config.path, get: () => runPromise(config.get()), diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b4e6fb815e..7043329779 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -525,6 +525,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { }) const args = useArgs() + const startupPrompt = args.prompt ? { text: args.prompt, files: [], agents: [], pasted: [] } : undefined onMount(() => { batch(() => { if (args.agent) local.agent.set(args.agent) @@ -542,6 +543,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { route.navigate({ type: "session", sessionID: args.sessionID, + prompt: startupPrompt, }) } }) @@ -564,12 +566,12 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { const match = response.data[0]?.id if (!match) return if (!args.fork) { - route.navigate({ type: "session", sessionID: match }) + route.navigate({ type: "session", sessionID: match, prompt: startupPrompt }) return } void client.api.session .fork({ sessionID: match }) - .then((result) => route.navigate({ type: "session", sessionID: result.id })) + .then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt })) .catch(toast.error) }) .catch(toast.error) @@ -582,7 +584,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { forked = true void client.api.session .fork({ sessionID: args.sessionID }) - .then((result) => route.navigate({ type: "session", sessionID: result.id })) + .then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt })) .catch(toast.error) }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index acf38c353c..5a4fd04e82 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -84,6 +84,7 @@ import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type import { switchLabel } from "../../util/model" import { findMessageBoundary, messageNavigationSlack } from "./message-navigation" import { stringWidth } from "../../util/string-width" +import { useArgs } from "../../context/args" addDefaultParsers(parsers.parsers) @@ -120,6 +121,7 @@ export function Session() { const { navigate } = useRoute() const data = useData() const local = useLocal() + const args = useArgs() const paths = useTuiPaths() const configState = useConfig() const config = configState.data @@ -216,6 +218,7 @@ export function Session() { const boundaries = createMemo(() => messageBoundaryIDs(rows, messages())) const [navigationMessage, setNavigationMessage] = createSignal() const [navigationSlack, setNavigationSlack] = createSignal(0) + const [synced, setSynced] = createSignal(false) const clearMessageNavigation = () => { setNavigationSlack(0) @@ -242,6 +245,7 @@ export function Session() { createEffect(() => { if (client.connection.status() !== "connected") return + setSynced(false) const sessionID = route.sessionID void (async () => { await Promise.all([ @@ -261,6 +265,7 @@ export function Session() { } editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) + setSynced(true) })().catch((error) => { if (route.sessionID !== sessionID) return toast.show({ @@ -273,15 +278,25 @@ export function Session() { }) let seeded = false + let sent = false let scroll: ScrollBoxRenderable - let prompt: PromptRef | undefined + const [prompt, setPrompt] = createSignal() const bind = (r: PromptRef | undefined) => { - prompt = r + setPrompt(r) promptRef.set(r) if (seeded || !route.prompt || !r) return seeded = true r.set(route.prompt) } + + createEffect(() => { + const current = prompt() + if (sent || !current || !synced() || !local.model.ready) return + if (!local.agent.current() || !local.model.current()) return + if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return + sent = true + current.submit() + }) const dialog = useDialog() const renderer = useRenderer() const unavailable = (feature: string) => { @@ -526,7 +541,7 @@ export function Session() { void client.api.session.revert .stage({ sessionID: route.sessionID, messageID: message.id }) .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) - prompt?.set({ + prompt()?.set({ ...projectedPromptInput(message), pasted: [], }) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 81a61684ba..a07e3e6b12 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -69,6 +69,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after setTitle(title) } const events = createEventStream() + let promptRequests = 0 const calls = createFetch((url) => { const session = { id: "dummy", @@ -88,6 +89,10 @@ test("session lifecycle updates the terminal title and prints the epilogue after if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) if (url.pathname === "/api/session/dummy/pending") return json({ data: [] }) if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) + if (url.pathname === "/api/session/dummy/prompt") { + promptRequests++ + return json({ data: {} }) + } }, events) const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) const originalWrite = process.stdout.write.bind(process.stdout) @@ -124,6 +129,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after expect(stdout).toContain("Renamed session") expect(stdout).toContain("opencode2 -s dummy") + expect(promptRequests).toBe(0) } finally { process.stdout.write = originalWrite if (!setup.renderer.isDestroyed) setup.renderer.destroy() @@ -131,3 +137,80 @@ test("session lifecycle updates the terminal title and prints the epilogue after mock.restore() } }) + +test("session startup prompt is submitted exactly once", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventStream() + const cwd = process.cwd() + const location = { directory: cwd, project: { id: "project", directory: cwd } } + const session = { + id: "dummy", + title: "Demo session", + projectID: "project", + location: { directory: cwd }, + agent: "build", + model: { providerID: "provider", id: "model" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const bodies: unknown[] = [] + const promptSubmitted = Promise.withResolvers() + const calls = createFetch(async (url, request) => { + if (url.pathname === "/api/location") return json(location) + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === "/api/session/dummy") return json({ data: session }) + if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session/dummy/pending") return json({ data: [] }) + if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) + if (url.pathname === "/api/agent") + return json({ + location, + data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }], + }) + if (url.pathname === "/api/model") + return json({ + location, + data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }], + }) + if (url.pathname === "/api/session/dummy/prompt") { + bodies.push(await request.json()) + promptSubmitted.resolve() + return json({ data: {} }) + } + }, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + args: { sessionID: "dummy", prompt: "RESUME_READY" }, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + + await Promise.race([ + promptSubmitted.promise, + Bun.sleep(2000).then(() => { + throw new Error("startup prompt was not submitted") + }), + ]) + await Bun.sleep(20) + setup.renderer.destroy() + await task + + expect(bodies).toHaveLength(1) + expect(bodies[0]).toMatchObject({ text: "RESUME_READY" }) + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + mock.restore() + } +})