From 0f31fd631b22fa29eb62e3d188fe52818c645f20 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 15 May 2026 23:04:20 +0200 Subject: [PATCH] Fix multiline mentions (#27649) --- .../opencode/src/cli/cmd/prompt-display.ts | 19 ++++++++++++++----- .../test/cli/run/prompt.shared.test.ts | 6 ++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/prompt-display.ts b/packages/opencode/src/cli/cmd/prompt-display.ts index 7ec4bc0af5..4e8cb9046a 100644 --- a/packages/opencode/src/cli/cmd/prompt-display.ts +++ b/packages/opencode/src/cli/cmd/prompt-display.ts @@ -1,11 +1,20 @@ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) +function promptOffsetWidth(value: string) { + let width = 0 + for (const part of graphemes.segment(value)) { + // Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero. + width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment) + } + return width +} + 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) + const next = width + promptOffsetWidth(part.segment) if (next > offset) return part.index width = next } @@ -13,20 +22,20 @@ function displayOffsetIndex(value: string, offset: number) { return value.length } -export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) { +export function displaySlice(value: string, start = 0, end = promptOffsetWidth(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) + const next = width + promptOffsetWidth(part.segment) if (offset === width || offset < next) return part.segment width = next } } -export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) { +export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) { const text = displaySlice(value, 0, offset) const index = text.lastIndexOf("@") if (index === -1) return @@ -34,6 +43,6 @@ export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(valu 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)) + return promptOffsetWidth(text.slice(0, index)) } } diff --git a/packages/opencode/test/cli/run/prompt.shared.test.ts b/packages/opencode/test/cli/run/prompt.shared.test.ts index 299751eaa3..35b35ec3e7 100644 --- a/packages/opencode/test/cli/run/prompt.shared.test.ts +++ b/packages/opencode/test/cli/run/prompt.shared.test.ts @@ -126,6 +126,12 @@ describe("run prompt shared", () => { 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("@file1\n@file2", 13)).toBe(7) + expect(displayCharAt("@file1\n@file2", 6)).toBe("\n") + expect(displaySlice("@file1\n@file2", 8, 13)).toBe("file2") + expect(mentionTriggerIndex("@file1\nfoo @file2", 17)).toBe(11) + expect(mentionTriggerIndex("δΈ­ζ–‡ @one\n@two", 14)).toBe(10) + expect(displaySlice("δΈ­ζ–‡ @one\n@two", 11, 14)).toBe("two") expect(mentionTriggerIndex("δΈ­ζ–‡@")).toBeUndefined() expect(mentionTriggerIndex("こんにけは@")).toBeUndefined() expect(mentionTriggerIndex("ν•œκ΅­μ–΄@")).toBeUndefined()