tui: simplify prompt input sync

Drop the burst-aware key interceptor that tried to keep derived
prompt state current for every control binding. Frame-batch content
updates only, and let exit, clear, stash, and autocomplete read or
flush live textarea state at the command boundary instead.
This commit is contained in:
Simon Klee 2026-07-30 20:42:15 +02:00
parent 9f72479515
commit f870e771e9
No known key found for this signature in database
GPG key ID: B91696044D47BEA3
6 changed files with 65 additions and 227 deletions

View file

@ -15,6 +15,7 @@ import {
MouseButton, MouseButton,
type CliRenderer, type CliRenderer,
type CliRendererConfig, type CliRendererConfig,
type KeyEvent,
type ThemeMode, type ThemeMode,
} from "@opentui/core" } from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route" import { RouteProvider, useRoute } from "./context/route"
@ -962,7 +963,11 @@ function App(props: { pair?: DialogPairCredentials }) {
name: "app.exit", name: "app.exit",
title: "Exit the app", title: "Exit the app",
slash: { name: "exit", aliases: ["quit", "q"] }, slash: { name: "exit", aliases: ["quit", "q"] },
run: () => exit(), run: (_input: string | undefined, event?: KeyEvent) => {
const current = promptRef.current
if (event?.sequence && current?.focused && !current.empty) return false
exit()
},
category: "System", category: "System",
}, },
{ {
@ -1118,14 +1123,7 @@ function App(props: { pair?: DialogPairCredentials }) {
bindings: pinnedSessionBindingCommands, bindings: pinnedSessionBindingCommands,
})) }))
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({ bindings: ["app.exit"] }))
enabled: () => {
const current = promptRef.current
if (!current?.focused) return true
return current.current.text === ""
},
bindings: ["app.exit"],
}))
event.on("tui.command.execute", (evt, { workspace }) => { event.on("tui.command.execute", (evt, { workspace }) => {
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return

View file

@ -69,15 +69,10 @@ export function Autocomplete(props: {
visible: false as AutocompleteRef["visible"], visible: false as AutocompleteRef["visible"],
input: "keyboard" as "keyboard" | "mouse", input: "keyboard" as "keyboard" | "mouse",
}) })
let popMode: (() => void) | undefined
const [positionTick, setPositionTick] = createSignal(0) const [positionTick, setPositionTick] = createSignal(0)
createEffect(() => {
if (!store.visible) return
const popMode = keymap.mode.push("autocomplete")
onCleanup(popMode)
})
createEffect(() => { createEffect(() => {
if (store.visible) { if (store.visible) {
let lastPos = { x: 0, y: 0, width: 0 } let lastPos = { x: 0, y: 0, width: 0 }
@ -271,7 +266,7 @@ export function Autocomplete(props: {
const { filename, part } = createFilePart({ path: item, type: "file" }, input.filePath, lineRange) const { filename, part } = createFilePart({ path: item, type: "file" }, input.filePath, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset const index = store.visible === "@" ? store.index : props.input().cursorOffset
setStore("visible", false) hide(false)
setStore("index", index) setStore("index", index)
insertPart(filename, part) insertPart(filename, part)
} }
@ -500,6 +495,7 @@ export function Autocomplete(props: {
function move(direction: -1 | 1) { function move(direction: -1 | 1) {
if (!store.visible) return if (!store.visible) return
syncSearch()
if (!options().length) return if (!options().length) return
let next = store.selected + direction let next = store.selected + direction
if (next < 0) next = options().length - 1 if (next < 0) next = options().length - 1
@ -520,6 +516,7 @@ export function Autocomplete(props: {
} }
function select() { function select() {
syncSearch()
const selected = options()[store.selected] const selected = options()[store.selected]
if (!selected) return if (!selected) return
hide() hide()
@ -591,6 +588,7 @@ export function Autocomplete(props: {
title: "Complete autocomplete item", title: "Complete autocomplete item",
group: "Autocomplete", group: "Autocomplete",
run() { run() {
syncSearch()
const selected = options()[store.selected] const selected = options()[store.selected]
if (selected?.isDirectory) { if (selected?.isDirectory) {
expandDirectory() expandDirectory()
@ -604,15 +602,16 @@ export function Autocomplete(props: {
})) }))
function show(mode: "@" | "/") { function show(mode: "@" | "/") {
popMode ??= keymap.mode.push("autocomplete")
setStore({ setStore({
visible: mode, visible: mode,
index: props.input().cursorOffset, index: props.input().cursorOffset,
}) })
} }
function hide() { function hide(clear = true) {
const text = props.input().plainText const text = props.input().plainText
if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) { if (clear && store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
const cursor = props.input().logicalCursor const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col) props.input().deleteRange(0, 0, cursor.row, cursor.col)
// Sync the prompt store immediately since onContentChange is async // Sync the prompt store immediately since onContentChange is async
@ -621,6 +620,8 @@ export function Autocomplete(props: {
}) })
} }
setStore("visible", false) setStore("visible", false)
popMode?.()
popMode = undefined
} }
onMount(() => { onMount(() => {
@ -632,35 +633,33 @@ export function Autocomplete(props: {
unsubscribeMention() unsubscribeMention()
}) })
props.ref({ const ref = {
get visible() { get visible() {
return store.visible return store.visible
}, },
onInput(value) { onInput(value?: string) {
if (!props.input().focused) return
if (store.visible) { if (store.visible) {
if ( if (
// Typed text before the trigger // Typed text before the trigger
props.input().cursorOffset <= store.index || props.input().cursorOffset <= store.index ||
// There is a space between the trigger and the cursor // There is a space between the trigger and the cursor
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) || props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/)
// "/<command>" is not the sole content
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
) { ) {
hide() hide(false)
} }
return return
} }
// Check if autocomplete should reopen (e.g., after backspace deleted a space)
const offset = props.input().cursorOffset const offset = props.input().cursorOffset
if (offset === 0) return if (offset === 0) return
const text = value ?? (props.input().getTextRange(0, 1) === "/" ? props.input().getTextRange(0, offset) : "")
// Check for "/" at position 0 - reopen slash commands if (text.startsWith("/") && !text.slice(0, offset).match(/\s/)) {
if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
show("/") show("/")
setStore("index", 0) setStore("index", 0)
return return
} }
if (value === undefined) return
// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between // Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
const idx = mentionTriggerIndex(value, offset) const idx = mentionTriggerIndex(value, offset)
@ -669,9 +668,22 @@ export function Autocomplete(props: {
setStore("index", idx) setStore("index", idx)
} }
}, },
}
props.ref(ref)
const stopInputSync = keymap.intercept("key", () => ref.onInput())
onCleanup(() => {
stopInputSync()
popMode?.()
}) })
}) })
function syncSearch() {
const next = props.input().getTextRange(store.index + 1, props.input().cursorOffset)
if (next === search()) return
setSearch(next)
setStore("selected", 0)
}
const height = createMemo(() => { const height = createMemo(() => {
const count = options().length || 1 const count = options().length || 1
if (!store.visible) return Math.min(10, count) if (!store.visible) return Math.min(10, count)

View file

@ -85,6 +85,7 @@ function pastedFilepath(value: string, platform: string) {
export type PromptRef = { export type PromptRef = {
focused: boolean focused: boolean
empty: boolean
current: PromptInfo current: PromptInfo
set(prompt: PromptInfo): void set(prompt: PromptInfo): void
reset(): void reset(): void
@ -94,20 +95,6 @@ export type PromptRef = {
} }
const DRAFT_RETENTION_MIN_CHARS = 20 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) { function randomIndex(count: number) {
if (count <= 0) return 0 if (count <= 0) return 0
@ -162,8 +149,6 @@ export function Prompt(props: PromptProps) {
let input: TextareaRenderable let input: TextareaRenderable
let anchor: BoxRenderable let anchor: BoxRenderable
let promptSyncQueued = false let promptSyncQueued = false
let promptContentChanged = false
let promptTextInputPending = false
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>() const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const leader = Keymap.useLeaderActive() const leader = Keymap.useLeaderActive()
@ -185,53 +170,7 @@ export function Prompt(props: PromptProps) {
const history = usePromptHistory() const history = usePromptHistory()
const stash = usePromptStash() const stash = usePromptStash()
const keymap = Keymap.use() 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 renderer = useRenderer()
const flushPromptSyncFrame = async () => flushPromptSync()
const exit = useExit() const exit = useExit()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const theme = useTheme() const theme = useTheme()
@ -409,6 +348,7 @@ export function Prompt(props: PromptProps) {
category: "Prompt", category: "Prompt",
palette: undefined, palette: undefined,
run: () => { run: () => {
if (input.getTextRange(0, 1) === "") return false
clearPrompt() clearPrompt()
dialog.clear() dialog.clear()
}, },
@ -513,6 +453,7 @@ export function Prompt(props: PromptProps) {
name: "prompt.editor", name: "prompt.editor",
slash: { name: "editor" }, slash: { name: "editor" },
run: async () => { run: async () => {
if (promptSyncQueued) await flushPromptSync()
dialog.clear() dialog.clear()
const editorPrompt = expandPromptInputPastedText(store.prompt, store.prompt.pasted) const editorPrompt = expandPromptInputPastedText(store.prompt, store.prompt.pasted)
@ -605,6 +546,9 @@ export function Prompt(props: PromptProps) {
get focused() { get focused() {
return input.focused return input.focused
}, },
get empty() {
return input.getTextRange(0, 1) === ""
},
get current() { get current() {
return store.prompt return store.prompt
}, },
@ -644,11 +588,7 @@ export function Prompt(props: PromptProps) {
}) })
onCleanup(() => { onCleanup(() => {
stopPromptSyncInterceptor() if (promptSyncQueued) void flushPromptSync()
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) { if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
} }
@ -774,9 +714,9 @@ export function Prompt(props: PromptProps) {
title: "Stash prompt", title: "Stash prompt",
name: "prompt.stash", name: "prompt.stash",
category: "Prompt", category: "Prompt",
enabled: !!store.prompt.text,
run: () => { run: () => {
if (!store.prompt.text) return if (input.getTextRange(0, 1) === "") return false
void flushPromptSync()
stash.push({ prompt: store.prompt }) stash.push({ prompt: store.prompt })
input.extmarks.clear() input.extmarks.clear()
input.clear() input.clear()
@ -847,7 +787,7 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => { Keymap.createLayer(() => {
return { return {
target: inputTarget, target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "", enabled: () => inputTarget() !== undefined && !props.disabled,
bindings: ["prompt.clear"], bindings: ["prompt.clear"],
} }
}) })
@ -871,6 +811,10 @@ export function Prompt(props: PromptProps) {
title: "Shell mode", title: "Shell mode",
group: "Prompt", group: "Prompt",
run: () => { run: () => {
if (input.visualCursor.offset !== 0) {
input.insertText("!")
return
}
setStore("placeholder", randomIndex(shell().length)) setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell") setStore("mode", "shell")
}, },
@ -993,6 +937,7 @@ export function Prompt(props: PromptProps) {
} }
async function submitInner() { async function submitInner() {
if (promptSyncQueued) await flushPromptSync()
// IME: double-defer may fire before onContentChange flushes the last // IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read // composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads. // plainText directly and sync before any downstream reads.
@ -1270,30 +1215,20 @@ export function Prompt(props: PromptProps) {
}, 0) }, 0)
} }
function flushPromptSync(value?: string) { async function flushPromptSync() {
const contentChanged = value !== undefined && value !== store.prompt.text
if (!promptSyncQueued && !contentChanged) return
promptSyncQueued = false promptSyncQueued = false
renderer.removeFrameCallback(flushPromptSyncFrame) renderer.removeFrameCallback(flushPromptSync)
const syncContent = promptContentChanged || contentChanged
promptContentChanged = false
if (!input || input.isDestroyed) return if (!input || input.isDestroyed) return
if (syncContent) { const value = input.plainText
const text = value ?? input.plainText setStore("prompt", "text", value)
setStore("prompt", "text", text) auto()?.onInput(value)
auto()?.onInput(text) syncExtmarksWithPromptParts()
syncExtmarksWithPromptParts()
}
setCursorVersion((value) => value + 1)
} }
function queuePromptSync(contentChanged: boolean) { function queuePromptSync() {
promptContentChanged ||= contentChanged
if (contentChanged) promptTextInputPending = false
if (promptSyncQueued) return if (promptSyncQueued) return
promptSyncQueued = true promptSyncQueued = true
// Keep derived prompt state to one update per rendered frame across split stdin chunks. renderer.setFrameCallback(flushPromptSync)
renderer.setFrameCallback(flushPromptSyncFrame)
} }
async function pasteAttachment(file: { filename?: string; uri: string }) { async function pasteAttachment(file: { filename?: string; uri: string }) {
@ -1337,6 +1272,7 @@ export function Prompt(props: PromptProps) {
} }
function clearPrompt() { function clearPrompt() {
if (promptSyncQueued) void flushPromptSync()
if ( if (
store.prompt.text.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.text.trim().length >= DRAFT_RETENTION_MIN_CHARS ||
store.prompt.pasted.length > 0 || store.prompt.pasted.length > 0 ||
@ -1451,8 +1387,8 @@ export function Prompt(props: PromptProps) {
focusedTextColor={leader() ? theme.text.subdued : theme.text.default} focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
minHeight={1} minHeight={1}
maxHeight={maxHeight()} maxHeight={maxHeight()}
onContentChange={() => queuePromptSync(true)} onContentChange={queuePromptSync}
onCursorChange={() => queuePromptSync(false)} onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => { onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) { if (props.disabled) {
e.preventDefault() e.preventDefault()

View file

@ -1,6 +1,6 @@
import type { KeymapActive, KeymapCommand, KeymapLayer, KeymapPending } from "@opencode-ai/plugin/tui/context" import type { KeymapActive, KeymapCommand, KeymapLayer, KeymapPending } from "@opencode-ai/plugin/tui/context"
import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core" import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core"
import { stringifyKeyStroke, type Binding, type CommandContext, type NormalizedKeyStroke } from "@opentui/keymap" import { stringifyKeyStroke, type Binding, type CommandContext } from "@opentui/keymap"
import { import {
registerBackspacePopsPendingSequence, registerBackspacePopsPendingSequence,
registerBaseLayoutFallback, registerBaseLayoutFallback,
@ -337,17 +337,6 @@ function useActiveKeys() {
return useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true })) 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() { function useState() {
const value = useValue() const value = useValue()
const commands = useCommands() const commands = useCommands()
@ -399,7 +388,6 @@ export const Keymap = {
useCommands, useCommands,
usePendingSequence, usePendingSequence,
useActiveKeys, useActiveKeys,
useCommandKeys,
useState, useState,
} as const } as const

View file

@ -1,5 +1,4 @@
import { expect, mock, test } from "bun:test" import { expect, mock, test } from "bun:test"
import { TextareaRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing" import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect" import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -215,87 +214,3 @@ test("session startup prompt is submitted exactly once", async () => {
mock.restore() mock.restore()
} }
}) })
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 }))
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 () => ({ keybinds: { input_clear: "q, z" } }), 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 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))
setup.renderer.stdin.emit("data", Buffer.from("\x04"))
await Bun.sleep(50)
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
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
}
})

View file

@ -30,15 +30,4 @@ describe("prompt display", () => {
expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined() expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined()
expect(mentionTriggerIndex("中文 @src file")).toBeUndefined() expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
}) })
test("skips display-width conversion when text has no mention", () => {
const value = {
includes: () => false,
[Symbol.toPrimitive]() {
throw new Error("display width conversion should be skipped")
},
}
expect(Reflect.apply(mentionTriggerIndex, undefined, [value])).toBeUndefined()
})
}) })