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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user