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>
This commit is contained in:
Orca丶
2026-06-01 16:42:14 +08:00
committed by GitHub
parent 50b4ad89b3
commit bba76009a8
4 changed files with 59 additions and 23 deletions
@@ -1,6 +1,6 @@
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
function promptOffsetWidth(value: string) { export function promptOffsetWidth(value: string) {
let width = 0 let width = 0
for (const part of graphemes.segment(value)) { for (const part of graphemes.segment(value)) {
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero. // Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
@@ -25,10 +25,11 @@ import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event" import { useEvent } from "@tui/context/event"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor" import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema" import { MessageID, PartID } from "@/session/schema"
import { promptOffsetWidth } from "@/cli/cmd/prompt-display"
import { createStore, produce, unwrap } from "solid-js/store" import { createStore, produce, unwrap } from "solid-js/store"
import { usePromptHistory, type PromptInfo } from "./history" import { usePromptHistory, type PromptInfo } from "./history"
import { computePromptTraits } from "./traits" import { computePromptTraits } from "./traits"
import { assign, expandPastedTextPlaceholders } from "./part" import { assign, expandPastedTextPlaceholders, expandTrackedPastedText } from "./part"
import { usePromptStash } from "./stash" import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash" import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { type AutocompleteRef, Autocomplete } from "./autocomplete"
@@ -1109,23 +1110,15 @@ export function Prompt(props: PromptProps) {
} }
const messageID = MessageID.ascending() const messageID = MessageID.ascending()
let inputText = store.prompt.input const inputText = expandTrackedPastedText(
store.prompt.input,
// Expand pasted text inline before submitting input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
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) const partIndex = store.extmarkToPartIndex.get(extmark.id)
if (partIndex !== undefined) { const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
const part = store.prompt.parts[partIndex] if (part?.type !== "text") return []
if (part?.type === "text" && part.text) { return [{ start: extmark.start, end: extmark.end, text: part.text }]
const before = inputText.slice(0, extmark.start) }),
const after = inputText.slice(extmark.end) )
inputText = before + part.text + after
}
}
}
// Filter out text parts (pasted content) since they're now expanded inline // Filter out text parts (pasted content) since they're now expanded inline
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
@@ -1242,9 +1235,9 @@ export function Prompt(props: PromptProps) {
const exit = useExit() const exit = useExit()
function pasteText(text: string, virtualText: string) { function pasteText(text: string, virtualText: string) {
const currentOffset = input.visualCursor.offset const currentOffset = input.cursorOffset
const extmarkStart = currentOffset const extmarkStart = currentOffset
const extmarkEnd = extmarkStart + virtualText.length const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText)
input.insertText(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 }) { 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 extmarkStart = currentOffset
const pdf = file.mime === "application/pdf" const pdf = file.mime === "application/pdf"
const count = store.prompt.parts.filter((x) => { const count = store.prompt.parts.filter((x) => {
@@ -1,4 +1,5 @@
import { PartID } from "@/session/schema" import { PartID } from "@/session/schema"
import { displaySlice } from "@/cli/cmd/prompt-display"
import type { PromptInfo } from "./history" import type { PromptInfo } from "./history"
type Item = PromptInfo["parts"][number] 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) return result.replace(part.source.text.value, part.text)
}, 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,
)
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history" 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", () => { describe("prompt part", () => {
test("strip removes persisted ids from reused file parts", () => { test("strip removes persisted ids from reused file parts", () => {
@@ -44,4 +44,36 @@ describe("prompt part", () => {
url: "data:image/png;base64,abc", 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`)
})
}) })