From c2216370c56de4304cb7e9a9cab0333eedae17d7 Mon Sep 17 00:00:00 2001 From: dauphinYan <584485321@qq.com> Date: Thu, 28 May 2026 16:41:25 +0800 Subject: [PATCH 1/3] fix(tui): preserve pasted text around wide characters --- .../cli/cmd/tui/component/prompt/index.tsx | 22 +++++-------------- .../test/cli/cmd/tui/prompt-part.test.ts | 22 ++++++++++++++++++- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 966ba932d41a..7d94f48798db 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1111,21 +1111,9 @@ 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 - } - } - } + // Extmark offsets are display-oriented and can drift from JS string indices + // around wide characters. Replace the visible placeholders in the text buffer instead. + inputText = expandPastedTextPlaceholders(inputText, store.prompt.parts) // Filter out text parts (pasted content) since they're now expanded inline const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") @@ -1242,7 +1230,7 @@ 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 @@ -1336,7 +1324,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) => { diff --git a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts index 326d3e624d27..e3dc69ade311 100644 --- a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts +++ b/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, expandPastedTextPlaceholders, strip } from "../../../../src/cli/cmd/tui/component/prompt/part" describe("prompt part", () => { test("strip removes persisted ids from reused file parts", () => { @@ -44,4 +44,24 @@ describe("prompt part", () => { url: "data:image/png;base64,abc", }) }) + + test("expandPastedTextPlaceholders preserves wide characters around pasted text", () => { + const parts = [ + { + type: "text" as const, + text: "public:\n\tvoid ExecuteTask();\nprivate:", + source: { + text: { + start: 8, + end: 26, + value: "[Pasted ~3 lines]", + }, + }, + }, + ] satisfies PromptInfo["parts"] + + expect(expandPastedTextPlaceholders("你好你好\n[Pasted ~3 lines]\n阿斯顿法国红酒看来", parts)).toBe( + "你好你好\npublic:\n\tvoid ExecuteTask();\nprivate:\n阿斯顿法国红酒看来", + ) + }) }) From 5bdd6f20bb6c70a954e24ffac4fd5c03fb940228 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 1 Jun 2026 07:27:27 +0000 Subject: [PATCH 2/3] chore: generate --- packages/opencode/src/cli/cmd/run/footer.command.tsx | 5 ++++- packages/opencode/src/cli/cmd/run/footer.view.tsx | 8 ++++++-- packages/opencode/src/cli/cmd/run/runtime.queue.ts | 1 - packages/opencode/test/cli/run/footer.view.test.tsx | 4 ++-- packages/opencode/test/cli/run/runtime.queue.test.ts | 6 +++--- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx index d007707e8bda..90ba6fc6734c 100644 --- a/packages/opencode/src/cli/cmd/run/footer.command.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -329,7 +329,10 @@ export function RunCommandMenuBody(props: { category: "Suggested", display: "Manage queued prompts", footer: `${props.queued().length} queued`, - keywords: props.queued().map((item) => item.prompt.text).join(" "), + keywords: props + .queued() + .map((item) => item.prompt.text) + .join(" "), }, ] : []), diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index 839765d0aff5..816f6c992644 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -203,7 +203,9 @@ export function RunFooterView(props: RunFooterViewProps) { const subagentShortcut = useKeymapSelector( (keymap: OpenTuiKeymap) => formatKeyBindings( - keymap.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] }).get("session.child.first"), + keymap + .getCommandBindings({ visibility: "registered", commands: ["session.child.first"] }) + .get("session.child.first"), props.tuiConfig, ) ?? "", ) @@ -697,7 +699,9 @@ export function RunFooterView(props: RunFooterViewProps) { gap={1} flexShrink={0} > - 0 || queuedIndicator() || subagentIndicator()}> + 0 || queuedIndicator() || subagentIndicator()} + > diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/opencode/src/cli/cmd/run/runtime.queue.ts index 63ce618beb24..64172e8280e7 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.queue.ts @@ -210,7 +210,6 @@ export async function runPromptQueue(input: QueueInput): Promise { if (next.type === "error") { throw next.error } - } finally { if (state.ctrl === ctrl) { state.ctrl = undefined diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index a4f4c296e6e5..ed2df583c06a 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -198,8 +198,8 @@ async function renderFooter( onVariantSelect={() => {}} onRows={() => {}} onLayout={() => {}} - onStatus={() => {}} - onQueuedRemove={async () => true} + onStatus={() => {}} + onQueuedRemove={async () => true} /> ) diff --git a/packages/opencode/test/cli/run/runtime.queue.test.ts b/packages/opencode/test/cli/run/runtime.queue.test.ts index ead73af89003..728e18fcfeb7 100644 --- a/packages/opencode/test/cli/run/runtime.queue.test.ts +++ b/packages/opencode/test/cli/run/runtime.queue.test.ts @@ -326,9 +326,9 @@ describe("run runtime queue", () => { const first = ui.events.find((item) => item.type === "queued.prompts") const event = ui.events.findLast((item) => item.type === "queued.prompts") expect(first?.type === "queued.prompts" ? first.prompts : []).toEqual([]) - expect(first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true).toBe( - false, - ) + expect( + first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true, + ).toBe(false) expect(ui.events.findLast((item) => item.type === "queue")).toEqual({ type: "queue", queue: 1 }) expect(event?.type === "queued.prompts" ? event.prompts.map((item) => item.prompt.text) : []).toEqual(["two"]) if (event?.type === "queued.prompts") ui.removeQueued(event.prompts[0]!.messageID) From dbf320e2fc9437ab7ccdbc177cd20208ce44bd8e Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 1 Jun 2026 10:29:34 +0200 Subject: [PATCH 3/3] tui: fix pasted text marker expansion Use the tracked display range for collapsed paste markers when submitting a prompt. This preserves text around wide characters and avoids expanding identical marker text that the user typed separately. --- .../opencode/src/cli/cmd/prompt-display.ts | 2 +- .../cli/cmd/tui/component/prompt/index.tsx | 19 +++++--- .../src/cli/cmd/tui/component/prompt/part.ts | 11 +++++ .../test/cli/cmd/tui/prompt-part.test.ts | 44 ++++++++++++------- 4 files changed, 52 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/cli/cmd/prompt-display.ts b/packages/opencode/src/cli/cmd/prompt-display.ts index 4e8cb9046ac6..4c22942ea897 100644 --- a/packages/opencode/src/cli/cmd/prompt-display.ts +++ b/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. diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 7d94f48798db..d2877dd77f3c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/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,11 +1110,15 @@ export function Prompt(props: PromptProps) { } const messageID = MessageID.ascending() - let inputText = store.prompt.input - - // Extmark offsets are display-oriented and can drift from JS string indices - // around wide characters. Replace the visible placeholders in the text buffer instead. - inputText = expandPastedTextPlaceholders(inputText, store.prompt.parts) + 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") @@ -1232,7 +1237,7 @@ export function Prompt(props: PromptProps) { function pasteText(text: string, virtualText: string) { const currentOffset = input.cursorOffset const extmarkStart = currentOffset - const extmarkEnd = extmarkStart + virtualText.length + const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText) input.insertText(virtualText + " ") diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts index c5ab85bc1ec1..4ef870492a3c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts +++ b/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, + ) +} diff --git a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts index e3dc69ade311..661a368d1c7a 100644 --- a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts +++ b/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, expandPastedTextPlaceholders, 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", () => { @@ -45,23 +45,35 @@ describe("prompt part", () => { }) }) - test("expandPastedTextPlaceholders preserves wide characters around pasted text", () => { - const parts = [ - { - type: "text" as const, - text: "public:\n\tvoid ExecuteTask();\nprivate:", - source: { - text: { - start: 8, - end: 26, - value: "[Pasted ~3 lines]", - }, - }, - }, - ] satisfies PromptInfo["parts"] + test("expandTrackedPastedText preserves wide characters around pasted text", () => { + const marker = "[Pasted ~3 lines]" + const prefix = "你好你好\n" - expect(expandPastedTextPlaceholders("你好你好\n[Pasted ~3 lines]\n阿斯顿法国红酒看来", parts)).toBe( + 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`) + }) })