Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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("#")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 13 additions & 16 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
45 changes: 45 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/offset.ts
Original file line number Diff line number Diff line change
@@ -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))
}
20 changes: 20 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/part.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PartID } from "@/session/schema"
import type { PromptInfo } from "./history"
import { widthToStringIndex } from "./offset"

type Item = PromptInfo["parts"][number]

Expand All @@ -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)
}
55 changes: 55 additions & 0 deletions packages/opencode/test/cli/cmd/tui/autocomplete-detect.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
101 changes: 101 additions & 0 deletions packages/opencode/test/cli/cmd/tui/offset.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading