@d3ara1n/pi-command-palette 0.6.0 → 0.7.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 CHANGED
@@ -34,17 +34,16 @@ Or add to `~/.pi/agent/settings.json`:
34
34
  |----------|--------|
35
35
  | `Ctrl+Shift+P` _(default, configurable)_ | Open command palette |
36
36
 
37
- The palette opens as a single macOS-launcher-style overlay with nested pages. Selecting a category with **Enter** replaces the current list in the same overlay instead of opening a second overlay. Press **Backspace** with an empty search field to return to the parent page; press **Esc** to close the palette immediately.
37
+ The palette opens as a single macOS-launcher-style overlay with nested pages. The root page mixes leaves and sub-pages: built-in actions and the model selector sit directly on the root, while the remaining categories open as sub-pages — selecting one with **Enter** replaces the current list in the same overlay instead of opening a second overlay. Press **Backspace** with an empty search field to return to the parent page; press **Esc** to close the palette immediately.
38
38
 
39
- The palette lists:
39
+ The root page lists:
40
40
 
41
- - **Built-in Actions** — curated shortcuts for common operations (detailed below)
42
- - **Extension Actions** — entries registered by other extensions that run a callback directly (see below)
43
- - **Commands** — all registered `/command` entries
44
- - **Skills** — installed skill commands
45
- - **Templates** — prompt templates
41
+ - **Built-in actions** — curated shortcuts for common operations, shown directly on the root page so urgent entries like Restore never hide behind a sub-page (detailed below)
42
+ - **Models** — a sub-page listing every model with a configured API key (see below)
43
+ - **Extension Actions** — a sub-page of entries registered by other extensions that run a callback directly (see below)
44
+ - **Commands** / **Skills** / **Templates** sub-pages for all registered `/command` entries, installed skills, and prompt templates; entries are labeled with their bare `/name` since the breadcrumb already names the category
46
45
 
47
- Use **↑/↓** to move through entries and **←/→** to edit the search cursor. Search is fuzzy within the current page and updates as you type; Backspace uses the normal text-editing behavior while the query is non-empty.
46
+ Use **↑/↓** to move through entries and **←/→** to edit the search cursor. Search is fuzzy within the current page and updates as you type; searching the root page also matches entries from every sub-page, with each match's category shown next to its description. Backspace uses the normal text-editing behavior while the query is non-empty.
48
47
 
49
48
  ### Built-in actions
50
49
 
@@ -54,7 +53,6 @@ Built-in actions are grouped by how they run:
54
53
 
55
54
  | Action | What it does |
56
55
  |--------|--------------|
57
- | Model: Switch Model | Open a model selector overlay; switch instantly |
58
56
  | Session: Compact | Compact the conversation right away |
59
57
  | Editor: Copy Content | Copy current editor text to the clipboard |
60
58
  | Editor: Clear Content | Clear the editor, saving the current text to the restore buffer |
@@ -95,7 +93,7 @@ When a command replaces your editor text, or you run **Editor: Clear Content**,
95
93
 
96
94
  ### Model selector
97
95
 
98
- The "Model: Switch Model" entry opens a model page inside the same overlay. Models are loaded when the page is first entered, then can be searched and selected without stacking another overlay.
96
+ The "Models" entry opens a model page inside the same overlay. Models are loaded when the page is first entered, then can be searched and selected without stacking another overlay.
99
97
 
100
98
  **Scoped models float to the top**, marked with a ★ (favorite) prefix. "Scoped" here means the same set pi uses for its built-in selector's scoped tab and `Ctrl+P` cycling — the `enabledModels` patterns in your `settings.json` (project `.pi/settings.json` overrides global `~/.pi/agent/settings.json`). Everything else follows alphabetically. Filtering preserves that boundary too — scoped matches stay above the rest while you type, rather than collapsing into one score-ordered list. If no scope is configured, the list is a plain alphabetical roster — nothing breaks.
101
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-command-palette",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Global command palette for pi — press Ctrl+Shift+P to search and run commands from anywhere",
6
6
  "main": "src/index.ts",
package/src/index.test.ts CHANGED
@@ -1,37 +1,22 @@
1
1
  /**
2
- * Regression tests for model reference parsing, the partitioned fuzzy
3
- * filter that keeps scoped models on top while searching, and the palette
4
- * item ordering that keeps built-ins → native commands → editor-fill entries.
2
+ * Regression tests for the partitioned fuzzy filter that keeps scoped
3
+ * models on top while searching, and the palette item ordering that keeps
4
+ * built-ins → native commands → editor-fill entries.
5
5
  */
