From 9f72479515457d8ce2823037ca135b9d972e57ab Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 30 Jul 2026 18:36:12 +0200 Subject: [PATCH] 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. --- packages/tui/src/component/prompt/index.tsx | 99 ++++++++++++++++++--- packages/tui/src/context/keymap.tsx | 14 ++- packages/tui/test/app-lifecycle.test.tsx | 59 ++++++++++-- packages/tui/test/prompt/display.test.ts | 9 +- 4 files changed, 157 insertions(+), 24 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 4e5127c74c..8ecd5fdcb9 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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() 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 }) { diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index a7a28efa12..87cf47a1d7 100644 --- a/packages/tui/src/context/keymap.tsx +++ b/packages/tui/src/context/keymap.tsx @@ -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): Accessor { + 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 diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 44c440da17..389e002dc1 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -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 diff --git a/packages/tui/test/prompt/display.test.ts b/packages/tui/test/prompt/display.test.ts index 1e9e0f2b5a..fe3a62e0a7 100644 --- a/packages/tui/test/prompt/display.test.ts +++ b/packages/tui/test/prompt/display.test.ts @@ -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() }) })