فهرست منبع

prompt: fix cursor math for wide characters (#27017)

String.length counts code points, not display columns, so CJK
characters and emoji that occupy two terminal cells caused
misaligned cursors, broken mention triggers, and incorrect
history restoration offsets.

Use Bun.stringWidth for now, we need an alternative for this.

Fix #26716
Close #26922
Simon Klee 3 ماه پیش
والد
کامیت
8f05bbfaa6

+ 39 - 0
packages/opencode/src/cli/cmd/prompt-display.ts

@@ -0,0 +1,39 @@
+const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
+
+function displayOffsetIndex(value: string, offset: number) {
+  if (offset <= 0) return 0
+
+  let width = 0
+  for (const part of graphemes.segment(value)) {
+    const next = width + Bun.stringWidth(part.segment)
+    if (next > offset) return part.index
+    width = next
+  }
+
+  return value.length
+}
+
+export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) {
+  return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
+}
+
+export function displayCharAt(value: string, offset: number) {
+  let width = 0
+  for (const part of graphemes.segment(value)) {
+    const next = width + Bun.stringWidth(part.segment)
+    if (offset === width || offset < next) return part.segment
+    width = next
+  }
+}
+
+export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) {
+  const text = displaySlice(value, 0, offset)
+  const index = text.lastIndexOf("@")
+  if (index === -1) return
+
+  const before = index === 0 ? undefined : text[index - 1]
+  const query = text.slice(index)
+  if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
+    return Bun.stringWidth(text.slice(0, index))
+  }
+}

+ 16 - 18
packages/opencode/src/cli/cmd/run/footer.prompt.tsx

@@ -14,7 +14,10 @@ import { createEffect, createMemo, createResource, createSignal, onCleanup, onMo
 import * as Locale from "@/util/locale"
 import * as Locale from "@/util/locale"
 import {
 import {
   createPromptHistory,
   createPromptHistory,
+  displayCharAt,
+  displaySlice,
   isExitCommand,
   isExitCommand,
+  mentionTriggerIndex,
   isNewCommand,
   isNewCommand,
   movePromptHistory,
   movePromptHistory,
   promptCycle,
   promptCycle,
@@ -537,7 +540,7 @@ export function createPromptState(input: PromptInput): PromptState {
     })
     })
   }
   }
 
 
-  const restore = (value: RunPrompt, cursor = value.text.length) => {
+  const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => {
     draft = clonePrompt(value)
     draft = clonePrompt(value)
     if (!area || area.isDestroyed) {
     if (!area || area.isDestroyed) {
       return
       return
@@ -546,7 +549,7 @@ export function createPromptState(input: PromptInput): PromptState {
     hide()
     hide()
     area.setText(value.text)
     area.setText(value.text)
     restoreParts(value.parts)
     restoreParts(value.parts)
-    area.cursorOffset = Math.min(cursor, area.plainText.length)
+    area.cursorOffset = Math.min(cursor, Bun.stringWidth(area.plainText))
     scheduleRows()
     scheduleRows()
     area.focus()
     area.focus()
   }
   }
@@ -577,7 +580,7 @@ export function createPromptState(input: PromptInput): PromptState {
     area.setText(text)
     area.setText(text)
     clearParts()
     clearParts()
     draft = { text: area.plainText, parts: [] }
     draft = { text: area.plainText, parts: [] }
-    area.cursorOffset = Math.min(text.length, area.plainText.length)
+    area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText))
     scheduleRows()
     scheduleRows()
     area.focus()
     area.focus()
   }
   }
@@ -610,12 +613,13 @@ export function createPromptState(input: PromptInput): PromptState {
     }
     }
 
 
     if (visible() && mode() === "mention") {
     if (visible() && mode() === "mention") {
-      if (cursor <= at() || /\s/.test(text.slice(at(), cursor))) {
+      const query = displaySlice(text, at(), cursor)
+      if (cursor <= at() || /\s/.test(query)) {
         hide()
         hide()
         return
         return
       }
       }
 
 
-      setQuery(text.slice(at() + 1, cursor))
+      setQuery(displaySlice(text, at() + 1, cursor))
       return
       return
     }
     }
 
 
@@ -623,19 +627,12 @@ export function createPromptState(input: PromptInput): PromptState {
       return
       return
     }
     }
 
 
