@rol1n/prompt-snipaste 0.0.0-stage → 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/README.md +26 -2
- package/index.ts +143 -0
- package/menu.ts +159 -0
- package/package.json +39 -4
- package/prompts/ask-questions.md +8 -0
- package/prompts/delegate-exploration.md +7 -0
- package/prompts/diagnose-report.md +7 -0
- package/prompts/frontend-tech-preset.md +18 -0
- package/prompts/orchestrator-mode.md +7 -0
- package/prompts/session-kickoff.md +7 -0
- package/prompts/verify-not-assume.md +7 -0
- package/renderers.ts +87 -0
- package/snippets.ts +206 -0
- package/types.ts +18 -0
package/README.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# prompt-snipaste
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Select prompt snippets to prepend or append to the next user message, with live preview and a session transcript card. Bundles 7 built-in prompt snippets under `prompts/`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:@rol1n/prompt-snipaste
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Also included in `@rol1n/pi-kit`.
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
Open with `/snipaste` or `Alt+S`. Navigate with arrow keys, toggle with `Space`, preview with `Tab`, confirm with `Enter`, and cancel with `Escape`.
|
|
16
|
+
|
|
17
|
+
## Frontmatter
|
|
18
|
+
|
|
19
|
+
```markdown
|
|
20
|
+
---
|
|
21
|
+
name: Review checklist
|
|
22
|
+
description: Add review criteria to the next message
|
|
23
|
+
placement: append
|
|
24
|
+
order: 100
|
|
25
|
+
---
|
|
26
|
+
Check correctness, error handling, and missing validation.
|
|
27
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** Select prompt snippets for the next user message with /snipaste or Alt+S. */
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { PromptCardData, Snippet } from "./types.ts";
|
|
5
|
+
import { loadSnippets } from "./snippets.ts";
|
|
6
|
+
import { showSnippetMenu } from "./menu.ts";
|
|
7
|
+
import { buildPromptCardComponent, updateSnippetWidget } from "./renderers.ts";
|
|
8
|
+
|
|
9
|
+
export default function promptSnipaste(pi: ExtensionAPI) {
|
|
10
|
+
let enabled = new Set<string>();
|
|
11
|
+
let snippets: Snippet[] = [];
|
|
12
|
+
|
|
13
|
+
function updateWidget(ctx: ExtensionContext) {
|
|
14
|
+
updateSnippetWidget(ctx, () => ({ snippets, enabled }));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function openMenu(ctx: ExtensionContext) {
|
|
18
|
+
if (ctx.mode !== "tui") {
|
|
19
|
+
ctx.ui.notify("Prompt snippets menu is only available in interactive terminals", "warning");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
snippets = loadSnippets(ctx.cwd, pi);
|
|
24
|
+
enabled = new Set([...enabled].filter((id) => snippets.some((s) => s.id === id)));
|
|
25
|
+
|
|
26
|
+
if (snippets.length === 0) {
|
|
27
|
+
ctx.ui.notify("No Markdown files found in prompt directories", "warning");
|
|
28
|
+
updateWidget(ctx);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const working = new Set(enabled);
|
|
33
|
+
|
|
34
|
+
const confirmed = await showSnippetMenu(ctx, snippets, working);
|
|
35
|
+
|
|
36
|
+
if (confirmed) {
|
|
37
|
+
enabled = working;
|
|
38
|
+
}
|
|
39
|
+
updateWidget(ctx);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let pendingTurnSnippets: Snippet[] | null = null;
|
|
43
|
+
|
|
44
|
+
pi.registerEntryRenderer<PromptCardData>("prompt-snipaste-card", (entry, _options, theme) => {
|
|
45
|
+
const data = entry.data;
|
|
46
|
+
if (!data?.snippets || data.snippets.length === 0) return undefined;
|
|
47
|
+
return buildPromptCardComponent(data.snippets, theme);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
pi.on("session_start", (_event, ctx) => {
|
|
51
|
+
enabled = new Set();
|
|
52
|
+
pendingTurnSnippets = null;
|
|
53
|
+
snippets = loadSnippets(ctx.cwd, pi);
|
|
54
|
+
updateWidget(ctx);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
pi.on("input", async (_event, ctx) => {
|
|
58
|
+
if (enabled.size === 0) return undefined;
|
|
59
|
+
|
|
60
|
+
snippets = loadSnippets(ctx.cwd, pi);
|
|
61
|
+
const active = snippets.filter((s) => enabled.has(s.id));
|
|
62
|
+
enabled = new Set();
|
|
63
|
+
updateWidget(ctx);
|
|
64
|
+
|
|
65
|
+
if (active.length === 0) return undefined;
|
|
66
|
+
|
|
67
|
+
pendingTurnSnippets = active;
|
|
68
|
+
|
|
69
|
+
pi.appendEntry<PromptCardData>("prompt-snipaste-card", {
|
|
70
|
+
snippets: active.map((s) => ({
|
|
71
|
+
id: s.id,
|
|
72
|
+
name: s.name,
|
|
73
|
+
placement: s.placement,
|
|
74
|
+
order: s.order,
|
|
75
|
+
body: s.body,
|
|
76
|
+
})),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return undefined;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
pi.on("context", async (event, _ctx) => {
|
|
83
|
+
if (!pendingTurnSnippets || pendingTurnSnippets.length === 0) return undefined;
|
|
84
|
+
|
|
85
|
+
const active = pendingTurnSnippets;
|
|
86
|
+
pendingTurnSnippets = null;
|
|
87
|
+
|
|
88
|
+
const prepends = active.filter((s) => s.placement === "prepend");
|
|
89
|
+
const appends = active.filter((s) => s.placement === "append");
|
|
90
|
+
|
|
91
|
+
const messages = [...event.messages];
|
|
92
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
93
|
+
const msg = messages[i];
|
|
94
|
+
if (msg.role === "user") {
|
|
95
|
+
if (typeof msg.content === "string") {
|
|
96
|
+
const parts: string[] = [];
|
|
97
|
+
|
|
98
|
+
if (prepends.length > 0) {
|
|
99
|
+
parts.push(prepends.map((s) => s.body.trim()).join("\n\n"));
|
|
100
|
+
}
|
|
101
|
+
if (msg.content.trim()) {
|
|
102
|
+
parts.push(msg.content.trim());
|
|
103
|
+
}
|
|
104
|
+
if (appends.length > 0) {
|
|
105
|
+
parts.push(appends.map((s) => s.body.trim()).join("\n\n"));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
messages[i] = {
|
|
109
|
+
...msg,
|
|
110
|
+
content: parts.join("\n\n"),
|
|
111
|
+
};
|
|
112
|
+
} else if (Array.isArray(msg.content)) {
|
|
113
|
+
const prependText = prepends.map((s) => s.body.trim()).join("\n\n");
|
|
114
|
+
const appendText = appends.map((s) => s.body.trim()).join("\n\n");
|
|
115
|
+
const newContent = [...msg.content];
|
|
116
|
+
if (prependText) {
|
|
117
|
+
newContent.unshift({ type: "text", text: prependText });
|
|
118
|
+
}
|
|
119
|
+
if (appendText) {
|
|
120
|
+
newContent.push({ type: "text", text: appendText });
|
|
121
|
+
}
|
|
122
|
+
messages[i] = {
|
|
123
|
+
...msg,
|
|
124
|
+
content: newContent as any,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return { messages };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return undefined;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
pi.registerCommand("snipaste", {
|
|
135
|
+
description: "Select prepended or appended prompt snippets to inject into the next message",
|
|
136
|
+
handler: (_args, ctx) => openMenu(ctx),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
pi.registerShortcut(Key.alt("s"), {
|
|
140
|
+
description: "Open prompt snippets menu",
|
|
141
|
+
handler: (ctx) => openMenu(ctx),
|
|
142
|
+
});
|
|
143
|
+
}
|
package/menu.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { Snippet } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** Edit a working selection; the caller commits it only after confirmation. */
|
|
6
|
+
export function showSnippetMenu(ctx: ExtensionContext, snippets: Snippet[], working: Set<string>): Promise<boolean> {
|
|
7
|
+
return ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
|
|
8
|
+
const prepends = snippets.filter((s) => s.placement === "prepend");
|
|
9
|
+
const appends = snippets.filter((s) => s.placement === "append");
|
|
10
|
+
const items = [...prepends, ...appends];
|
|
11
|
+
|
|
12
|
+
let mode: "list" | "preview" = "list";
|
|
13
|
+
let cursor = 0;
|
|
14
|
+
let listScroll = 0;
|
|
15
|
+
let previewScroll = 0;
|
|
16
|
+
|
|
17
|
+
const itemRow = (snippet: Snippet, idx: number, width: number): string => {
|
|
18
|
+
const pointer = idx === cursor ? theme.fg("accent", "> ") : " ";
|
|
19
|
+
const checkbox = working.has(snippet.id) ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
|
|
20
|
+
const desc = snippet.description ? theme.fg("dim", ` — ${snippet.description}`) : "";
|
|
21
|
+
return truncateToWidth(`${pointer}${checkbox} ${theme.bold(snippet.name)}${desc}`, width);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const buildListRows = (width: number): { text: string; itemIndex: number | null }[] => {
|
|
25
|
+
const rows: { text: string; itemIndex: number | null }[] = [];
|
|
26
|
+
rows.push({ text: theme.fg("dim", "↑ Prepend Prompts"), itemIndex: null });
|
|
27
|
+
prepends.forEach((s, i) => rows.push({ text: itemRow(s, i, width), itemIndex: i }));
|
|
28
|
+
rows.push({ text: "", itemIndex: null });
|
|
29
|
+
rows.push({ text: theme.fg("dim", "↓ Append Prompts"), itemIndex: null });
|
|
30
|
+
appends.forEach((s, i) => rows.push({ text: itemRow(s, prepends.length + i, width), itemIndex: prepends.length + i }));
|
|
31
|
+
return rows;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const buildPreviewRows = (snippet: Snippet, width: number): string[] => {
|
|
35
|
+
const rows: string[] = [];
|
|
36
|
+
rows.push(truncateToWidth(theme.bold(snippet.name), width));
|
|
37
|
+
rows.push(truncateToWidth(theme.fg("dim", `${snippet.placement === "prepend" ? "Prepend" : "Append"} · Order ${snippet.order} · ${snippet.id}`), width));
|
|
38
|
+
rows.push(theme.fg("dim", "─".repeat(Math.min(width, 40))));
|
|
39
|
+
for (const line of snippet.body.split("\n")) {
|
|
40
|
+
for (const wrapped of wrapTextWithAnsi(line, width)) {
|
|
41
|
+
rows.push(truncateToWidth(wrapped, width));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return rows;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const viewport = (
|
|
48
|
+
lines: string[],
|
|
49
|
+
scroll: number,
|
|
50
|
+
maxView: number,
|
|
51
|
+
focusRow?: number,
|
|
52
|
+
): { out: string[]; scroll: number } => {
|
|
53
|
+
const clipped = lines.length > maxView;
|
|
54
|
+
const view = clipped ? Math.max(1, maxView - 2) : maxView;
|
|
55
|
+
|
|
56
|
+
let s = Math.min(Math.max(0, scroll), Math.max(0, lines.length - view));
|
|
57
|
+
if (focusRow !== undefined) {
|
|
58
|
+
if (focusRow < s) s = focusRow;
|
|
59
|
+
else if (focusRow >= s + view) s = focusRow - view + 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const visible = lines.slice(s, s + view);
|
|
63
|
+
if (!clipped) return { out: visible, scroll: s };
|
|
64
|
+
|
|
65
|
+
const above = s;
|
|
66
|
+
const below = lines.length - (s + view);
|
|
67
|
+
return {
|
|
68
|
+
out: [
|
|
69
|
+
above > 0 ? theme.fg("dim", ` ↑ ${above} more`) : "",
|
|
70
|
+
...visible,
|
|
71
|
+
below > 0 ? theme.fg("dim", ` ↓ ${below} more`) : "",
|
|
72
|
+
],
|
|
73
|
+
scroll: s,
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
render(width: number): string[] {
|
|
79
|
+
const maxView = Math.max(5, tui.terminal.rows - 10);
|
|
80
|
+
|
|
81
|
+
let content: string[];
|
|
82
|
+
let title: string;
|
|
83
|
+
let hints: string;
|
|
84
|
+
if (mode === "list") {
|
|
85
|
+
const rows = buildListRows(width);
|
|
86
|
+
const cursorRow = rows.findIndex((r) => r.itemIndex === cursor);
|
|
87
|
+
const v = viewport(rows.map((r) => r.text), listScroll, maxView, cursorRow);
|
|
88
|
+
content = v.out;
|
|
89
|
+
listScroll = v.scroll;
|
|
90
|
+
title = "Prompt Snippets";
|
|
91
|
+
hints = "↑↓ Navigate • Space Select • Tab Preview • Enter Confirm • Esc Cancel";
|
|
92
|
+
} else {
|
|
93
|
+
const snippet = items[cursor];
|
|
94
|
+
const rows = buildPreviewRows(snippet, width);
|
|
95
|
+
const v = viewport(rows, previewScroll, maxView);
|
|
96
|
+
content = v.out;
|
|
97
|
+
previewScroll = v.scroll;
|
|
98
|
+
title = `Preview: ${snippet.name}`;
|
|
99
|
+
hints = "↑↓ Scroll • Tab/Esc Back to list";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const innerW = width - 4;
|
|
103
|
+
const titleVisW = visibleWidth(title);
|
|
104
|
+
const hintsVisW = visibleWidth(hints);
|
|
105
|
+
const titlePad = Math.max(0, innerW - titleVisW);
|
|
106
|
+
const hintsPad = Math.max(0, innerW - hintsVisW);
|
|
107
|
+
|
|
108
|
+
const rawLines = [
|
|
109
|
+
theme.fg("border", `╭─ ${theme.bold(title)} ${"─".repeat(titlePad)}╮`),
|
|
110
|
+
...content.map((l) => {
|
|
111
|
+
const pad = Math.max(0, innerW - visibleWidth(l));
|
|
112
|
+
return theme.fg("border", "│ ") + l + " ".repeat(pad) + theme.fg("border", " │");
|
|
113
|
+
}),
|
|
114
|
+
theme.fg("border", `├${"─".repeat(innerW + 2)}┤`),
|
|
115
|
+
theme.fg("border", "│ ") + theme.fg("dim", hints) + " ".repeat(hintsPad) + theme.fg("border", " │"),
|
|
116
|
+
theme.fg("border", `╰${"─".repeat(innerW + 2)}╯`),
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
return rawLines.map((line) => truncateToWidth(line, width));
|
|
120
|
+
},
|
|
121
|
+
invalidate() {},
|
|
122
|
+
handleInput(data: string) {
|
|
123
|
+
if (mode === "list") {
|
|
124
|
+
if (matchesKey(data, Key.up)) {
|
|
125
|
+
cursor = (cursor - 1 + items.length) % items.length;
|
|
126
|
+
tui.requestRender();
|
|
127
|
+
} else if (matchesKey(data, Key.down)) {
|
|
128
|
+
cursor = (cursor + 1) % items.length;
|
|
129
|
+
tui.requestRender();
|
|
130
|
+
} else if (matchesKey(data, Key.space)) {
|
|
131
|
+
const id = items[cursor].id;
|
|
132
|
+
if (working.has(id)) working.delete(id);
|
|
133
|
+
else working.add(id);
|
|
134
|
+
tui.requestRender();
|
|
135
|
+
} else if (matchesKey(data, Key.tab)) {
|
|
136
|
+
mode = "preview";
|
|
137
|
+
previewScroll = 0;
|
|
138
|
+
tui.requestRender();
|
|
139
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
140
|
+
done(true);
|
|
141
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
142
|
+
done(false);
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
if (matchesKey(data, Key.up)) {
|
|
146
|
+
previewScroll--;
|
|
147
|
+
tui.requestRender();
|
|
148
|
+
} else if (matchesKey(data, Key.down)) {
|
|
149
|
+
previewScroll++;
|
|
150
|
+
tui.requestRender();
|
|
151
|
+
} else if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
|
|
152
|
+
mode = "list";
|
|
153
|
+
tui.requestRender();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rol1n/prompt-snipaste",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "prompt-snipaste extension from pi-kit",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/RolinShmily/pi-kit.git",
|
|
12
|
+
"directory": "prompt-snipaste"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/RolinShmily/pi-kit#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/RolinShmily/pi-kit/issues"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.19.0"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@earendil-works/pi-ai": "*",
|
|
26
|
+
"@earendil-works/pi-agent-core": "*",
|
|
27
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
28
|
+
"@earendil-works/pi-tui": "*",
|
|
29
|
+
"typebox": "*"
|
|
30
|
+
},
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./index.ts"
|
|
34
|
+
],
|
|
35
|
+
"skills": [],
|
|
36
|
+
"prompts": [
|
|
37
|
+
"./prompts"
|
|
38
|
+
],
|
|
39
|
+
"themes": []
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Ask questions
|
|
3
|
+
description: Ask questions to reach a shared understanding.
|
|
4
|
+
placement: append
|
|
5
|
+
order: 10
|
|
6
|
+
---
|
|
7
|
+
Use the `ask_user_question` tool to ask questions until you are 100% sure you know exactly what to do.
|
|
8
|
+
Do not act on this until I confirm we have reached a shared understanding.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Delegate exploration
|
|
3
|
+
description: Keep context lean. Subagents explore, you verify critical parts
|
|
4
|
+
placement: append
|
|
5
|
+
order: 30
|
|
6
|
+
---
|
|
7
|
+
Keep your context window lean. Delegate codebase exploration to subagents — ask them specific, targeted questions instead of reading many files yourself. Read files directly only to verify critical parts.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Diagnose, don't fix
|
|
3
|
+
description: Read-only, report findings and a proposed fix
|
|
4
|
+
placement: append
|
|
5
|
+
order: 40
|
|
6
|
+
---
|
|
7
|
+
Investigate and diagnose the issue. Do not change any code. Report back with your findings and a proposed fix. Do not apply the fix yet.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Frontend tech preset
|
|
3
|
+
description: Reuse a unified React/Next.js frontend stack
|
|
4
|
+
placement: prepend
|
|
5
|
+
order: 15
|
|
6
|
+
---
|
|
7
|
+
For new frontend work, prefer this [React](https://react.dev/) stack; respect existing project constraints. Reuse mature components, keep one design language, avoid overlapping libraries, and add dependencies only when needed and approved.
|
|
8
|
+
|
|
9
|
+
- Full-stack & routing: [Next.js App Router](https://nextjs.org/docs/app); no extra router.
|
|
10
|
+
- UI: [shadcn/ui](https://ui.shadcn.com/create), Nova style; own and customize the component source.
|
|
11
|
+
- Custom styling: [Tailwind CSS](https://tailwindcss.com/docs).
|
|
12
|
+
- Fonts: [Google Fonts](https://fonts.google.com/), loaded via `next/font/google`.
|
|
13
|
+
- Icons: [Lucide React](https://lucide.dev/guide/packages/lucide-react).
|
|
14
|
+
- Animation: [Motion for React](https://motion.dev/docs/react).
|
|
15
|
+
- Resizable layouts: [react-resizable-panels](https://github.com/bvaughn/react-resizable-panels).
|
|
16
|
+
- Drag & drop: [dnd-kit](https://dndkit.com/), React integration.
|
|
17
|
+
- Client state: [Zustand](https://zustand.docs.pmnd.rs/).
|
|
18
|
+
- 3D/WebGPU: [Three.js](https://threejs.org/), `WebGPURenderer`; optional React integration: [React Three Fiber](https://r3f.docs.pmnd.rs/).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Orchestrator mode
|
|
3
|
+
description: Pure high-level orchestrator session
|
|
4
|
+
placement: prepend
|
|
5
|
+
order: 30
|
|
6
|
+
---
|
|
7
|
+
This is a pure high-level orchestrator session. Outsource mechanical work — file exploration, code reading, implementation — to subagents. Keep your own context window lean so your reasoning stays sharp; do not bloat it by reading code yourself.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Session kickoff
|
|
3
|
+
description: Get oriented, report back before starting work
|
|
4
|
+
placement: prepend
|
|
5
|
+
order: 10
|
|
6
|
+
---
|
|
7
|
+
Familiarize yourself with this project before we start. Once you have a clear picture, report back. Do not begin any work until we have aligned on what's next.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Verify, don't assume
|
|
3
|
+
description: Do not assume — verify
|
|
4
|
+
placement: append
|
|
5
|
+
order: 20
|
|
6
|
+
---
|
|
7
|
+
Do not assume — verify. Verify critical facts rather than guessing. And ask me, if you cannot verify something. Only begin once you are 100% sure of what to do. If you catch yourself being even slightly unsure, that's a sign to check first.
|
package/renderers.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { PromptCardData, Snippet } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const WIDGET_ID = "prompt-snipaste";
|
|
6
|
+
|
|
7
|
+
export function buildPromptCardComponent(
|
|
8
|
+
snippets: PromptCardData["snippets"],
|
|
9
|
+
theme: Theme,
|
|
10
|
+
) {
|
|
11
|
+
const borderCol = (s: string) => theme.fg("borderAccent", s);
|
|
12
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
13
|
+
const bold = (s: string) => theme.bold(s);
|
|
14
|
+
|
|
15
|
+
const titleText = ` Prompt Snippets (${snippets.length}) `;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
render(width: number): string[] {
|
|
19
|
+
const effectiveWidth = Math.max(20, Math.min(width, 100));
|
|
20
|
+
const innerW = effectiveWidth - 4;
|
|
21
|
+
|
|
22
|
+
const titleVisW = visibleWidth(titleText);
|
|
23
|
+
const topDashLen = Math.max(1, innerW - titleVisW);
|
|
24
|
+
const topLine = borderCol("╭─") + bold(titleText) + borderCol("─".repeat(topDashLen) + "╮");
|
|
25
|
+
const botLine = borderCol("╰" + "─".repeat(innerW + 2) + "╯");
|
|
26
|
+
|
|
27
|
+
const lines: string[] = [];
|
|
28
|
+
lines.push(topLine);
|
|
29
|
+
|
|
30
|
+
snippets.forEach((item, idx) => {
|
|
31
|
+
if (idx > 0) {
|
|
32
|
+
lines.push(borderCol("├" + "─".repeat(innerW + 2) + "┤"));
|
|
33
|
+
}
|
|
34
|
+
const header = `● ${item.name}`;
|
|
35
|
+
const headerVisW = visibleWidth(header);
|
|
36
|
+
const headerPad = Math.max(0, innerW - headerVisW);
|
|
37
|
+
lines.push(borderCol("│ ") + bold(header) + " ".repeat(headerPad) + borderCol(" │"));
|
|
38
|
+
|
|
39
|
+
const bodyLines = item.body.split(/\r?\n/);
|
|
40
|
+
for (const line of bodyLines) {
|
|
41
|
+
if (!line.trim()) continue;
|
|
42
|
+
for (const wrapped of wrapTextWithAnsi(line, innerW)) {
|
|
43
|
+
const pad = Math.max(0, innerW - visibleWidth(wrapped));
|
|
44
|
+
lines.push(borderCol("│ ") + dim(wrapped) + " ".repeat(pad) + borderCol(" │"));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
lines.push(botLine);
|
|
50
|
+
return lines.map((l) => truncateToWidth(l, width));
|
|
51
|
+
},
|
|
52
|
+
invalidate() {},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function updateSnippetWidget(
|
|
57
|
+
ctx: ExtensionContext,
|
|
58
|
+
getSelection: () => { snippets: Snippet[]; enabled: Set<string> },
|
|
59
|
+
) {
|
|
60
|
+
if (getSelection().enabled.size === 0) {
|
|
61
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
ctx.ui.setWidget(WIDGET_ID, (_tui, theme) => ({
|
|
66
|
+
render(width: number): string[] {
|
|
67
|
+
const { snippets, enabled } = getSelection();
|
|
68
|
+
const active = snippets.filter((s) => enabled.has(s.id));
|
|
69
|
+
if (active.length === 0) return [];
|
|
70
|
+
|
|
71
|
+
const prepends = active.filter((s) => s.placement === "prepend");
|
|
72
|
+
const appends = active.filter((s) => s.placement === "append");
|
|
73
|
+
|
|
74
|
+
const parts: string[] = [];
|
|
75
|
+
if (prepends.length > 0) {
|
|
76
|
+
parts.push(theme.fg("accent", `↑ ${prepends.map((s) => s.name).join(", ")}`));
|
|
77
|
+
}
|
|
78
|
+
if (appends.length > 0) {
|
|
79
|
+
parts.push(theme.fg("warning", `↓ ${appends.map((s) => s.name).join(", ")}`));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const text = `[Prompts: ${parts.join(" · ")}]`;
|
|
83
|
+
return [truncateToWidth(text, width)];
|
|
84
|
+
},
|
|
85
|
+
invalidate() {},
|
|
86
|
+
}));
|
|
87
|
+
}
|
package/snippets.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const BUNDLED_PROMPTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "prompts");
|
|
7
|
+
import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { Snippet } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export function parseSnippet(filename: string, raw: string): Snippet | null {
|
|
11
|
+
const cleanRaw = raw.replace(/^\uFEFF/, "");
|
|
12
|
+
const match = cleanRaw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
13
|
+
|
|
14
|
+
let name = filename.replace(/\.md$/, "");
|
|
15
|
+
let description = "";
|
|
16
|
+
let placement: "prepend" | "append" = "append";
|
|
17
|
+
let order = 100;
|
|
18
|
+
let body = "";
|
|
19
|
+
|
|
20
|
+
if (match) {
|
|
21
|
+
const meta: Record<string, string> = {};
|
|
22
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
23
|
+
const kv = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*)$/);
|
|
24
|
+
if (kv) meta[kv[1].toLowerCase()] = kv[2].trim().replace(/^["']|["']$/g, "");
|
|
25
|
+
}
|
|
26
|
+
if (meta.name) name = meta.name;
|
|
27
|
+
if (meta.description) description = meta.description;
|
|
28
|
+
if (meta.placement === "prepend") placement = "prepend";
|
|
29
|
+
const parsedOrder = Number.parseInt(meta.order ?? "100", 10);
|
|
30
|
+
if (!Number.isNaN(parsedOrder)) order = parsedOrder;
|
|
31
|
+
body = match[2].trim();
|
|
32
|
+
} else {
|
|
33
|
+
body = cleanRaw.trim();
|
|
34
|
+
if (!body) return null;
|
|
35
|
+
const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
36
|
+
description = firstLine.replace(/^#+\s*/, "").slice(0, 80);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!body) return null;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
id: filename,
|
|
43
|
+
name,
|
|
44
|
+
description,
|
|
45
|
+
placement,
|
|
46
|
+
order,
|
|
47
|
+
body,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveSnippetPath(rawPath: string, cwd: string): string {
|
|
52
|
+
const trimmed = rawPath.trim();
|
|
53
|
+
if (trimmed === "~" || trimmed.startsWith("~/") || (process.platform === "win32" && trimmed.startsWith("~\\"))) {
|
|
54
|
+
return resolve(homedir(), trimmed.replace(/^~[/\\]?/, ""));
|
|
55
|
+
}
|
|
56
|
+
if (isAbsolute(trimmed)) {
|
|
57
|
+
return resolve(trimmed);
|
|
58
|
+
}
|
|
59
|
+
const fromCwd = resolve(cwd, trimmed);
|
|
60
|
+
if (existsSync(fromCwd)) return fromCwd;
|
|
61
|
+
const fromAgent = resolve(getAgentDir(), trimmed);
|
|
62
|
+
if (existsSync(fromAgent)) return fromAgent;
|
|
63
|
+
return fromCwd;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getSettingsPrompts(cwd: string, pi?: ExtensionAPI): string[] {
|
|
67
|
+
const results: string[] = [];
|
|
68
|
+
|
|
69
|
+
if (pi) {
|
|
70
|
+
try {
|
|
71
|
+
const settings = pi.getSettings();
|
|
72
|
+
if (Array.isArray(settings?.prompts)) {
|
|
73
|
+
results.push(...settings.prompts);
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// pi.getSettings() may throw before runner binds
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const settingsFiles = [
|
|
82
|
+
join(getAgentDir(), "settings.json"),
|
|
83
|
+
join(cwd, CONFIG_DIR_NAME, "settings.json"),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
for (const sf of settingsFiles) {
|
|
87
|
+
if (!existsSync(sf)) continue;
|
|
88
|
+
try {
|
|
89
|
+
const content = readFileSync(sf, "utf-8");
|
|
90
|
+
const parsed = JSON.parse(content.replace(/^\uFEFF/, ""));
|
|
91
|
+
if (Array.isArray(parsed?.prompts)) {
|
|
92
|
+
for (const p of parsed.prompts) {
|
|
93
|
+
if (typeof p === "string" && !results.includes(p)) {
|
|
94
|
+
results.push(p);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore JSON parse errors
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return results;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function loadSnippets(cwd: string, pi?: ExtensionAPI): Snippet[] {
|
|
107
|
+
const seen = new Map<string, Snippet>();
|
|
108
|
+
const excludedNames = new Set<string>();
|
|
109
|
+
|
|
110
|
+
const loadedPromptPaths = pi?.getCommands()
|
|
111
|
+
.filter((command) => command.source === "prompt")
|
|
112
|
+
.map((command) => command.sourceInfo.path);
|
|
113
|
+
const defaultDirs = loadedPromptPaths ? [] : [
|
|
114
|
+
BUNDLED_PROMPTS_DIR,
|
|
115
|
+
join(getAgentDir(), "prompts"),
|
|
116
|
+
join(cwd, CONFIG_DIR_NAME, "prompts"),
|
|
117
|
+
];
|
|
118
|
+
const settingsPrompts = loadedPromptPaths ?? getSettingsPrompts(cwd, pi);
|
|
119
|
+
|
|
120
|
+
const processDirectory = (dir: string) => {
|
|
121
|
+
if (!existsSync(dir)) return;
|
|
122
|
+
let files: string[];
|
|
123
|
+
try {
|
|
124
|
+
files = readdirSync(dir);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (!(error instanceof Error) || !("code" in error)) throw error;
|
|
127
|
+
console.error(`Failed to read prompt directory ${dir}: ${error.message}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const file of files) {
|
|
131
|
+
if (!file.endsWith(".md")) continue;
|
|
132
|
+
if (excludedNames.has(file)) continue;
|
|
133
|
+
const fullPath = join(dir, file);
|
|
134
|
+
try {
|
|
135
|
+
const snippet = parseSnippet(file, readFileSync(fullPath, "utf-8"));
|
|
136
|
+
if (snippet) seen.set(file, snippet);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (!(error instanceof Error) || !("code" in error)) throw error;
|
|
139
|
+
console.error(`Failed to read prompt ${fullPath}: ${error.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const processFile = (filePath: string) => {
|
|
145
|
+
if (!existsSync(filePath) || !filePath.endsWith(".md")) return;
|
|
146
|
+
const filename = basename(filePath);
|
|
147
|
+
if (excludedNames.has(filename)) return;
|
|
148
|
+
try {
|
|
149
|
+
const snippet = parseSnippet(filename, readFileSync(filePath, "utf-8"));
|
|
150
|
+
if (snippet) seen.set(filename, snippet);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (!(error instanceof Error) || !("code" in error)) throw error;
|
|
153
|
+
console.error(`Failed to read prompt file ${filePath}: ${error.message}`);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Collect exclusions first
|
|
158
|
+
for (const raw of settingsPrompts) {
|
|
159
|
+
if (typeof raw !== "string") continue;
|
|
160
|
+
const trimmed = raw.trim();
|
|
161
|
+
if (trimmed.startsWith("-") || trimmed.startsWith("!")) {
|
|
162
|
+
const clean = trimmed.slice(1).trim();
|
|
163
|
+
excludedNames.add(basename(clean));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 1. Load default prompt directories
|
|
168
|
+
for (const dir of defaultDirs) {
|
|
169
|
+
processDirectory(dir);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 2. Load Pi's resolved prompts, or explicit settings paths without a runtime.
|
|
173
|
+
for (const raw of settingsPrompts) {
|
|
174
|
+
if (typeof raw !== "string") continue;
|
|
175
|
+
let trimmed = raw.trim();
|
|
176
|
+
if (trimmed.startsWith("-") || trimmed.startsWith("!")) continue;
|
|
177
|
+
if (trimmed.startsWith("+")) trimmed = trimmed.slice(1).trim();
|
|
178
|
+
if (!trimmed) continue;
|
|
179
|
+
|
|
180
|
+
const resolved = resolveSnippetPath(trimmed, cwd);
|
|
181
|
+
if (!existsSync(resolved)) continue;
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const stat = statSync(resolved);
|
|
185
|
+
if (stat.isDirectory()) {
|
|
186
|
+
processDirectory(resolved);
|
|
187
|
+
} else if (stat.isFile() && resolved.endsWith(".md")) {
|
|
188
|
+
processFile(resolved);
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (!(error instanceof Error) || !("code" in error)) throw error;
|
|
192
|
+
console.error(`Failed to inspect prompt path ${resolved}: ${error.message}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Remove any excluded items
|
|
197
|
+
for (const ex of excludedNames) {
|
|
198
|
+
seen.delete(ex);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return Array.from(seen.values()).sort((a, b) => {
|
|
202
|
+
if (a.placement !== b.placement) return a.placement === "prepend" ? -1 : 1;
|
|
203
|
+
if (a.order !== b.order) return a.order - b.order;
|
|
204
|
+
return a.name.localeCompare(b.name);
|
|
205
|
+
});
|
|
206
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface Snippet {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
placement: "prepend" | "append";
|
|
6
|
+
order: number;
|
|
7
|
+
body: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PromptCardData {
|
|
11
|
+
snippets: {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
placement: "prepend" | "append";
|
|
15
|
+
order: number;
|
|
16
|
+
body: string;
|
|
17
|
+
}[];
|
|
18
|
+
}
|