pi-prompt-templates 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/LICENSE +21 -0
- package/README.md +135 -0
- package/examples/prompts/coding-guide.md +4 -0
- package/extensions/index.ts +672 -0
- package/package.json +38 -0
- package/src/core.ts +83 -0
- package/test/core.test.ts +32 -0
- package/tsconfig.json +12 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zane Chee
|
|
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,135 @@
|
|
|
1
|
+
# pi-prompt-templates
|
|
2
|
+
|
|
3
|
+
`pi-prompt-templates` adds reusable, editable prompt snippets to [Pi](https://pi.dev).
|
|
4
|
+
|
|
5
|
+
Type `$` while writing a prompt, choose a template, and Pi inserts a reference such as `$coding-guide`. When you send the prompt, the reference expands into the template's full instructions.
|
|
6
|
+
|
|
7
|
+
This is useful for guidance you reuse often: coding standards, review checklists, research methods, writing styles, or project-specific workflows.
|
|
8
|
+
|
|
9
|
+
## What this package does
|
|
10
|
+
|
|
11
|
+
This is a Pi extension. After installation, you can:
|
|
12
|
+
|
|
13
|
+
- type `$` to find prompt templates
|
|
14
|
+
- insert a template without leaving the editor
|
|
15
|
+
- press Tab on a reference to inspect or modify it
|
|
16
|
+
- apply edits once, update the saved template, or save a new copy
|
|
17
|
+
- keep personal templates globally or share project templates with a repository
|
|
18
|
+
|
|
19
|
+
Prompt templates become part of your normal user message. They do not replace Pi's system prompt.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install git:github.com/injaneity/pi-prompt-templates
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Restart Pi after installation.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
Type `$` in Pi:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
You are a coding agent. Use $coding-guide to investigate this repository.
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The completion menu shows saved templates plus **create prompt template** and **delete prompt template** actions.
|
|
38
|
+
|
|
39
|
+
1. Use the arrow keys to choose a template.
|
|
40
|
+
2. Press Tab to insert it.
|
|
41
|
+
3. Continue writing and press Enter normally.
|
|
42
|
+
|
|
43
|
+
Pi expands `$coding-guide` into its full prompt when the message is submitted.
|
|
44
|
+
|
|
45
|
+
Press Backspace or Delete while the cursor is on a template reference to remove the entire reference.
|
|
46
|
+
|
|
47
|
+
## Editing a template
|
|
48
|
+
|
|
49
|
+
Move the cursor onto `$coding-guide` and press Tab. A popup opens with the template's full content.
|
|
50
|
+
|
|
51
|
+
**Shift+Tab** changes what Enter will do:
|
|
52
|
+
|
|
53
|
+
- **modify prompt for this turn only** — use the edit once without changing the file
|
|
54
|
+
- **modify prompt for all future turns** — update the saved template
|
|
55
|
+
- **create new prompt** — save the edit under a new name
|
|
56
|
+
|
|
57
|
+
Popup controls:
|
|
58
|
+
|
|
59
|
+
- **Esc** — cancel
|
|
60
|
+
- **Shift+Enter** — add a newline
|
|
61
|
+
- **Enter** — continue with the selected action
|
|
62
|
+
|
|
63
|
+
## Creating a template
|
|
64
|
+
|
|
65
|
+
Type `$`, move to **create prompt template**, and press Tab.
|
|
66
|
+
|
|
67
|
+
This opens a two-step draft with **create new prompt** as its only action:
|
|
68
|
+
|
|
69
|
+
1. Type the title directly beside `Prompt Template:` in the popup header, then press Enter.
|
|
70
|
+
2. The popup moves focus to the template content; use Shift+Enter for newlines.
|
|
71
|
+
3. Press Enter again to create the Markdown file and insert its new `$template-name` reference back into your original chat prompt.
|
|
72
|
+
|
|
73
|
+
There are no temporary or update modes for a template that does not exist yet.
|
|
74
|
+
|
|
75
|
+
## Deleting a template
|
|
76
|
+
|
|
77
|
+
You can delete templates in either of these ways:
|
|
78
|
+
|
|
79
|
+
- type `$` and choose **delete prompt template**
|
|
80
|
+
- run `/prompt-templates`, highlight a template, and press Delete
|
|
81
|
+
|
|
82
|
+
The delete action reuses the same `$` completion window, so the picker looks and behaves exactly like normal template selection. It shows up to five templates at once and adds an `(x/y)` position indicator when the list scrolls. Choosing a template deletes it immediately. Bundled examples can also be removed; Pi records them as hidden without modifying the installed package.
|
|
83
|
+
|
|
84
|
+
## Where templates are stored
|
|
85
|
+
|
|
86
|
+
| Scope | Folder |
|
|
87
|
+
|---|---|
|
|
88
|
+
| Global, available in every project | `~/.pi/agent/prompt-templates/` |
|
|
89
|
+
| Project, available in a trusted repository | `.pi/prompt-templates/` |
|
|
90
|
+
| Examples bundled with this package | `examples/prompts/` |
|
|
91
|
+
|
|
92
|
+
Project templates override global templates with the same name. Global templates override bundled examples.
|
|
93
|
+
|
|
94
|
+
## Template format
|
|
95
|
+
|
|
96
|
+
Each template is a Markdown file. Its filename becomes the reference name:
|
|
97
|
+
|
|
98
|
+
```markdown
|
|
99
|
+
---
|
|
100
|
+
description: Repository-aware coding guidance
|
|
101
|
+
---
|
|
102
|
+
Inspect the repository before proposing changes. Follow its existing conventions,
|
|
103
|
+
verify your work, and recommend the smallest high-leverage improvements.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Saving this as `coding-guide.md` makes it available as `$coding-guide`.
|
|
107
|
+
|
|
108
|
+
The `description` is optional. Template previews in the completion menu come from the actual prompt content.
|
|
109
|
+
|
|
110
|
+
## Commands
|
|
111
|
+
|
|
112
|
+
- `/prompt-templates` — browse saved templates
|
|
113
|
+
- `/prompt-template-edit [name]` — open a template by name
|
|
114
|
+
- `/prompt-template-new` — open a blank draft
|
|
115
|
+
|
|
116
|
+
Most workflows only need `$` and Tab.
|
|
117
|
+
|
|
118
|
+
## What this package is not
|
|
119
|
+
|
|
120
|
+
This package does not switch modes or modify the system prompt. It inserts reusable instructions into the user message where you reference them.
|
|
121
|
+
|
|
122
|
+
For system-prompt modes, see [pi-modes](https://github.com/MaximeRivest/pi-modes), which inspired the discoverable, in-terminal editing workflow used here.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npm install
|
|
128
|
+
npm run check
|
|
129
|
+
npm test
|
|
130
|
+
pi -e ./extensions/index.ts
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Investigate and improve code using the repository's own conventions
|
|
3
|
+
---
|
|
4
|
+
Inspect the repository before proposing changes. Identify its architecture, conventions, tests, and existing guidance. Then recommend the smallest high-leverage improvements, grounding each recommendation in specific files or behavior. Preserve established patterns unless there is a clear reason to change them, and include verification steps.
|
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONFIG_DIR_NAME,
|
|
3
|
+
CustomEditor,
|
|
4
|
+
DynamicBorder,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
type Theme,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
Container,
|
|
11
|
+
Editor,
|
|
12
|
+
type EditorTheme,
|
|
13
|
+
Input,
|
|
14
|
+
Key,
|
|
15
|
+
matchesKey,
|
|
16
|
+
SelectList,
|
|
17
|
+
type SelectItem,
|
|
18
|
+
Text,
|
|
19
|
+
truncateToWidth,
|
|
20
|
+
visibleWidth,
|
|
21
|
+
} from "@earendil-works/pi-tui";
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import {
|
|
26
|
+
expandBlockReferences,
|
|
27
|
+
isValidBlockName,
|
|
28
|
+
parseBlockFile,
|
|
29
|
+
referenceAtCursor,
|
|
30
|
+
serializeBlock,
|
|
31
|
+
type BlockScope,
|
|
32
|
+
type PromptBlock,
|
|
33
|
+
} from "../src/core.js";
|
|
34
|
+
|
|
35
|
+
const CREATE_TEMPLATE_SENTINEL = "__pi_create_prompt_template__";
|
|
36
|
+
const DELETE_TEMPLATE_SENTINEL = "__pi_delete_prompt_template__";
|
|
37
|
+
const DELETE_SELECTION_PREFIX = "__pi_delete_prompt_template_selection__:";
|
|
38
|
+
|
|
39
|
+
function agentDir(): string {
|
|
40
|
+
return process.env.PI_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function globalPromptDir(): string {
|
|
44
|
+
return join(agentDir(), "prompt-templates");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function bundledPromptDir(): string {
|
|
48
|
+
return join(__dirname, "..", "examples", "prompts");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function projectPromptDir(cwd: string): string {
|
|
52
|
+
return join(cwd, CONFIG_DIR_NAME, "prompt-templates");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function deletedBundledPath(): string {
|
|
56
|
+
return join(globalPromptDir(), ".deleted-bundled.json");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function deletedBundledNames(): Set<string> {
|
|
60
|
+
try {
|
|
61
|
+
const names = JSON.parse(readFileSync(deletedBundledPath(), "utf8"));
|
|
62
|
+
return new Set(Array.isArray(names) ? names.filter((name): name is string => typeof name === "string") : []);
|
|
63
|
+
} catch {
|
|
64
|
+
return new Set();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hideBundledTemplate(name: string): void {
|
|
69
|
+
const names = deletedBundledNames();
|
|
70
|
+
names.add(name);
|
|
71
|
+
mkdirSync(globalPromptDir(), { recursive: true });
|
|
72
|
+
writeFileSync(deletedBundledPath(), JSON.stringify([...names].sort(), null, 2) + "\n", "utf8");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readBlockDir(directory: string, scope: BlockScope): PromptBlock[] {
|
|
76
|
+
if (!existsSync(directory)) return [];
|
|
77
|
+
const blocks: PromptBlock[] = [];
|
|
78
|
+
for (const filename of readdirSync(directory).filter((file) => file.endsWith(".md")).sort()) {
|
|
79
|
+
try {
|
|
80
|
+
const path = join(directory, filename);
|
|
81
|
+
const block = parseBlockFile(readFileSync(path, "utf8"), path, scope);
|
|
82
|
+
if (block) blocks.push(block);
|
|
83
|
+
} catch {
|
|
84
|
+
// One malformed block should not hide the rest.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return blocks;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function loadBlocks(ctx: ExtensionContext): PromptBlock[] {
|
|
91
|
+
const blocks = new Map<string, PromptBlock>();
|
|
92
|
+
const deletedBundled = deletedBundledNames();
|
|
93
|
+
for (const block of readBlockDir(bundledPromptDir(), "bundled")) {
|
|
94
|
+
if (!deletedBundled.has(block.name)) blocks.set(block.name, block);
|
|
95
|
+
}
|
|
96
|
+
for (const block of readBlockDir(globalPromptDir(), "global")) blocks.set(block.name, block);
|
|
97
|
+
if (ctx.isProjectTrusted()) {
|
|
98
|
+
for (const block of readBlockDir(projectPromptDir(ctx.cwd), "project")) blocks.set(block.name, block);
|
|
99
|
+
}
|
|
100
|
+
return [...blocks.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function writablePath(block: PromptBlock): string {
|
|
104
|
+
return block.scope === "bundled" ? join(globalPromptDir(), `${block.name}.md`) : block.path;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function saveBlock(block: PromptBlock, description: string, content: string): PromptBlock {
|
|
108
|
+
const path = writablePath(block);
|
|
109
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
110
|
+
writeFileSync(path, serializeBlock(description, content), "utf8");
|
|
111
|
+
return parseBlockFile(readFileSync(path, "utf8"), path, block.scope === "project" ? "project" : "global")!;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function panelLine(_theme: Theme, text: string, width: number): string {
|
|
115
|
+
const clipped = truncateToWidth(text, width, "");
|
|
116
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Colour references without losing the colour when the editor inserts ANSI cursor sequences inside them. */
|
|
120
|
+
function highlightBlockReferences(line: string, colour: (text: string) => string): string {
|
|
121
|
+
const ansiPattern = /\x1b(?:\[[0-?]*[ -/]*[@-~]|_[^\x07]*\x07)/y;
|
|
122
|
+
let plain = "";
|
|
123
|
+
const rawIndices: number[] = [];
|
|
124
|
+
for (let rawIndex = 0; rawIndex < line.length;) {
|
|
125
|
+
ansiPattern.lastIndex = rawIndex;
|
|
126
|
+
const escape = ansiPattern.exec(line);
|
|
127
|
+
if (escape) {
|
|
128
|
+
rawIndex += escape[0].length;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
rawIndices.push(rawIndex);
|
|
132
|
+
plain += line[rawIndex];
|
|
133
|
+
rawIndex++;
|
|
134
|
+
}
|
|
135
|
+
rawIndices.push(line.length);
|
|
136
|
+
|
|
137
|
+
const colourAcrossEscapes = (text: string): string => {
|
|
138
|
+
const escapePattern = /\x1b(?:\[[0-?]*[ -/]*[@-~]|_[^\x07]*\x07)/g;
|
|
139
|
+
let result = "";
|
|
140
|
+
let cursor = 0;
|
|
141
|
+
for (const escape of text.matchAll(escapePattern)) {
|
|
142
|
+
const index = escape.index ?? cursor;
|
|
143
|
+
if (index > cursor) result += colour(text.slice(cursor, index));
|
|
144
|
+
result += escape[0];
|
|
145
|
+
cursor = index + escape[0].length;
|
|
146
|
+
}
|
|
147
|
+
if (cursor < text.length) result += colour(text.slice(cursor));
|
|
148
|
+
return result;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const matches = [...plain.matchAll(/\$[a-z][a-z0-9_-]*/gi)];
|
|
152
|
+
let highlighted = line;
|
|
153
|
+
for (const match of matches.reverse()) {
|
|
154
|
+
const start = match.index ?? 0;
|
|
155
|
+
const end = start + match[0].length;
|
|
156
|
+
const rawStart = rawIndices[start] ?? 0;
|
|
157
|
+
const rawEnd = rawIndices[end] ?? line.length;
|
|
158
|
+
const reference = highlighted.slice(rawStart, rawEnd);
|
|
159
|
+
highlighted = highlighted.slice(0, rawStart) + colourAcrossEscapes(reference) + highlighted.slice(rawEnd);
|
|
160
|
+
}
|
|
161
|
+
return highlighted;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function displayPath(path: string): string {
|
|
165
|
+
const home = homedir();
|
|
166
|
+
return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function promptPreview(content: string, maxWidth = 120): string {
|
|
170
|
+
return truncateToWidth(content.replace(/\s+/g, " ").trim(), maxWidth, "…");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function framedBorder(theme: Theme, width: number, left: "╭" | "├" | "╰", right: "╮" | "┤" | "╯", label = ""): string {
|
|
174
|
+
const styledLabel = label ? ` ${label} ` : "";
|
|
175
|
+
const fill = Math.max(0, width - 2 - visibleWidth(styledLabel));
|
|
176
|
+
return theme.fg("border", left + "─") + styledLabel + theme.fg("border", "─".repeat(Math.max(0, fill - 1)) + right);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
type PromptEditorResult =
|
|
180
|
+
| { action: "apply"; content: string }
|
|
181
|
+
| { action: "saved"; content: string; template: PromptBlock; savedAsNew: boolean };
|
|
182
|
+
|
|
183
|
+
function slugifyName(value: string): string {
|
|
184
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function saveAsNewTemplate(ctx: ExtensionContext, content: string, suggestedName = ""): Promise<PromptBlock | null> {
|
|
188
|
+
if (!content.trim()) {
|
|
189
|
+
ctx.ui.notify("Cannot save an empty prompt template.", "warning");
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const enteredName = suggestedName || await ctx.ui.input("Save as new prompt template", "template-name");
|
|
193
|
+
if (enteredName === undefined) return null;
|
|
194
|
+
const name = slugifyName(enteredName);
|
|
195
|
+
if (!isValidBlockName(name)) {
|
|
196
|
+
ctx.ui.notify("Names must start with a letter and use lowercase letters, numbers, - or _.", "warning");
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (loadBlocks(ctx).some((template) => template.name === name)) {
|
|
200
|
+
ctx.ui.notify(`Prompt template already exists: ${name}`, "warning");
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const description = await ctx.ui.input("Template description", "What is this prompt for?");
|
|
204
|
+
if (description === undefined) return null;
|
|
205
|
+
const draft: PromptBlock = {
|
|
206
|
+
name,
|
|
207
|
+
description: description.trim() || `Reusable prompt template: ${name}`,
|
|
208
|
+
content,
|
|
209
|
+
path: join(globalPromptDir(), `${name}.md`),
|
|
210
|
+
scope: "global",
|
|
211
|
+
};
|
|
212
|
+
return saveBlock(draft, draft.description, content);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function editPromptTemplate(ctx: ExtensionContext, initial: PromptBlock | null): Promise<PromptEditorResult | null> {
|
|
216
|
+
if (ctx.mode !== "tui") return null;
|
|
217
|
+
return await ctx.ui.custom<PromptEditorResult | null>((tui, theme, _keybindings, done) => {
|
|
218
|
+
const editorTheme: EditorTheme = {
|
|
219
|
+
borderColor: (text) => theme.fg("accent", text),
|
|
220
|
+
selectList: {
|
|
221
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
222
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
223
|
+
description: (text) => theme.fg("muted", text),
|
|
224
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
225
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
const editor = new Editor(tui, editorTheme);
|
|
229
|
+
editor.setText(initial?.content ?? "");
|
|
230
|
+
editor.disableSubmit = true;
|
|
231
|
+
const titleInput = new Input();
|
|
232
|
+
let draftStage: "title" | "content" = initial ? "content" : "title";
|
|
233
|
+
let draftName = "";
|
|
234
|
+
let focused = true;
|
|
235
|
+
let saving = false;
|
|
236
|
+
type SubmitMode = "temporary" | "save" | "create";
|
|
237
|
+
const submitModes: readonly SubmitMode[] = initial ? ["temporary", "save", "create"] : ["create"];
|
|
238
|
+
let submitMode: SubmitMode = initial ? "temporary" : "create";
|
|
239
|
+
|
|
240
|
+
const content = () => editor.getExpandedText().trim();
|
|
241
|
+
const apply = () => done({ action: "apply", content: content() });
|
|
242
|
+
const acceptTitle = () => {
|
|
243
|
+
const name = slugifyName(titleInput.getValue());
|
|
244
|
+
if (!isValidBlockName(name)) {
|
|
245
|
+
ctx.ui.notify("Names must start with a letter and use lowercase letters, numbers, - or _.", "warning");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (loadBlocks(ctx).some((template) => template.name === name)) {
|
|
249
|
+
ctx.ui.notify(`Prompt template already exists: ${name}`, "warning");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
draftName = name;
|
|
253
|
+
draftStage = "content";
|
|
254
|
+
titleInput.focused = false;
|
|
255
|
+
editor.focused = focused;
|
|
256
|
+
tui.requestRender();
|
|
257
|
+
};
|
|
258
|
+
const createDraft = () => {
|
|
259
|
+
const body = content();
|
|
260
|
+
if (!body) {
|
|
261
|
+
ctx.ui.notify("Cannot save an empty prompt template.", "warning");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const draft: PromptBlock = {
|
|
266
|
+
name: draftName,
|
|
267
|
+
description: promptPreview(body, 100) || `Reusable prompt template: ${draftName}`,
|
|
268
|
+
content: body,
|
|
269
|
+
path: join(globalPromptDir(), `${draftName}.md`),
|
|
270
|
+
scope: "global",
|
|
271
|
+
};
|
|
272
|
+
const saved = saveBlock(draft, draft.description, body);
|
|
273
|
+
done({ action: "saved", content: saved.content, template: saved, savedAsNew: true });
|
|
274
|
+
} catch (error) {
|
|
275
|
+
ctx.ui.notify(`Could not save ${draftName}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const saveExisting = () => {
|
|
279
|
+
if (!initial) {
|
|
280
|
+
ctx.ui.notify("This prompt is unsaved. Use Shift+Tab to select Create new.", "warning");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!content()) {
|
|
284
|
+
ctx.ui.notify("Cannot save an empty prompt template.", "warning");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const saved = saveBlock(initial, initial.description, content());
|
|
289
|
+
done({ action: "saved", content: saved.content, template: saved, savedAsNew: false });
|
|
290
|
+
} catch (error) {
|
|
291
|
+
ctx.ui.notify(`Could not save ${initial.name}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const saveAsNew = () => {
|
|
295
|
+
if (saving) return;
|
|
296
|
+
saving = true;
|
|
297
|
+
void saveAsNewTemplate(ctx, content())
|
|
298
|
+
.then((saved) => {
|
|
299
|
+
if (saved) done({ action: "saved", content: saved.content, template: saved, savedAsNew: true });
|
|
300
|
+
})
|
|
301
|
+
.catch((error) => {
|
|
302
|
+
ctx.ui.notify(`Could not save prompt template: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
303
|
+
})
|
|
304
|
+
.finally(() => { saving = false; tui.requestRender(); });
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
get focused() { return focused; },
|
|
309
|
+
set focused(value: boolean) {
|
|
310
|
+
focused = value;
|
|
311
|
+
titleInput.focused = value && draftStage === "title";
|
|
312
|
+
editor.focused = value && draftStage === "content";
|
|
313
|
+
},
|
|
314
|
+
handleInput(data: string) {
|
|
315
|
+
if (saving) return;
|
|
316
|
+
if (matchesKey(data, Key.escape)) return done(null);
|
|
317
|
+
if (!initial && draftStage === "title") {
|
|
318
|
+
if (matchesKey(data, Key.enter)) acceptTitle();
|
|
319
|
+
else titleInput.handleInput(data);
|
|
320
|
+
tui.requestRender();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (matchesKey(data, Key.shift("tab"))) {
|
|
324
|
+
const current = submitModes.indexOf(submitMode);
|
|
325
|
+
submitMode = submitModes[(current + 1) % submitModes.length] ?? "temporary";
|
|
326
|
+
tui.requestRender();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (matchesKey(data, Key.shift("enter"))) {
|
|
330
|
+
editor.handleInput(data);
|
|
331
|
+
tui.requestRender();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (matchesKey(data, Key.enter)) {
|
|
335
|
+
if (!initial) return createDraft();
|
|
336
|
+
if (submitMode === "save") return saveExisting();
|
|
337
|
+
if (submitMode === "create") return saveAsNew();
|
|
338
|
+
return apply();
|
|
339
|
+
}
|
|
340
|
+
editor.handleInput(data);
|
|
341
|
+
tui.requestRender();
|
|
342
|
+
},
|
|
343
|
+
render(width: number) {
|
|
344
|
+
const lines: string[] = [];
|
|
345
|
+
const outerPaddingX = 2;
|
|
346
|
+
const safeWidth = Math.max(1, width - outerPaddingX * 2);
|
|
347
|
+
const row = (value = "") => {
|
|
348
|
+
const innerWidth = Math.max(0, safeWidth - 6);
|
|
349
|
+
const clipped = truncateToWidth(value, innerWidth, "");
|
|
350
|
+
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
|
|
351
|
+
return panelLine(theme, `${theme.fg("border", "│")} ${clipped}${padding} ${theme.fg("border", "│")}`, safeWidth);
|
|
352
|
+
};
|
|
353
|
+
let title: string;
|
|
354
|
+
if (!initial && draftStage === "title") {
|
|
355
|
+
const renderedTitleInput = titleInput.render(Math.max(4, safeWidth - 23))[0] ?? "> ";
|
|
356
|
+
const inlineInput = renderedTitleInput.startsWith("> ") ? renderedTitleInput.slice(2) : renderedTitleInput;
|
|
357
|
+
title = `${theme.fg("border", theme.bold("Prompt Template:"))} ${inlineInput}`;
|
|
358
|
+
} else {
|
|
359
|
+
const titlePath = initial
|
|
360
|
+
? displayPath(writablePath(initial))
|
|
361
|
+
: displayPath(join(globalPromptDir(), `${draftName}.md`));
|
|
362
|
+
const visibleTitlePath = truncateToWidth(titlePath, Math.max(8, safeWidth - 24), "…");
|
|
363
|
+
title = `${theme.fg("border", theme.bold("Prompt Template:"))} ${theme.fg("muted", visibleTitlePath)}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const modeMessage: Record<SubmitMode, string> = {
|
|
367
|
+
temporary: "modify prompt for this turn only",
|
|
368
|
+
save: "modify prompt for all future turns",
|
|
369
|
+
create: "create new prompt",
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
lines.push(panelLine(theme, framedBorder(theme, safeWidth, "╭", "╮", title), safeWidth));
|
|
373
|
+
lines.push(row());
|
|
374
|
+
|
|
375
|
+
if (!initial && draftStage === "title") {
|
|
376
|
+
for (let i = 0; i < 4; i++) lines.push(row());
|
|
377
|
+
} else {
|
|
378
|
+
const renderedEditor = editor.render(Math.max(10, safeWidth - 6));
|
|
379
|
+
const contentLines = renderedEditor.length > 2 ? renderedEditor.slice(1, -1) : renderedEditor;
|
|
380
|
+
for (const line of contentLines) lines.push(row(line));
|
|
381
|
+
for (let i = contentLines.length; i < 4; i++) lines.push(row());
|
|
382
|
+
}
|
|
383
|
+
const modeLine = initial
|
|
384
|
+
? `${theme.fg("mdHeading", theme.bold(modeMessage[submitMode]))} ${theme.fg("dim", "(shift+tab)")}`
|
|
385
|
+
: theme.fg("mdHeading", theme.bold(draftStage === "title" ? "enter a title" : modeMessage.create));
|
|
386
|
+
const innerWidth = Math.max(0, safeWidth - 6);
|
|
387
|
+
const commandText = draftStage === "title"
|
|
388
|
+
? "esc: cancel • enter: continue"
|
|
389
|
+
: "esc: cancel • shift+enter: newline • enter: continue";
|
|
390
|
+
const commandWidth = Math.max(0, innerWidth - visibleWidth(modeLine) - 2);
|
|
391
|
+
const commandLine = theme.fg("muted", truncateToWidth(commandText, commandWidth, "…"));
|
|
392
|
+
const footerGap = " ".repeat(Math.max(1, innerWidth - visibleWidth(commandLine) - visibleWidth(modeLine)));
|
|
393
|
+
lines.push(row());
|
|
394
|
+
lines.push(row(commandLine + footerGap + modeLine));
|
|
395
|
+
lines.push(panelLine(theme, framedBorder(theme, safeWidth, "╰", "╯"), safeWidth));
|
|
396
|
+
|
|
397
|
+
const horizontalPadding = " ".repeat(outerPaddingX);
|
|
398
|
+
const blankLine = " ".repeat(width);
|
|
399
|
+
return [blankLine, ...lines.map((line) => horizontalPadding + line + horizontalPadding), blankLine];
|
|
400
|
+
},
|
|
401
|
+
invalidate() { editor.invalidate(); },
|
|
402
|
+
};
|
|
403
|
+
}, { overlay: true, overlayOptions: { width: "76%", maxHeight: "78%", minWidth: 76, margin: 1 } });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function deletePromptTemplate(ctx: ExtensionContext, template: PromptBlock): boolean {
|
|
407
|
+
try {
|
|
408
|
+
if (template.scope === "bundled") hideBundledTemplate(template.name);
|
|
409
|
+
else unlinkSync(template.path);
|
|
410
|
+
ctx.ui.notify(`Deleted prompt template: ${template.name}`, "info");
|
|
411
|
+
return true;
|
|
412
|
+
} catch (error) {
|
|
413
|
+
ctx.ui.notify(`Could not delete ${template.name}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function selectBlock(ctx: ExtensionContext, title: string): Promise<PromptBlock | null> {
|
|
419
|
+
const blocks = loadBlocks(ctx);
|
|
420
|
+
if (blocks.length === 0 || ctx.mode !== "tui") return null;
|
|
421
|
+
const selected = await ctx.ui.custom<string | null>((tui, theme, _keybindings, done) => {
|
|
422
|
+
const container = new Container();
|
|
423
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
424
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
|
425
|
+
const items: SelectItem[] = blocks.map((block) => ({
|
|
426
|
+
value: block.name,
|
|
427
|
+
label: `$${block.name}`,
|
|
428
|
+
description: promptPreview(block.content),
|
|
429
|
+
}));
|
|
430
|
+
const list = new SelectList(items, Math.min(items.length, 12), {
|
|
431
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
432
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
433
|
+
description: (text) => theme.fg("muted", text),
|
|
434
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
435
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
436
|
+
});
|
|
437
|
+
let deleting = false;
|
|
438
|
+
list.onSelect = (item) => done(item.value);
|
|
439
|
+
list.onCancel = () => done(null);
|
|
440
|
+
container.addChild(list);
|
|
441
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • Enter open • Delete remove • Esc cancel"), 1, 0));
|
|
442
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
443
|
+
return {
|
|
444
|
+
render: (width) => container.render(width),
|
|
445
|
+
invalidate: () => container.invalidate(),
|
|
446
|
+
handleInput: (data) => {
|
|
447
|
+
if (!deleting && matchesKey(data, Key.delete)) {
|
|
448
|
+
const selectedItem = list.getSelectedItem();
|
|
449
|
+
const template = selectedItem && blocks.find((block) => block.name === selectedItem.value);
|
|
450
|
+
if (template) {
|
|
451
|
+
deleting = true;
|
|
452
|
+
if (deletePromptTemplate(ctx, template)) done(null);
|
|
453
|
+
deleting = false;
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (!deleting) list.handleInput(data);
|
|
458
|
+
tui.requestRender();
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
}, { overlay: true, overlayOptions: { width: "70%", maxHeight: "80%", minWidth: 56 } });
|
|
462
|
+
return blocks.find((block) => block.name === selected) ?? null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export default function promptTemplates(pi: ExtensionAPI): void {
|
|
466
|
+
const appliedDrafts = new Map<string, string>();
|
|
467
|
+
let deletePickerActive = false;
|
|
468
|
+
const rememberResult = (template: PromptBlock, result: PromptEditorResult | null) => {
|
|
469
|
+
if (result) appliedDrafts.set(template.name, result.content);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
pi.registerCommand("prompt-templates", {
|
|
473
|
+
description: "Browse and edit reusable prompt templates",
|
|
474
|
+
handler: async (_args, ctx) => {
|
|
475
|
+
const template = await selectBlock(ctx, "Prompt templates");
|
|
476
|
+
if (!template) return;
|
|
477
|
+
const working = { ...template, content: appliedDrafts.get(template.name) ?? template.content };
|
|
478
|
+
rememberResult(template, await editPromptTemplate(ctx, working));
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
pi.registerCommand("prompt-template-edit", {
|
|
483
|
+
description: "Edit a prompt template",
|
|
484
|
+
handler: async (args, ctx) => {
|
|
485
|
+
const name = args.trim().replace(/^\$/, "").toLowerCase();
|
|
486
|
+
const template = name ? loadBlocks(ctx).find((item) => item.name === name) : await selectBlock(ctx, "Edit prompt template");
|
|
487
|
+
if (!template) return ctx.ui.notify(name ? `Unknown prompt template: ${name}` : "No prompt template selected", "warning");
|
|
488
|
+
const working = { ...template, content: appliedDrafts.get(template.name) ?? template.content };
|
|
489
|
+
rememberResult(template, await editPromptTemplate(ctx, working));
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
pi.registerCommand("prompt-template-new", {
|
|
494
|
+
description: "Open a new unsaved prompt template",
|
|
495
|
+
handler: async (_args, ctx) => {
|
|
496
|
+
const result = await editPromptTemplate(ctx, null);
|
|
497
|
+
if (result?.action === "apply") ctx.ui.setEditorText(result.content);
|
|
498
|
+
else if (result?.action === "saved") ctx.ui.setEditorText(`$${result.template.name}`);
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
pi.on("input", (event, ctx) => {
|
|
503
|
+
const templates = loadBlocks(ctx).map((template) => ({
|
|
504
|
+
...template,
|
|
505
|
+
content: appliedDrafts.get(template.name) ?? template.content,
|
|
506
|
+
}));
|
|
507
|
+
const expanded = expandBlockReferences(event.text, templates);
|
|
508
|
+
appliedDrafts.clear();
|
|
509
|
+
if (expanded !== event.text) return { action: "transform" as const, text: expanded, images: event.images };
|
|
510
|
+
return { action: "continue" as const };
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
pi.on("session_start", (_event, ctx) => {
|
|
514
|
+
mkdirSync(globalPromptDir(), { recursive: true });
|
|
515
|
+
ctx.ui.addAutocompleteProvider((current) => ({
|
|
516
|
+
triggerCharacters: ["$"],
|
|
517
|
+
async getSuggestions(lines, line, col, options) {
|
|
518
|
+
const before = (lines[line] ?? "").slice(0, col);
|
|
519
|
+
const match = before.match(/(?:^|[\s(])\$([a-z0-9_-]*)$/i);
|
|
520
|
+
if (!match) return current.getSuggestions(lines, line, col, options);
|
|
521
|
+
const query = (match[1] ?? "").toLowerCase();
|
|
522
|
+
const allTemplates = loadBlocks(ctx);
|
|
523
|
+
const templates = allTemplates.filter((template) => template.name.includes(query));
|
|
524
|
+
if (deletePickerActive) {
|
|
525
|
+
const items = templates.map((template) => ({
|
|
526
|
+
value: `${DELETE_SELECTION_PREFIX}${template.name}`,
|
|
527
|
+
label: `$${template.name}`,
|
|
528
|
+
description: promptPreview(template.content),
|
|
529
|
+
}));
|
|
530
|
+
if (items.length === 0) deletePickerActive = false;
|
|
531
|
+
return items.length ? { prefix: `$${query}`, items } : null;
|
|
532
|
+
}
|
|
533
|
+
const items = templates.map((template) => ({
|
|
534
|
+
value: `$${template.name}`,
|
|
535
|
+
label: `$${template.name}`,
|
|
536
|
+
description: promptPreview(template.content),
|
|
537
|
+
}));
|
|
538
|
+
items.push({
|
|
539
|
+
value: CREATE_TEMPLATE_SENTINEL,
|
|
540
|
+
label: "create prompt template",
|
|
541
|
+
description: `Save in ${displayPath(globalPromptDir())}`,
|
|
542
|
+
});
|
|
543
|
+
if (allTemplates.length > 0) {
|
|
544
|
+
items.push({
|
|
545
|
+
value: DELETE_TEMPLATE_SENTINEL,
|
|
546
|
+
label: "delete prompt template",
|
|
547
|
+
description: "Choose a saved template to remove",
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return { prefix: `$${query}`, items };
|
|
551
|
+
},
|
|
552
|
+
applyCompletion: (lines, line, col, item, prefix) => current.applyCompletion(lines, line, col, item, prefix),
|
|
553
|
+
shouldTriggerFileCompletion: (lines, line, col) => current.shouldTriggerFileCompletion?.(lines, line, col) ?? true,
|
|
554
|
+
}));
|
|
555
|
+
|
|
556
|
+
if (ctx.mode !== "tui") return;
|
|
557
|
+
class BlockAwareEditor extends CustomEditor {
|
|
558
|
+
private opening = false;
|
|
559
|
+
handleInput(data: string): void {
|
|
560
|
+
if (deletePickerActive && this.isShowingAutocomplete() && matchesKey(data, Key.escape)) {
|
|
561
|
+
deletePickerActive = false;
|
|
562
|
+
super.handleInput(data);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (!this.opening && !this.isShowingAutocomplete() && (matchesKey(data, Key.backspace) || matchesKey(data, Key.delete))) {
|
|
566
|
+
const { line, col } = this.getCursor();
|
|
567
|
+
const templates = loadBlocks(ctx);
|
|
568
|
+
const reference = referenceAtCursor(
|
|
569
|
+
this.getLines()[line] ?? "",
|
|
570
|
+
col,
|
|
571
|
+
new Set(templates.map((template) => template.name)),
|
|
572
|
+
);
|
|
573
|
+
if (reference) {
|
|
574
|
+
for (let i = reference.start; i < col; i++) super.handleInput("\x7f");
|
|
575
|
+
for (let i = col; i < reference.end; i++) super.handleInput("\x1b[3~");
|
|
576
|
+
this.tui.requestRender();
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (!this.opening && !this.isShowingAutocomplete() && matchesKey(data, Key.tab)) {
|
|
582
|
+
const { line, col } = this.getCursor();
|
|
583
|
+
const blocks = loadBlocks(ctx);
|
|
584
|
+
const reference = referenceAtCursor(this.getLines()[line] ?? "", col, new Set(blocks.map((block) => block.name)));
|
|
585
|
+
const template = reference && blocks.find((item) => item.name === reference.name);
|
|
586
|
+
if (template) {
|
|
587
|
+
this.opening = true;
|
|
588
|
+
const working = { ...template, content: appliedDrafts.get(template.name) ?? template.content };
|
|
589
|
+
void editPromptTemplate(ctx, working)
|
|
590
|
+
.then((result) => {
|
|
591
|
+
rememberResult(template, result);
|
|
592
|
+
if (result?.action === "saved" && result.savedAsNew) {
|
|
593
|
+
ctx.ui.notify(`Saved new prompt template: ${result.template.name}`, "info");
|
|
594
|
+
}
|
|
595
|
+
})
|
|
596
|
+
.finally(() => { this.opening = false; this.tui.requestRender(); });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
super.handleInput(data);
|
|
601
|
+
|
|
602
|
+
const { line, col } = this.getCursor();
|
|
603
|
+
const currentLine = this.getLines()[line] ?? "";
|
|
604
|
+
if (currentLine.slice(0, col).endsWith(CREATE_TEMPLATE_SENTINEL)) {
|
|
605
|
+
for (let i = 0; i < CREATE_TEMPLATE_SENTINEL.length; i++) super.handleInput("\x7f");
|
|
606
|
+
this.opening = true;
|
|
607
|
+
void editPromptTemplate(ctx, null)
|
|
608
|
+
.then((result) => {
|
|
609
|
+
if (result?.action === "apply") this.insertTextAtCursor(result.content);
|
|
610
|
+
else if (result?.action === "saved") this.insertTextAtCursor(`$${result.template.name}`);
|
|
611
|
+
})
|
|
612
|
+
.finally(() => {
|
|
613
|
+
this.opening = false;
|
|
614
|
+
this.tui.requestRender();
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const beforeCursor = currentLine.slice(0, col);
|
|
619
|
+
const deleteSelectionIndex = beforeCursor.lastIndexOf(DELETE_SELECTION_PREFIX);
|
|
620
|
+
if (deleteSelectionIndex !== -1) {
|
|
621
|
+
const marker = beforeCursor.slice(deleteSelectionIndex);
|
|
622
|
+
const name = marker.slice(DELETE_SELECTION_PREFIX.length);
|
|
623
|
+
for (let i = 0; i < marker.length; i++) super.handleInput("\x7f");
|
|
624
|
+
const template = loadBlocks(ctx).find((item) => item.name === name);
|
|
625
|
+
if (template && deletePromptTemplate(ctx, template)) appliedDrafts.delete(template.name);
|
|
626
|
+
deletePickerActive = false;
|
|
627
|
+
this.tui.requestRender();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (beforeCursor.endsWith(DELETE_TEMPLATE_SENTINEL)) {
|
|
631
|
+
for (let i = 0; i < DELETE_TEMPLATE_SENTINEL.length; i++) super.handleInput("\x7f");
|
|
632
|
+
const deletable = loadBlocks(ctx).length > 0;
|
|
633
|
+
if (!deletable) {
|
|
634
|
+
ctx.ui.notify("No deletable prompt templates found.", "warning");
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
deletePickerActive = true;
|
|
638
|
+
super.handleInput("$");
|
|
639
|
+
this.tui.requestRender();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
render(width: number): string[] {
|
|
644
|
+
return super.render(width).map((line) =>
|
|
645
|
+
highlightBlockReferences(line, (reference) => ctx.ui.theme.fg("accent", reference)),
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
650
|
+
const selectedRow = (text: string, colour: "accent" | "mdHeading" | "error") => {
|
|
651
|
+
const columns = text.match(/^(.*\S)(\s{2,})(\S.*)$/);
|
|
652
|
+
if (!columns) return ctx.ui.theme.fg(colour, text);
|
|
653
|
+
return ctx.ui.theme.fg(colour, columns[1]!) + ctx.ui.theme.fg("muted", columns[2]! + columns[3]!);
|
|
654
|
+
};
|
|
655
|
+
const promptTemplateTheme: EditorTheme = {
|
|
656
|
+
...theme,
|
|
657
|
+
selectList: {
|
|
658
|
+
...theme.selectList,
|
|
659
|
+
description: (text) => ctx.ui.theme.fg("muted", text),
|
|
660
|
+
selectedText: (text) => {
|
|
661
|
+
if (text.includes("create prompt template")) return selectedRow(text, "mdHeading");
|
|
662
|
+
if (text.includes("delete prompt template")) return selectedRow(text, "error");
|
|
663
|
+
return selectedRow(text, "accent");
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
const editor = new BlockAwareEditor(tui, promptTemplateTheme, keybindings);
|
|
668
|
+
editor.setAutocompleteMaxVisible(5);
|
|
669
|
+
return editor;
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-prompt-templates",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Composable, editable prompt templates for pi",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"check": "tsc",
|
|
8
|
+
"test": "node --import tsx --test test/*.test.ts"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-package",
|
|
12
|
+
"prompt-templates"
|
|
13
|
+
],
|
|
14
|
+
"author": "Zane Chee",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/injaneity/pi-prompt-templates.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/injaneity/pi-prompt-templates#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/injaneity/pi-prompt-templates/issues"
|
|
23
|
+
},
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./extensions/index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^26.1.1",
|
|
31
|
+
"tsx": "^4.20.6",
|
|
32
|
+
"typescript": "^7.0.2"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@earendil-works/pi-coding-agent": "^0.80.6",
|
|
36
|
+
"@earendil-works/pi-tui": "^0.80.6"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export type BlockScope = "bundled" | "global" | "project";
|
|
2
|
+
|
|
3
|
+
export interface PromptBlock {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
content: string;
|
|
7
|
+
path: string;
|
|
8
|
+
scope: BlockScope;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BlockReference {
|
|
12
|
+
name: string;
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
raw: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const VALID_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
19
|
+
|
|
20
|
+
export function isValidBlockName(name: string): boolean {
|
|
21
|
+
return VALID_NAME.test(name);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseBlockFile(raw: string, path: string, scope: BlockScope): PromptBlock | null {
|
|
25
|
+
let body = raw.replace(/\r\n?/g, "\n");
|
|
26
|
+
let description = "";
|
|
27
|
+
if (body.startsWith("---\n")) {
|
|
28
|
+
const end = body.indexOf("\n---\n", 4);
|
|
29
|
+
if (end !== -1) {
|
|
30
|
+
const frontmatter = body.slice(4, end);
|
|
31
|
+
const match = frontmatter.match(/^description:\s*(.+)$/m);
|
|
32
|
+
description = match?.[1]?.trim().replace(/^(["'])(.*)\1$/, "$2") ?? "";
|
|
33
|
+
body = body.slice(end + 5);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const filename = path.split(/[\\/]/).pop() ?? "";
|
|
37
|
+
const name = filename.replace(/\.md$/i, "").toLowerCase();
|
|
38
|
+
if (!isValidBlockName(name) || !body.trim()) return null;
|
|
39
|
+
if (!description) description = body.split("\n").find((line) => line.trim())?.trim().slice(0, 100) ?? name;
|
|
40
|
+
return { name, description, content: body.trim(), path, scope };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function serializeBlock(description: string, content: string): string {
|
|
44
|
+
const safeDescription = description.trim().replace(/\n+/g, " ");
|
|
45
|
+
return `---\ndescription: ${safeDescription}\n---\n${content.trim()}\n`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function referencesInLine(line: string): BlockReference[] {
|
|
49
|
+
const references: BlockReference[] = [];
|
|
50
|
+
const pattern = /\$([a-z][a-z0-9_-]*)/gi;
|
|
51
|
+
for (const match of line.matchAll(pattern)) {
|
|
52
|
+
const start = match.index ?? 0;
|
|
53
|
+
if (start > 0 && line[start - 1] === "\\") continue;
|
|
54
|
+
const name = (match[1] ?? "").toLowerCase();
|
|
55
|
+
references.push({ name, start, end: start + match[0].length, raw: match[0] });
|
|
56
|
+
}
|
|
57
|
+
return references;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function referenceAtCursor(line: string, col: number, availableNames: ReadonlySet<string>): BlockReference | undefined {
|
|
61
|
+
return referencesInLine(line).find((reference) =>
|
|
62
|
+
availableNames.has(reference.name) && col >= reference.start && col <= reference.end,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function expandBlockReferences(text: string, blocks: readonly PromptBlock[]): string {
|
|
67
|
+
const byName = new Map(blocks.map((block) => [block.name, block]));
|
|
68
|
+
return text
|
|
69
|
+
.split("\n")
|
|
70
|
+
.map((line) => {
|
|
71
|
+
let output = "";
|
|
72
|
+
let cursor = 0;
|
|
73
|
+
for (const reference of referencesInLine(line)) {
|
|
74
|
+
const block = byName.get(reference.name);
|
|
75
|
+
if (!block) continue;
|
|
76
|
+
output += line.slice(cursor, reference.start);
|
|
77
|
+
output += `<prompt-template name="${block.name}">\n${block.content}\n</prompt-template>`;
|
|
78
|
+
cursor = reference.end;
|
|
79
|
+
}
|
|
80
|
+
return output + line.slice(cursor);
|
|
81
|
+
})
|
|
82
|
+
.join("\n");
|
|
83
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { expandBlockReferences, parseBlockFile, referenceAtCursor, referencesInLine } from "../src/core.js";
|
|
4
|
+
|
|
5
|
+
const block = parseBlockFile("---\ndescription: Coding guide\n---\nRead the repo first.\n", "/tmp/coding-guide.md", "global")!;
|
|
6
|
+
|
|
7
|
+
test("parses markdown prompt templates", () => {
|
|
8
|
+
assert.equal(block.name, "coding-guide");
|
|
9
|
+
assert.equal(block.description, "Coding guide");
|
|
10
|
+
assert.equal(block.content, "Read the repo first.");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("recognizes dollar references", () => {
|
|
14
|
+
assert.deepEqual(referencesInLine("use $coding-guide").map((item) => item.name), ["coding-guide"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("finds a reference under the cursor", () => {
|
|
18
|
+
const line = "use $coding-guide to investigate";
|
|
19
|
+
assert.equal(referenceAtCursor(line, 9, new Set(["coding-guide"]))?.name, "coding-guide");
|
|
20
|
+
assert.equal(referenceAtCursor(line, 0, new Set(["coding-guide"])), undefined);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("expands prompt templates inline into the user chat message", () => {
|
|
24
|
+
assert.equal(
|
|
25
|
+
expandBlockReferences("Use $coding-guide now", [block]),
|
|
26
|
+
"Use <prompt-template name=\"coding-guide\">\nRead the repo first.\n</prompt-template> now",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("leaves unknown and escaped references alone", () => {
|
|
31
|
+
assert.equal(expandBlockReferences("$unknown \\$coding-guide", [block]), "$unknown \\$coding-guide");
|
|
32
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"types": ["node"]
|
|
10
|
+
},
|
|
11
|
+
"include": ["extensions/**/*.ts", "src/**/*.ts", "test/**/*.ts"]
|
|
12
|
+
}
|