diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete-detect.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete-detect.ts new file mode 100644 index 000000000..9b9584870 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete-detect.ts @@ -0,0 +1,34 @@ +import { stringIndexToWidth, widthToStringIndex } from "./offset" + +export type TriggerKind = "@" | "$" | "/" + +// Decide whether an autocomplete popup should open for the current input. +// +// `value` is the editor plainText (UTF-16) and `cursorWidth` is the editor's +// display-width cursor offset (CJK = 2 columns). We convert the cursor to a +// UTF-16 index before doing any string work, then report the trigger position +// back in width coordinates so it matches the editor's extmark/cursor space. +export function detectTrigger(value: string, cursorWidth: number): { kind: TriggerKind; index: number } | undefined { + if (cursorWidth === 0) return undefined + + const cursorIndex = widthToStringIndex(value, cursorWidth) + + // "/" command only when it is the very first character and nothing before the cursor is whitespace. + if (value.startsWith("/") && !value.slice(0, cursorIndex).match(/\s/)) { + return { kind: "/", index: 0 } + } + + // Nearest "@" (files) or "$" (agents) before the cursor with no whitespace in between. + const text = value.slice(0, cursorIndex) + const idx = Math.max(text.lastIndexOf("@"), text.lastIndexOf("$")) + if (idx === -1) return undefined + + const kind: TriggerKind = idx === text.lastIndexOf("$") ? "$" : "@" + const before = idx === 0 ? undefined : value[idx - 1] + const between = text.slice(idx) + if ((before === undefined || /\s/.test(before)) && !between.match(/\s/)) { + return { kind, index: stringIndexToWidth(value, idx) } + } + + return undefined +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index bcb427c11..b80134552 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -17,6 +17,8 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" +import { detectTrigger } from "./autocomplete-detect" +import { charAfterCursor } from "./offset" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -158,8 +160,7 @@ export function Autocomplete(props: { const input = props.input() const currentCursorOffset = input.cursorOffset - const charAfterCursor = props.value.at(currentCursorOffset) - const needsSpace = charAfterCursor !== " " + const needsSpace = charAfterCursor(props.value, currentCursorOffset) !== " " const append = prefix + text + (needsSpace ? " " : "") input.cursorOffset = store.index @@ -531,32 +532,12 @@ export function Autocomplete(props: { 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/)) { - show("/") - setStore("index", 0) - return - } - - // Check for "@" (files) or "$" (agents) trigger - find the nearest one before - // the cursor with no whitespace between it and the cursor. - const text = value.slice(0, offset) - const atIdx = text.lastIndexOf("@") - const dollarIdx = text.lastIndexOf("$") - const idx = Math.max(atIdx, dollarIdx) - if (idx === -1) return - - const trigger = idx === dollarIdx ? "$" : "@" - const between = text.slice(idx) - const before = idx === 0 ? undefined : value[idx - 1] - if ((before === undefined || /\s/.test(before)) && !between.match(/\s/)) { - show(trigger) - setStore("index", idx) - } + // Check if autocomplete should reopen (e.g., after backspace deleted a space). + // detectTrigger works in width coordinates so CJK before/after the trigger stays correct. + const trigger = detectTrigger(value, props.input().cursorOffset) + if (!trigger) return + show(trigger.kind) + setStore("index", trigger.index) }, onKeyDown(e: KeyEvent) { if (store.visible) { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index f748fe179..f9fe899bb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -16,7 +16,7 @@ import { MessageID, PartID } from "@/session/schema" import { createStore, produce, unwrap } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" -import { assign } from "./part" +import { assign, expandPlaceholders } from "./part" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" @@ -1091,23 +1091,20 @@ export function Prompt(props: PromptProps) { } const messageID = MessageID.ascending() - let inputText = store.prompt.input - // Expand pasted text inline before submitting - const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) - const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start) - - for (const extmark of sortedExtmarks) { - const partIndex = store.extmarkToPartIndex.get(extmark.id) - if (partIndex !== undefined) { + // Expand pasted text inline before submitting. Extmark offsets are + // display-width based while plainText is UTF-16, so expandPlaceholders + // bridges the two coordinate systems (otherwise CJK content desyncs them). + const marks = input.extmarks + .getAllForTypeId(promptPartTypeId) + .flatMap((extmark: { id: number; start: number; end: number }) => { + const partIndex = store.extmarkToPartIndex.get(extmark.id) + if (partIndex === undefined) return [] const part = store.prompt.parts[partIndex] - if (part?.type === "text" && part.text) { - const before = inputText.slice(0, extmark.start) - const after = inputText.slice(extmark.end) - inputText = before + part.text + after - } - } - } + if (part?.type !== "text" || !part.text) return [] + return [{ start: extmark.start, end: extmark.end, text: part.text }] + }) + const inputText = expandPlaceholders(store.prompt.input, marks) // Filter out text parts (pasted content) since they're now expanded inline const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/offset.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/offset.ts new file mode 100644 index 000000000..7420832bf --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/offset.ts @@ -0,0 +1,45 @@ +// The editor (@opentui/core) tracks cursor/extmark positions as display-WIDTH +// offsets: a wide CJK character counts as 2 columns. The plainText we slice in +// JS is a UTF-16 string where that same character is 1 unit. These helpers +// translate between the two coordinate systems so the two never get mixed. +// Inputs are assumed to sit on character (code-point) boundaries, which is all +// the editor ever emits; an offset landing inside a wide char rounds up to the +// next boundary. + +// The editor advances its offset by 1 for a newline and 2 for a tab, but +// Bun.stringWidth returns 0 for both, so we special-case them to stay aligned +// with the editor. (Pasted "\r" never reaches here — paste input is normalized +// to "\n" and the editor itself maps "\r" to "\n".) +function charWidth(ch: string): number { + if (ch === "\n") return 1 + if (ch === "\t") return 2 + return Bun.stringWidth(ch) +} + +export function widthToStringIndex(text: string, widthOffset: number): number { + let width = 0 + let index = 0 + for (const ch of text) { + if (width >= widthOffset) break + width += charWidth(ch) + index += ch.length + } + return index +} + +export function stringIndexToWidth(text: string, stringIndex: number): number { + let width = 0 + let index = 0 + for (const ch of text) { + if (index >= stringIndex) break + width += charWidth(ch) + index += ch.length + } + return width +} + +// The character immediately after a width-based cursor offset, or undefined at +// end of input. Used to decide whether an inserted mention needs a trailing space. +export function charAfterCursor(text: string, cursorWidth: number): string | undefined { + return text.at(widthToStringIndex(text, cursorWidth)) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts index 8cdcef606..57c10f572 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts @@ -1,5 +1,6 @@ import { PartID } from "@/session/schema" import type { PromptInfo } from "./history" +import { widthToStringIndex } from "./offset" type Item = PromptInfo["parts"][number] @@ -14,3 +15,22 @@ export function assign(part: Item): Item & { id: PartID } { id: PartID.ascending(), } } + +// Editor extmark offsets are display-WIDTH based (a wide CJK char counts as 2), +// while plainText is a JS UTF-16 string (a CJK char is 1 unit). widthToStringIndex +// converts a width offset into the matching UTF-16 string index so .slice lines up. +// Replace each placeholder span (given in width-based offsets) in the editor +// plainText with its real pasted content. Marks are applied right-to-left so +// earlier offsets stay valid as the string is rewritten. +export function expandPlaceholders( + plainText: string, + marks: { start: number; end: number; text: string }[], +): string { + return [...marks] + .sort((a, b) => b.start - a.start) + .reduce((text, mark) => { + const start = widthToStringIndex(text, mark.start) + const end = widthToStringIndex(text, mark.end) + return text.slice(0, start) + mark.text + text.slice(end) + }, plainText) +} diff --git a/packages/opencode/test/cli/cmd/tui/autocomplete-detect.test.ts b/packages/opencode/test/cli/cmd/tui/autocomplete-detect.test.ts new file mode 100644 index 000000000..ff8807b74 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/autocomplete-detect.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test" +import { detectTrigger } from "../../../../src/cli/cmd/tui/component/prompt/autocomplete-detect" + +// cursorWidth is the editor's display-width cursor offset (CJK = 2 columns). +// detectTrigger inspects the plainText and returns the trigger kind plus the +// trigger's position expressed in the SAME width coordinate (matching store.index), +// or undefined when nothing should open. +describe("detectTrigger", () => { + test("returns undefined at start of input", () => { + expect(detectTrigger("", 0)).toBeUndefined() + expect(detectTrigger("@foo", 0)).toBeUndefined() + }) + + test("detects a leading slash command", () => { + expect(detectTrigger("/hel", 4)).toEqual({ kind: "/", index: 0 }) + }) + + test("detects @ trigger in pure ascii", () => { + // "hi @fo" cursor at end (width 6) + expect(detectTrigger("hi @fo", 6)).toEqual({ kind: "@", index: 3 }) + }) + + test("detects $ trigger in pure ascii", () => { + expect(detectTrigger("run $ag", 7)).toEqual({ kind: "$", index: 4 }) + }) + + test("returns width-based index when CJK precedes the trigger", () => { + // "你好 @fo" — 你好 width 4, space width 1, so @ sits at width index 5 (string index 3) + // cursor at end: width = 5 + 3 = 8 + expect(detectTrigger("你好 @fo", 8)).toEqual({ kind: "@", index: 5 }) + }) + + test("returns width-based index for a $ trigger preceded by CJK", () => { + // "你好 $ag" — same geometry as the @ case but for the agent trigger. + expect(detectTrigger("你好 $ag", 8)).toEqual({ kind: "$", index: 5 }) + }) + + test("does not over-read past the cursor when CJK follows the trigger", () => { + // "你好 @x尾巴", cursor right after "@x": 你好(4)+space(1)+@x(2) = width 7 (string index 5). + // There is no whitespace between @ and the cursor, so it must still trigger, + // and must NOT be fooled by the trailing "尾巴" after the cursor. + expect(detectTrigger("你好 @x尾巴", 7)).toEqual({ kind: "@", index: 5 }) + }) + + test("does not trigger when whitespace sits between trigger and cursor", () => { + // "你 @ x" — @ is preceded by a space (valid start) but a space sits between @ and the cursor. + // 你(2)+space(1)+@(1)+space(1)+x(1) = width 6 + expect(detectTrigger("你 @ x", 6)).toBeUndefined() + }) + + test("does not trigger when char before @ is non-whitespace", () => { + // "a@fo" — '@' is glued to 'a', not a fresh mention + expect(detectTrigger("a@fo", 4)).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/offset.test.ts b/packages/opencode/test/cli/cmd/tui/offset.test.ts new file mode 100644 index 000000000..9433abff3 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/offset.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test" +import { + charAfterCursor, + stringIndexToWidth, + widthToStringIndex, +} from "../../../../src/cli/cmd/tui/component/prompt/offset" + +// The editor uses display-width offsets (a wide CJK char counts as 2 columns) +// while plainText is a JS UTF-16 string (a CJK char is 1 unit). These helpers +// translate between the two coordinate systems. +describe("offset conversion", () => { + test("widthToStringIndex maps a width offset to a UTF-16 index", () => { + // "你好" is width 4 but 2 UTF-16 units + expect(widthToStringIndex("你好world", 4)).toBe(2) + expect(widthToStringIndex("你好world", 6)).toBe(4) // 你好wo + expect(widthToStringIndex("hello", 3)).toBe(3) // ascii: width == index + }) + + test("stringIndexToWidth maps a UTF-16 index to a width offset", () => { + expect(stringIndexToWidth("你好world", 2)).toBe(4) // 你好 -> width 4 + expect(stringIndexToWidth("你好world", 4)).toBe(6) // 你好wo + expect(stringIndexToWidth("hello", 3)).toBe(3) + }) + + test("the two conversions round-trip on character boundaries", () => { + const text = "前缀@x后缀" + for (let i = 0; i <= text.length; i++) { + const width = stringIndexToWidth(text, i) + expect(widthToStringIndex(text, width)).toBe(i) + } + }) + + test("counts a newline as width 1 (matching the editor, not Bun.stringWidth)", () => { + // The editor advances its width offset by 1 per "\n", but Bun.stringWidth("\n") + // is 0. The converters must follow the editor, otherwise every newline before an + // offset desyncs the two coordinate systems by one. + // "你好\n[" — 你好=4, \n=1, so "[" sits at width 5 / string index 3 + expect(stringIndexToWidth("你好\n[", 3)).toBe(5) + expect(widthToStringIndex("你好\n[", 5)).toBe(3) + }) + + test("round-trips across newlines and CJK together", () => { + const text = "你好\n世界\nA" + let index = 0 + for (const ch of text) { + const width = stringIndexToWidth(text, index) + expect(widthToStringIndex(text, width)).toBe(index) + index += ch.length + } + }) + + test("counts a tab as width 2 (matching the editor, not Bun.stringWidth)", () => { + // The editor advances its width offset by 2 per "\t", but Bun.stringWidth("\t") + // is 0. Pasted code often contains tabs, so the converters must follow the editor. + // "ab\tc" — ab=2, \t=2, so "c" sits at width 4 / string index 3 + expect(stringIndexToWidth("ab\tc", 3)).toBe(4) + expect(widthToStringIndex("ab\tc", 4)).toBe(3) + }) + + test("round-trips across tabs, newlines and CJK together", () => { + const text = "你好\t世界\nA\tB" + let index = 0 + for (const ch of text) { + const width = stringIndexToWidth(text, index) + expect(widthToStringIndex(text, width)).toBe(index) + index += ch.length + } + }) + + test("round-trips across supplementary-plane (emoji) code-point boundaries", () => { + // 😀 is one code point but 2 UTF-16 units and display width 2. The converters + // are only contracted to agree on real code-point boundaries (the editor never + // emits an offset that splits a surrogate pair), so iterate by code point. + const text = "a😀好b" + let index = 0 + for (const ch of text) { + const width = stringIndexToWidth(text, index) + expect(widthToStringIndex(text, width)).toBe(index) + index += ch.length + } + }) +}) + +describe("charAfterCursor", () => { + test("reads the char right after the cursor in pure ascii", () => { + // "ab cd", cursor (width 2) sits before the space + expect(charAfterCursor("ab cd", 2)).toBe(" ") + }) + + test("reads the correct char when CJK precedes the cursor", () => { + // "你好 x" — cursor after 你好 (width 4) must land on the space, not be shifted + // by the width/UTF-16 mismatch. + expect(charAfterCursor("你好 x", 4)).toBe(" ") + // cursor after "你好 " (width 5) lands on "x" + expect(charAfterCursor("你好 x", 5)).toBe("x") + }) + + test("returns undefined at end of input", () => { + expect(charAfterCursor("你好", 4)).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts index 326d3e624..580f9894b 100644 --- a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts +++ b/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history" -import { assign, strip } from "../../../../src/cli/cmd/tui/component/prompt/part" +import { assign, expandPlaceholders, strip } from "../../../../src/cli/cmd/tui/component/prompt/part" describe("prompt part", () => { test("strip removes persisted ids from reused file parts", () => { @@ -45,3 +45,53 @@ describe("prompt part", () => { }) }) }) + +describe("expandPlaceholders", () => { + // Editor extmark offsets are display-WIDTH based (CJK = 2 columns), while the + // plain text is a JS UTF-16 string (CJK = 1 unit). expandPlaceholders must + // bridge the two coordinate systems. + + test("expands a single ascii placeholder", () => { + const result = expandPlaceholders("[Pasted ~3 lines] ", [ + { start: 0, end: "[Pasted ~3 lines]".length, text: "line1\nline2\nline3" }, + ]) + expect(result).toBe("line1\nline2\nline3 ") + }) + + test("does not leave residue or swallow content when preceded by CJK", () => { + // User typed "你好" (width 4, but string index 2) then pasted. + // plainText shown in editor: "你好[Pasted ~3 lines] " + // extmark.start is width-based = 4, extmark.end = 4 + 17 = 21 + const result = expandPlaceholders("你好[Pasted ~3 lines] ", [ + { start: 4, end: 4 + "[Pasted ~3 lines]".length, text: "line1\nline2\nline3" }, + ]) + expect(result).toBe("你好line1\nline2\nline3 ") + }) + + test("handles multiple placeholders interleaved with CJK", () => { + const v1 = "[Pasted ~3 lines]" + const v2 = "[Pasted ~2 lines]" + // plainText: "你好" + v1 + " " + "世界" + v2 + " " + "末" + // widths: 你好=4 -> v1 start 4, end 4+17=21; after v1+space width=18 => 22; 世界=4 => 26; v2 start 26 end 43 + const plain = `你好${v1} 世界${v2} 末` + const result = expandPlaceholders(plain, [ + { start: 4, end: 4 + v1.length, text: "AAA" }, + { start: 26, end: 26 + v2.length, text: "BBB" }, + ]) + expect(result).toBe("你好AAA 世界BBB 末") + }) + + test("expands a placeholder that sits on its own line after CJK", () => { + const v = "[Pasted ~3 lines]" + // User typed CJK on line 1, pasted on line 2, typed CJK on line 3: + // "你好\n" + v + " " + "\n世界" + // Editor offsets count "\n" as width 1: 你好=4, \n=5, so the placeholder starts at width 5. + const plain = `你好\n${v} \n世界` + const result = expandPlaceholders(plain, [{ start: 5, end: 5 + v.length, text: "CONTENT" }]) + expect(result).toBe("你好\nCONTENT \n世界") + }) + + test("returns input unchanged when there are no placeholders", () => { + expect(expandPlaceholders("你好世界", [])).toBe("你好世界") + }) +})