@d3ara1n/pi-command-palette 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/package.json +33 -0
- package/src/index.ts +333 -0
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# pi-command-palette
|
|
2
|
+
|
|
3
|
+
Global command palette for [Pi Coding Agent](https://pi.dev) — press **Ctrl+Shift+P** to search and run commands from anywhere.
|
|
4
|
+
|
|
5
|
+
## Why?
|
|
6
|
+
|
|
7
|
+
Pi's slash commands (`/model`, `/compact`, extension commands, etc.) only work when the editor is empty. If you've typed something and want to switch models or run a command, you're stuck. This extension opens a floating command palette via keyboard shortcut, regardless of editor state.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pi install npm:@d3ara1n/pi-command-palette
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or add to `~/.pi/agent/settings.json`:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"extensions": ["/path/to/pi-command-palette"]
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
| Shortcut | Action |
|
|
26
|
+
|----------|--------|
|
|
27
|
+
| `Ctrl+Shift+P` | Open command palette |
|
|
28
|
+
|
|
29
|
+
The palette lists:
|
|
30
|
+
|
|
31
|
+
- **Built-in actions** — Model selector, New session, Compact, Reload, Fork, Tree, Resume
|
|
32
|
+
- **Extension commands** — All registered `/command` entries
|
|
33
|
+
- **Skills & Templates** — Skill commands and prompt templates
|
|
34
|
+
|
|
35
|
+
### Editor text preservation
|
|
36
|
+
|
|
37
|
+
When a command replaces your current editor text, the original content is saved and a **Restore: Previous Editor Text** entry appears at the top of the palette. Select it to get your text back.
|
|
38
|
+
|
|
39
|
+
### Model selector
|
|
40
|
+
|
|
41
|
+
The "Model: Switch Model" action opens a secondary overlay listing all models with configured API keys. Select one to switch instantly — no need to go through `/model` or `Ctrl+P`.
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
No configuration needed. Works out of the box.
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@d3ara1n/pi-command-palette",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Global command palette for pi — press Ctrl+Shift+P to search and run commands from anywhere",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi"
|
|
9
|
+
],
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"@earendil-works/pi-ai": "*",
|
|
12
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
13
|
+
},
|
|
14
|
+
"peerDependenciesMeta": {
|
|
15
|
+
"@earendil-works/pi-ai": {
|
|
16
|
+
"optional": true
|
|
17
|
+
},
|
|
18
|
+
"@earendil-works/pi-coding-agent": {
|
|
19
|
+
"optional": true
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": [
|
|
24
|
+
"./src/index.ts"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/d3ara1n/pi-extensions",
|
|
30
|
+
"directory": "packages/pi-command-palette"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT"
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-command-palette — Global command palette for pi.
|
|
3
|
+
*
|
|
4
|
+
* Press Ctrl+Shift+P to open a searchable command palette overlay,
|
|
5
|
+
* regardless of whether the editor has content.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Lists extension commands, skills, and prompt templates (from pi.getCommands())
|
|
9
|
+
* - Built-in actions: model selector, new session, compact, reload
|
|
10
|
+
* - Fuzzy search via SelectList
|
|
11
|
+
* - Floating overlay on top of existing content
|
|
12
|
+
* - Saves editor text before overwriting; offers "Restore" in palette
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import {
|
|
18
|
+
Container,
|
|
19
|
+
type SelectItem,
|
|
20
|
+
SelectList,
|
|
21
|
+
Text,
|
|
22
|
+
} from "@earendil-works/pi-tui";
|
|
23
|
+
|
|
24
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
type CommandAction =
|
|
27
|
+
| { type: "editor"; text: string }
|
|
28
|
+
| { type: "model-select" }
|
|
29
|
+
| { type: "compact" }
|
|
30
|
+
| { type: "reload" }
|
|
31
|
+
| { type: "restore" };
|
|
32
|
+
|
|
33
|
+
interface PaletteItem {
|
|
34
|
+
value: string;
|
|
35
|
+
label: string;
|
|
36
|
+
description: string;
|
|
37
|
+
category: string;
|
|
38
|
+
action: CommandAction;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Module state ───────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Editor text saved before the palette overwrites it. */
|
|
44
|
+
let savedEditorText: string | null = null;
|
|
45
|
+
|
|
46
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function buildPaletteItems(pi: ExtensionAPI, ctx: ExtensionContext): PaletteItem[] {
|
|
49
|
+
const items: PaletteItem[] = [];
|
|
50
|
+
|
|
51
|
+
// ── Restore option (if previous editor text was saved) ────────
|
|
52
|
+
if (savedEditorText) {
|
|
53
|
+
const preview =
|
|
54
|
+
savedEditorText.length > 40
|
|
55
|
+
? `${savedEditorText.slice(0, 37)}...`
|
|
56
|
+
: savedEditorText;
|
|
57
|
+
items.push({
|
|
58
|
+
value: "__restore",
|
|
59
|
+
label: "Restore: Previous Editor Text",
|
|
60
|
+
description: preview.replace(/\n/g, "⏎"),
|
|
61
|
+
category: "Built-in",
|
|
62
|
+
action: { type: "restore" },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Built-in actions ──────────────────────────────────────────
|
|
67
|
+
items.push({
|
|
68
|
+
value: "__model_select",
|
|
69
|
+
label: "Model: Switch Model",
|
|
70
|
+
description: "Select a model from the registry",
|
|
71
|
+
category: "Built-in",
|
|
72
|
+
action: { type: "model-select" },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
items.push({
|
|
76
|
+
value: "__new_session",
|
|
77
|
+
label: "Session: New",
|
|
78
|
+
description: "Start a new session",
|
|
79
|
+
category: "Built-in",
|
|
80
|
+
action: { type: "editor", text: "/new" },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
items.push({
|
|
84
|
+
value: "__compact",
|
|
85
|
+
label: "Session: Compact",
|
|
86
|
+
description: "Compact conversation to free context",
|
|
87
|
+
category: "Built-in",
|
|
88
|
+
action: { type: "compact" },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
items.push({
|
|
92
|
+
value: "__reload",
|
|
93
|
+
label: "Session: Reload",
|
|
94
|
+
description: "Reload extensions, skills, and config",
|
|
95
|
+
category: "Built-in",
|
|
96
|
+
action: { type: "reload" },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
items.push({
|
|
100
|
+
value: "__fork",
|
|
101
|
+
label: "Session: Fork",
|
|
102
|
+
description: "Fork from selected entry",
|
|
103
|
+
category: "Built-in",
|
|
104
|
+
action: { type: "editor", text: "/fork" },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
items.push({
|
|
108
|
+
value: "__tree",
|
|
109
|
+
label: "Session: Tree",
|
|
110
|
+
description: "Navigate session tree",
|
|
111
|
+
category: "Built-in",
|
|
112
|
+
action: { type: "editor", text: "/tree" },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
items.push({
|
|
116
|
+
value: "__resume",
|
|
117
|
+
label: "Session: Resume",
|
|
118
|
+
description: "Resume a previous session",
|
|
119
|
+
category: "Built-in",
|
|
120
|
+
action: { type: "editor", text: "/resume" },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── Extension commands, skills, templates ────────────────────
|
|
124
|
+
const commands = pi.getCommands();
|
|
125
|
+
for (const cmd of commands) {
|
|
126
|
+
const editorText = `/${cmd.name}`;
|
|
127
|
+
const sourceLabel =
|
|
128
|
+
cmd.source === "extension"
|
|
129
|
+
? "Command"
|
|
130
|
+
: cmd.source === "skill"
|
|
131
|
+
? "Skill"
|
|
132
|
+
: "Template";
|
|
133
|
+
|
|
134
|
+
items.push({
|
|
135
|
+
value: `cmd:${cmd.name}`,
|
|
136
|
+
label: `${sourceLabel}: /${cmd.name}`,
|
|
137
|
+
description: cmd.description ?? "",
|
|
138
|
+
category: sourceLabel,
|
|
139
|
+
action: { type: "editor", text: editorText },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Sort: built-in first, then alphabetically within category
|
|
144
|
+
items.sort((a, b) => {
|
|
145
|
+
if (a.category === "Built-in" && b.category !== "Built-in") return -1;
|
|
146
|
+
if (a.category !== "Built-in" && b.category === "Built-in") return 1;
|
|
147
|
+
return a.label.localeCompare(b.label);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return items;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Model selector ─────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
156
|
+
let models: Awaited<ReturnType<typeof ctx.modelRegistry.getAvailable>>;
|
|
157
|
+
try {
|
|
158
|
+
models = await ctx.modelRegistry.getAvailable();
|
|
159
|
+
} catch {
|
|
160
|
+
ctx.ui.notify("Cannot enumerate models. Use Ctrl+L instead.", "warning");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (models.length === 0) {
|
|
165
|
+
ctx.ui.notify("No models available.", "warning");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const items: SelectItem[] = models.map((m) => ({
|
|
170
|
+
value: `${m.provider}/${m.id}`,
|
|
171
|
+
label: m.name,
|
|
172
|
+
description: m.provider,
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
176
|
+
const container = new Container();
|
|
177
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
178
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Switch Model")), 1, 0));
|
|
179
|
+
|
|
180
|
+
const selectList = new SelectList(items, Math.min(items.length, 12), {
|
|
181
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
182
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
183
|
+
description: (t: string) => theme.fg("muted", t),
|
|
184
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
185
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
selectList.onSelect = (item) => done(item.value);
|
|
189
|
+
selectList.onCancel = () => done(null);
|
|
190
|
+
|
|
191
|
+
container.addChild(selectList);
|
|
192
|
+
container.addChild(
|
|
193
|
+
new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0),
|
|
194
|
+
);
|
|
195
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
render(w: number) {
|
|
199
|
+
return container.render(w);
|
|
200
|
+
},
|
|
201
|
+
invalidate() {
|
|
202
|
+
container.invalidate();
|
|
203
|
+
},
|
|
204
|
+
handleInput(data: string) {
|
|
205
|
+
selectList.handleInput(data);
|
|
206
|
+
tui.requestRender();
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}, { overlay: true });
|
|
210
|
+
|
|
211
|
+
if (!result) return;
|
|
212
|
+
|
|
213
|
+
const [provider, modelId] = result.split("/");
|
|
214
|
+
const model = ctx.modelRegistry.find(provider, modelId);
|
|
215
|
+
if (model) {
|
|
216
|
+
const success = await pi.setModel(model);
|
|
217
|
+
if (success) {
|
|
218
|
+
ctx.ui.notify(`Model: ${provider}/${modelId}`, "info");
|
|
219
|
+
} else {
|
|
220
|
+
ctx.ui.notify(`No API key for ${provider}/${modelId}`, "error");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Command palette overlay ────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
228
|
+
if (ctx.mode !== "tui") return;
|
|
229
|
+
|
|
230
|
+
const paletteItems = buildPaletteItems(pi, ctx);
|
|
231
|
+
const selectItems: SelectItem[] = paletteItems.map((item) => ({
|
|
232
|
+
value: item.value,
|
|
233
|
+
label: item.label,
|
|
234
|
+
description: item.description,
|
|
235
|
+
}));
|
|
236
|
+
|
|
237
|
+
const result = await ctx.ui.custom<PaletteItem | null>(
|
|
238
|
+
(tui, theme, _kb, done) => {
|
|
239
|
+
const container = new Container();
|
|
240
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
241
|
+
container.addChild(
|
|
242
|
+
new Text(theme.fg("accent", theme.bold("Command Palette")), 1, 0),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const selectList = new SelectList(selectItems, Math.min(selectItems.length, 15), {
|
|
246
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
247
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
248
|
+
description: (t: string) => theme.fg("muted", t),
|
|
249
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
250
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
selectList.onSelect = (item) => {
|
|
254
|
+
const paletteItem = paletteItems.find((p) => p.value === item.value);
|
|
255
|
+
done(paletteItem ?? null);
|
|
256
|
+
};
|
|
257
|
+
selectList.onCancel = () => done(null);
|
|
258
|
+
|
|
259
|
+
container.addChild(selectList);
|
|
260
|
+
container.addChild(
|
|
261
|
+
new Text(theme.fg("dim", "type to filter • ↑↓ navigate • enter select • esc cancel"), 1, 0),
|
|
262
|
+
);
|
|
263
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
render(w: number) {
|
|
267
|
+
return container.render(w);
|
|
268
|
+
},
|
|
269
|
+
invalidate() {
|
|
270
|
+
container.invalidate();
|
|
271
|
+
},
|
|
272
|
+
handleInput(data: string) {
|
|
273
|
+
selectList.handleInput(data);
|
|
274
|
+
tui.requestRender();
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
},
|
|
278
|
+
{ overlay: true },
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
if (!result) return;
|
|
282
|
+
|
|
283
|
+
// Execute the selected action
|
|
284
|
+
const action = result.action;
|
|
285
|
+
switch (action.type) {
|
|
286
|
+
case "restore": {
|
|
287
|
+
if (savedEditorText !== null) {
|
|
288
|
+
ctx.ui.setEditorText(savedEditorText);
|
|
289
|
+
savedEditorText = null;
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
case "editor": {
|
|
294
|
+
// Save current editor text before overwriting, so user can restore
|
|
295
|
+
const currentText = ctx.ui.getEditorText();
|
|
296
|
+
if (currentText && currentText.trim()) {
|
|
297
|
+
savedEditorText = currentText;
|
|
298
|
+
}
|
|
299
|
+
ctx.ui.setEditorText(action.text);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
case "model-select": {
|
|
303
|
+
await showModelSelector(pi, ctx);
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
case "compact": {
|
|
307
|
+
ctx.compact({
|
|
308
|
+
onComplete: () => ctx.ui.notify("Compaction completed", "info"),
|
|
309
|
+
onError: (err) => ctx.ui.notify(`Compaction failed: ${err.message}`, "error"),
|
|
310
|
+
});
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case "reload": {
|
|
314
|
+
const currentText = ctx.ui.getEditorText();
|
|
315
|
+
if (currentText && currentText.trim()) {
|
|
316
|
+
savedEditorText = currentText;
|
|
317
|
+
}
|
|
318
|
+
ctx.ui.setEditorText("/reload");
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Extension entry point ──────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
export default function commandPaletteExtension(pi: ExtensionAPI) {
|
|
327
|
+
pi.registerShortcut("ctrl+shift+p", {
|
|
328
|
+
description: "Open command palette",
|
|
329
|
+
handler: async (ctx) => {
|
|
330
|
+
await showCommandPalette(pi, ctx);
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|