Skip to content

Commit bba7600

Browse files
dauphinYanopencode-agent[bot]simonklee
authored
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>
1 parent 50b4ad8 commit bba7600

4 files changed

Lines changed: 59 additions & 23 deletions

File tree

packages/opencode/src/cli/cmd/prompt-display.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
22

3-
function promptOffsetWidth(value: string) {
3+
export function promptOffsetWidth(value: string) {
44
let width = 0
55
for (const part of graphemes.segment(value)) {
66
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ import { useSync } from "@tui/context/sync"
2525
import { useEvent } from "@tui/context/event"
2626
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
2727
import { MessageID, PartID } from "@/session/schema"
28+
import { promptOffsetWidth } from "@/cli/cmd/prompt-display"
2829
import { createStore, produce, unwrap } from "solid-js/store"
2930
import { usePromptHistory, type PromptInfo } from "./history"
3031
import { computePromptTraits } from "./traits"
31-
import { assign, expandPastedTextPlaceholders } from "./part"
32+
import { assign, expandPastedTextPlaceholders, expandTrackedPastedText } from "./part"
3233
import { usePromptStash } from "./stash"
3334
import { DialogStash } from "../dialog-stash"
3435
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
@@ -1109,23 +1110,15 @@ export function Prompt(props: PromptProps) {
11091110
}
11101111

11111112
const messageID = MessageID.ascending()
1112-
let inputText = store.prompt.input
1113-
1114-
// Expand pasted text inline before submitting
1115-
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
1116-
const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
1117-
1118-
for (const extmark of sortedExtmarks) {
1119-
const partIndex = store.extmarkToPartIndex.get(extmark.id)
1120-
if (partIndex !== undefined) {
1121-
const part = store.prompt.parts[partIndex]
1122-
if (part?.type === "text" && part.text) {
1123-
const before = inputText.slice(0, extmark.start)
1124-
const after = inputText.slice(extmark.end)
1125-
inputText = before + part.text + after
1126-
}
1127-
}
1128-
}
1113+
const inputText = expandTrackedPastedText(
1114+
store.prompt.input,
1115+
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
1116+
const partIndex = store.extmarkToPartIndex.get(extmark.id)
1117+
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
1118+
if (part?.type !== "text") return []
1119+
return [{ start: extmark.start, end: extmark.end, text: part.text }]
1120+
}),
1121+
)
11291122

11301123
// Filter out text parts (pasted content) since they're now expanded inline
11311124
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
@@ -1242,9 +1235,9 @@ export function Prompt(props: PromptProps) {
12421235
const exit = useExit()
12431236

12441237
function pasteText(text: string, virtualText: string) {
1245-
const currentOffset = input.visualCursor.offset
1238+
const currentOffset = input.cursorOffset
12461239
const extmarkStart = currentOffset
1247-
const extmarkEnd = extmarkStart + virtualText.length
1240+
const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText)
12481241

12491242
input.insertText(virtualText + " ")
12501243

@@ -1336,7 +1329,7 @@ export function Prompt(props: PromptProps) {
13361329
}
13371330

13381331
async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) {
1339-
const currentOffset = input.visualCursor.offset
1332+
const currentOffset = input.cursorOffset
13401333
const extmarkStart = currentOffset
13411334
const pdf = file.mime === "application/pdf"
13421335
const count = store.prompt.parts.filter((x) => {

packages/opencode/src/cli/cmd/tui/component/prompt/part.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { PartID } from "@/session/schema"
2+
import { displaySlice } from "@/cli/cmd/prompt-display"
23
import type { PromptInfo } from "./history"
34

45
type Item = PromptInfo["parts"][number]
@@ -21,3 +22,13 @@ export function expandPastedTextPlaceholders(text: string, parts: PromptInfo["pa
2122
return result.replace(part.source.text.value, part.text)
2223
}, text)
2324
}
25+
26+
export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) {
27+
return ranges
28+
.slice()
29+
.sort((a, b) => b.start - a.start)
30+
.reduce(
31+
(result, part) => displaySlice(result, 0, part.start) + part.text + displaySlice(result, part.end),
32+
text,
33+
)
34+
}

packages/opencode/test/cli/cmd/tui/prompt-part.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from "bun:test"
22
import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history"
3-
import { assign, strip } from "../../../../src/cli/cmd/tui/component/prompt/part"
3+
import { assign, expandTrackedPastedText, strip } from "../../../../src/cli/cmd/tui/component/prompt/part"
44

55
describe("prompt part", () => {
66
test("strip removes persisted ids from reused file parts", () => {
@@ -44,4 +44,36 @@ describe("prompt part", () => {
4444
url: "data:image/png;base64,abc",
4545
})
4646
})
47+
48+
test("expandTrackedPastedText preserves wide characters around pasted text", () => {
49+
const marker = "[Pasted ~3 lines]"
50+
const prefix = "你好你好\n"
51+
52+
expect(
53+
expandTrackedPastedText(prefix + marker + "\n阿斯顿法国红酒看来", [
54+
{
55+
start: Bun.stringWidth("你好你好") + 1,
56+
end: Bun.stringWidth("你好你好") + 1 + Bun.stringWidth(marker),
57+
text: "public:\n\tvoid ExecuteTask();\nprivate:",
58+
},
59+
]),
60+
).toBe(
61+
"你好你好\npublic:\n\tvoid ExecuteTask();\nprivate:\n阿斯顿法国红酒看来",
62+
)
63+
})
64+
65+
test("expandTrackedPastedText only expands the tracked placeholder occurrence", () => {
66+
const marker = "[Pasted ~3 lines]"
67+
const prefix = `keep ${marker} then `
68+
69+
expect(
70+
expandTrackedPastedText(prefix + marker + " tail", [
71+
{
72+
start: Bun.stringWidth(prefix),
73+
end: Bun.stringWidth(prefix + marker),
74+
text: "alpha\nbeta\ngamma",
75+
},
76+
]),
77+
).toBe(`keep ${marker} then alpha\nbeta\ngamma tail`)
78+
})
4779
})

0 commit comments

Comments
 (0)