diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 57f372ef709a..c0dcbc36ee71 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -476,6 +476,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }) const args = useArgs() + const startupPrompt = args.prompt ? { input: args.prompt, parts: [] } : undefined onMount(() => { batch(() => { if (args.agent) local.agent.set(args.agent) @@ -493,6 +494,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi route.navigate({ type: "session", sessionID: args.sessionID, + prompt: startupPrompt, }) } }) @@ -510,13 +512,13 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi if (args.fork) { void sdk.client.session.fork({ sessionID: match }).then((result) => { if (result.data?.id) { - route.navigate({ type: "session", sessionID: result.data.id }) + route.navigate({ type: "session", sessionID: result.data.id, prompt: startupPrompt }) } else { toast.show({ message: "Failed to fork session", variant: "error" }) } }) } else { - route.navigate({ type: "session", sessionID: match }) + route.navigate({ type: "session", sessionID: match, prompt: startupPrompt }) } } }) @@ -530,7 +532,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi forked = true void sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => { if (result.data?.id) { - route.navigate({ type: "session", sessionID: result.data.id }) + route.navigate({ type: "session", sessionID: result.data.id, prompt: startupPrompt }) } else { toast.show({ message: "Failed to fork session", variant: "error" }) } diff --git a/packages/tui/src/component/prompt/startup.ts b/packages/tui/src/component/prompt/startup.ts new file mode 100644 index 000000000000..37fee6d48bb8 --- /dev/null +++ b/packages/tui/src/component/prompt/startup.ts @@ -0,0 +1,23 @@ +import type { PromptInfo } from "./history" +import type { PromptRef } from "." + +export function createStartupPrompt(value: PromptInfo | undefined) { + let ref: PromptRef | undefined + let seeded = false + let submitted = false + + return { + bind(next: PromptRef | undefined) { + ref = next + if (!value || !next || seeded) return + seeded = true + next.set(value) + }, + submitWhenReady(ready: boolean) { + if (!value || !ref || !ready || submitted) return + if (ref.current.input !== value.input) return + submitted = true + ref.submit() + }, + } +} diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index d4145e2d87be..a734ed50e53f 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -12,6 +12,7 @@ import { useEditorContext } from "../context/editor" import { useTerminalDimensions } from "@opentui/solid" import { useTuiConfig } from "../config" import { HomeSessionDestinationProvider } from "./home/session-destination" +import { createStartupPrompt } from "../component/prompt/startup" let once = false const placeholder = { @@ -35,7 +36,7 @@ export function Home() { if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7)) return configured ?? 75 }) - let sent = false + const startupPrompt = createStartupPrompt(args.prompt ? { input: args.prompt, parts: [] } : undefined) onMount(() => { editor.clearSelection() @@ -51,20 +52,14 @@ export function Home() { return } if (!args.prompt) return - r.set({ input: args.prompt, parts: [] }) + startupPrompt.bind(r) once = true } // Wait for sync and model store to be ready before auto-submitting --prompt createEffect(() => { const r = ref() - if (sent) return - if (!r) return - if (!sync.ready || !local.model.ready) return - if (!args.prompt) return - if (r.current.input !== args.prompt) return - sent = true - r.submit() + startupPrompt.submitWhenReady(!!r && sync.ready && local.model.ready) }) return ( diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6d77b0ea58fd..34d9bc80350d 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -27,6 +27,7 @@ import { Spinner } from "../../component/spinner" import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" +import { createStartupPrompt } from "../../component/prompt/startup" import type { AssistantMessage, Part, @@ -275,6 +276,7 @@ export function Session() { const toast = useToast() const sdk = useSDK() const editor = useEditorContext() + const [sessionReady, setSessionReady] = createSignal(false) createEffect(() => { const sessionID = route.sessionID @@ -304,6 +306,7 @@ export function Session() { } editor.reconnect(result.data.directory) await sync.session.sync(sessionID) + if (route.sessionID === sessionID) setSessionReady(true) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) })().catch((error) => { if (route.sessionID !== sessionID) return @@ -333,16 +336,21 @@ export function Session() { } }) - let seeded = false let scroll: ScrollBoxRenderable let prompt: PromptRef | undefined + const [startupRef, setStartupRef] = createSignal() + const startupPrompt = createStartupPrompt(route.prompt) const bind = (r: PromptRef | undefined) => { prompt = r promptRef.set(r) - if (seeded || !route.prompt || !r) return - seeded = true - r.set(route.prompt) + setStartupRef(() => r) + startupPrompt.bind(r) } + + createEffect(() => { + startupRef() + startupPrompt.submitWhenReady(sessionReady() && sync.ready && local.model.ready) + }) const keymap = useOpencodeKeymap() const dialog = useDialog() const renderer = useRenderer() diff --git a/packages/tui/test/cli/tui/startup-prompt.test.ts b/packages/tui/test/cli/tui/startup-prompt.test.ts new file mode 100644 index 000000000000..165e2332a802 --- /dev/null +++ b/packages/tui/test/cli/tui/startup-prompt.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test" +import type { PromptRef } from "../../../src/component/prompt" +import type { PromptInfo } from "../../../src/component/prompt/history" +import { createStartupPrompt } from "../../../src/component/prompt/startup" + +function promptRef(input: { + current(): PromptInfo + set(value: PromptInfo): void + submit(): void +}): PromptRef { + return { + get current() { + return input.current() + }, + get focused() { + return false + }, + set: input.set, + submit: input.submit, + reset() {}, + blur() {}, + focus() {}, + } +} + +test("seeds and submits a startup prompt exactly once after readiness", () => { + const calls = { + set: 0, + submit: 0, + } + let current: PromptInfo = { input: "", parts: [] } + const ref = promptRef({ + current: () => current, + set(value) { + calls.set++ + current = value + }, + submit() { + calls.submit++ + }, + }) + const startup = createStartupPrompt({ input: "continue the work", parts: [] }) + + startup.bind(ref) + startup.bind(ref) + startup.submitWhenReady(false) + startup.submitWhenReady(true) + startup.submitWhenReady(true) + + expect(calls).toEqual({ set: 1, submit: 1 }) +}) + +test("does not submit after the seeded prompt has been edited", () => { + let current: PromptInfo = { input: "", parts: [] } + let submits = 0 + const ref = promptRef({ + current: () => current, + set(value) { + current = value + }, + submit() { + submits++ + }, + }) + const startup = createStartupPrompt({ input: "continue the work", parts: [] }) + + startup.bind(ref) + current = { input: "user edit", parts: [] } + startup.submitWhenReady(true) + + expect(submits).toBe(0) +})