فهرست منبع

fix(tui): prevent prompt corruption when pasting near wide characters (#29710)

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Orca丶 2 ماه پیش
والد
کامیت
bba76009a8

+ 1 - 1
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.

+ 14 - 21
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) => {

+ 11 - 0
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,
+    )
+}

+ 33 - 1
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`)
+  })
 })