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/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/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 966ba932d41a..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,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) => { 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 326d3e624d27..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, 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`) + }) }) 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)