Skip to content
Closed
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
4 changes: 2 additions & 2 deletions packages/opencode/bunfig.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
preload = ["@opentui/solid/preload"]
preload = ["./src/cli/cmd/tui/util/input-preload.ts", "@opentui/solid/preload"]

[test]
preload = ["@opentui/solid/preload", "./test/preload.ts"]
preload = ["./src/cli/cmd/tui/util/input-preload.ts", "@opentui/solid/preload", "./test/preload.ts"]
# timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
# using --timeout in package.json scripts instead
# https://github.com/oven-sh/bun/issues/7789
21 changes: 21 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { InputBuffer } from "@tui/util/input-buffer"
import { Log } from "@/util/log"

const log = Log.create({ service: "tui-app" })

async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
Expand Down Expand Up @@ -125,6 +129,7 @@ import { DialogVariant } from "./component/dialog-variant"

function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
return {
stdin: process.stdin,
externalOutputMode: "passthrough",
targetFps: 60,
gatherStats: false,
Expand Down Expand Up @@ -174,6 +179,9 @@ export function tui(input: {
return new Promise<void>(async (resolve) => {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
// Idempotent: the preload already called install(), but this is a safety
// net for cases where the preload was skipped (e.g. direct function call).
InputBuffer.install()

const mode = await getTerminalBackgroundColor()

Expand All @@ -182,11 +190,13 @@ export function tui(input: {
win32DisableProcessedInput()

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

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

Expand Down Expand Up @@ -289,6 +299,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
api.dispose()
})
const [ready, setReady] = createSignal(false)
let flushed = false
TuiPluginRuntime.init(api)
.catch((error) => {
console.error("Failed to load TUI plugins", error)
Expand Down Expand Up @@ -332,6 +343,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
renderer.clearSelection()
})

// Replay startup bytes only after the prompt exists so the normal prompt
// handlers consume them through the same stdin path as live input.
createEffect(() => {
if (flushed || !promptRef.current) return
flushed = true
const bytes = InputBuffer.pending()
if (bytes > 0) log.info("Flushing", { bytes })
InputBuffer.flush()
})

// Wire up console copy-to-clipboard via opentui's onCopySelection callback
renderer.console.onCopySelection = async (text: string) => {
if (!text || text.length === 0) return
Expand Down
12 changes: 9 additions & 3 deletions packages/opencode/src/cli/cmd/tui/context/prompt.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { createSignal } from "solid-js"
import { createSimpleContext } from "./helper"
import type { PromptRef } from "../component/prompt"

export const { use: usePromptRef, provider: PromptRefProvider } = createSimpleContext({
name: "PromptRef",
init: () => {
let current: PromptRef | undefined
// Backed by a signal so that createEffect() in app.tsx can track when the
// prompt mounts. A plain `let` would not be observable by SolidJS, and the
// effect that triggers InputBuffer.flush() would never re-run.
const [current, setCurrent] = createSignal<PromptRef | undefined>()

return {
get current() {
return current
return current()
},
set(ref: PromptRef | undefined) {
current = ref
// Wrap in a thunk so SolidJS stores the object itself as the value
// rather than calling it as a functional updater.
setCurrent(() => ref)
},
}
},
Expand Down
180 changes: 180 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/input-buffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
type Emit = typeof process.stdin.emit

let buffer: Buffer[] = []
let emit: Emit | undefined
let live = false
let paste = false
let sink: (() => void) | undefined

const PASTE_START = Buffer.from("\x1b[200~")
const PASTE_END = Buffer.from("\x1b[201~")

// Split raw bytes into chunks at control/escape boundaries so each gets a
// separate push/drain cycle in opentui's StdinParser. Printable runs stay
// together; each control byte or escape sequence becomes its own chunk.
// Bracketed paste (ESC[200~ ... ESC[201~) is kept as one atomic chunk so
// the paste event fires in a single drain cycle.
// Returns {chunk, paste} tuples so flush() knows which chunks need an
// async yield afterward (paste handlers do async I/O like reading images).
function split(data: Buffer): { chunk: Buffer; paste: boolean }[] {
const out: { chunk: Buffer; paste: boolean }[] = []
let i = 0
let text = i // start of current printable run
while (i < data.length) {
const b = data[i]
if (b === 0x1b) {
if (i > text) out.push({ chunk: data.subarray(text, i), paste: false })
const start = i
// Check for bracketed paste start
if (data.length - i >= PASTE_START.length && data.subarray(i, i + PASTE_START.length).equals(PASTE_START)) {
i += PASTE_START.length
// Scan for paste end marker
while (i < data.length) {
if (data.length - i >= PASTE_END.length && data.subarray(i, i + PASTE_END.length).equals(PASTE_END)) {
i += PASTE_END.length
break
}
i++
}
out.push({ chunk: data.subarray(start, i), paste: true })
text = i
continue
}
i++ // consume ESC
if (i < data.length) {
const next = data[i]
if (next === 0x5b) {
// CSI: ESC [ <params> <final 0x40-0x7E>
i++
while (i < data.length && data[i] < 0x40) i++
if (i < data.length) i++ // final byte
} else if (next === 0x5d) {
// OSC: ESC ] ... (ST = ESC \ or BEL = 0x07)
i++
while (i < data.length) {
if (data[i] === 0x07) {
i++
break
}
if (data[i] === 0x1b && i + 1 < data.length && data[i + 1] === 0x5c) {
i += 2
break
}
i++
}
} else {
// Two-byte sequence (SS2, SS3, or simple ESC+char)
i++
}
}
out.push({ chunk: data.subarray(start, i), paste: false })
text = i
} else if (b < 0x20) {
// Single control byte (Ctrl+A, Enter, Tab, etc.)
if (i > text) out.push({ chunk: data.subarray(text, i), paste: false })
out.push({ chunk: data.subarray(i, i + 1), paste: false })
i++
text = i
} else {
i++
}
}
if (i > text) out.push({ chunk: data.subarray(text, i), paste: false })
return out
}

export namespace InputBuffer {
export function install() {
if (live) return

const input = process.stdin
if (process.stdout.isTTY && !paste) {
// Enable bracketed paste before opentui boots so startup pastes are
// tagged as paste events instead of collapsing into plain text.
process.stdout.write("\x1b[?2004h")
paste = true
}
// Raw mode makes keystrokes arrive individually instead of waiting for
// Enter (cooked/line mode). Without this, early input is line-buffered
// and never reaches us.
if (input.isTTY) input.setRawMode(true)
emit = input.emit.bind(input)
// A no-op data listener keeps the stream flowing. Without at least one
// listener, Node pauses the stream and no data events are emitted.
sink = () => {}
input.on("data", sink)
input.resume()
live = true
buffer = []

// Patch emit to intercept "data" events. All events are forwarded to
// existing listeners (so theme detection, terminal queries etc. work
// normally). Every chunk is also copied into the buffer. No filtering
// happens here; opentui's StdinParser handles escape sequences properly,
// classifying terminal responses as harmless "response" events while
// preserving user actions (bracketed paste, arrow keys, Ctrl combos).
input.emit = ((event: string | symbol, ...args: unknown[]) => {
if (!live || event !== "data") return emit!(event, ...args)

const chunk = args[0]
const data = typeof chunk === "string" ? Buffer.from(chunk) : chunk
if (Buffer.isBuffer(data) && data.length > 0) buffer.push(Buffer.from(data))

return emit!(event, ...args)
}) as Emit
}

// Called once the prompt is mounted and ready to receive input. Restores
// original emit, then replays buffered chunks individually so opentui's
// StdinParser runs a separate push/drain cycle per chunk. This keeps
// cursor position accurate: text is inserted first, then the paste event
// fires while the cursor is still at the paste site (not at end of input).
// Paste chunks get an async yield afterward because the prompt's onPaste
// handler does async I/O (reading image files from disk) before inserting
// the [Image N] marker. Without the yield, subsequent text chunks would
// be emitted before the paste handler finishes, moving the cursor past
// the paste site.
export async function flush() {
if (!live || !emit) return

const input = process.stdin
const next = emit
const data = buffer.length > 0 ? Buffer.concat(buffer) : null
buffer = []
live = false
input.emit = next
emit = undefined

if (!data || data.length === 0) return
for (const entry of split(data)) {
next("data", entry.chunk)
// Yield after paste chunks so async paste handlers (image read, base64
// encode) complete before the next chunk moves the cursor.
if (entry.paste) await new Promise((r) => setTimeout(r, 100))
}
}

export function pending() {
return buffer.reduce((n, b) => n + b.length, 0)
}

export function uninstall() {
buffer = []
live = false

if (paste && process.stdout.isTTY) {
process.stdout.write("\x1b[?2004l")
paste = false
}

if (emit) {
process.stdin.emit = emit
emit = undefined
}

if (sink) {
process.stdin.removeListener("data", sink)
sink = undefined
}
}
}
8 changes: 8 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/input-preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { InputBuffer } from "./input-buffer"

const args = process.argv.slice(2)
const command = args.find((arg) => !arg.startsWith("-"))

if (command === undefined || command === "attach") {
InputBuffer.install()
}
86 changes: 86 additions & 0 deletions packages/opencode/test/cli/tui/keyboard-buffer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, expect, test } from "bun:test"
import { spawn } from "bun-pty"
import { join } from "path"
import stripAnsi from "strip-ansi"

const STARTUP_TIMEOUT = 30_000
const READY_TIMEOUT = 20_000
const TEST_INPUT = "buffer from byte zero 123"

const ptys = new Set<ReturnType<typeof spawn>>()

afterEach(() => {
ptys.forEach((pty) => pty.kill())
ptys.clear()
})

function waitFor(condition: () => boolean, timeout = 5000): Promise<void> {
return new Promise((resolve, reject) => {
const start = Date.now()
const interval = setInterval(() => {
if (condition()) {
clearInterval(interval)
resolve()
} else if (Date.now() - start > timeout) {
clearInterval(interval)
reject(new Error("Timeout waiting for condition"))
}
}, 100)
})
}

function clean(text: string) {
return stripAnsi(text)
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
.replace(/\r/g, "\n")
.replace(/\n+/g, "\n")
}

function prompt(text: string) {
// The TUI renders the prompt input area between ┃ border characters. In
// bun-pty output the rows are concatenated and cursor-movement artifacts
// appear as stray non-ASCII characters. Extract the region between the first
// ┃ that's followed by our content and the next ┃, stripping noise.
const idx = text.indexOf("┃")
if (idx === -1) return ""
// Take everything after the first border appearance
const after = text.slice(idx)
// Remove border chars and non-printable noise, collapse whitespace
return after
.replace(/┃/g, " ")
.replace(/[^\x20-\x7e]/g, " ")
.replace(/\s+/g, " ")
.trim()
}

test(
"keyboard buffering captures all keystrokes during startup",
async () => {
const cwd = join(__dirname, "../../..")
const pty = spawn("bun", ["dev"], {
name: "xterm-256color",
cols: 120,
rows: 30,
cwd,
env: { ...process.env, FORCE_COLOR: "0" },
})
ptys.add(pty)

let output = ""
pty.onData((data) => {
output += data
})

pty.write(TEST_INPUT)

await waitFor(() => output.includes("┃"), READY_TIMEOUT)
// Allow time for the flush to replay buffered bytes and re-render
await waitFor(() => clean(output).includes(TEST_INPUT), 5000)

const text = clean(output)
const field = prompt(text)
expect(field).toContain(TEST_INPUT)
},
STARTUP_TIMEOUT + 10_000,
)
Loading