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:
parent
0fc1d61fdc
commit
9f72479515
4 changed files with 156 additions and 23 deletions
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue