diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a44b38b6a..df86bd1b1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ - Expanded `tools.format` to support additional in-band tool-call syntaxes, including `anthropic`, `deepseek`, `harmony`, `pi`, and `qwen3` - Changed the experimental owned tool-calling prompt from a GLM-only toggle to syntax-specific grammar prompts and result formats. `PI_OWNED_TOOLS=1` still forces GLM; `PI_OWNED_TOOLS=` forces that syntax. +- Changed the large-paste menu to offer attachment XML blocks (``), local-file attachments, or inline paste as explicit actions. ### Fixed diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index b2843a426..ad3c9b610 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -44,26 +44,9 @@ function hasPasteText(value: unknown): value is PasteTarget { return typeof value === "object" && value !== null && typeof (value as PasteTarget).pasteText === "function"; } -/** Wrap pasted text in a fenced code block, using a backtick fence longer than any run of - * backticks already in the content so an embedded fence cannot terminate the block early. */ -function wrapPasteInCodeBlock(content: string): string { - let longestRun = 0; - let run = 0; - for (let i = 0; i < content.length; i++) { - if (content.charCodeAt(i) === 96 /* backtick */) { - run++; - if (run > longestRun) longestRun = run; - } else { - run = 0; - } - } - const fence = "`".repeat(Math.max(3, longestRun + 1)); - return `${fence}\n${content}\n${fence}`; -} - -/** Wrap pasted text in `` tags so the model treats it as one quoted block. */ -function wrapPasteInXml(content: string): string { - return `\n${content}\n`; +/** Wrap pasted text in `` tags so the model treats it as one quoted block. */ +function wrapPasteInAttachmentBlock(content: string): string { + return `\n${content}\n`; } const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000; @@ -99,8 +82,8 @@ export class InputController { // (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See // #detectLeftDoubleTap. #leftTapCount = 0; - // Sequential index for `local://attachment-N` references created by the large-paste "attach as - // file" action. Seeded from 0 and bumped past any existing attachment files in #attachPasteAsFile. + // Sequential index for `local://attachment-N` references created by the large-paste local-file + // action. Seeded from 0 and bumped past any existing attachment files in #attachPasteAsFile. #attachmentCounter = 0; #showTinyTitleDownloadProgress(modelKey: string): void { @@ -1282,24 +1265,24 @@ export class InputController { } /** - * Present the large-paste menu and apply the chosen action: wrap in a code block or in XML tags - * (both collapse to a `[Paste]` marker that expands on submit), or save the text to a file and - * reference its path so the agent can `read` it on demand. Cancelling (Esc) falls back to the - * default inline paste marker, so the pasted content is never lost. + * Present the large-paste menu and apply the chosen action: wrap in `` tags (collapsed + * to a `[Paste]` marker that expands on submit), save the text to a file and reference its path so + * the agent can `read` it on demand, or paste inline. Cancelling (Esc) falls back to the default + * inline paste marker, so the pasted content is never lost. */ async presentLargePasteMenu(text: string, lineCount: number): Promise { - const CODE_BLOCK = "Wrap in a code block"; - const XML = "Wrap in XML tags"; - const FILE = "Attach as a file"; + const WRAPPED_BLOCK = "Attach as a wrapped block"; + const LOCAL_FILE = "Attach as local file"; + const INLINE = "Paste inline"; let choice: string | undefined; try { choice = await this.ctx.showHookSelector( `Pasted ${lineCount} lines`, [ - { label: CODE_BLOCK, description: "Fence the text in a ``` block, collapsed to a marker" }, - { label: XML, description: "Wrap the text in tags, collapsed to a marker" }, - { label: FILE, description: "Save the text to a file and reference its path" }, + { label: WRAPPED_BLOCK, description: "Wrap the text in tags, collapsed to a marker" }, + { label: LOCAL_FILE, description: "Save the text to a local://attachment file" }, + { label: INLINE, description: "Collapse the text to an inline paste marker" }, ], { helpText: "Esc to paste inline" }, ); @@ -1309,15 +1292,15 @@ export class InputController { } switch (choice) { - case CODE_BLOCK: - this.ctx.editor.insertPaste(wrapPasteInCodeBlock(text)); + case WRAPPED_BLOCK: + this.ctx.editor.insertPaste(wrapPasteInAttachmentBlock(text)); break; - case XML: - this.ctx.editor.insertPaste(wrapPasteInXml(text)); - break; - case FILE: + case LOCAL_FILE: await this.#attachPasteAsFile(text, lineCount); break; + case INLINE: + this.ctx.editor.insertPaste(text); + break; default: // Esc / cancel: keep the original behavior — collapse to an inline paste marker. this.ctx.editor.insertPaste(text); diff --git a/packages/coding-agent/test/input-controller-large-paste.test.ts b/packages/coding-agent/test/input-controller-large-paste.test.ts index 455f5ecbd..2ab33710b 100644 --- a/packages/coding-agent/test/input-controller-large-paste.test.ts +++ b/packages/coding-agent/test/input-controller-large-paste.test.ts @@ -1,8 +1,8 @@ /** * Large-paste menu: when a paste reaches the configured `paste.largeMenuThreshold` line count, * the editor's `onLargePaste` hook routes through `InputController.handleLargePaste`, which offers - * to wrap the text in a code block, wrap it in XML tags, or save it to a `local://` file. Below the - * threshold (or when disabled) the editor keeps its default collapse-to-`[Paste]`-marker behavior. + * to attach the text as an `` block, save it to a `local://` file, or paste it inline. + * Below the threshold (or when disabled) the editor keeps its default collapse-to-`[Paste]`-marker behavior. */ import { afterEach, describe, expect, it, vi } from "bun:test"; @@ -66,29 +66,33 @@ describe("InputController.handleLargePaste gate", () => { }); describe("InputController.presentLargePasteMenu actions", () => { - it("wraps the paste in a fenced code block collapsed to a marker", async () => { - const { controller, spies } = createContext({ choice: "Wrap in a code block" }); - - await controller.presentLargePasteMenu("hello\nworld", 2); - - expect(spies.insertPaste).toHaveBeenCalledTimes(1); - expect(spies.insertPaste.mock.calls[0][0]).toBe("```\nhello\nworld\n```"); - }); - - it("widens the fence so an embedded code fence cannot terminate the block early", async () => { - const { controller, spies } = createContext({ choice: "Wrap in a code block" }); - - await controller.presentLargePasteMenu("```\ncode\n```", 3); - - expect(spies.insertPaste.mock.calls[0][0]).toBe("````\n```\ncode\n```\n````"); - }); - - it("wraps the paste in XML tags collapsed to a marker", async () => { - const { controller, spies } = createContext({ choice: "Wrap in XML tags" }); + it("offers the requested actions in order", async () => { + const { controller, spies } = createContext({ choice: undefined }); await controller.presentLargePasteMenu("payload", 1); - expect(spies.insertPaste).toHaveBeenCalledWith("\npayload\n"); + const options = spies.showHookSelector.mock.calls[0][1] as Array<{ label: string }>; + expect(options.map((option) => option.label)).toEqual([ + "Attach as a wrapped block", + "Attach as local file", + "Paste inline", + ]); + }); + + it("wraps the paste in attachment XML collapsed to a marker", async () => { + const { controller, spies } = createContext({ choice: "Attach as a wrapped block" }); + + await controller.presentLargePasteMenu("payload", 1); + + expect(spies.insertPaste).toHaveBeenCalledWith("\npayload\n"); + }); + + it("pastes inline when explicitly selected", async () => { + const { controller, spies } = createContext({ choice: "Paste inline" }); + + await controller.presentLargePasteMenu("payload", 1); + + expect(spies.insertPaste).toHaveBeenCalledWith("payload"); }); it("pastes inline when the menu is cancelled, so the content is not lost", async () => { @@ -118,7 +122,7 @@ describe("InputController.presentLargePasteMenu file attachment", () => { it("saves the paste to local:// and inserts a clean local://attachment reference", async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-paste-test-")); - const { controller, spies } = createContext({ choice: "Attach as a file", artifactsDir: dir }); + const { controller, spies } = createContext({ choice: "Attach as local file", artifactsDir: dir }); await controller.presentLargePasteMenu("line one\nline two", 2); @@ -132,7 +136,7 @@ describe("InputController.presentLargePasteMenu file attachment", () => { it("does not overwrite an existing attachment file", async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-paste-test-")); await Bun.write(path.join(dir, "local", "attachment-1"), "previous"); - const { controller, spies } = createContext({ choice: "Attach as a file", artifactsDir: dir }); + const { controller, spies } = createContext({ choice: "Attach as local file", artifactsDir: dir }); await controller.presentLargePasteMenu("fresh", 1);