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}结尾`, + ) + }) +})