Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -119,13 +120,15 @@ export function tui(input: {
return new Promise<void>(async (resolve) => {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
const startupInputBuffer = createStartupInputBuffer()

const onExit = async () => {
unguard?.()
resolve()
}

const onBeforeExit = async () => {
startupInputBuffer.dispose()
await TuiPluginRuntime.dispose()
}

Expand Down Expand Up @@ -171,7 +174,7 @@ export function tui(input: {
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<PromptRefProvider startupInputBuffer={startupInputBuffer}>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
Expand Down
13 changes: 12 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/prompt.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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
},
}
},
})
22 changes: 14 additions & 8 deletions packages/opencode/src/cli/cmd/tui/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
80 changes: 80 additions & 0 deletions packages/opencode/src/cli/cmd/tui/startup-input-buffer.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createStartupInputBuffer>
export type StartupInputBufferState = ReturnType<typeof createStartupInputBufferState>

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("")
}
31 changes: 31 additions & 0 deletions packages/opencode/test/cli/cmd/tui/startup-input-buffer.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
Original file line number Diff line number Diff line change
@@ -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 <box />
}

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(() => (
<PromptRefProvider
startupInputBuffer={{
drain() {
drained = true
return "typed during startup"
},
dispose() {
disposed = true
},
}}
>
<Probe ref={ref} onDrain={(value) => (applied = value)} />
</PromptRefProvider>
))

try {
expect(applied).toBe(false)
expect(drained).toBe(true)
expect(disposed).toBe(true)
expect(ref.current.input).toBe("seeded")
} finally {
app.renderer.destroy()
}
})
})
Loading