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" })
|
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 partIndex = store.extmarkToPartIndex.get(extmark.id)
|
||||||
const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
|
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
|
||||||
|
if (part?.type !== "text") return []
|
||||||
for (const extmark of sortedExtmarks) {
|
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user