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 @.
This commit is contained in:
parent
188c642d8c
commit
0fc1d61fdc
4 changed files with 74 additions and 10 deletions
|
|
@ -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<TextareaRenderable | undefined>()
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue