pi-yank 0.1.0

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 ADDED
@@ -0,0 +1,106 @@
1
+ # pi-yank
2
+
3
+ Lightweight `/yank` extension for [pi](https://github.com/badlogic/pi-mono).
4
+
5
+ `/yank` copies the last assistant response to the clipboard, or lets you choose an individual fenced code block from that response.
6
+
7
+ ## Features
8
+
9
+ - Copy the last assistant message to the clipboard
10
+ - Detect fenced Markdown code blocks in the last assistant message
11
+ - Use arrow keys + Enter to choose a specific code block
12
+ - Optionally disable the menu for the current session so `/yank` behaves like `/copy`
13
+ - Reuse pi's official clipboard pipeline via `copyToClipboard()` for `/copy`-compatible behavior
14
+ - Zero third-party runtime dependencies
15
+
16
+ ## Installation
17
+
18
+ ### Quick test
19
+
20
+ Load temporarily without installing:
21
+
22
+ ```bash
23
+ pi --extension /path/to/pi-yank/extensions/yank.ts
24
+ ```
25
+
26
+ ### Install as pi package
27
+
28
+ Globally:
29
+
30
+ ```bash
31
+ pi install /path/to/pi-yank
32
+ ```
33
+
34
+ Project-local:
35
+
36
+ ```bash
37
+ pi install -l /path/to/pi-yank
38
+ ```
39
+
40
+ ### Install as a project-local extension file
41
+
42
+ ```bash
43
+ mkdir -p .pi/extensions
44
+ cp /path/to/pi-yank/extensions/yank.ts .pi/extensions/yank.ts
45
+ ```
46
+
47
+ ### Install as a global extension file
48
+
49
+ ```bash
50
+ mkdir -p ~/.pi/agent/extensions
51
+ cp /path/to/pi-yank/extensions/yank.ts ~/.pi/agent/extensions/yank.ts
52
+ ```
53
+
54
+ ## Usage
55
+
56
+ Run:
57
+
58
+ ```text
59
+ /yank
60
+ ```
61
+
62
+ Behavior:
63
+
64
+ - 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
67
+ - Selecting a code block copies only that code block's content
68
+ - Selecting the session option disables the menu for later `/yank` calls in the current session
69
+ - Pressing `Escape` in the selector cancels without copying
70
+
71
+ ### Code block detection notes
72
+
73
+ `/yank` uses a line-by-line fenced block parser instead of a simple regex.
74
+ This makes it more robust for LLM output such as:
75
+
76
+ - a top-level ````markdown` fenced block containing literal nested ` ```js ` examples
77
+ - multiple top-level sibling code blocks
78
+ - `~~~` fenced blocks
79
+ - unterminated fences, which are ignored
80
+
81
+ Menu previews now include more context when available, for example:
82
+
83
+ - `1. Full last message (24 lines • 812 chars)`
84
+ - `2. Code block — markdown: # Title (18 lines • 640 chars)`
85
+ - `3. Code block — js: console.log("hi") (1 line • 18 chars)`
86
+ - `4. Always copy full last message in this session`
87
+
88
+ ## Development
89
+
90
+ Run parser tests:
91
+
92
+ ```bash
93
+ npm run check
94
+ ```
95
+
96
+ ## Package structure
97
+
98
+ This repository follows pi package conventions:
99
+
100
+ - extension entry: `extensions/yank.ts`
101
+ - `package.json` includes a `pi` manifest
102
+ - pi core package is declared as a `peerDependency`
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @typedef {{ content: string; preview: string; startLine: number; infoString: string }} CodeBlock
3
+ * @typedef {{ fenceChar: '`' | '~'; fenceLength: number; lines: string[]; startLine: number; infoString: string }} OpenCodeBlock
4
+ */
5
+
6
+ /**
7
+ * Extract top-level fenced code blocks from markdown-like text.
8
+ *
9
+ * This is intentionally tolerant of LLM output that contains a large fenced
10
+ * markdown block which itself includes literal nested fences as examples.
11
+ * Only fully-closed top-level blocks are returned as yank candidates.
12
+ *
13
+ * @param {string} markdown
14
+ * @returns {CodeBlock[]}
15
+ */
16
+ export function extractCodeBlocks(markdown) {
17
+ /** @type {CodeBlock[]} */
18
+ const blocks = [];
19
+ /** @type {OpenCodeBlock[]} */
20
+ const openBlocks = [];
21
+ const lines = markdown.split(/\r?\n/);
22
+
23
+ for (let index = 0; index < lines.length; index++) {
24
+ const line = lines[index];
25
+ const fence = parseFenceLine(line);
26
+
27
+ if (!fence) {
28
+ for (const block of openBlocks) {
29
+ block.lines.push(line);
30
+ }
31
+ continue;
32
+ }
33
+
34
+ if (openBlocks.length === 0) {
35
+ openBlocks.push({
36
+ fenceChar: fence.char,
37
+ fenceLength: fence.length,
38
+ lines: [],
39
+ startLine: index,
40
+ infoString: fence.info,
41
+ });
42
+ continue;
43
+ }
44
+
45
+ const currentBlock = openBlocks[openBlocks.length - 1];
46
+ const closesCurrentBlock =
47
+ fence.char === currentBlock.fenceChar &&
48
+ fence.length >= currentBlock.fenceLength &&
49
+ fence.info.length === 0;
50
+
51
+ if (closesCurrentBlock) {
52
+ for (
53
+ let parentIndex = 0;
54
+ parentIndex < openBlocks.length - 1;
55
+ parentIndex++
56
+ ) {
57
+ openBlocks[parentIndex].lines.push(line);
58
+ }
59
+
60
+ const finishedBlock = openBlocks.pop();
61
+ if (finishedBlock && openBlocks.length === 0) {
62
+ const content = finishedBlock.lines.join("\n");
63
+ blocks.push({
64
+ content,
65
+ preview: "",
66
+ startLine: finishedBlock.startLine,
67
+ infoString: finishedBlock.infoString,
68
+ });
69
+ }
70
+ continue;
71
+ }
72
+
73
+ for (const block of openBlocks) {
74
+ block.lines.push(line);
75
+ }
76
+ openBlocks.push({
77
+ fenceChar: fence.char,
78
+ fenceLength: fence.length,
79
+ lines: [],
80
+ startLine: index,
81
+ infoString: fence.info,
82
+ });
83
+ }
84
+
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
+ }));
95
+ }
96
+
97
+ /**
98
+ * @param {string} line
99
+ * @returns {{ char: '`' | '~'; length: number; info: string } | null}
100
+ */
101
+ export function parseFenceLine(line) {
102
+ const match = /^[ \t]*(`{3,}|~{3,})([^\n]*)$/.exec(line);
103
+ if (!match) return null;
104
+
105
+ const fence = match[1];
106
+ const info = match[2]?.trim() ?? "";
107
+ if (fence[0] === "`" && info.includes("`")) return null;
108
+
109
+ const char = fence[0];
110
+ if (char !== "`" && char !== "~") return null;
111
+
112
+ return {
113
+ char,
114
+ length: fence.length,
115
+ info,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * @param {string} content
121
+ * @param {number} index
122
+ * @param {string} [infoString]
123
+ * @returns {string}
124
+ */
125
+ export function buildCodeBlockPreview(content, index, infoString = "") {
126
+ const firstNonEmptyLine = content
127
+ .split(/\r?\n/)
128
+ .map((line) => line.trim())
129
+ .find((line) => line.length > 0);
130
+
131
+ const language = getFenceLabel(infoString);
132
+ if (!firstNonEmptyLine) {
133
+ return language
134
+ ? `${language}: Code block ${index}`
135
+ : `Code block ${index}`;
136
+ }
137
+
138
+ const body = truncate(firstNonEmptyLine, 72);
139
+ return language ? `${language}: ${body}` : body;
140
+ }
141
+
142
+ /**
143
+ * @param {string} infoString
144
+ * @returns {string}
145
+ */
146
+ export function getFenceLabel(infoString) {
147
+ const label = infoString.trim().split(/\s+/)[0] ?? "";
148
+ return label;
149
+ }
150
+
151
+ /**
152
+ * @param {string} value
153
+ * @param {number} maxLength
154
+ * @returns {string}
155
+ */
156
+ export function truncate(value, maxLength) {
157
+ if (value.length <= maxLength) return value;
158
+ return `${value.slice(0, maxLength - 1)}…`;
159
+ }
@@ -0,0 +1,157 @@
1
+ import {
2
+ copyToClipboard,
3
+ type ExtensionAPI,
4
+ type ExtensionCommandContext,
5
+ type SessionEntry,
6
+ } from "@mariozechner/pi-coding-agent";
7
+ import { extractCodeBlocks } from "./yank-core.js";
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";
16
+ const COPY_EMPTY_MESSAGE = "No agent messages to copy yet.";
17
+ const COPY_CODE_BLOCK_SUCCESS_MESSAGE = "Copied code block to clipboard";
18
+ 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
+ }
56
+
57
+ function countNonEmptyLines(text: string): number {
58
+ return text.split(/\r?\n/).filter((line) => line.trim().length > 0).length;
59
+ }
60
+
61
+ function summarizeBlock(content: string): string {
62
+ const lines = countNonEmptyLines(content);
63
+ const chars = content.length;
64
+ return `${lines} line${lines === 1 ? "" : "s"} • ${chars} chars`;
65
+ }
66
+
67
+ function buildMenuOptions(
68
+ text: string,
69
+ codeBlocks: ReturnType<typeof extractCodeBlocks>,
70
+ ) {
71
+ const fullMessageOption = `1. Full last message (${summarizeBlock(text)})`;
72
+ const codeBlockOptions = codeBlocks.map(
73
+ (block, index) =>
74
+ `${index + 2}. Code block — ${block.preview} (${summarizeBlock(block.content)})`,
75
+ );
76
+ const skipMenuOption = `${codeBlocks.length + 2}. Always copy full last message in this session`;
77
+
78
+ return {
79
+ fullMessageOption,
80
+ codeBlockOptions,
81
+ skipMenuOption,
82
+ all: [fullMessageOption, ...codeBlockOptions, skipMenuOption],
83
+ };
84
+ }
85
+
86
+ async function copyAndNotify(
87
+ text: string,
88
+ successMessage: string,
89
+ ctx: ExtensionCommandContext,
90
+ ): Promise<void> {
91
+ await copyToClipboard(text);
92
+ ctx.ui.notify(successMessage, "info");
93
+ }
94
+
95
+ export default function yankExtension(pi: ExtensionAPI) {
96
+ let skipMenuForSession = false;
97
+
98
+ 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());
102
+ if (!text) {
103
+ ctx.ui.notify(COPY_EMPTY_MESSAGE, "error");
104
+ return;
105
+ }
106
+
107
+ const codeBlocks = extractCodeBlocks(text);
108
+ if (!ctx.hasUI || skipMenuForSession || codeBlocks.length === 0) {
109
+ try {
110
+ await copyAndNotify(text, COPY_SUCCESS_MESSAGE, ctx);
111
+ } catch (error) {
112
+ ctx.ui.notify(
113
+ error instanceof Error ? error.message : String(error),
114
+ "error",
115
+ );
116
+ }
117
+ return;
118
+ }
119
+
120
+ const menu = buildMenuOptions(text, codeBlocks);
121
+ const selected = await ctx.ui.select("Yank from last response", menu.all);
122
+ if (!selected) return;
123
+
124
+ try {
125
+ if (selected === menu.fullMessageOption) {
126
+ await copyAndNotify(text, COPY_SUCCESS_MESSAGE, ctx);
127
+ return;
128
+ }
129
+
130
+ if (selected === menu.skipMenuOption) {
131
+ skipMenuForSession = true;
132
+ await copyAndNotify(text, COPY_SUCCESS_MESSAGE, ctx);
133
+ ctx.ui.notify(MENU_DISABLED_MESSAGE, "info");
134
+ return;
135
+ }
136
+
137
+ const codeBlockIndex = menu.codeBlockOptions.indexOf(selected);
138
+ const codeBlock = codeBlocks[codeBlockIndex];
139
+ if (!codeBlock) {
140
+ ctx.ui.notify("Invalid selection", "error");
141
+ return;
142
+ }
143
+
144
+ await copyAndNotify(
145
+ codeBlock.content,
146
+ COPY_CODE_BLOCK_SUCCESS_MESSAGE,
147
+ ctx,
148
+ );
149
+ } catch (error) {
150
+ ctx.ui.notify(
151
+ error instanceof Error ? error.message : String(error),
152
+ "error",
153
+ );
154
+ }
155
+ },
156
+ });
157
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "pi-yank",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "description": "Lightweight /yank extension for pi that copies the last assistant message or a selected code block.",
7
+ "keywords": [
8
+ "pi-package"
9
+ ],
10
+ "license": "MIT",
11
+ "scripts": {
12
+ "build": "echo 'nothing to build'",
13
+ "check": "node --test tests/*.test.mjs"
14
+ },
15
+ "peerDependencies": {
16
+ "@mariozechner/pi-coding-agent": "*"
17
+ },
18
+ "pi": {
19
+ "extensions": [
20
+ "./extensions/yank.ts"
21
+ ]
22
+ }
23
+ }
@@ -0,0 +1,84 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ buildCodeBlockPreview,
5
+ extractCodeBlocks,
6
+ } from "../extensions/yank-core.js";
7
+
8
+ test("extracts a single outer markdown block when nested fences appear as literal examples", () => {
9
+ const text = `我来为你创建一个 Markdown 格式的测试文本:
10
+
11
+ \`\`\`markdown
12
+ # Markdown 格式测试文档
13
+
14
+ ## 代码块
15
+
16
+ ### JavaScript 代码
17
+ \`\`\`javascript
18
+ function hello() {
19
+ console.log("Hello, World!");
20
+ }
21
+ \`\`\`
22
+
23
+ ### Python 代码
24
+ \`\`\`python
25
+ def hello():
26
+ print("Hello, World!")
27
+ \`\`\`
28
+
29
+ *文档结束*
30
+ \`\`\`
31
+
32
+ 说明文字`;
33
+
34
+ const blocks = extractCodeBlocks(text);
35
+ assert.equal(blocks.length, 1);
36
+ assert.match(blocks[0].content, /function hello\(\)/);
37
+ assert.match(blocks[0].content, /def hello\(\):/);
38
+ assert.equal(blocks[0].preview, "markdown: # Markdown 格式测试文档");
39
+ });
40
+
41
+ test("extracts sibling top-level code blocks independently", () => {
42
+ const text = `before
43
+ \`\`\`js
44
+ console.log("a");
45
+ \`\`\`
46
+
47
+ between
48
+
49
+ \`\`\`python
50
+ print("b")
51
+ \`\`\`
52
+ after`;
53
+
54
+ const blocks = extractCodeBlocks(text);
55
+ assert.equal(blocks.length, 2);
56
+ assert.equal(blocks[0].content, 'console.log("a");');
57
+ assert.equal(blocks[1].content, 'print("b")');
58
+ assert.equal(blocks[0].preview, 'js: console.log("a");');
59
+ assert.equal(blocks[1].preview, 'python: print("b")');
60
+ });
61
+
62
+ test("supports tilde fences", () => {
63
+ const text = `~~~sql
64
+ select 1;
65
+ ~~~`;
66
+ const blocks = extractCodeBlocks(text);
67
+ assert.equal(blocks.length, 1);
68
+ assert.equal(blocks[0].content, "select 1;");
69
+ assert.equal(blocks[0].preview, "sql: select 1;");
70
+ });
71
+
72
+ test("ignores unterminated fences", () => {
73
+ const text = `\`\`\`ts
74
+ const x = 1;`;
75
+ const blocks = extractCodeBlocks(text);
76
+ assert.equal(blocks.length, 0);
77
+ });
78
+
79
+ test("preview falls back cleanly for empty blocks", () => {
80
+ assert.equal(
81
+ buildCodeBlockPreview("", 3, "markdown"),
82
+ "markdown: Code block 3",
83
+ );
84
+ });