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
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,
type CliRenderer,
type CliRendererConfig,
type KeyEvent,
type ThemeMode,
} from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
@ -962,7 +963,11 @@ function App(props: { pair?: DialogPairCredentials }) {
name: "app.exit",
title: "Exit the app",
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",
},
{
@ -1118,14 +1123,7 @@ function App(props: { pair?: DialogPairCredentials }) {
bindings: pinnedSessionBindingCommands,
}))
Keymap.createLayer(() => ({
enabled: () => {
const current = promptRef.current
if (!current?.focused) return true
return current.current.text === ""
},
bindings: ["app.exit"],
}))
Keymap.createLayer(() => ({ bindings: ["app.exit"] }))
event.on("tui.command.execute", (evt, { workspace }) => {
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"],
input: "keyboard" as "keyboard" | "mouse",
})
let popMode: (() => void) | undefined
const [positionTick, setPositionTick] = createSignal(0)
createEffect(() => {
if (!store.visible) return
const popMode = keymap.mode.push("autocomplete")
onCleanup(popMode)
})
createEffect(() => {
if (store.visible) {
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 index = store.visible === "@" ? store.index : props.input().cursorOffset
setStore("visible", false)
hide(false)
setStore("index", index)
insertPart(filename, part)
}
@ -500,6 +495,7 @@ export function Autocomplete(props: {
function move(direction: -1 | 1) {
if (!store.visible) return
syncSearch()
if (!options().length) return
let next = store.selected + direction
if (next < 0) next = options().length - 1
@ -520,6 +516,7 @@ export function Autocomplete(props: {
}
function select() {
syncSearch()
const selected = options()[store.selected]
if (!selected) return
hide()
@ -591,6 +588,7 @@ export function Autocomplete(props: {
title: "Complete autocomplete item",
group: "Autocomplete",
run() {
syncSearch()
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
@ -604,15 +602,16 @@ export function Autocomplete(props: {
}))
function show(mode: "@" | "/") {
popMode ??= keymap.mode.push("autocomplete")
setStore({
visible: mode,
index: props.input().cursorOffset,
})
}
function hide() {
function hide(clear = true) {
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
props.input().deleteRange(0, 0, cursor.row, cursor.col)
// Sync the prompt store immediately since onContentChange is async
@ -621,6 +620,8 @@ export function Autocomplete(props: {
})
}
setStore("visible", false)
popMode?.()
popMode = undefined
}
onMount(() => {
@ -632,35 +633,33 @@ export function Autocomplete(props: {
unsubscribeMention()
})
props.ref({
const ref = {
get visible() {
return store.visible
},
onInput(value) {
onInput(value?: string) {
if (!props.input().focused) return
if (store.visible) {
if (
// Typed text before the trigger
props.input().cursorOffset <= store.index ||
// There is a space between the trigger and the cursor
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
// "/<command>" is not the sole content
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/)
) {
hide()
hide(false)
}
return
}
// Check if autocomplete should reopen (e.g., after backspace deleted a space)
const offset = props.input().cursorOffset
if (offset === 0) return
// Check for "/" at position 0 - reopen slash commands
if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
const text = value ?? (props.input().getTextRange(0, 1) === "/" ? props.input().getTextRange(0, offset) : "")
if (text.startsWith("/") && !text.slice(0, offset).match(/\s/)) {
show("/")
setStore("index", 0)
return
}
if (value === undefined) return
// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
const idx = mentionTriggerIndex(value, offset)
@ -669,9 +668,22 @@ export function Autocomplete(props: {
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 count = options().length || 1
if (!store.visible) return Math.min(10, count)

View file

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

View file

@ -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, type NormalizedKeyStroke } from "@opentui/keymap"
import { stringifyKeyStroke, type Binding, type CommandContext } from "@opentui/keymap"
import {
registerBackspacePopsPendingSequence,
registerBaseLayoutFallback,
@ -337,17 +337,6 @@ 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()
@ -399,7 +388,6 @@ export const Keymap = {
useCommands,
usePendingSequence,
useActiveKeys,
useCommandKeys,
useState,
} as const

View file

@ -1,5 +1,4 @@
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"
@ -215,87 +214,3 @@ test("session startup prompt is submitted exactly once", async () => {
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("中文 @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()
})
})