-    const head = text.slice(0, cursor)
-    const idx = head.lastIndexOf("@")
-    if (idx === -1) {
-      return
-    }
-
-    const before = idx === 0 ? undefined : head[idx - 1]
-    const tail = head.slice(idx)
-    if ((before === undefined || /\s/.test(before)) && !/\s/.test(tail)) {
+    const idx = mentionTriggerIndex(text, cursor)
+    if (idx !== undefined) {
       setAt(idx)
       setAt(idx)
       menu.reset()
       menu.reset()
       setMode("mention")
       setMode("mention")
-      setQuery(head.slice(idx + 1))
+      setQuery(displaySlice(text, idx + 1, cursor))
     }
     }
   }
   }
 
 
@@ -782,7 +779,7 @@ export function createPromptState(input: PromptInput): PromptState {
     }
     }
 
 
     const cursor = area.cursorOffset
     const cursor = area.cursorOffset
-    const tail = area.plainText.at(cursor)
+    const tail = displayCharAt(area.plainText, cursor)
     const append = "@" + next.value + (tail === " " ? "" : " ")
     const append = "@" + next.value + (tail === " " ? "" : " ")
     area.cursorOffset = at()
     area.cursorOffset = at()
     const start = area.logicalCursor
     const start = area.logicalCursor
@@ -941,7 +938,8 @@ export function createPromptState(input: PromptInput): PromptState {
     }
     }
 
 
     const dir = up ? -1 : 1
     const dir = up ? -1 : 1
-    if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === area.plainText.length)) {
+    const endOffset = Bun.stringWidth(area.plainText)
+    if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === endOffset)) {
       move(dir, event)
       move(dir, event)
       return
       return
     }
     }
@@ -955,7 +953,7 @@ export function createPromptState(input: PromptInput): PromptState {
         ? area.height - 1
         ? area.height - 1
         : Math.max(0, (area.virtualLineCount ?? 1) - 1)
         : Math.max(0, (area.virtualLineCount ?? 1) - 1)
     if (dir === 1 && area.visualCursor.visualRow === end) {
     if (dir === 1 && area.visualCursor.visualRow === end) {
-      area.cursorOffset = area.plainText.length
+      area.cursorOffset = endOffset
     }
     }
   }
   }
 
 

+ 4 - 3
packages/opencode/src/cli/cmd/run/prompt.shared.ts

@@ -12,6 +12,7 @@
 // The leader-key cycle (promptCycle) uses a two-step pattern: first press
 // The leader-key cycle (promptCycle) uses a two-step pattern: first press
 // arms the leader, second press within the timeout fires the action.
 // arms the leader, second press within the timeout fires the action.
 import type { KeyBinding } from "@opentui/core"
 import type { KeyBinding } from "@opentui/core"
+export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display"
 import { formatBinding, parseBindings } from "./keymap.shared"
 import { formatBinding, parseBindings } from "./keymap.shared"
 import type { FooterKeybinds, RunPrompt } from "./types"
 import type { FooterKeybinds, RunPrompt } from "./types"
 
 
@@ -275,7 +276,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
     return { state, apply: false }
     return { state, apply: false }
   }
   }
 
 
-  if (dir === 1 && cursor !== text.length) {
+  if (dir === 1 && cursor !== Bun.stringWidth(text)) {
     return { state, apply: false }
     return { state, apply: false }
   }
   }
 
 
@@ -309,7 +310,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
         index: null,
         index: null,
       },
       },
       text: state.draft,
       text: state.draft,
-      cursor: state.draft.length,
+      cursor: Bun.stringWidth(state.draft),
       apply: true,
       apply: true,
     }
     }
   }
   }
@@ -320,7 +321,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
       index: idx,
       index: idx,
     },
     },
     text: state.items[idx].text,
     text: state.items[idx].text,
-    cursor: dir === -1 ? 0 : state.items[idx].text.length,
+    cursor: dir === -1 ? 0 : Bun.stringWidth(state.items[idx].text),
     apply: true,
     apply: true,
   }
   }
 }
 }

+ 4 - 8
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -20,6 +20,7 @@ import { useFrecency } from "./frecency"
 import { useBindings } from "../../keymap"
 import { useBindings } from "../../keymap"
 import { Reference } from "@/reference/reference"
 import { Reference } from "@/reference/reference"
 import type { Config } from "@/config/config"
 import type { Config } from "@/config/config"
+import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
 
 
 function removeLineRange(input: string) {
 function removeLineRange(input: string) {
   const hashIndex = input.lastIndexOf("#")
   const hashIndex = input.lastIndexOf("#")
@@ -159,7 +160,7 @@ export function Autocomplete(props: {
     const input = props.input()
     const input = props.input()
     const currentCursorOffset = input.cursorOffset
     const currentCursorOffset = input.cursorOffset
 
 
-    const charAfterCursor = props.value.at(currentCursorOffset)
+    const charAfterCursor = displayCharAt(props.value, currentCursorOffset)
     const needsSpace = charAfterCursor !== " "
     const needsSpace = charAfterCursor !== " "
     const append = "@" + text + (needsSpace ? " " : "")
     const append = "@" + text + (needsSpace ? " " : "")
 
 
@@ -787,13 +788,8 @@ export function Autocomplete(props: {
         }
         }
 
 
         // 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 text = value.slice(0, offset)
-        const idx = text.lastIndexOf("@")
-        if (idx === -1) return
-
-        const between = text.slice(idx)
-        const before = idx === 0 ? undefined : value[idx - 1]
-        if ((before === undefined || /\s/.test(before)) && !between.match(/\s/)) {
+        const idx = mentionTriggerIndex(value, offset)
+        if (idx !== undefined) {
           show("@")
           show("@")
           setStore("index", idx)
           setStore("index", idx)
         }
         }

+ 50 - 0
packages/opencode/test/cli/run/prompt.shared.test.ts

@@ -1,8 +1,11 @@
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
 import {
 import {
   createPromptHistory,
   createPromptHistory,
+  displayCharAt,
+  displaySlice,
   isExitCommand,
   isExitCommand,
   isNewCommand,
   isNewCommand,
+  mentionTriggerIndex,
   movePromptHistory,
   movePromptHistory,
   printableBinding,
   printableBinding,
   promptCycle,
   promptCycle,
@@ -85,6 +88,53 @@ describe("run prompt shared", () => {
     expect(draft.state.index).toBeNull()
     expect(draft.state.index).toBeNull()
   })
   })
 
 
+  test("uses display-width cursors for history restoration", () => {
+    const base = createPromptHistory([prompt("one"), prompt("中文")])
+
+    const latest = movePromptHistory(base, -1, "草稿", 0)
+    expect(latest.apply).toBe(true)
+    expect(latest.text).toBe("中文")
+    expect(latest.cursor).toBe(0)
+
+    const older = movePromptHistory(latest.state, -1, "中文", 0)
+    expect(older.apply).toBe(true)
+    expect(older.text).toBe("one")
+    expect(older.cursor).toBe(0)
+
+    const newer = movePromptHistory(older.state, 1, "one", Bun.stringWidth("one"))
+    expect(newer.apply).toBe(true)
+    expect(newer.text).toBe("中文")
+    expect(newer.cursor).toBe(Bun.stringWidth("中文"))
+
+    const draft = movePromptHistory(newer.state, 1, "中文", Bun.stringWidth("中文"))
+    expect(draft.apply).toBe(true)
+    expect(draft.text).toBe("草稿")
+    expect(draft.cursor).toBe(Bun.stringWidth("草稿"))
+  })
+
+  test("uses display-width offsets for mention helpers", () => {
+    expect(mentionTriggerIndex("@")).toBe(0)
+    expect(mentionTriggerIndex("test @")).toBe(5)
+    expect(mentionTriggerIndex("中文 @")).toBe(5)
+    expect(mentionTriggerIndex("こんにちは @")).toBe(11)
+    expect(mentionTriggerIndex("한국어 @")).toBe(7)
+    expect(mentionTriggerIndex("🙂 @")).toBe(3)
+    expect(mentionTriggerIndex("中文 @src file", Bun.stringWidth("中文 @src"))).toBe(5)
+    expect(displayCharAt("中文 @src", Bun.stringWidth("中文 @"))).toBe("s")
+    expect(displaySlice("中文 @src", 5, Bun.stringWidth("中文 @src"))).toBe("@src")
+    expect(displaySlice("中文 @src", 6, Bun.stringWidth("中文 @src"))).toBe("src")
+    expect(mentionTriggerIndex("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe(3)
+    expect(displayCharAt("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @"))).toBe("s")
+    expect(displaySlice("👨‍👩‍👧‍👦 @src", 3, Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe("@src")
+    expect(mentionTriggerIndex("中文@")).toBeUndefined()
+    expect(mentionTriggerIndex("こんにちは@")).toBeUndefined()
+    expect(mentionTriggerIndex("한국어@")).toBeUndefined()
+    expect(mentionTriggerIndex("🙂@")).toBeUndefined()
+    expect(mentionTriggerIndex("hello@")).toBeUndefined()
+    expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined()
+    expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
+  })
+
   test("handles direct and leader-based variant cycling", () => {
   test("handles direct and leader-based variant cycling", () => {
     const keys = promptKeys(keybinds)
     const keys = promptKeys(keybinds)