From 23cad9537c12b9ab80f6018013a643b793afa966 Mon Sep 17 00:00:00 2001 From: zhaochangle Date: Tue, 5 May 2026 18:40:07 +0800 Subject: [PATCH] fix(tui): preserve summarized paste order with wide text OpenTUI extmark ranges are display-width offsets, but the prompt submit path used them directly as JavaScript string indices when replacing virtual paste summaries with the original pasted text. If the prompt before a [Pasted ~N lines] marker contained wide characters such as Chinese, the replacement range was shifted and the submitted prompt could splice the pasted content into the wrong position. Convert display offsets to string indices before expanding text parts, and fall back to the virtual marker text when extmark offsets were adjusted using string length semantics. Centralize prompt display-width calculations in prompt-display using the runtime-agnostic string-width package so this path no longer depends on Bun.stringWidth. Add regression coverage for Chinese text, newlines, multiple paste summaries, and shifted extmarks. --- bun.lock | 1 + packages/opencode/package.json | 1 + .../opencode/src/cli/cmd/prompt-display.ts | 29 ++++- .../cmd/tui/component/prompt/autocomplete.tsx | 6 +- .../cli/cmd/tui/component/prompt/index.tsx | 30 ++--- .../src/cli/cmd/tui/component/prompt/paste.ts | 52 ++++++++ .../test/cli/cmd/tui/prompt-paste.test.ts | 118 ++++++++++++++++++ 7 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/paste.ts create mode 100644 packages/opencode/test/cli/cmd/tui/prompt-paste.test.ts diff --git a/bun.lock b/bun.lock index 930ee3324363..599287eae791 100644 --- a/bun.lock +++ b/bun.lock @@ -473,6 +473,7 @@ "remeda": "catalog:", "semver": "^7.6.3", "solid-js": "catalog:", + "string-width": "7.2.0", "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 4618db1f9e2f..d1b279efda99 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -154,6 +154,7 @@ "remeda": "catalog:", "semver": "^7.6.3", "solid-js": "catalog:", + "string-width": "7.2.0", "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", diff --git a/packages/opencode/src/cli/cmd/prompt-display.ts b/packages/opencode/src/cli/cmd/prompt-display.ts index 4e8cb9046ac6..9b29cc3a7bad 100644 --- a/packages/opencode/src/cli/cmd/prompt-display.ts +++ b/packages/opencode/src/cli/cmd/prompt-display.ts @@ -1,27 +1,44 @@ +import stringWidth from "string-width" + const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) -function promptOffsetWidth(value: string) { +function promptSegmentWidth(value: string) { + // Textarea offsets count newlines as one position; string-width counts them as zero. + if (value === "\n") return 1 + return stringWidth(value) +} + +export 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) + width += promptSegmentWidth(part.segment) } return width } -function displayOffsetIndex(value: string, offset: number) { +export function displayOffsetIndex(value: string, offset: number) { if (offset <= 0) return 0 let width = 0 for (const part of graphemes.segment(value)) { - const next = width + promptOffsetWidth(part.segment) + const next = width + promptSegmentWidth(part.segment) if (next > offset) return part.index + if (next === offset) return part.index + part.segment.length width = next } return value.length } +export function stringIndexDisplayOffset(value: string, index: number) { + let width = 0 + for (const part of graphemes.segment(value)) { + if (part.index >= index) return width + width += promptSegmentWidth(part.segment) + } + return width +} + export function displaySlice(value: string, start = 0, end = promptOffsetWidth(value)) { return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end)) } @@ -29,7 +46,7 @@ export function displaySlice(value: string, start = 0, end = promptOffsetWidth(v export function displayCharAt(value: string, offset: number) { let width = 0 for (const part of graphemes.segment(value)) { - const next = width + promptOffsetWidth(part.segment) + const next = width + promptSegmentWidth(part.segment) if (offset === width || offset < next) return part.segment width = next } 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 2bda73cff76d..ba78d8576611 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -20,7 +20,7 @@ import { useFrecency } from "./frecency" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { Reference } from "@/reference/reference" import { ConfigReference } from "@/config/reference" -import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display" +import { displayCharAt, mentionTriggerIndex, promptOffsetWidth } from "@/cli/cmd/prompt-display" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -182,7 +182,7 @@ export function Autocomplete(props: { const virtualText = "@" + text const extmarkStart = store.index - const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText) + const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText) const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined @@ -557,7 +557,7 @@ export function Autocomplete(props: { const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) props.input().insertText(newText) - props.input().cursorOffset = Bun.stringWidth(newText) + props.input().cursorOffset = promptOffsetWidth(newText) }, }) } 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 e4907dcc27d5..d525ae53a8cd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -61,6 +61,8 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { type WorkspaceStatus } from "../workspace-label" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" import { useTuiConfig } from "../../context/tui-config" +import { promptOffsetWidth } from "@/cli/cmd/prompt-display" +import { expandPromptTextParts } from "./paste" export type PromptProps = { sessionID?: string @@ -573,7 +575,7 @@ export function Prompt(props: PromptProps) { parts: updatedNonTextParts, }) restoreExtmarksFromParts(updatedNonTextParts) - input.cursorOffset = Bun.stringWidth(content) + input.cursorOffset = promptOffsetWidth(content) }, }, { @@ -1092,23 +1094,13 @@ 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) { - 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 - } - } - } + const inputText = expandPromptTextParts( + store.prompt.input, + input.extmarks.getAllForTypeId(promptPartTypeId), + store.extmarkToPartIndex, + store.prompt.parts, + ) // Filter out text parts (pasted content) since they're now expanded inline const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") @@ -1227,7 +1219,7 @@ export function Prompt(props: PromptProps) { function pasteText(text: string, virtualText: string) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset - const extmarkEnd = extmarkStart + virtualText.length + const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText) input.insertText(virtualText + " ") @@ -1328,7 +1320,7 @@ export function Prompt(props: PromptProps) { return x.mime.startsWith("image/") }).length const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]` - const extmarkEnd = extmarkStart + virtualText.length + const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText) const textToInsert = virtualText + " " input.insertText(textToInsert) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/paste.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/paste.ts new file mode 100644 index 000000000000..ecb5ecdb4a1c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/paste.ts @@ -0,0 +1,52 @@ +import type { PromptInfo } from "./history" +import { displayOffsetIndex, stringIndexDisplayOffset } from "@/cli/cmd/prompt-display" + +export type PromptPartExtmark = { + id: number + start: number + end: number +} + +function virtualTextRange(text: string, extmark: PromptPartExtmark, virtualText: string) { + const start = displayOffsetIndex(text, extmark.start) + const end = displayOffsetIndex(text, extmark.end) + if (text.slice(start, end) === virtualText) return { start, end } + + const ranges = [] + let index = text.indexOf(virtualText) + while (index !== -1) { + ranges.push({ start: index, end: index + virtualText.length }) + index = text.indexOf(virtualText, index + virtualText.length) + } + + return ( + ranges.sort( + (a, b) => + Math.abs(stringIndexDisplayOffset(text, a.start) - extmark.start) - + Math.abs(stringIndexDisplayOffset(text, b.start) - extmark.start) || b.start - a.start, + )[0] ?? { start, end } + ) +} + +export function expandPromptTextParts( + input: string, + extmarks: readonly PromptPartExtmark[], + extmarkToPartIndex: ReadonlyMap, + parts: PromptInfo["parts"], +) { + return [...extmarks] + .sort((a, b) => b.start - a.start) + .reduce((text, extmark) => { + const partIndex = extmarkToPartIndex.get(extmark.id) + const part = partIndex === undefined ? undefined : parts[partIndex] + if (part?.type !== "text" || !part.text) return text + + const range = part.source?.text.value + ? virtualTextRange(text, extmark, part.source.text.value) + : { + start: displayOffsetIndex(text, extmark.start), + end: displayOffsetIndex(text, extmark.end), + } + return text.slice(0, range.start) + part.text + text.slice(range.end) + }, input) +} diff --git a/packages/opencode/test/cli/cmd/tui/prompt-paste.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-paste.test.ts new file mode 100644 index 000000000000..f081a5fd7aac --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/prompt-paste.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test" +import { displayOffsetIndex, promptOffsetWidth } from "../../../../src/cli/cmd/prompt-display" +import { expandPromptTextParts } from "../../../../src/cli/cmd/tui/component/prompt/paste" +import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history" + +describe("displayOffsetIndex", () => { + test("maps display offsets across wide characters and newlines", () => { + expect(displayOffsetIndex("第一行\n中文x", 11)).toBe("第一行\n中文".length) + }) +}) + +describe("expandPromptTextParts", () => { + test("expands summarized paste text after wide characters", () => { + const virtualText = "[Pasted ~3 lines]" + const pastedText = "第一行\n第二行\n第三行" + const start = promptOffsetWidth("中文abc") + const end = start + promptOffsetWidth(virtualText) + const parts = [ + { + type: "text", + text: pastedText, + source: { + text: { + start, + end, + value: virtualText, + }, + }, + }, + ] satisfies PromptInfo["parts"] + + expect( + expandPromptTextParts( + `中文abc${virtualText} 后文`, + [{ id: 1, start, end }], + new Map([[1, 0]]), + parts, + ), + ).toBe(`中文abc${pastedText} 后文`) + }) + + test("expands multiple summarized paste blocks using their original visual ranges", () => { + const firstVirtualText = "[Pasted ~2 lines]" + const secondVirtualText = "[Pasted ~3 lines]" + const firstPastedText = "一\n二" + const secondPastedText = "甲\n乙\n丙" + const beforeFirst = "开头中文" + const between = " 中段中文" + const firstStart = promptOffsetWidth(beforeFirst) + const firstEnd = firstStart + promptOffsetWidth(firstVirtualText) + const secondStart = promptOffsetWidth(`${beforeFirst}${firstVirtualText}${between}`) + const secondEnd = secondStart + promptOffsetWidth(secondVirtualText) + const parts = [ + { + type: "text", + text: firstPastedText, + source: { + text: { + start: firstStart, + end: firstEnd, + value: firstVirtualText, + }, + }, + }, + { + type: "text", + text: secondPastedText, + source: { + text: { + start: secondStart, + end: secondEnd, + value: secondVirtualText, + }, + }, + }, + ] satisfies PromptInfo["parts"] + + expect( + expandPromptTextParts( + `${beforeFirst}${firstVirtualText}${between}${secondVirtualText}结尾`, + [ + { id: 1, start: firstStart, end: firstEnd }, + { id: 2, start: secondStart, end: secondEnd }, + ], + new Map([ + [1, 0], + [2, 1], + ]), + parts, + ), + ).toBe(`${beforeFirst}${firstPastedText}${between}${secondPastedText}结尾`) + }) + + test("falls back to virtual text when an extmark was shifted by string length", () => { + const virtualText = "[Pasted ~2 lines]" + const pastedText = "第一行\n第二行" + const input = `abc中${virtualText}结尾` + const start = "abc中".length + const end = start + virtualText.length + const parts = [ + { + type: "text", + text: pastedText, + source: { + text: { + start, + end, + value: virtualText, + }, + }, + }, + ] satisfies PromptInfo["parts"] + + expect(expandPromptTextParts(input, [{ id: 1, start, end }], new Map([[1, 0]]), parts)).toBe( + `abc中${pastedText}结尾`, + ) + }) +})