feat(coding-agent/modes): updated large-paste menu with explicit attachment actions

- Replaced large-paste wrap options with a single `<attachment>` wrapper action.
- Added explicit local-file attachment and inline paste choices to the selector.
- Updated the changelog to document the new large-paste action set.
This commit is contained in:
can1357
2026-06-15 06:53:52 +02:00
parent fbba331f8a
commit a1070d055e
3 changed files with 50 additions and 62 deletions
+1
View File
@@ -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=<syntax>` forces that syntax.
- Changed the large-paste menu to offer attachment XML blocks (`<attachment>`), local-file attachments, or inline paste as explicit actions.
### Fixed
@@ -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 `<pasted_text>` tags so the model treats it as one quoted block. */
function wrapPasteInXml(content: string): string {
return `<pasted_text>\n${content}\n</pasted_text>`;
/** Wrap pasted text in `<attachment>` tags so the model treats it as one quoted block. */
function wrapPasteInAttachmentBlock(content: string): string {
return `<attachment>\n${content}\n</attachment>`;
}
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 `<attachment>` 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<void> {
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 <pasted_text> 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 <attachment> 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);
@@ -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 `<attachment>` 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("<pasted_text>\npayload\n</pasted_text>");
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("<attachment>\npayload\n</attachment>");
});
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);