pi-paste-context 0.1.1
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/LICENSE +21 -0
- package/README.md +86 -0
- package/assets/pi-paste-context-screenshot.jpg +0 -0
- package/index.ts +247 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# pi-paste-context: add coding support to any editor
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
A pi extension to add coding support to any editor by using the clipboard (aka CTRL+C) for light integration. It searches the current project for the text in your clipboard, and then sends the matching file and snippet into the conversation as context, potentially with your instructions on top.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Reads your clipboard with `pbpaste`, `wl-paste`, or `xclip`
|
|
10
|
+
- Searches the current working directory for matching text
|
|
11
|
+
- Lets you choose between multiple matches when needed
|
|
12
|
+
- Injects a context-rich prompt so pi can explain, edit, or refactor the matched file
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
### From npm
|
|
17
|
+
|
|
18
|
+
After publishing, install it from npm with:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pi install npm:pi-paste-context
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
You can also pin a version:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi install npm:pi-paste-context@0.1.1
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### From GitHub
|
|
31
|
+
|
|
32
|
+
For a local or unreleased checkout, install directly from the repo:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi install git:github.com/timnon/pi-paste-context
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
You can also pin a tag or branch:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pi install git:github.com/timnon/pi-paste-context@main
|
|
42
|
+
pi install git:github.com/timnon/pi-paste-context@v1.0.0
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Local development
|
|
46
|
+
|
|
47
|
+
For a local checkout, add it to your pi settings:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"packages": ["/absolute/path/to/pi-paste-context"]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or load the extension directly while testing:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pi -e ./index.ts
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
Once installed, use the `/paste` command inside pi.
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
/paste
|
|
67
|
+
/paste explain this function
|
|
68
|
+
/paste refactor this to use async/await
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- If you omit arguments, it defaults to “explain this”.
|
|
72
|
+
- If multiple files contain the clipboard text, pi will prompt you to pick one.
|
|
73
|
+
- If the clipboard is empty or no match is found, the extension will notify you.
|
|
74
|
+
|
|
75
|
+
## How it works
|
|
76
|
+
|
|
77
|
+
The extension scans files in the current directory and ignores:
|
|
78
|
+
|
|
79
|
+
- `.git/`
|
|
80
|
+
- `node_modules/`
|
|
81
|
+
|
|
82
|
+
It also skips files larger than 2 MB and binary files.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
Binary file
|
package/index.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
type Match = {
|
|
6
|
+
path: string;
|
|
7
|
+
line: number;
|
|
8
|
+
preview: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const MAX_FILE_SIZE = 2 * 1024 * 1024;
|
|
12
|
+
const SKIP_DIRS = new Set([".git", "node_modules"]);
|
|
13
|
+
const MAX_SNIPPET_IN_PROMPT = 8000;
|
|
14
|
+
|
|
15
|
+
export default function (pi: ExtensionAPI) {
|
|
16
|
+
pi.registerCommand("paste", {
|
|
17
|
+
description: "Find the current clipboard text in the project and use that file/snippet as context; defaults to explain if no instruction is given",
|
|
18
|
+
handler: async (args, ctx) => {
|
|
19
|
+
const rawInstruction = args.trim();
|
|
20
|
+
const instruction = rawInstruction || "explain this";
|
|
21
|
+
const explainByDefault = !rawInstruction;
|
|
22
|
+
|
|
23
|
+
const clipboard = await getClipboardText(pi);
|
|
24
|
+
if (!clipboard.trim()) {
|
|
25
|
+
ctx.ui.notify("Clipboard is empty.", "warning");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const candidates = buildNeedleCandidates(clipboard);
|
|
30
|
+
let selectedNeedle = "";
|
|
31
|
+
let matches: Match[] = [];
|
|
32
|
+
|
|
33
|
+
for (const candidate of candidates) {
|
|
34
|
+
const candidateMatches = await findMatches(ctx.cwd, candidate);
|
|
35
|
+
if (candidateMatches.length > 0) {
|
|
36
|
+
selectedNeedle = candidate;
|
|
37
|
+
matches = candidateMatches;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (matches.length === 0) {
|
|
43
|
+
ctx.ui.notify("No file in the current directory contains the clipboard text.", "warning");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const match = await chooseMatch(matches, ctx);
|
|
48
|
+
if (!match) return;
|
|
49
|
+
|
|
50
|
+
const relativePath = path.relative(ctx.cwd, match.path) || path.basename(match.path);
|
|
51
|
+
const prompt = buildPrompt({
|
|
52
|
+
instruction,
|
|
53
|
+
explainByDefault,
|
|
54
|
+
relativePath,
|
|
55
|
+
line: match.line,
|
|
56
|
+
needle: selectedNeedle,
|
|
57
|
+
matchCount: matches.length,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (ctx.isIdle()) {
|
|
61
|
+
pi.sendUserMessage(prompt);
|
|
62
|
+
ctx.ui.notify(`Using clipboard match in ${relativePath}:${match.line}`, "info");
|
|
63
|
+
} else {
|
|
64
|
+
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
65
|
+
ctx.ui.notify(`Queued /paste for ${relativePath}:${match.line}`, "info");
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function getClipboardText(pi: ExtensionAPI): Promise<string> {
|
|
72
|
+
const commands = [
|
|
73
|
+
{ command: "pbpaste", args: [] as string[] },
|
|
74
|
+
{ command: "wl-paste", args: ["-n"] },
|
|
75
|
+
{ command: "xclip", args: ["-selection", "clipboard", "-o"] },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
for (const entry of commands) {
|
|
79
|
+
try {
|
|
80
|
+
const result = await pi.exec(entry.command, entry.args, { timeout: 3000 });
|
|
81
|
+
if (result.code === 0 && result.stdout) return result.stdout;
|
|
82
|
+
} catch {
|
|
83
|
+
// Try the next clipboard command.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildNeedleCandidates(clipboard: string): string[] {
|
|
91
|
+
const values = [
|
|
92
|
+
clipboard,
|
|
93
|
+
normalizeNewlines(clipboard),
|
|
94
|
+
clipboard.trim(),
|
|
95
|
+
normalizeNewlines(clipboard).trim(),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
const candidates: string[] = [];
|
|
100
|
+
|
|
101
|
+
for (const value of values) {
|
|
102
|
+
if (!value || !value.trim() || seen.has(value)) continue;
|
|
103
|
+
seen.add(value);
|
|
104
|
+
candidates.push(value);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return candidates;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeNewlines(value: string): string {
|
|
111
|
+
return value.replace(/\r\n/g, "\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function findMatches(root: string, needle: string): Promise<Match[]> {
|
|
115
|
+
const matches: Match[] = [];
|
|
116
|
+
|
|
117
|
+
async function walk(dir: string): Promise<void> {
|
|
118
|
+
let entries;
|
|
119
|
+
try {
|
|
120
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
121
|
+
} catch {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
const fullPath = path.join(dir, entry.name);
|
|
127
|
+
|
|
128
|
+
if (entry.isDirectory()) {
|
|
129
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
130
|
+
await walk(fullPath);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!entry.isFile()) continue;
|
|
135
|
+
|
|
136
|
+
let stats;
|
|
137
|
+
try {
|
|
138
|
+
stats = await fs.stat(fullPath);
|
|
139
|
+
} catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (stats.size === 0 || stats.size > MAX_FILE_SIZE) continue;
|
|
144
|
+
|
|
145
|
+
let buffer: Buffer;
|
|
146
|
+
try {
|
|
147
|
+
buffer = await fs.readFile(fullPath);
|
|
148
|
+
} catch {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (buffer.includes(0)) continue;
|
|
153
|
+
|
|
154
|
+
const content = normalizeNewlines(buffer.toString("utf8"));
|
|
155
|
+
const index = content.indexOf(needle);
|
|
156
|
+
if (index === -1) continue;
|
|
157
|
+
|
|
158
|
+
const line = content.slice(0, index).split("\n").length;
|
|
159
|
+
const previewLine = content.slice(index).split("\n", 2)[0].trim();
|
|
160
|
+
matches.push({
|
|
161
|
+
path: fullPath,
|
|
162
|
+
line,
|
|
163
|
+
preview: previewLine,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await walk(root);
|
|
169
|
+
matches.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line);
|
|
170
|
+
return matches;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function chooseMatch(matches: Match[], ctx: ExtensionCommandContext): Promise<Match | null> {
|
|
174
|
+
if (matches.length === 1) return matches[0];
|
|
175
|
+
if (!ctx.hasUI) return matches[0];
|
|
176
|
+
|
|
177
|
+
const labels = matches.map((match, index) => {
|
|
178
|
+
const rel = path.relative(ctx.cwd, match.path) || path.basename(match.path);
|
|
179
|
+
const preview = match.preview || "(match starts on a blank line)";
|
|
180
|
+
return `${index + 1}. ${rel}:${match.line} — ${preview}`;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const choice = await ctx.ui.select("Multiple files match the clipboard text", labels);
|
|
184
|
+
if (!choice) return null;
|
|
185
|
+
|
|
186
|
+
const selectedIndex = labels.indexOf(choice);
|
|
187
|
+
return selectedIndex >= 0 ? matches[selectedIndex] : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildPrompt(args: {
|
|
191
|
+
instruction: string;
|
|
192
|
+
explainByDefault: boolean;
|
|
193
|
+
relativePath: string;
|
|
194
|
+
line: number;
|
|
195
|
+
needle: string;
|
|
196
|
+
matchCount: number;
|
|
197
|
+
}): string {
|
|
198
|
+
|
|
199
|
+
const snippet =
|
|
200
|
+
args.needle.length > MAX_SNIPPET_IN_PROMPT
|
|
201
|
+
? `${args.needle.slice(0, MAX_SNIPPET_IN_PROMPT)}\n[clipboard snippet truncated in this prompt; the full clipboard text was used for matching]`
|
|
202
|
+
: args.needle;
|
|
203
|
+
|
|
204
|
+
const multipleMatchesNote =
|
|
205
|
+
args.matchCount > 1
|
|
206
|
+
? `Note: ${args.matchCount} files matched the clipboard text, but this one was selected as the target.`
|
|
207
|
+
: "";
|
|
208
|
+
|
|
209
|
+
const header = "=== Paste Command Context ===";
|
|
210
|
+
const instructionLine = args.explainByDefault
|
|
211
|
+
? "Instruction: Explain the selected text in the context of this file."
|
|
212
|
+
: `User request: ${args.instruction}`;
|
|
213
|
+
const locationLine = `Location: ${args.relativePath}:${args.line}`;
|
|
214
|
+
const explanationFormat = args.explainByDefault
|
|
215
|
+
? [
|
|
216
|
+
"• Response format: use Markdown so the TUI can render structure clearly.",
|
|
217
|
+
"• Start with a short TL;DR summary.",
|
|
218
|
+
"• Then use real bullet lists for the important details.",
|
|
219
|
+
"• Prefer short sections like **What it does**, **How it works**, and **Key details**.",
|
|
220
|
+
"• Keep paragraphs short; avoid a wall of text.",
|
|
221
|
+
]
|
|
222
|
+
: [];
|
|
223
|
+
|
|
224
|
+
// Use bullet points to structure the prompt.
|
|
225
|
+
const bullets = [
|
|
226
|
+
`• ${instructionLine}`,
|
|
227
|
+
`• ${locationLine}`,
|
|
228
|
+
...(multipleMatchesNote ? [`• ${multipleMatchesNote}`] : []),
|
|
229
|
+
...explanationFormat,
|
|
230
|
+
`• Context snippet (anchor):`,
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
return [
|
|
234
|
+
header,
|
|
235
|
+
"", // blank line for readability
|
|
236
|
+
...bullets,
|
|
237
|
+
"```",
|
|
238
|
+
snippet,
|
|
239
|
+
"```",
|
|
240
|
+
"", // blank line
|
|
241
|
+
"Use the target file and this snippet as the primary context for the request.",
|
|
242
|
+
"Decide what to do based on the instruction itself: explain, edit, refactor, answer a question, or make no changes.",
|
|
243
|
+
"If the snippet no longer exists in that file, explain that and stop.",
|
|
244
|
+
]
|
|
245
|
+
.filter(Boolean)
|
|
246
|
+
.join("\n\n");
|
|
247
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-paste-context",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A pi extension to add coding support to any editor by using the clipboard (aka CTRL+C) for light integration. It searches the current project for the text in your clipboard, and then sends the matching file and snippet into the conversation as context, potentially with your instructions on top.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Tim Nonner",
|
|
8
|
+
"keywords": ["pi-package", "pi-extension", "clipboard", "context"],
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/timnon/pi-paste-context.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/timnon/pi-paste-context/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/timnon/pi-paste-context#readme",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"index.ts",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"assets"
|
|
31
|
+
],
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": ["./index.ts"],
|
|
34
|
+
"image": "https://raw.githubusercontent.com/timnon/pi-paste-context/main/assets/pi-paste-context-screenshot.jpg"
|
|
35
|
+
}
|
|
36
|
+
}
|