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 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
- - Detect fenced Markdown code blocks in the last assistant message
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
- - Last assistant message has no fenced code blocks copies the full message
66
- - Last assistant message has code blocks → opens a selection menu
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 now include more context when available, for example:
85
+ Menu previews include more context when available, for example:
82
86
 
83
- - `1. Full last message (24 lines • 812 chars)`
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 last message in this session`
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
 
@@ -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
- .sort((left, right) => left.startLine - right.startLine)
87
- .map((block, index) => ({
88
- ...block,
89
- preview: buildCodeBlockPreview(
90
- block.content,
91
- index + 1,
92
- block.infoString,
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
+ }
@@ -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
- interface AssistantMessageLike {
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 last message in this session";
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: "Copy the last assistant message or a code block from it",
100
- handler: async (_args, ctx) => {
101
- const text = getLastAssistantText(ctx.sessionManager.getBranch());
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(COPY_EMPTY_MESSAGE, "error");
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("Yank from last response", menu.all);
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-yank",
3
3
  "private": false,
4
- "version": "0.1.0",
4
+ "version": "0.1.2",
5
5
  "type": "module",
6
6
  "description": "Lightweight /yank extension for pi that copies the last assistant message or a selected code block.",
7
7
  "keywords": [
@@ -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
+ });
@@ -5,7 +5,7 @@ import {
5
5
  extractCodeBlocks,
6
6
  } from "../extensions/yank-core.js";
7
7
 
8
- test("extracts a single outer markdown block when nested fences appear as literal examples", () => {
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, 1);
35
+ assert.equal(blocks.length, 2);
36
36
  assert.match(blocks[0].content, /function hello\(\)/);
37
- assert.match(blocks[0].content, /def hello\(\):/);
38
- assert.equal(blocks[0].preview, "markdown: # Markdown 格式测试文档");
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;