diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 0a81682d30..7e7f7f9b96 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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 diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 8912a052b3..31e69d7770 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -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/) || - // "/" 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) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 8ecd5fdcb9..125f946c63 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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() 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() diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index 87cf47a1d7..a7a28efa12 100644 --- a/packages/tui/src/context/keymap.tsx +++ b/packages/tui/src/context/keymap.tsx @@ -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): Accessor { - 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 diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 389e002dc1..a07e3e6b12 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -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() - } -}) diff --git a/packages/tui/test/prompt/display.test.ts b/packages/tui/test/prompt/display.test.ts index fe3a62e0a7..6f56377a63 100644 --- a/packages/tui/test/prompt/display.test.ts +++ b/packages/tui/test/prompt/display.test.ts @@ -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() - }) })