@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.
Files changed (3) hide show
  1. package/README.md +45 -0
  2. package/package.json +33 -0
  3. 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
+ }