mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-02 06:16:48 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user