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:
parent
9f72479515
commit
f870e771e9
6 changed files with 65 additions and 227 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue