Skip to content

Commit 8f05bbf

Browse files
authored
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
1 parent d276d96 commit 8f05bbf

5 files changed

Lines changed: 113 additions & 29 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
2+
3+
function displayOffsetIndex(value: string, offset: number) {
4+
if (offset <= 0) return 0
5+
6+
let width = 0
7+
for (const part of graphemes.segment(value)) {
8+
const next = width + Bun.stringWidth(part.segment)
9+
if (next > offset) return part.index
10+
width = next
11+
}
12+
13+
return value.length
14+
}
15+
16+
export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) {
17+
return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
18+
}
19+
20+
export function displayCharAt(value: string, offset: number) {
21+
let width = 0
22+
for (const part of graphemes.segment(value)) {
23+
const next = width + Bun.stringWidth(part.segment)
24+
if (offset === width || offset < next) return part.segment
25+
width = next
26+
}
27+
}
28+
29+
export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) {
30+
const text = displaySlice(value, 0, offset)
31+
const index = text.lastIndexOf("@")
32+
if (index === -1) return
33+
34+
const before = index === 0 ? undefined : text[index - 1]
35+
const query = text.slice(index)
36+
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
37+
return Bun.stringWidth(text.slice(0, index))
38+
}
39+
}

packages/opencode/src/cli/cmd/run/footer.prompt.tsx

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import { createEffect, createMemo, createResource, createSignal, onCleanup, onMo
1414
import * as Locale from "@/util/locale"
1515
import {
1616
createPromptHistory,
17+
displayCharAt,
18+
displaySlice,
1719
isExitCommand,
20+
mentionTriggerIndex,
1821
isNewCommand,
1922
movePromptHistory,
2023
promptCycle,
@@ -537,7 +540,7 @@ export function createPromptState(input: PromptInput): PromptState {
537540
})
538541
}
539542

540-
const restore = (value: RunPrompt, cursor = value.text.length) => {
543+
const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => {
541544
draft = clonePrompt(value)
542545
if (!area || area.isDestroyed) {
543546
return
@@ -546,7 +549,7 @@ export function createPromptState(input: PromptInput): PromptState {
546549
hide()
547550
area.setText(value.text)
548551
restoreParts(value.parts)
549-
area.cursorOffset = Math.min(cursor, area.plainText.length)
552+
area.cursorOffset = Math.min(cursor, Bun.stringWidth(area.plainText))
550553
scheduleRows()
551554
area.focus()
552555
}
@@ -577,7 +580,7 @@ export function createPromptState(input: PromptInput): PromptState {
577580
area.setText(text)
578581
clearParts()
579582
draft = { text: area.plainText, parts: [] }
580-
area.cursorOffset = Math.min(text.length, area.plainText.length)
583+
area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText))
581584
scheduleRows()
582585
area.focus()
583586
}
@@ -610,32 +613,26 @@ export function createPromptState(input: PromptInput): PromptState {
610613
}
611614

612615
if (visible() && mode() === "mention") {
613-
if (cursor <= at() || /\s/.test(text.slice(at(), cursor))) {
616+
const query = displaySlice(text, at(), cursor)
617+
if (cursor <= at() || /\s/.test(query)) {
614618
hide()
615619
return
616620
}
617621

618-
setQuery(text.slice(at() + 1, cursor))
622+
setQuery(displaySlice(text, at() + 1, cursor))
619623
return
620624
}
621625

622626
if (cursor === 0) {
623627
return
624628
}
625629

626-
const head = text.slice(0, cursor)
627-
const idx = head.lastIndexOf("@")
628-
if (idx === -1) {
629-
return
630-
}
631-
632-
const before = idx === 0 ? undefined : head[idx - 1]
633-
const tail = head.slice(idx)
634-
if ((before === undefined || /\s/.test(before)) && !/\s/.test(tail)) {
630+
const idx = mentionTriggerIndex(text, cursor)
631+
if (idx !== undefined) {
635632
setAt(idx)
636633
menu.reset()
637634
setMode("mention")
638-
setQuery(head.slice(idx + 1))
635+
setQuery(displaySlice(text, idx + 1, cursor))
639636
}
640637
}
641638

@@ -782,7 +779,7 @@ export function createPromptState(input: PromptInput): PromptState {
782779
}
783780

784781
const cursor = area.cursorOffset
785-
const tail = area.plainText.at(cursor)
782+
const tail = displayCharAt(area.plainText, cursor)
786783
const append = "@" + next.value + (tail === " " ? "" : " ")
787784
area.cursorOffset = at()
788785
const start = area.logicalCursor
@@ -941,7 +938,8 @@ export function createPromptState(input: PromptInput): PromptState {
941938
}
942939

943940
const dir = up ? -1 : 1
944-
if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === area.plainText.length)) {
941+
const endOffset = Bun.stringWidth(area.plainText)
942+
if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === endOffset)) {
945943
move(dir, event)
946944
return
947945
}
@@ -955,7 +953,7 @@ export function createPromptState(input: PromptInput): PromptState {
955953
? area.height - 1
956954
: Math.max(0, (area.virtualLineCount ?? 1) - 1)
957955
if (dir === 1 && area.visualCursor.visualRow === end) {
958-
area.cursorOffset = area.plainText.length
956+
area.cursorOffset = endOffset
959957
}
960958
}
961959

packages/opencode/src/cli/cmd/run/prompt.shared.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// The leader-key cycle (promptCycle) uses a two-step pattern: first press
1313
// arms the leader, second press within the timeout fires the action.
1414
import type { KeyBinding } from "@opentui/core"
15+
export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display"
1516
import { formatBinding, parseBindings } from "./keymap.shared"
1617
import type { FooterKeybinds, RunPrompt } from "./types"
1718

@@ -275,7 +276,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
275276
return { state, apply: false }
276277
}
277278

278-
if (dir === 1 && cursor !== text.length) {
279+
if (dir === 1 && cursor !== Bun.stringWidth(text)) {
279280
return { state, apply: false }
280281
}
281282

@@ -309,7 +310,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
309310
index: null,
310311
},
311312
text: state.draft,
312-
cursor: state.draft.length,
313+
cursor: Bun.stringWidth(state.draft),
313314
apply: true,
314315
}
315316
}
@@ -320,7 +321,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
320321
index: idx,
321322
},
322323
text: state.items[idx].text,
323-
cursor: dir === -1 ? 0 : state.items[idx].text.length,
324+
cursor: dir === -1 ? 0 : Bun.stringWidth(state.items[idx].text),
324325
apply: true,
325326
}
326327
}

packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useFrecency } from "./frecency"
2020
import { useBindings } from "../../keymap"
2121
import { Reference } from "@/reference/reference"
2222
import type { Config } from "@/config/config"
23+
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
2324

2425
function removeLineRange(input: string) {
2526
const hashIndex = input.lastIndexOf("#")
@@ -159,7 +160,7 @@ export function Autocomplete(props: {
159160
const input = props.input()
160161
const currentCursorOffset = input.cursorOffset
161162

162-
const charAfterCursor = props.value.at(currentCursorOffset)
163+
const charAfterCursor = displayCharAt(props.value, currentCursorOffset)
163164
const needsSpace = charAfterCursor !== " "
164165
const append = "@" + text + (needsSpace ? " " : "")
165166

@@ -787,13 +788,8 @@ export function Autocomplete(props: {
787788
}
788789

789790
// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
790-
const text = value.slice(0, offset)
791-
const idx = text.lastIndexOf("@")
792-
if (idx === -1) return
793-
794-
const between = text.slice(idx)
795-
const before = idx === 0 ? undefined : value[idx - 1]
796-
if ((before === undefined || /\s/.test(before)) && !between.match(/\s/)) {
791+
const idx = mentionTriggerIndex(value, offset)
792+
if (idx !== undefined) {
797793
show("@")
798794
setStore("index", idx)
799795
}

packages/opencode/test/cli/run/prompt.shared.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { describe, expect, test } from "bun:test"
22
import {
33
createPromptHistory,
4+
displayCharAt,
5+
displaySlice,
46
isExitCommand,
57
isNewCommand,
8+
mentionTriggerIndex,
69
movePromptHistory,
710
printableBinding,
811
promptCycle,
@@ -85,6 +88,53 @@ describe("run prompt shared", () => {
8588
expect(draft.state.index).toBeNull()
8689
})
8790

91+
test("uses display-width cursors for history restoration", () => {
92+
const base = createPromptHistory([prompt("one"), prompt("中文")])
93+
94+
const latest = movePromptHistory(base, -1, "草稿", 0)
95+
expect(latest.apply).toBe(true)
96+
expect(latest.text).toBe("中文")
97+
expect(latest.cursor).toBe(0)
98+
99+
const older = movePromptHistory(latest.state, -1, "中文", 0)
100+
expect(older.apply).toBe(true)
101+
expect(older.text).toBe("one")
102+
expect(older.cursor).toBe(0)
103+
104+
const newer = movePromptHistory(older.state, 1, "one", Bun.stringWidth("one"))
105+
expect(newer.apply).toBe(true)
106+
expect(newer.text).toBe("中文")
107+
expect(newer.cursor).toBe(Bun.stringWidth("中文"))
108+
109+
const draft = movePromptHistory(newer.state, 1, "中文", Bun.stringWidth("中文"))
110+
expect(draft.apply).toBe(true)
111+
expect(draft.text).toBe("草稿")
112+
expect(draft.cursor).toBe(Bun.stringWidth("草稿"))
113+
})
114+
115+
test("uses display-width offsets for mention helpers", () => {
116+
expect(mentionTriggerIndex("@")).toBe(0)
117+
expect(mentionTriggerIndex("test @")).toBe(5)
118+
expect(mentionTriggerIndex("中文 @")).toBe(5)
119+
expect(mentionTriggerIndex("こんにちは @")).toBe(11)
120+
expect(mentionTriggerIndex("한국어 @")).toBe(7)
121+
expect(mentionTriggerIndex("🙂 @")).toBe(3)
122+
expect(mentionTriggerIndex("中文 @src file", Bun.stringWidth("中文 @src"))).toBe(5)
123+
expect(displayCharAt("中文 @src", Bun.stringWidth("中文 @"))).toBe("s")
124+
expect(displaySlice("中文 @src", 5, Bun.stringWidth("中文 @src"))).toBe("@src")
125+
expect(displaySlice("中文 @src", 6, Bun.stringWidth("中文 @src"))).toBe("src")
126+
expect(mentionTriggerIndex("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe(3)
127+
expect(displayCharAt("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @"))).toBe("s")
128+
expect(displaySlice("👨‍👩‍👧‍👦 @src", 3, Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe("@src")
129+
expect(mentionTriggerIndex("中文@")).toBeUndefined()
130+
expect(mentionTriggerIndex("こんにちは@")).toBeUndefined()
131+
expect(mentionTriggerIndex("한국어@")).toBeUndefined()
132+
expect(mentionTriggerIndex("🙂@")).toBeUndefined()
133+
expect(mentionTriggerIndex("hello@")).toBeUndefined()
134+
expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined()
135+
expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
136+
})
137+
88138
test("handles direct and leader-based variant cycling", () => {
89139
const keys = promptKeys(keybinds)
90140

0 commit comments

Comments
 (0)