From 0fc1d61fdc6b02a162ee56f256005d90384a2fa7 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 30 Jul 2026 14:30:57 +0200 Subject: [PATCH] tui: coalesce prompt sync on text bursts Per-keystroke store, autocomplete, and extmark work made large stdin paste bursts stall the input path. Defer one microtask sync per burst and skip mention scanning when text has no @. --- packages/tui/src/component/prompt/index.tsx | 31 +++++++++++---- packages/tui/src/prompt/display.ts | 5 ++- packages/tui/test/app-lifecycle.test.tsx | 44 +++++++++++++++++++++ packages/tui/test/prompt/display.test.ts | 4 ++ 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 2d554a49b1..4e5127c74c 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -147,6 +147,8 @@ function argumentSlash(input: string, commands: readonly KeymapCommand[]) { export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable + let promptSyncQueued = false + let promptContentChanged = false const [inputTarget, setInputTarget] = createSignal() const leader = Keymap.useLeaderActive() @@ -1202,6 +1204,25 @@ export function Prompt(props: PromptProps) { }, 0) } + function queuePromptSync(contentChanged: boolean) { + promptContentChanged ||= contentChanged + 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) + }) + } + async function pasteAttachment(file: { filename?: string; uri: string }) { const currentOffset = input.cursorOffset const extmarkStart = currentOffset @@ -1357,14 +1378,8 @@ export function Prompt(props: PromptProps) { focusedTextColor={leader() ? theme.text.subdued : theme.text.default} minHeight={1} maxHeight={maxHeight()} - onContentChange={() => { - const value = input.plainText - setStore("prompt", "text", value) - auto()?.onInput(value) - syncExtmarksWithPromptParts() - setCursorVersion((value) => value + 1) - }} - onCursorChange={() => setCursorVersion((value) => value + 1)} + onContentChange={() => queuePromptSync(true)} + onCursorChange={() => queuePromptSync(false)} onKeyDown={(e: { preventDefault(): void }) => { if (props.disabled) { e.preventDefault() diff --git a/packages/tui/src/prompt/display.ts b/packages/tui/src/prompt/display.ts index 94bbc7e15f..054add341f 100644 --- a/packages/tui/src/prompt/display.ts +++ b/packages/tui/src/prompt/display.ts @@ -37,8 +37,9 @@ export function displayCharAt(value: string, offset: number) { } } -export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) { - const text = displaySlice(value, 0, offset) +export function mentionTriggerIndex(value: string, offset?: number) { + if (!value.includes("@")) return + const text = displaySlice(value, 0, offset ?? promptOffsetWidth(value)) const index = text.lastIndexOf("@") if (index === -1) return diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index a07e3e6b12..44c440da17 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1,4 +1,5 @@ import { expect, mock, test } from "bun:test" +import { TextareaRenderable } from "@opentui/core" import { createTestRenderer } from "@opentui/core/testing" import { Effect, FileSystem } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -214,3 +215,46 @@ test("session startup prompt is submitted exactly once", async () => { mock.restore() } }) + +test("one raw text burst avoids per-key prompt synchronization", 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 })) + const events = createEventStream() + const calls = createFetch(undefined, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + args: {}, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + while (!(setup.renderer.currentFocusedEditor instanceof TextareaRenderable)) { + await Bun.sleep(10) + } + const input = setup.renderer.currentFocusedEditor + const text = "x".repeat(1_000) + const start = performance.now() + setup.renderer.stdin.emit("data", Buffer.from(text)) + while (input.plainText.length < text.length && performance.now() - start < 1_000) { + await Bun.sleep(1) + } + + expect(input.plainText).toBe(text) + expect(performance.now() - start).toBeLessThan(1_000) + + setup.renderer.destroy() + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + mock.restore() + } +}) diff --git a/packages/tui/test/prompt/display.test.ts b/packages/tui/test/prompt/display.test.ts index 6f56377a63..1e9e0f2b5a 100644 --- a/packages/tui/test/prompt/display.test.ts +++ b/packages/tui/test/prompt/display.test.ts @@ -30,4 +30,8 @@ describe("prompt display", () => { expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined() expect(mentionTriggerIndex("中文 @src file")).toBeUndefined() }) + + test("skips display-width conversion when text has no mention", () => { + expect(mentionTriggerIndex("dictated text ".repeat(5_000))).toBeUndefined() + }) })