From bba76009a8842db4265787cb364c64bd114e7c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orca=E4=B8=B6?= <93272799+dauphinYan@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:42:14 +0800 Subject: [PATCH] fix(tui): prevent prompt corruption when pasting near wide characters (#29710) Co-authored-by: opencode-agent[bot] Co-authored-by: Simon Klee --- .../opencode/src/cli/cmd/prompt-display.ts | 2 +- .../cli/cmd/tui/component/prompt/index.tsx | 35 ++++++++----------- .../src/cli/cmd/tui/component/prompt/part.ts | 11 ++++++ .../test/cli/cmd/tui/prompt-part.test.ts | 34 +++++++++++++++++- 4 files changed, 59 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/cli/cmd/prompt-display.ts b/packages/opencode/src/cli/cmd/prompt-display.ts index 4e8cb9046..4c22942ea 100644 --- a/packages/opencode/src/cli/cmd/prompt-display.ts +++ b/packages/opencode/src/cli/cmd/prompt-display.ts @@ -1,6 +1,6 @@ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) -function promptOffsetWidth(value: string) { +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. 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 966ba932d..d2877dd77 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -25,10 +25,11 @@ import { useSync } from "@tui/context/sync" import { useEvent } from "@tui/context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" +import { promptOffsetWidth } from "@/cli/cmd/prompt-display" import { createStore, produce, unwrap } from "solid-js/store" import { usePromptHistory, type PromptInfo } from "./history" import { computePromptTraits } from "./traits" -import { assign, expandPastedTextPlaceholders } from "./part" +import { assign, expandPastedTextPlaceholders, expandTrackedPastedText } from "./part" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" @@ -1109,23 +1110,15 @@ 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 = expandTrackedPastedText( + store.prompt.input, + input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => { + const partIndex = store.extmarkToPartIndex.get(extmark.id) + const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex] + if (part?.type !== "text") return [] + return [{ start: extmark.start, end: extmark.end, text: part.text }] + }), + ) // Filter out text parts (pasted content) since they're now expanded inline const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") @@ -1242,9 +1235,9 @@ export function Prompt(props: PromptProps) { const exit = useExit() function pasteText(text: string, virtualText: string) { - const currentOffset = input.visualCursor.offset + const currentOffset = input.cursorOffset const extmarkStart = currentOffset - const extmarkEnd = extmarkStart + virtualText.length + const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText) input.insertText(virtualText + " ") @@ -1336,7 +1329,7 @@ export function Prompt(props: PromptProps) { } async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) { - const currentOffset = input.visualCursor.offset + const currentOffset = input.cursorOffset const extmarkStart = currentOffset const pdf = file.mime === "application/pdf" const count = store.prompt.parts.filter((x) => { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts index c5ab85bc1..4ef870492 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts @@ -1,4 +1,5 @@ import { PartID } from "@/session/schema" +import { displaySlice } from "@/cli/cmd/prompt-display" import type { PromptInfo } from "./history" type Item = PromptInfo["parts"][number] @@ -21,3 +22,13 @@ export function expandPastedTextPlaceholders(text: string, parts: PromptInfo["pa return result.replace(part.source.text.value, part.text) }, text) } + +export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) { + return ranges + .slice() + .sort((a, b) => b.start - a.start) + .reduce( + (result, part) => displaySlice(result, 0, part.start) + part.text + displaySlice(result, part.end), + text, + ) +} diff --git a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts index 326d3e624..661a368d1 100644 --- a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts +++ b/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history" -import { assign, strip } from "../../../../src/cli/cmd/tui/component/prompt/part" +import { assign, expandTrackedPastedText, strip } from "../../../../src/cli/cmd/tui/component/prompt/part" describe("prompt part", () => { test("strip removes persisted ids from reused file parts", () => { @@ -44,4 +44,36 @@ describe("prompt part", () => { url: "data:image/png;base64,abc", }) }) + + test("expandTrackedPastedText preserves wide characters around pasted text", () => { + const marker = "[Pasted ~3 lines]" + const prefix = "你好你好\n" + + expect( + expandTrackedPastedText(prefix + marker + "\n阿斯顿法国红酒看来", [ + { + start: Bun.stringWidth("你好你好") + 1, + end: Bun.stringWidth("你好你好") + 1 + Bun.stringWidth(marker), + text: "public:\n\tvoid ExecuteTask();\nprivate:", + }, + ]), + ).toBe( + "你好你好\npublic:\n\tvoid ExecuteTask();\nprivate:\n阿斯顿法国红酒看来", + ) + }) + + test("expandTrackedPastedText only expands the tracked placeholder occurrence", () => { + const marker = "[Pasted ~3 lines]" + const prefix = `keep ${marker} then ` + + expect( + expandTrackedPastedText(prefix + marker + " tail", [ + { + start: Bun.stringWidth(prefix), + end: Bun.stringWidth(prefix + marker), + text: "alpha\nbeta\ngamma", + }, + ]), + ).toBe(`keep ${marker} then alpha\nbeta\ngamma tail`) + }) })