6
6
 
7
7
  import assert from "node:assert/strict";
8
8
  import { after, test } from "node:test";
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
  import { paletteCommandRegistry } from "@d3ara1n/pi-command-palette-core";
11
- import { buildPaletteItems, parseModelRef, partitionedFuzzyFilter } from "./index.ts";
11
+ import { buildPaletteItems, partitionedFuzzyFilter } from "./index.ts";
12
12
 
13
13
  /** Minimal fake of the pi API surface buildPaletteItems uses. */
14
- function fakePi(commands: { name: string; description?: string }[]): ExtensionAPI {
14
+ function fakePi(
15
+ commands: { name: string; description?: string; source?: "extension" | "skill" | "template" }[],
16
+ ): ExtensionAPI {
15
17
  return { getCommands: () => commands } as unknown as ExtensionAPI;
16
18
  }
17
19
 
18
- test("parseModelRef splits provider and model at the first slash", () => {
19
- assert.deepEqual(parseModelRef("anthropic/claude-sonnet"), {
20
- provider: "anthropic",
21
- modelId: "claude-sonnet",
22
- });
23
- assert.deepEqual(parseModelRef("openrouter/vendor/model/with/slashes"), {
24
- provider: "openrouter",
25
- modelId: "vendor/model/with/slashes",
26
- });
27
- });
28
-
29
- test("parseModelRef preserves empty provider or model segments", () => {
30
- assert.equal(parseModelRef("model-without-provider"), undefined);
31
- assert.deepEqual(parseModelRef("/model"), { provider: "", modelId: "model" });
32
- assert.deepEqual(parseModelRef("provider/"), { provider: "provider", modelId: "" });
33
- });
34
-
35
20
  // ── partitionedFuzzyFilter ─────────────────────────────────────────
36
21
 
37
22
  test("partitionedFuzzyFilter concatenates partitions unchanged for empty query", () => {
@@ -108,7 +93,7 @@ test("buildPaletteItems orders built-ins above native commands above editor fill
108
93
  });
109
94
 
110
95
  const items = buildPaletteItems(
111
- fakePi([{ name: "some-command", description: "extension command" }]),
96
+ fakePi([{ name: "some-command", description: "extension command", source: "extension" }]),
112
97
  );
113
98
 
114
99
  const ranks = items.map((item) =>
@@ -122,6 +107,14 @@ test("buildPaletteItems orders built-ins above native commands above editor fill
122
107
  assert.ok(native);
123
108
  assert.equal(native.label, "Peek: Ask This Session");
124
109
  assert.equal(native.action.type, "native");
110
+
111
+ // Command/skill/template labels carry no category prefix — the page
112
+ // breadcrumb already names the category, and root search shows it via
113
+ // the description decoration.
114
+ const cmd = items.find((item) => item.value === "cmd:some-command");
115
+ assert.ok(cmd);
116
+ assert.equal(cmd.label, "/some-command");
117
+ assert.equal(cmd.category, "Command");
125
118
  });
126
119
 
127
120
  test("buildPaletteItems picks up native commands registered after load", () => {
package/src/index.ts CHANGED
@@ -5,9 +5,11 @@
5
5
  * regardless of whether the editor has content.
6
6
  *
7
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
8
+ * - Single overlay with nested pages: built-in actions on the root page,
9
+ * extension actions / commands / skills / templates as sub-pages, models
10
+ * loaded on first visit
11
+ * - Fuzzy search within the current page; searching the root page matches
12
+ * every leaf across sub-pages
11
13
  * - Floating overlay on top of existing content
12
14
  * - Saves editor text before overwriting; offers "Restore" in palette
13
15
  * - Clear editor into the restore buffer
@@ -25,6 +27,7 @@ import {
25
27
  matchesKey,
26
28
  SelectList,
27
29
  Text,
30
+ type TUI,
28
31
  } from "@earendil-works/pi-tui";
29
32
  import { resolveShortcutKey } from "./config.ts";
30
33
 
@@ -33,7 +36,6 @@ import { resolveShortcutKey } from "./config.ts";
33
36
  type CommandAction =
34
37
  | { type: "editor"; text: string }
35
38
  | { type: "native"; id: string }
36
- | { type: "model-select" }
37
39
  | { type: "model"; provider: string; modelId: string }
38
40
  | { type: "compact" }
39
41
  | { type: "reload" }
@@ -60,10 +62,9 @@ let savedEditorText: string | null = null;
60
62
  * non-built-in entries always sort after built-ins.
61
63
  */
62
64
  const BUILTIN_ORDER: Record<string, number> = {
63
- __model_select: 0,
64
- __restore: 1,
65
- __copy_editor: 2,
66
- __clear_editor: 3,
65
+ __restore: 0,
66
+ __copy_editor: 1,
67
+ __clear_editor: 2,
67
68
  };
68
69
 
69
70
  // ── Helpers ────────────────────────────────────────────────────────
@@ -100,14 +101,6 @@ export function buildPaletteItems(pi: ExtensionAPI): PaletteItem[] {
100
101
  }
101
102
 
102
103
  // ── Built-in actions ──────────────────────────────────────────
103
- items.push({
104
- value: "__model_select",
105
- label: "Model: Switch Model",
106
- description: "Select a model from the registry",
107
- category: "Built-in",
108
- action: { type: "model-select" },
109
- });
110
-
111
104
  items.push({
112
105
  value: "__new_session",
113
106
  label: "Session: New",
@@ -195,7 +188,7 @@ export function buildPaletteItems(pi: ExtensionAPI): PaletteItem[] {
195
188
 
196
189
  items.push({
197
190
  value: `cmd:${cmd.name}`,
198
- label: `${sourceLabel}: /${cmd.name}`,
191
+ label: `/${cmd.name}`,
199
192
  description: cmd.description ?? "",
200
193
  category: sourceLabel,
201
194
  action: { type: "editor", text: editorText },
@@ -219,22 +212,6 @@ export function buildPaletteItems(pi: ExtensionAPI): PaletteItem[] {
219
212
  return items;
220
213
  }
221
214
 
222
- // ── Model selector ─────────────────────────────────────────────────
223
-
224
- const STAR = "★ ";
225
-
226
- /**
227
- * @internal — exported for testing; parses the selector's `provider/model-id` values.
228
- */
229
- export function parseModelRef(modelRef: string): { provider: string; modelId: string } | undefined {
230
- const slash = modelRef.indexOf("/");
231
- if (slash === -1) return undefined;
232
- return {
233
- provider: modelRef.slice(0, slash),
234
- modelId: modelRef.slice(slash + 1),
235
- };
236
- }
237
-
238
215
  // ── Partitioned fuzzy filter ───────────────────────────────────────
239
216
 
240
217
  /**
@@ -261,17 +238,20 @@ export function partitionedFuzzyFilter<T>(
261
238
 
262
239
  // ── Command palette overlay ────────────────────────────────────────
263
240
 
241
+ interface PageEntry {
242
+ type: "page";
243
+ value: string;
244
+ label: string;
245
+ description: string;
246
+ page: PalettePage;
247
+ }
248
+
264
249
  interface PalettePage {
265
250
  title: string;
266
- items: Array<PaletteItem | { type: "page"; value: string; label: string; description: string; page: PalettePage }>;
251
+ items: Array<PaletteItem | PageEntry>;
267
252
  }
268
253
 
269
- function pageItem(
270
- value: string,
271
- label: string,
272
- description: string,
273
- page: PalettePage,
274
- ): PalettePage["items"][number] {
254
+ function pageItem(value: string, label: string, description: string, page: PalettePage): PageEntry {
275
255
  return { type: "page", value, label, description, page };
276
256
  }
277
257
 
@@ -283,15 +263,20 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
283
263
  paletteItems.filter((item) => item.category === category);
284
264
  const leafPage = (title: string, items: PaletteItem[]): PalettePage => ({ title, items });
285
265
 
286
- const builtins = leaves("Built-in").filter((item) => item.action.type !== "model-select");
266
+ const builtins = leaves("Built-in");
287
267
  const native = leaves("Native");
288
268
  const commands = leaves("Command");
289
269
  const skills = leaves("Skill");
290
270
  const templates = leaves("Template");
271
+ // Leaves reachable only through sub-pages — merged into the root list
272
+ // while searching there. Built-in leaves live on the root page itself.
273
+ const subLeaves = paletteItems.filter((item) => item.category !== "Built-in");
291
274
  const modelPage: PalettePage = { title: "Models", items: [] };
292
275
  const rootItems: PalettePage["items"] = [
293
- pageItem("models", "Model: Switch Model", "Choose a model", modelPage),
294
- ...(builtins.length ? [pageItem("builtins", "Built-in Actions", "Session and editor actions", leafPage("Built-in Actions", builtins))] : []),
276
+ pageItem("models", "Models", "Switch the active model", modelPage),
277
+ // Built-in actions sit directly on the root page so urgent entries like
278
+ // Restore are visible without descending into a sub-page.
279
+ ...builtins,
295
280
  ...(native.length ? [pageItem("native", "Extension Actions", "Actions provided by extensions", leafPage("Extension Actions", native))] : []),
296
281
  ...(commands.length ? [pageItem("commands", "Commands", "Extension slash commands", leafPage("Commands", commands))] : []),
297
282
  ...(skills.length ? [pageItem("skills", "Skills", "Installed skills", leafPage("Skills", skills))] : []),
@@ -299,8 +284,43 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
299
284
  ];
300
285
  const root: PalettePage = { title: "Command Palette", items: rootItems };
301
286
 
287
+ // Captured from the overlay factory so palette actions can request a
288
+ // render after mutating editor state (see the action switch below).
289
+ let tuiRef: TUI | undefined;
290
+
291
+ /** Scoped-model marker prefix (★). */
292
+ const STAR = "★ ";
293
+
294
+ /**
295
+ * Build the model list for the Models page: scoped models (★) first, then
296
+ * the rest — alphabetical within each group. Reads the registry
297
+ * synchronously; throws if enumeration fails.
298
+ */
299
+ function buildModelItems(): PaletteItem[] {
300
+ const scopedIds = new Set(ctx.scopedModels.map((s) => `${s.model.provider}/${s.model.id}`));
301
+ return ctx.modelRegistry
302
+ .getAvailable()
303
+ .map((m): PaletteItem => {
304
+ const scoped = scopedIds.has(`${m.provider}/${m.id}`);
305
+ return {
306
+ value: `${m.provider}/${m.id}`,
307
+ label: scoped ? `${STAR}${m.name}` : m.name,
308
+ description: m.provider,
309
+ category: "Models",
310
+ action: { type: "model", provider: m.provider, modelId: m.id },
311
+ };
312
+ })
313
+ .sort((a, b) => {
314
+ const aScoped = scopedIds.has(a.value);
315
+ const bScoped = scopedIds.has(b.value);
316
+ if (aScoped !== bScoped) return aScoped ? -1 : 1;
317
+ return a.label.localeCompare(b.label);
318
+ });
319
+ }
320
+
302
321
  const result = await ctx.ui.custom<PaletteItem | null>(
303
322
  (tui, theme, _kb, done) => {
323
+ tuiRef = tui;
304
324
  const container = new Container();
305
325
  const listHost = new Container();
306
326
  const queryInput = new Input();
@@ -309,8 +329,7 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
309
329
  { page: root, input: "" },
310
330
  ];
311
331
  let selectList!: SelectList;
312
- let visibleItems: PalettePage["items"] = [];
313
- let modelLoading = false;
332
+ let visibleItems: Array<PaletteItem | PageEntry> = [];
314
333
 
315
334
  const listTheme = {
316
335
  selectedPrefix: (t: string) => theme.fg("accent", t),
@@ -328,12 +347,7 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
328
347
  const { page } = current();
329
348
  const query = queryInput.getValue();
330
349
  visibleItems =
331
- page === root && query.trim()
332
- ? [
333
- ...root.items,
334
- ...paletteItems.filter((item) => item.action.type !== "model-select"),
335
- ]
336
- : page.items;
350
+ page === root && query.trim() ? [...root.items, ...subLeaves] : page.items;
337
351
  const items = visibleItems.map((item) => ({
338
352
  value: item.value,
339
353
  label: item.label,
@@ -364,68 +378,45 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
364
378
  current().selectedValue = selected.value;
365
379
  const item = visibleItems.find((candidate) => candidate.value === selected.value);
366
380
  if (!item) return;
367
- if ("page" in item) {
368
- if (item.value === "models" && !modelLoading && item.page.items.length === 0) {
369
- modelLoading = true;
370
- try {
371
- const models = ctx.modelRegistry.getAvailable();
372
- const scopedIds = new Set(
373
- ctx.scopedModels.map((s) => `${s.model.provider}/${s.model.id}`),
374
- );
375
- const decorated = models
376
- .map((m) => {
377
- const value = `${m.provider}/${m.id}`;
378
- const scoped = scopedIds.has(value);
379
- return {
380
- scoped,
381
- item: {
382
- value,
383
- label: scoped ? `${STAR}${m.name}` : m.name,
384
- description: m.provider,
385
- category: "Built-in",
386
- action: { type: "model", provider: m.provider, modelId: m.id } as CommandAction,
387
- },
388
- };
389
- })
390
- .sort((a, b) =>
391
- a.scoped === b.scoped
392
- ? a.item.label.localeCompare(b.item.label)
393
- : a.scoped
394
- ? -1
395
- : 1,
396
- );
397
- item.page.items = decorated.map((d) => d.item);
398
- modelLoading = false;
399
- if (item.page.items.length === 0) {
400
- ctx.ui.notify("No models available.", "warning");
401
- return;
402
- }
403
- pushPage(item.page);
404
- } catch {
405
- modelLoading = false;
406
- ctx.ui.notify("Cannot enumerate models.", "warning");
407
- }
381
+ if (!("page" in item)) {
382
+ done(item);
383
+ return;
384
+ }
385
+ // Entering the Models page loads the registry once per palette
386
+ // session; later visits reuse the cached list.
387
+ if (item.page === modelPage && item.page.items.length === 0) {
388
+ try {
389
+ item.page.items = buildModelItems();
390
+ } catch {
391
+ ctx.ui.notify("Cannot enumerate models. Use Ctrl+L instead.", "warning");
392
+ return;
393
+ }
394
+ if (item.page.items.length === 0) {
395
+ ctx.ui.notify("No models available.", "warning");
408
396
  return;
409
397
  }
410
- pushPage(item.page);
411
- } else {
412
- done(item);
413
398
  }
399
+ pushPage(item.page);
414
400
  };
415
401
  selectList.onSelectionChange = (selected) => {
416
402
  current().selectedValue = selected.value;
417
403
  };
418
- selectList.onCancel = () => done(null);
419
- if (modelLoading && page.title === "Models") {
420
- listHost.clear();
421
- listHost.addChild(new Text(theme.fg("muted", "Loading models…"), 1, 0));
422
- }
404
+ }
405
+
406
+ // Breadcrumb title: updated in place whenever the page stack changes.
407
+ const titleText = new Text(theme.fg("accent", theme.bold(root.title)), 1, 0);
408
+
409
+ function updateTitle() {
410
+ titleText.setText(
411
+ theme.fg("accent", theme.bold(stack.map((frame) => frame.page.title).join(" › "))),
412
+ );
423
413
  }
424
414
 
425
415
  function pushPage(page: PalettePage) {
426
416
  current().input = queryInput.getValue();
427
417
  stack.push({ page, input: "" });
428
418
  queryInput.setValue("");
419
+ updateTitle();
429
420
  rebuild();
430
421
  tui.requestRender();
431
422
  }
@@ -434,16 +425,19 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
434
425
  if (stack.length <= 1) return;
435
426
  stack.pop();
436
427
  queryInput.setValue(current().input);
428
+ updateTitle();
437
429
  rebuild();
438
430
  tui.requestRender();
439
431
  }
440
432
 
441
433
  rebuild();
442
434
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
443
- container.addChild(new Text(theme.fg("accent", theme.bold(root.title)), 1, 0));
435
+ container.addChild(titleText);
444
436
  container.addChild(queryInput);
445
437
  container.addChild(listHost);
446
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter open/select • backspace on empty returns • esc closes"), 1, 0));
438
+ container.addChild(
439
+ new Text(theme.fg("dim", "↑↓ navigate • enter open/select • backspace go back • esc close"), 1, 0),
440
+ );
447
441
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
448
442
 
449
443
  return {
@@ -455,10 +449,7 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
455
449
  queryInput.focused = value;
456
450
  },
457
451
  render(w: number) {
458
- const lines = container.render(w);
459
- const title = stack.map((frame) => frame.page.title).join(" › ");
460
- lines[1] = theme.fg("accent", theme.bold(title));
461
- return lines;
452
+ return container.render(w);
462
453
  },
463
454
  invalidate() {
464
455
  container.invalidate();
@@ -514,10 +505,6 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
514
505
  }
515
506
  break;
516
507
  }
517
- case "model-select": {
518
- // Kept for compatibility with callers that may construct this action.
519
- break;
520
- }
521
508
  case "restore": {
522
509
  if (savedEditorText !== null) {
523
510
  ctx.ui.setEditorText(savedEditorText);
@@ -584,6 +571,11 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
584
571
  break;
585
572
  }
586
573
  }
574
+
575
+ // The overlay close renders the underlying UI before this promise
576
+ // resolves, and setEditorText mutates state without requesting a render —
577
+ // without this, the editor shows stale text until the next keypress.
578
+ tuiRef?.requestRender();
587
579
  }
588
580
 
589
581
  // ── Extension entry point ──────────────────────────────────────────