Kaynağa Gözat

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.
Simon Klee 2 hafta önce
ebeveyn
işleme
2db3bd72ee

+ 7 - 9
packages/tui/src/app.tsx

@@ -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"
@@ -971,7 +972,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",
       },
       },
       {
       {
@@ -1127,14 +1132,7 @@ function App(props: { pair?: DialogPairCredentials }) {
     bindings: pinnedSessionBindingCommands,
     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 }) => {
   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

+ 31 - 19
packages/tui/src/component/prompt/autocomplete.tsx

@@ -70,15 +70,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 }
@@ -272,7 +267,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)
   }
   }
@@ -501,6 +496,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
     moveTo(moveSelection(store.selected, { count: options().length, delta: direction, policy: "wrap" }))
     moveTo(moveSelection(store.selected, { count: options().length, delta: direction, policy: "wrap" }))
   }
   }
@@ -518,6 +514,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()
@@ -589,6 +586,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()
@@ -602,15 +600,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
@@ -619,6 +618,8 @@ export function Autocomplete(props: {
       })
       })
     }
     }
     setStore("visible", false)
     setStore("visible", false)
+    popMode?.()
+    popMode = undefined
   }
   }
 
 
   onMount(() => {
   onMount(() => {
@@ -630,35 +631,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/) ||
-            // "/<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
           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
-
-        // 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("/")
           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)
@@ -667,9 +666,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)

+ 26 - 90
packages/tui/src/component/prompt/index.tsx

@@ -82,6 +82,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
@@ -91,20 +92,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
@@ -159,8 +146,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()
@@ -182,53 +167,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()
@@ -405,6 +344,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()
         },
         },
@@ -509,6 +449,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)
@@ -601,6 +542,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
     },
     },
@@ -640,11 +584,7 @@ export function Prompt(props: PromptProps) {
   })
   })
 
 
   onCleanup(() => {
   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) {
     if (store.prompt.text) {
       stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
       stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
     }
     }
@@ -770,9 +710,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()
@@ -843,7 +783,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"],
     }
     }
   })
   })
@@ -867,6 +807,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")
           },
           },
@@ -989,6 +933,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.
@@ -1291,30 +1236,20 @@ export function Prompt(props: PromptProps) {
     }, 0)
     }, 0)
   }
   }
 
 
-  function flushPromptSync(value?: string) {
-    const contentChanged = value !== undefined && value !== store.prompt.text
-    if (!promptSyncQueued && !contentChanged) return
+  async function flushPromptSync() {
     promptSyncQueued = false
     promptSyncQueued = false
-    renderer.removeFrameCallback(flushPromptSyncFrame)
-    const syncContent = promptContentChanged || contentChanged
-    promptContentChanged = false
+    renderer.removeFrameCallback(flushPromptSync)
     if (!input || input.isDestroyed) return
     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
     if (promptSyncQueued) return
     promptSyncQueued = true
     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 }) {
   async function pasteAttachment(file: { filename?: string; uri: string }) {
@@ -1358,6 +1293,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 ||
@@ -1484,8 +1420,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)}
-              onCursorChange={() => queuePromptSync(false)}
+              onContentChange={queuePromptSync}
+              onCursorChange={() => setCursorVersion((value) => value + 1)}
               onKeyDown={(e: { preventDefault(): void }) => {
               onKeyDown={(e: { preventDefault(): void }) => {
                 if (props.disabled) {
                 if (props.disabled) {
                   e.preventDefault()
                   e.preventDefault()

+ 1 - 13
packages/tui/src/context/keymap.tsx

@@ -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
 
 

+ 0 - 85
packages/tui/test/app-lifecycle.test.tsx

@@ -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"
@@ -303,87 +302,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()
-  }
-})

+ 0 - 11
packages/tui/test/prompt/display.test.ts

@@ -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()
-  })
 })
 })