From 523a3422b0a7ef986893162b25612dd05e5969ac 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, fall back to the virtual marker text when extmark offsets were adjusted using string length semantics, and size virtual extmark ranges with Bun.stringWidth. Add regression coverage for Chinese text, newlines, multiple paste summaries, and shifted extmarks. --- .../cli/cmd/tui/component/prompt/index.tsx | 27 ++-- .../src/cli/cmd/tui/component/prompt/paste.ts | 81 ++++++++++++ .../test/cli/cmd/tui/prompt-paste.test.ts | 117 ++++++++++++++++++ 3 files changed, 207 insertions(+), 18 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/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 74332c77be77..83d99245339b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -47,6 +47,7 @@ import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" import { Flag } from "@opencode-ai/core/flag/flag" import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label" +import { expandPromptTextParts } from "./paste" export type PromptProps = { sessionID?: string @@ -892,23 +893,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") @@ -1026,7 +1017,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 + Bun.stringWidth(virtualText) input.insertText(virtualText + " ") @@ -1067,7 +1058,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 + Bun.stringWidth(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..ecdd7ab652e4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/paste.ts @@ -0,0 +1,81 @@ +import type { PromptInfo } from "./history" + +export type PromptPartExtmark = { + id: number + start: number + end: number +} + +const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) + +function segmentWidth(segment: string) { + if (segment === "\n") return 1 + return Bun.stringWidth(segment) +} + +function stringIndexToDisplayOffset(text: string, index: number) { + let displayOffset = 0 + for (const segment of segmenter.segment(text)) { + if (segment.index >= index) return displayOffset + displayOffset += segmentWidth(segment.segment) + } + return displayOffset +} + +export function displayOffsetToStringIndex(text: string, offset: number) { + if (offset <= 0) return 0 + + let displayOffset = 0 + for (const segment of segmenter.segment(text)) { + const nextDisplayOffset = displayOffset + segmentWidth(segment.segment) + if (nextDisplayOffset > offset) return segment.index + if (nextDisplayOffset === offset) return segment.index + segment.segment.length + displayOffset = nextDisplayOffset + } + + return text.length +} + +function virtualTextRange(text: string, extmark: PromptPartExtmark, virtualText: string) { + const start = displayOffsetToStringIndex(text, extmark.start) + const end = displayOffsetToStringIndex(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(stringIndexToDisplayOffset(text, a.start) - extmark.start) - + Math.abs(stringIndexToDisplayOffset(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: displayOffsetToStringIndex(text, extmark.start), + end: displayOffsetToStringIndex(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..de12f860fa59 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/prompt-paste.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test" +import { displayOffsetToStringIndex, expandPromptTextParts } from "../../../../src/cli/cmd/tui/component/prompt/paste" +import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history" + +describe("displayOffsetToStringIndex", () => { + test("maps display offsets across wide characters and newlines", () => { + expect(displayOffsetToStringIndex("第一行\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 = Bun.stringWidth("中文abc") + const end = start + Bun.stringWidth(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 = Bun.stringWidth(beforeFirst) + const firstEnd = firstStart + Bun.stringWidth(firstVirtualText) + const secondStart = Bun.stringWidth(`${beforeFirst}${firstVirtualText}${between}`) + const secondEnd = secondStart + Bun.stringWidth(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}结尾`, + ) + }) +})