diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 015b0ed8f46d..bce139f041eb 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -63,6 +63,7 @@ import { FormatError, FormatUnknownError } from "@/cli/error" import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" +import { createStartupInputBuffer } from "./startup-input-buffer" function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true) @@ -119,6 +120,7 @@ export function tui(input: { return new Promise(async (resolve) => { const unguard = win32InstallCtrlCGuard() win32DisableProcessedInput() + const startupInputBuffer = createStartupInputBuffer() const onExit = async () => { unguard?.() @@ -126,6 +128,7 @@ export function tui(input: { } const onBeforeExit = async () => { + startupInputBuffer.dispose() await TuiPluginRuntime.dispose() } @@ -171,7 +174,7 @@ export function tui(input: { - + diff --git a/packages/opencode/src/cli/cmd/tui/context/prompt.tsx b/packages/opencode/src/cli/cmd/tui/context/prompt.tsx index efbb050645ef..34a688029ead 100644 --- a/packages/opencode/src/cli/cmd/tui/context/prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/prompt.tsx @@ -1,10 +1,12 @@ import { createSimpleContext } from "./helper" import type { PromptRef } from "../component/prompt" +import type { StartupInputBuffer } from "../startup-input-buffer" export const { use: usePromptRef, provider: PromptRefProvider } = createSimpleContext({ name: "PromptRef", - init: () => { + init: (props: { startupInputBuffer: StartupInputBuffer }) => { let current: PromptRef | undefined + let startupDrained = false return { get current() { @@ -13,6 +15,15 @@ export const { use: usePromptRef, provider: PromptRefProvider } = createSimpleCo set(ref: PromptRef | undefined) { current = ref }, + drainStartupInputBuffer(ref = current) { + if (startupDrained || !ref) return false + startupDrained = true + const input = props.startupInputBuffer.drain() + props.startupInputBuffer.dispose() + if (!input || ref.current.input) return false + ref.set({ input, parts: [] }) + return true + }, } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 2f0ff07e9a9c..130e3aa79194 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -29,15 +29,21 @@ export function Home() { const bind = (r: PromptRef | undefined) => { setRef(r) promptRef.set(r) - if (once || !r) return - if (route.prompt) { - r.set(route.prompt) - once = true - return + if (!r) return + + // Seed explicit prompt state first. + if (!once) { + if (route.prompt) { + r.set(route.prompt) + once = true + } else if (args.prompt) { + r.set({ input: args.prompt, parts: [] }) + once = true + } } - if (!args.prompt) return - r.set({ input: args.prompt, parts: [] }) - once = true + + // Fill with startup typing only if still empty. + promptRef.drainStartupInputBuffer(r) } // Wait for sync and model store to be ready before auto-submitting --prompt diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 516f406aea07..3a54493d49f4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -243,9 +243,16 @@ export function Session() { const bind = (r: PromptRef | undefined) => { prompt = r promptRef.set(r) - if (seeded || !route.prompt || !r) return - seeded = true - r.set(route.prompt) + if (!r) return + + // Seed explicit prompt state first. + if (!seeded && route.prompt) { + seeded = true + r.set(route.prompt) + } + + // Fill with startup typing only if still empty. + promptRef.drainStartupInputBuffer(r) } const keybind = useKeybind() const dialog = useDialog() diff --git a/packages/opencode/src/cli/cmd/tui/startup-input-buffer.ts b/packages/opencode/src/cli/cmd/tui/startup-input-buffer.ts new file mode 100644 index 000000000000..2b9c9f93c883 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/startup-input-buffer.ts @@ -0,0 +1,80 @@ +import { decodePasteBytes, StdinParser } from "@opentui/core" + +/** + * Captures input typed before the prompt UI appears so users can start typing immediately. + */ +export type StartupInputBuffer = ReturnType +export type StartupInputBufferState = ReturnType + +const encoder = new TextEncoder() + +export function createStartupInputBufferState() { + return { + input: "", + // Keep parsing synchronous; startup capture should not own timers. + parser: new StdinParser({ armTimeouts: false }), + } +} + +export function appendStartupInputBufferChunk(state: StartupInputBufferState, chunk: string | Uint8Array) { + state.parser.push(typeof chunk === "string" ? encoder.encode(chunk) : chunk) + state.parser.drain((event) => { + if (event.type === "response") return + if (event.type === "paste") { + state.input += decodePasteBytes(event.bytes) + return + } + if (event.type !== "key") return + + // Backspace/delete edit buffered startup text. + if (event.key.name === "backspace" || event.key.name === "delete") { + state.input = dropLast(state.input) + return + } + + // Ctrl+U clears the startup buffer. + if (event.key.ctrl && event.key.name === "u") { + state.input = "" + return + } + + // Enter before the prompt appears should not submit anything. + if (event.key.name === "return") return + + // Preserve pasted newlines and append normal typed characters. + if (event.key.name === "linefeed" || (!event.key.ctrl && !event.key.meta && Array.from(event.raw).length === 1)) { + state.input += event.raw + } + }) + + return state +} + +export function createStartupInputBuffer() { + const state = createStartupInputBufferState() + let disposed = false + + const onData = (data: Buffer | string) => { + appendStartupInputBufferChunk(state, data) + } + + if (process.stdin.isTTY) process.stdin.on("data", onData) + + return { + drain() { + const result = state.input + state.input = "" + return result + }, + dispose() { + if (disposed) return + disposed = true + process.stdin.off("data", onData) + state.parser.destroy() + }, + } +} + +function dropLast(input: string) { + return Array.from(input).slice(0, -1).join("") +} diff --git a/packages/opencode/test/cli/cmd/tui/startup-input-buffer.test.ts b/packages/opencode/test/cli/cmd/tui/startup-input-buffer.test.ts new file mode 100644 index 000000000000..2f1c431af0e0 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/startup-input-buffer.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test" +import { + appendStartupInputBufferChunk, + createStartupInputBufferState, +} from "../../../../src/cli/cmd/tui/startup-input-buffer" + +function append(chunks: string[]) { + return chunks.reduce(appendStartupInputBufferChunk, createStartupInputBufferState()).input +} + +describe("startup input buffer", () => { + test("keeps printable text", () => { + expect(append(["hello world"])).toBe("hello world") + }) + + test("handles simple editing controls", () => { + expect(append(["hello", "\x7f!\x15again"])).toBe("again") + }) + + test("drops terminal escape responses", () => { + expect(append(["\x1b]11;rgb:ffff/ffff/ffff\x07hello\x1b[?2026;1$y\x1bPignored\x1b\\"])).toBe("hello") + }) + + test("keeps bracketed paste content", () => { + expect(append(["\x1b[200~one\ntwo\x1b[201~"])).toBe("one\ntwo") + }) + + test("drops terminal responses split across chunks", () => { + expect(append(["\x1b]11;rgb:", "0000/afaf/ffff\x07hello"])).toBe("hello") + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/startup-prompt-precedence.test.tsx b/packages/opencode/test/cli/cmd/tui/startup-prompt-precedence.test.tsx new file mode 100644 index 000000000000..b5b15aa1c787 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/startup-prompt-precedence.test.tsx @@ -0,0 +1,73 @@ +/** @jsxImportSource @opentui/solid */ +import { describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { PromptRefProvider, usePromptRef } from "../../../../src/cli/cmd/tui/context/prompt" +import type { PromptRef } from "../../../../src/cli/cmd/tui/component/prompt" +import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history" + +function createPromptRef(input: string) { + const prompt: PromptInfo = { input, parts: [] } + + return { + get focused() { + return true + }, + get current() { + return prompt + }, + set(next) { + prompt.input = next.input + prompt.parts = next.parts + }, + reset() {}, + blur() {}, + focus() {}, + submit() {}, + } satisfies PromptRef +} + +function Probe(props: { ref: PromptRef; onDrain: (value: boolean) => void }) { + const promptRef = usePromptRef() + + onMount(() => { + promptRef.set(props.ref) + props.onDrain(promptRef.drainStartupInputBuffer(props.ref)) + }) + + return +} + +describe("startup input buffer precedence", () => { + test("does not overwrite an explicitly seeded prompt with startup input", async () => { + let drained = false + let disposed = false + let applied = true + const ref = createPromptRef("seeded") + + const app = await testRender(() => ( + + (applied = value)} /> + + )) + + try { + expect(applied).toBe(false) + expect(drained).toBe(true) + expect(disposed).toBe(true) + expect(ref.current.input).toBe("seeded") + } finally { + app.renderer.destroy() + } + }) +})