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() }) })