tui: flush prompt sync before commands

Coalescing burst text to one frame update hid pending
input from submit and other prompt keys. Flush before
bound commands so they see the full text while plain
typing stays frame-batched.
This commit is contained in:
Simon Klee 2026-07-30 18:36:12 +02:00
commit 9f72479515
No known key found for this signature in database
GPG key ID: B91696044D47BEA3
4 changed files with 156 additions and 23 deletions

View file

@ -94,6 +94,20 @@ export type PromptRef = {
}
const DRAFT_RETENTION_MIN_CHARS = 20
const PROMPT_SYNC_COMMANDS = [
"app.exit",
"prompt.clear",
"prompt.submit",
"prompt.editor",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
"prompt.autocomplete.prev",
"prompt.autocomplete.next",
"prompt.autocomplete.hide",
"prompt.autocomplete.select",
"prompt.autocomplete.complete",
]
function randomIndex(count: number) {
if (count <= 0) return 0
@ -149,6 +163,7 @@ export function Prompt(props: PromptProps) {
let anchor: BoxRenderable
let promptSyncQueued = false
let promptContentChanged = false
let promptTextInputPending = false
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const leader = Keymap.useLeaderActive()
@ -170,7 +185,53 @@ export function Prompt(props: PromptProps) {
const history = usePromptHistory()
const stash = usePromptStash()
const keymap = Keymap.use()
const activeKeys = Keymap.useActiveKeys()
const commandKeys = Keymap.useCommandKeys(() => PROMPT_SYNC_COMMANDS)
// Commands must see earlier burst text, while native textarea edits remain frame-batched.
const stopPromptSyncInterceptor = keymap.intercept("key", ({ event }) => {
const code = event.sequence.charCodeAt(0)
const textInput =
!event.ctrl &&
!event.meta &&
!event.super &&
!event.hyper &&
(event.name === "space" || (code >= 32 && code !== 127))
const pending = promptSyncQueued || promptTextInputPending
const rawBase = event.baseCode === undefined ? undefined : String.fromCodePoint(event.baseCode)
const base = rawBase && rawBase >= "A" && rawBase <= "Z" ? rawBase.toLowerCase() : rawBase
const configured = commandKeys().some(
(stroke) =>
(stroke.name === event.name || stroke.name === base) &&
stroke.ctrl === event.ctrl &&
stroke.shift === event.shift &&
stroke.meta === event.meta &&
stroke.super === !!event.super &&
(stroke.hyper ?? false) === !!event.hyper,
)
const matched = pending
? activeKeys().filter(
(key) =>
(key.stroke.name === event.name || key.stroke.name === base) &&
key.stroke.ctrl === event.ctrl &&
key.stroke.shift === event.shift &&
key.stroke.meta === event.meta &&
key.stroke.super === !!event.super &&
(key.stroke.hyper ?? false) === !!event.hyper,
)
: []
const bound = matched.some((key) => typeof key.command !== "string" || !key.command.startsWith("input."))
if (textInput && (!input?.focused || !pending || (!bound && !configured))) {
if (input?.focused) promptTextInputPending = true
return
}
if (!textInput && matched.length > 0 && !bound && !configured) return
const value = promptTextInputPending && input && !input.isDestroyed ? input.plainText : undefined
promptTextInputPending = false
flushPromptSync(value)
if (textInput && input?.focused) promptTextInputPending = true
})
const renderer = useRenderer()
const flushPromptSyncFrame = async () => flushPromptSync()
const exit = useExit()
const dimensions = useTerminalDimensions()
const theme = useTheme()
@ -583,6 +644,11 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
stopPromptSyncInterceptor()
flushPromptSync(!input || input.isDestroyed ? undefined : input.plainText)
if (promptSyncQueued) flushPromptSync(!input || input.isDestroyed ? undefined : input.plainText)
renderer.removeFrameCallback(flushPromptSyncFrame)
promptSyncQueued = false
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@ -1204,23 +1270,30 @@ export function Prompt(props: PromptProps) {
}, 0)
}
function flushPromptSync(value?: string) {
const contentChanged = value !== undefined && value !== store.prompt.text
if (!promptSyncQueued && !contentChanged) return
promptSyncQueued = false
renderer.removeFrameCallback(flushPromptSyncFrame)
const syncContent = promptContentChanged || contentChanged
promptContentChanged = false
if (!input || input.isDestroyed) return
if (syncContent) {
const text = value ?? input.plainText
setStore("prompt", "text", text)
auto()?.onInput(text)
syncExtmarksWithPromptParts()
}
setCursorVersion((value) => value + 1)
}
function queuePromptSync(contentChanged: boolean) {
promptContentChanged ||= contentChanged
if (contentChanged) promptTextInputPending = false
if (promptSyncQueued) return
promptSyncQueued = true
queueMicrotask(() => {
promptSyncQueued = false
const syncContent = promptContentChanged
promptContentChanged = false
if (!input || input.isDestroyed) return
if (syncContent) {
const value = input.plainText
setStore("prompt", "text", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
}
setCursorVersion((value) => value + 1)
})
// Keep derived prompt state to one update per rendered frame across split stdin chunks.
renderer.setFrameCallback(flushPromptSyncFrame)
}
async function pasteAttachment(file: { filename?: string; uri: string }) {

View file

@ -1,6 +1,6 @@
import type { KeymapActive, KeymapCommand, KeymapLayer, KeymapPending } from "@opencode-ai/plugin/tui/context"
import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core"
import { stringifyKeyStroke, type Binding, type CommandContext } from "@opentui/keymap"
import { stringifyKeyStroke, type Binding, type CommandContext, type NormalizedKeyStroke } from "@opentui/keymap"
import {
registerBackspacePopsPendingSequence,
registerBaseLayoutFallback,
@ -337,6 +337,17 @@ function useActiveKeys() {
return useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true }))
}
function useCommandKeys(commands: Accessor<readonly string[]>): Accessor<readonly NormalizedKeyStroke[]> {
useValue()
return useKeymapSelector((keymap) => {
const ids = commands()
const bindings = keymap.getCommandBindings({ visibility: "registered", commands: ids })
return ids.flatMap((id) =>
(bindings.get(id) ?? []).flatMap((binding) => (binding.sequence[0] ? [binding.sequence[0].stroke] : [])),
)
})
}
function useState() {
const value = useValue()
const commands = useCommands()
@ -388,6 +399,7 @@ export const Keymap = {
useCommands,
usePendingSequence,
useActiveKeys,
useCommandKeys,
useState,
} as const

View file

@ -216,7 +216,7 @@ test("session startup prompt is submitted exactly once", async () => {
}
})
test("one raw text burst avoids per-key prompt synchronization", async () => {
test("raw text bursts coalesce prompt synchronization without hiding text from control keys", 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 }))
@ -230,7 +230,7 @@ test("one raw text burst avoids per-key prompt synchronization", async () => {
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
config: { get: async () => ({ keybinds: { input_clear: "q, z" } }), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: {},
log: () => {},
@ -240,15 +240,56 @@ test("one raw text burst avoids per-key prompt synchronization", async () => {
await Bun.sleep(10)
}
const input = setup.renderer.currentFocusedEditor
const text = "x".repeat(1_000)
const start = performance.now()
const readPlainText = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(TextareaRenderable.prototype),
"plainText",
)?.get?.bind(input)
if (!readPlainText) throw new Error("Textarea plainText getter is missing")
let plainTextReads = 0
Object.defineProperty(input, "plainText", {
get() {
plainTextReads++
return readPlainText()
},
})
const text = "x".repeat(999) + "!"
setup.renderer.stdin.emit("data", Buffer.from(text))
while (input.plainText.length < text.length && performance.now() - start < 1_000) {
await Bun.sleep(1)
}
setup.renderer.stdin.emit("data", Buffer.from("\x04"))
await Bun.sleep(50)
expect(input.plainText).toBe(text)
expect(performance.now() - start).toBeLessThan(1_000)
expect(setup.renderer.isDestroyed).toBe(false)
expect(readPlainText()).toBe(text)
expect(plainTextReads).toBeLessThanOrEqual(3)
plainTextReads = 0
const dribbled = "y".repeat(100)
for (const character of dribbled) {
setup.renderer.stdin.emit("data", Buffer.from(character))
await Promise.resolve()
}
await Bun.sleep(50)
expect(readPlainText()).toBe(text + dribbled)
expect(plainTextReads).toBe(1)
plainTextReads = 0
for (const backspace of "\x7f".repeat(dribbled.length)) {
setup.renderer.stdin.emit("data", Buffer.from(backspace))
await Promise.resolve()
}
await Bun.sleep(50)
expect(readPlainText()).toBe(text)
expect(plainTextReads).toBe(1)
input.clear()
await Bun.sleep(50)
plainTextReads = 0
setup.renderer.stdin.emit("data", Buffer.from("az"))
await Bun.sleep(50)
expect(readPlainText()).toBe("")
expect(plainTextReads).toBeLessThanOrEqual(2)
setup.renderer.destroy()
await task

View file

@ -32,6 +32,13 @@ describe("prompt display", () => {
})
test("skips display-width conversion when text has no mention", () => {
expect(mentionTriggerIndex("dictated text ".repeat(5_000))).toBeUndefined()
const value = {
includes: () => false,
[Symbol.toPrimitive]() {
throw new Error("display width conversion should be skipped")
},
}
expect(Reflect.apply(mentionTriggerIndex, undefined, [value])).toBeUndefined()
})
})