pi-yank 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -9
- package/extensions/yank-core.js +30 -12
- package/extensions/yank-helpers.js +78 -0
- package/extensions/yank.ts +28 -50
- package/package.json +1 -1
- package/tests/yank-command.test.mjs +48 -0
- package/tests/yank-core.test.mjs +34 -4
package/README.md
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
Lightweight `/yank` extension for [pi](https://github.com/badlogic/pi-mono).
|
|
4
4
|
|
|
5
5
|
`/yank` copies the last assistant response to the clipboard, or lets you choose an individual fenced code block from that response.
|
|
6
|
+
You can also target earlier messages with `/yank N` (e.g. `/yank 2` operates on the 2nd-to-last assistant message).
|
|
6
7
|
|
|
7
8
|
## Features
|
|
8
9
|
|
|
9
10
|
- Copy the last assistant message to the clipboard
|
|
10
|
-
-
|
|
11
|
+
- Use `/yank N` to target the N-th latest assistant message (`/yank 2` = 2nd-to-last)
|
|
12
|
+
- Detect fenced Markdown code blocks in the selected assistant message
|
|
11
13
|
- Use arrow keys + Enter to choose a specific code block
|
|
12
14
|
- Optionally disable the menu for the current session so `/yank` behaves like `/copy`
|
|
13
15
|
- Reuse pi's official clipboard pipeline via `copyToClipboard()` for `/copy`-compatible behavior
|
|
@@ -53,17 +55,19 @@ cp /path/to/pi-yank/extensions/yank.ts ~/.pi/agent/extensions/yank.ts
|
|
|
53
55
|
|
|
54
56
|
## Usage
|
|
55
57
|
|
|
56
|
-
Run:
|
|
57
|
-
|
|
58
58
|
```text
|
|
59
|
-
/yank
|
|
59
|
+
/yank # copy from the latest assistant message
|
|
60
|
+
/yank 1 # same as /yank
|
|
61
|
+
/yank 2 # copy from the 2nd-to-last assistant message
|
|
62
|
+
/yank N # copy from the N-th latest assistant message
|
|
60
63
|
```
|
|
61
64
|
|
|
62
65
|
Behavior:
|
|
63
66
|
|
|
64
67
|
- No assistant message yet → shows `No agent messages to copy yet.`
|
|
65
|
-
-
|
|
66
|
-
-
|
|
68
|
+
- N-th latest message not found → shows `No N-th latest agent message to copy yet.`
|
|
69
|
+
- Selected assistant message has no fenced code blocks → copies the full message
|
|
70
|
+
- Selected assistant message has code blocks → opens a selection menu
|
|
67
71
|
- Selecting a code block copies only that code block's content
|
|
68
72
|
- Selecting the session option disables the menu for later `/yank` calls in the current session
|
|
69
73
|
- Pressing `Escape` in the selector cancels without copying
|
|
@@ -78,12 +82,14 @@ This makes it more robust for LLM output such as:
|
|
|
78
82
|
- `~~~` fenced blocks
|
|
79
83
|
- unterminated fences, which are ignored
|
|
80
84
|
|
|
81
|
-
Menu previews
|
|
85
|
+
Menu previews include more context when available, for example:
|
|
82
86
|
|
|
83
|
-
- `1. Full
|
|
87
|
+
- `1. Full latest message (24 lines • 812 chars)`
|
|
84
88
|
- `2. Code block — markdown: # Title (18 lines • 640 chars)`
|
|
85
89
|
- `3. Code block — js: console.log("hi") (1 line • 18 chars)`
|
|
86
|
-
- `4. Always copy full
|
|
90
|
+
- `4. Always copy full selected message in this session`
|
|
91
|
+
|
|
92
|
+
When using `/yank N` with N > 1, the menu title shows the target (e.g. "Yank from 2nd latest response").
|
|
87
93
|
|
|
88
94
|
## Development
|
|
89
95
|
|
package/extensions/yank-core.js
CHANGED
|
@@ -14,6 +14,23 @@
|
|
|
14
14
|
* @returns {CodeBlock[]}
|
|
15
15
|
*/
|
|
16
16
|
export function extractCodeBlocks(markdown) {
|
|
17
|
+
const topLevelBlocks = extractTopLevelCodeBlocks(markdown);
|
|
18
|
+
if (topLevelBlocks.length === 1) {
|
|
19
|
+
const [wrapper] = topLevelBlocks;
|
|
20
|
+
const innerBlocks = extractTopLevelCodeBlocks(wrapper.content);
|
|
21
|
+
if (innerBlocks.length > 1) {
|
|
22
|
+
return withPreviews(innerBlocks);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return withPreviews(topLevelBlocks);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} markdown
|
|
31
|
+
* @returns {CodeBlock[]}
|
|
32
|
+
*/
|
|
33
|
+
function extractTopLevelCodeBlocks(markdown) {
|
|
17
34
|
/** @type {CodeBlock[]} */
|
|
18
35
|
const blocks = [];
|
|
19
36
|
/** @type {OpenCodeBlock[]} */
|
|
@@ -59,9 +76,8 @@ export function extractCodeBlocks(markdown) {
|
|
|
59
76
|
|
|
60
77
|
const finishedBlock = openBlocks.pop();
|
|
61
78
|
if (finishedBlock && openBlocks.length === 0) {
|
|
62
|
-
const content = finishedBlock.lines.join("\n");
|
|
63
79
|
blocks.push({
|
|
64
|
-
content,
|
|
80
|
+
content: finishedBlock.lines.join("\n"),
|
|
65
81
|
preview: "",
|
|
66
82
|
startLine: finishedBlock.startLine,
|
|
67
83
|
infoString: finishedBlock.infoString,
|
|
@@ -82,16 +98,18 @@ export function extractCodeBlocks(markdown) {
|
|
|
82
98
|
});
|
|
83
99
|
}
|
|
84
100
|
|
|
85
|
-
return blocks
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
return blocks.sort((left, right) => left.startLine - right.startLine);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {CodeBlock[]} blocks
|
|
106
|
+
* @returns {CodeBlock[]}
|
|
107
|
+
*/
|
|
108
|
+
function withPreviews(blocks) {
|
|
109
|
+
return blocks.map((block, index) => ({
|
|
110
|
+
...block,
|
|
111
|
+
preview: buildCodeBlockPreview(block.content, index + 1, block.infoString),
|
|
112
|
+
}));
|
|
95
113
|
}
|
|
96
114
|
|
|
97
115
|
/**
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ type?: unknown; message?: { role?: unknown; stopReason?: unknown; content?: unknown } }} SessionEntryLike
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} args
|
|
7
|
+
* @returns {{ ok: true, nth: number } | { ok: false, message: string }}
|
|
8
|
+
*/
|
|
9
|
+
export function parseYankArgs(args) {
|
|
10
|
+
const trimmed = args.trim();
|
|
11
|
+
if (trimmed.length === 0) {
|
|
12
|
+
return { ok: true, nth: 1 };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
message: 'Usage: /yank [N] where N is a positive integer',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const nth = Number.parseInt(trimmed, 10);
|
|
23
|
+
if (!Number.isSafeInteger(nth) || nth < 1) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
message: 'Usage: /yank [N] where N is a positive integer',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { ok: true, nth };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {SessionEntryLike['message']} message
|
|
35
|
+
* @returns {string | undefined}
|
|
36
|
+
*/
|
|
37
|
+
export function extractAssistantText(message) {
|
|
38
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
39
|
+
if (message?.stopReason === 'aborted' && content.length === 0) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let text = '';
|
|
44
|
+
for (const item of content) {
|
|
45
|
+
if (!item || typeof item !== 'object') continue;
|
|
46
|
+
const part = /** @type {{ type?: unknown; text?: unknown }} */ (item);
|
|
47
|
+
if (part.type === 'text' && typeof part.text === 'string') {
|
|
48
|
+
text += part.text;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const trimmed = text.trim();
|
|
53
|
+
return trimmed || undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {SessionEntryLike[]} entries
|
|
58
|
+
* @param {number} nth
|
|
59
|
+
* @returns {string | undefined}
|
|
60
|
+
*/
|
|
61
|
+
export function getNthLatestAssistantText(entries, nth = 1) {
|
|
62
|
+
let seen = 0;
|
|
63
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
64
|
+
const entry = entries[i];
|
|
65
|
+
if (entry?.type !== 'message') continue;
|
|
66
|
+
|
|
67
|
+
const message = entry.message;
|
|
68
|
+
if (message?.role !== 'assistant') continue;
|
|
69
|
+
|
|
70
|
+
const text = extractAssistantText(message);
|
|
71
|
+
if (!text) continue;
|
|
72
|
+
|
|
73
|
+
seen += 1;
|
|
74
|
+
if (seen === nth) return text;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
package/extensions/yank.ts
CHANGED
|
@@ -2,57 +2,15 @@ import {
|
|
|
2
2
|
copyToClipboard,
|
|
3
3
|
type ExtensionAPI,
|
|
4
4
|
type ExtensionCommandContext,
|
|
5
|
-
type SessionEntry,
|
|
6
5
|
} from "@mariozechner/pi-coding-agent";
|
|
7
6
|
import { extractCodeBlocks } from "./yank-core.js";
|
|
7
|
+
import { getNthLatestAssistantText, parseYankArgs } from "./yank-helpers.js";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
role?: unknown;
|
|
11
|
-
stopReason?: unknown;
|
|
12
|
-
content?: unknown;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const COPY_SUCCESS_MESSAGE = "Copied last agent message to clipboard";
|
|
9
|
+
const COPY_SUCCESS_MESSAGE = "Copied agent message to clipboard";
|
|
16
10
|
const COPY_EMPTY_MESSAGE = "No agent messages to copy yet.";
|
|
17
11
|
const COPY_CODE_BLOCK_SUCCESS_MESSAGE = "Copied code block to clipboard";
|
|
18
12
|
const MENU_DISABLED_MESSAGE =
|
|
19
|
-
"Future /yank commands will copy the full
|
|
20
|
-
|
|
21
|
-
function getLastAssistantText(entries: SessionEntry[]): string | undefined {
|
|
22
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
23
|
-
const entry = entries[i];
|
|
24
|
-
if (entry.type !== "message") continue;
|
|
25
|
-
|
|
26
|
-
const message = entry.message as AssistantMessageLike;
|
|
27
|
-
if (message.role !== "assistant") continue;
|
|
28
|
-
|
|
29
|
-
const text = extractAssistantText(message);
|
|
30
|
-
if (text) return text;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return undefined;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function extractAssistantText(
|
|
37
|
-
message: AssistantMessageLike,
|
|
38
|
-
): string | undefined {
|
|
39
|
-
const content = Array.isArray(message.content) ? message.content : [];
|
|
40
|
-
if (message.stopReason === "aborted" && content.length === 0) {
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
let text = "";
|
|
45
|
-
for (const item of content) {
|
|
46
|
-
if (!item || typeof item !== "object") continue;
|
|
47
|
-
const part = item as { type?: unknown; text?: unknown };
|
|
48
|
-
if (part.type === "text" && typeof part.text === "string") {
|
|
49
|
-
text += part.text;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const trimmed = text.trim();
|
|
54
|
-
return trimmed || undefined;
|
|
55
|
-
}
|
|
13
|
+
"Future /yank commands will copy the full selected message in this session";
|
|
56
14
|
|
|
57
15
|
function countNonEmptyLines(text: string): number {
|
|
58
16
|
return text.split(/\r?\n/).filter((line) => line.trim().length > 0).length;
|
|
@@ -96,11 +54,26 @@ export default function yankExtension(pi: ExtensionAPI) {
|
|
|
96
54
|
let skipMenuForSession = false;
|
|
97
55
|
|
|
98
56
|
pi.registerCommand("yank", {
|
|
99
|
-
description:
|
|
100
|
-
|
|
101
|
-
|
|
57
|
+
description:
|
|
58
|
+
"Copy the latest assistant message, or use /yank N for the N-th latest one",
|
|
59
|
+
handler: async (args, ctx) => {
|
|
60
|
+
const parsed = parseYankArgs(args);
|
|
61
|
+
if (!parsed.ok) {
|
|
62
|
+
ctx.ui.notify(parsed.message, "error");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const text = getNthLatestAssistantText(
|
|
67
|
+
ctx.sessionManager.getBranch(),
|
|
68
|
+
parsed.nth,
|
|
69
|
+
);
|
|
102
70
|
if (!text) {
|
|
103
|
-
ctx.ui.notify(
|
|
71
|
+
ctx.ui.notify(
|
|
72
|
+
parsed.nth === 1
|
|
73
|
+
? COPY_EMPTY_MESSAGE
|
|
74
|
+
: `No ${parsed.nth}-th latest agent message to copy yet.`,
|
|
75
|
+
"error",
|
|
76
|
+
);
|
|
104
77
|
return;
|
|
105
78
|
}
|
|
106
79
|
|
|
@@ -118,7 +91,12 @@ export default function yankExtension(pi: ExtensionAPI) {
|
|
|
118
91
|
}
|
|
119
92
|
|
|
120
93
|
const menu = buildMenuOptions(text, codeBlocks);
|
|
121
|
-
const selected = await ctx.ui.select(
|
|
94
|
+
const selected = await ctx.ui.select(
|
|
95
|
+
parsed.nth === 1
|
|
96
|
+
? "Yank from latest response"
|
|
97
|
+
: `Yank from ${parsed.nth}-th latest response`,
|
|
98
|
+
menu.all,
|
|
99
|
+
);
|
|
122
100
|
if (!selected) return;
|
|
123
101
|
|
|
124
102
|
try {
|
package/package.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
extractAssistantText,
|
|
5
|
+
getNthLatestAssistantText,
|
|
6
|
+
parseYankArgs,
|
|
7
|
+
} from "../extensions/yank-helpers.js";
|
|
8
|
+
|
|
9
|
+
test("parseYankArgs defaults to latest message", () => {
|
|
10
|
+
assert.deepEqual(parseYankArgs(""), { ok: true, nth: 1 });
|
|
11
|
+
assert.deepEqual(parseYankArgs(" "), { ok: true, nth: 1 });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("parseYankArgs accepts a positive integer", () => {
|
|
15
|
+
assert.deepEqual(parseYankArgs("1"), { ok: true, nth: 1 });
|
|
16
|
+
assert.deepEqual(parseYankArgs("2"), { ok: true, nth: 2 });
|
|
17
|
+
assert.deepEqual(parseYankArgs(" 12 "), { ok: true, nth: 12 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("parseYankArgs rejects invalid values", () => {
|
|
21
|
+
assert.equal(parseYankArgs("0").ok, false);
|
|
22
|
+
assert.equal(parseYankArgs("-1").ok, false);
|
|
23
|
+
assert.equal(parseYankArgs("1 2").ok, false);
|
|
24
|
+
assert.equal(parseYankArgs("abc").ok, false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("extractAssistantText ignores aborted empty assistant messages", () => {
|
|
28
|
+
assert.equal(
|
|
29
|
+
extractAssistantText({ role: "assistant", stopReason: "aborted", content: [] }),
|
|
30
|
+
undefined,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("getNthLatestAssistantText returns the requested assistant message", () => {
|
|
35
|
+
const entries = [
|
|
36
|
+
{ type: "message", message: { role: "user", content: [{ type: "text", text: "u1" }] } },
|
|
37
|
+
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "a1" }] } },
|
|
38
|
+
{ type: "message", message: { role: "assistant", stopReason: "aborted", content: [] } },
|
|
39
|
+
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "a2" }] } },
|
|
40
|
+
{ type: "message", message: { role: "user", content: [{ type: "text", text: "u2" }] } },
|
|
41
|
+
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "a3" }] } },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
assert.equal(getNthLatestAssistantText(entries, 1), "a3");
|
|
45
|
+
assert.equal(getNthLatestAssistantText(entries, 2), "a2");
|
|
46
|
+
assert.equal(getNthLatestAssistantText(entries, 3), "a1");
|
|
47
|
+
assert.equal(getNthLatestAssistantText(entries, 4), undefined);
|
|
48
|
+
});
|
package/tests/yank-core.test.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
extractCodeBlocks,
|
|
6
6
|
} from "../extensions/yank-core.js";
|
|
7
7
|
|
|
8
|
-
test("
|
|
8
|
+
test("prefers inner sibling code blocks when a single outer markdown wrapper encloses them", () => {
|
|
9
9
|
const text = `我来为你创建一个 Markdown 格式的测试文本:
|
|
10
10
|
|
|
11
11
|
\`\`\`markdown
|
|
@@ -32,10 +32,11 @@ def hello():
|
|
|
32
32
|
说明文字`;
|
|
33
33
|
|
|
34
34
|
const blocks = extractCodeBlocks(text);
|
|
35
|
-
assert.equal(blocks.length,
|
|
35
|
+
assert.equal(blocks.length, 2);
|
|
36
36
|
assert.match(blocks[0].content, /function hello\(\)/);
|
|
37
|
-
assert.match(blocks[
|
|
38
|
-
assert.equal(blocks[0].preview, "
|
|
37
|
+
assert.match(blocks[1].content, /def hello\(\):/);
|
|
38
|
+
assert.equal(blocks[0].preview, "javascript: function hello() {");
|
|
39
|
+
assert.equal(blocks[1].preview, "python: def hello():");
|
|
39
40
|
});
|
|
40
41
|
|
|
41
42
|
test("extracts sibling top-level code blocks independently", () => {
|
|
@@ -59,6 +60,35 @@ after`;
|
|
|
59
60
|
assert.equal(blocks[1].preview, 'python: print("b")');
|
|
60
61
|
});
|
|
61
62
|
|
|
63
|
+
test("unwraps a single outer markdown wrapper around filename and script blocks", () => {
|
|
64
|
+
const text = `下面给你一套 pi / pi-mono 迁移的一键备份 +恢复脚本(macOS/Linux)。
|
|
65
|
+
|
|
66
|
+
保存为:
|
|
67
|
+
|
|
68
|
+
\`\`\`bash
|
|
69
|
+
backup-pi.sh
|
|
70
|
+
\`\`\`
|
|
71
|
+
|
|
72
|
+
内容如下:
|
|
73
|
+
|
|
74
|
+
\`\`\`bash
|
|
75
|
+
#!/usr/bin/env bash
|
|
76
|
+
set -euo pipefail
|
|
77
|
+
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
|
78
|
+
\`\`\``;
|
|
79
|
+
|
|
80
|
+
const wrapped = `\`\`\`markdown
|
|
81
|
+
${text}
|
|
82
|
+
\`\`\``;
|
|
83
|
+
const blocks = extractCodeBlocks(wrapped);
|
|
84
|
+
|
|
85
|
+
assert.equal(blocks.length, 2);
|
|
86
|
+
assert.equal(blocks[0].content, "backup-pi.sh");
|
|
87
|
+
assert.match(blocks[1].content, /set -euo pipefail/);
|
|
88
|
+
assert.equal(blocks[0].preview, "bash: backup-pi.sh");
|
|
89
|
+
assert.equal(blocks[1].preview, "bash: #!/usr/bin/env bash");
|
|
90
|
+
});
|
|
91
|
+
|
|
62
92
|
test("supports tilde fences", () => {
|
|
63
93
|
const text = `~~~sql
|
|
64
94
|
select 1;
|