@d3ara1n/pi-command-palette 0.5.3 → 0.6.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 +10 -5
  2. package/package.json +1 -1
  3. package/src/index.ts +209 -187
package/README.md CHANGED
@@ -34,12 +34,17 @@ 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.
38
+
37
39
  The palette lists:
38
40
 
39
- - **Built-in actions** — curated shortcuts for common operations (detailed below)
40
- - **Native commands** — entries registered by other extensions that run a callback directly (see below)
41
- - **Extension commands** — All registered `/command` entries
42
- - **Skills & Templates** — Skill commands and prompt templates
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
46
+
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.
43
48
 
44
49
  ### Built-in actions
45
50
 
@@ -90,7 +95,7 @@ When a command replaces your editor text, or you run **Editor: Clear Content**,
90
95
 
91
96
  ### Model selector
92
97
 
93
- 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`.
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.
94
99
 
95
100
  **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.
96
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-command-palette",
3
- "version": "0.5.3",
3
+ "version": "0.6.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.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  Container,
21
21
  type SelectItem,
22
22
  fuzzyFilter,
23
+ Input,
23
24
  Key,
24
25
  matchesKey,
25
26
  SelectList,
@@ -33,6 +34,7 @@ type CommandAction =
33
34
  | { type: "editor"; text: string }
34
35
  | { type: "native"; id: string }
35
36
  | { type: "model-select" }
37
+ | { type: "model"; provider: string; modelId: string }
36
38
  | { type: "compact" }
37
39
  | { type: "reload" }
38
40
  | { type: "restore" }
@@ -257,231 +259,240 @@ export function partitionedFuzzyFilter<T>(
257
259
  return [...fuzzyFilter(primary, query, getText), ...fuzzyFilter(secondary, query, getText)];
258
260
  }
259
261
 
260
- async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
261
- let models: Awaited<ReturnType<typeof ctx.modelRegistry.getAvailable>>;
262
- try {
263
- models = await ctx.modelRegistry.getAvailable();
264
- } catch {
265
- ctx.ui.notify("Cannot enumerate models. Use Ctrl+L instead.", "warning");
266
- return;
267
- }
268
-
269
- if (models.length === 0) {
270
- ctx.ui.notify("No models available.", "warning");
271
- return;
272
- }
273
-
274
- const scopedIds = new Set(ctx.scopedModels.map((s) => `${s.model.provider}/${s.model.id}`));
275
-
276
- // Build each model's SelectItem alongside whether it's scope-pinned, then
277
- // sort into two stable groups — scoped first (★), then the rest —
278
- // alphabetical within each. We keep the groups as separate arrays so the
279
- // search pipeline below can re-apply the same partitioning.
280
- const decorated = models
281
- .map((m) => {
282
- const value = `${m.provider}/${m.id}`;
283
- const scoped = scopedIds.has(value);
284
- return {
285
- scoped,
286
- item: {
287
- value,
288
- label: scoped ? `${STAR}${m.name}` : m.name,
289
- description: m.provider,
290
- } satisfies SelectItem,
291
- };
292
- })
293
- .sort((a, b) => {
294
- if (a.scoped !== b.scoped) return a.scoped ? -1 : 1;
295
- return a.item.label.localeCompare(b.item.label);
296
- });
297
-
298
- // Scoped models (★) stay above the rest whether browsing or filtering: each
299
- // group is filtered and ranked independently, then concatenated, so a search
300
- // never merges the two into one score-ordered list.
301
- const scopedItems: SelectItem[] = decorated.filter((d) => d.scoped).map((d) => d.item);
302
- const otherItems: SelectItem[] = decorated.filter((d) => !d.scoped).map((d) => d.item);
303
- const items: SelectItem[] = [...scopedItems, ...otherItems];
304
-
305
- const result = await ctx.ui.custom<string | null>(
306
- (tui, theme, _kb, done) => {
307
- const container = new Container();
308
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
309
- container.addChild(new Text(theme.fg("accent", theme.bold("Switch Model")), 1, 0));
310
-
311
- const selectList = new SelectList(items, Math.min(items.length, 12), {
312
- selectedPrefix: (t: string) => theme.fg("accent", t),
313
- selectedText: (t: string) => theme.fg("accent", t),
314
- description: (t: string) => theme.fg("muted", t),
315
- scrollInfo: (t: string) => theme.fg("dim", t),
316
- noMatch: (t: string) => theme.fg("warning", t),
317
- });
318
-
319
- selectList.onSelect = (item) => done(item.value);
320
- selectList.onCancel = () => done(null);
321
-
322
- // Type-to-filter state
323
- let query = "";
324
- const queryText = new Text(theme.fg("accent", "> "), 1, 0);
325
-
326
- function applyQuery() {
327
- const filtered = partitionedFuzzyFilter(
328
- scopedItems,
329
- otherItems,
330
- query,
331
- (item: SelectItem) => `${item.label} ${item.description ?? ""}`,
332
- );
333
- // FRAGILE: SelectList has no public filter/setItems API, so we poke its
334
- // private filteredItems directly. If pi-tui renames it, filtering breaks
335
- // silently with no compile error.
336
- (selectList as any).filteredItems = filtered;
337
- selectList.setSelectedIndex(0);
338
- queryText.setText(theme.fg("accent", `> ${query}▎`));
339
- container.invalidate();
340
- tui.requestRender();
341
- }
342
-
343
- container.addChild(queryText);
344
- container.addChild(selectList);
345
- container.addChild(
346
- new Text(theme.fg("dim", "type to filter • ↑↓ navigate • enter select • esc cancel"), 1, 0),
347
- );
348
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
349
-
350
- return {
351
- render(w: number) {
352
- return container.render(w);
353
- },
354
- invalidate() {
355
- container.invalidate();
356
- },
357
- handleInput(data: string) {
358
- // Backspace → trim query
359
- if (matchesKey(data, Key.backspace)) {
360
- if (query.length > 0) {
361
- query = query.slice(0, -1);
362
- applyQuery();
363
- }
364
- return;
365
- }
366
- // Printable character → append to query
367
- if (data.length === 1 && data.charCodeAt(0) >= 32) {
368
- query += data;
369
- applyQuery();
370
- return;
371
- }
372
- // Navigation / confirm / cancel → pass to SelectList
373
- selectList.handleInput(data);
374
- tui.requestRender();
375
- },
376
- };
377
- },
378
- { overlay: true },
379
- );
380
-
381
- if (!result) return;
262
+ // ── Command palette overlay ────────────────────────────────────────
382
263
 
383
- const parsed = parseModelRef(result);
384
- if (!parsed) return;
385
- const { provider, modelId } = parsed;
386
- const model = ctx.modelRegistry.find(provider, modelId);
387
- if (model) {
388
- const success = await pi.setModel(model);
389
- if (success) {
390
- ctx.ui.notify(`Model: ${provider}/${modelId}`, "info");
391
- } else {
392
- ctx.ui.notify(`No API key for ${provider}/${modelId}`, "error");
393
- }
394
- }
264
+ interface PalettePage {
265
+ title: string;
266
+ items: Array<PaletteItem | { type: "page"; value: string; label: string; description: string; page: PalettePage }>;
395
267
  }
396
268
 
397
- // ── Command palette overlay ────────────────────────────────────────
269
+ function pageItem(
270
+ value: string,
271
+ label: string,
272
+ description: string,
273
+ page: PalettePage,
274
+ ): PalettePage["items"][number] {
275
+ return { type: "page", value, label, description, page };
276
+ }
398
277
 
399
278
  async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
400
- if (!ctx.hasUI) return;
279
+ if (ctx.mode !== "tui") return;
401
280
 
402
281
  const paletteItems = buildPaletteItems(pi);
403
- const selectItems: SelectItem[] = paletteItems.map((item) => ({
404
- value: item.value,
405
- label: item.label,
406
- description: item.description,
407
- }));
282
+ const leaves = (category: string) =>
283
+ paletteItems.filter((item) => item.category === category);
284
+ const leafPage = (title: string, items: PaletteItem[]): PalettePage => ({ title, items });
285
+
286
+ const builtins = leaves("Built-in").filter((item) => item.action.type !== "model-select");
287
+ const native = leaves("Native");
288
+ const commands = leaves("Command");
289
+ const skills = leaves("Skill");
290
+ const templates = leaves("Template");
291
+ const modelPage: PalettePage = { title: "Models", items: [] };
292
+ 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))] : []),
295
+ ...(native.length ? [pageItem("native", "Extension Actions", "Actions provided by extensions", leafPage("Extension Actions", native))] : []),
296
+ ...(commands.length ? [pageItem("commands", "Commands", "Extension slash commands", leafPage("Commands", commands))] : []),
297
+ ...(skills.length ? [pageItem("skills", "Skills", "Installed skills", leafPage("Skills", skills))] : []),
298
+ ...(templates.length ? [pageItem("templates", "Templates", "Prompt templates", leafPage("Templates", templates))] : []),
299
+ ];
300
+ const root: PalettePage = { title: "Command Palette", items: rootItems };
408
301
 
409
302
  const result = await ctx.ui.custom<PaletteItem | null>(
410
303
  (tui, theme, _kb, done) => {
411
304
  const container = new Container();
412
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
413
- container.addChild(new Text(theme.fg("accent", theme.bold("Command Palette")), 1, 0));
414
-
415
- const selectList = new SelectList(selectItems, Math.min(selectItems.length, 15), {
305
+ const listHost = new Container();
306
+ const queryInput = new Input();
307
+ let focused = true;
308
+ const stack: Array<{ page: PalettePage; input: string; selectedValue?: string }> = [
309
+ { page: root, input: "" },
310
+ ];
311
+ let selectList!: SelectList;
312
+ let visibleItems: PalettePage["items"] = [];
313
+ let modelLoading = false;
314
+
315
+ const listTheme = {
416
316
  selectedPrefix: (t: string) => theme.fg("accent", t),
417
317
  selectedText: (t: string) => theme.fg("accent", t),
418
318
  description: (t: string) => theme.fg("muted", t),
419
319
  scrollInfo: (t: string) => theme.fg("dim", t),
420
320
  noMatch: (t: string) => theme.fg("warning", t),
421
- });
422
-
423
- selectList.onSelect = (item) => {
424
- const paletteItem = paletteItems.find((p) => p.value === item.value);
425
- done(paletteItem ?? null);
426
321
  };
427
- selectList.onCancel = () => done(null);
428
322
 
429
- // Type-to-filter state
430
- let query = "";
431
- const queryText = new Text(theme.fg("accent", "> "), 1, 0);
323
+ function current() {
324
+ return stack[stack.length - 1]!;
325
+ }
432
326
 
433
- function applyQuery() {
327
+ function rebuild() {
328
+ const { page } = current();
329
+ const query = queryInput.getValue();
330
+ visibleItems =
331
+ page === root && query.trim()
332
+ ? [
333
+ ...root.items,
334
+ ...paletteItems.filter((item) => item.action.type !== "model-select"),
335
+ ]
336
+ : page.items;
337
+ const items = visibleItems.map((item) => ({
338
+ value: item.value,
339
+ label: item.label,
340
+ description:
341
+ page === root && query.trim() && !("page" in item)
342
+ ? `${item.category} › ${item.description}`
343
+ : item.description,
344
+ }));
345
+ const getText = (item: SelectItem) => `${item.label} ${item.description ?? ""}`;
434
346
  const filtered = query
435
- ? fuzzyFilter(
436
- selectItems,
437
- query,
438
- (item: SelectItem) => `${item.label} ${item.description ?? ""}`,
439
- )
440
- : selectItems;
441
- // FRAGILE: see model selector — depends on SelectList.filteredItems.
442
- (selectList as any).filteredItems = filtered;
443
- selectList.setSelectedIndex(0);
444
- queryText.setText(theme.fg("accent", `> ${query}▎`));
445
- container.invalidate();
347
+ ? page === modelPage
348
+ ? partitionedFuzzyFilter(
349
+ items.filter((item) => item.label.startsWith(STAR)),
350
+ items.filter((item) => !item.label.startsWith(STAR)),
351
+ query,
352
+ getText,
353
+ )
354
+ : fuzzyFilter(items, query, getText)
355
+ : items;
356
+ selectList = new SelectList(filtered, Math.min(Math.max(filtered.length, 1), 15), listTheme);
357
+ const restoredIndex = filtered.findIndex(
358
+ (item) => item.value === current().selectedValue,
359
+ );
360
+ if (restoredIndex >= 0) selectList.setSelectedIndex(restoredIndex);
361
+ listHost.clear();
362
+ listHost.addChild(selectList);
363
+ selectList.onSelect = (selected) => {
364
+ current().selectedValue = selected.value;
365
+ const item = visibleItems.find((candidate) => candidate.value === selected.value);
366
+ 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
+ }
408
+ return;
409
+ }
410
+ pushPage(item.page);
411
+ } else {
412
+ done(item);
413
+ }
414
+ };
415
+ selectList.onSelectionChange = (selected) => {
416
+ current().selectedValue = selected.value;
417
+ };
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
+ }
423
+ }
424
+
425
+ function pushPage(page: PalettePage) {
426
+ current().input = queryInput.getValue();
427
+ stack.push({ page, input: "" });
428
+ queryInput.setValue("");
429
+ rebuild();
430
+ tui.requestRender();
431
+ }
432
+
433
+ function popPage() {
434
+ if (stack.length <= 1) return;
435
+ stack.pop();
436
+ queryInput.setValue(current().input);
437
+ rebuild();
446
438
  tui.requestRender();
447
439
  }
448
440
 
449
- container.addChild(queryText);
450
- container.addChild(selectList);
451
- container.addChild(
452
- new Text(theme.fg("dim", "type to filter • ↑↓ navigate • enter select • esc cancel"), 1, 0),
453
- );
441
+ rebuild();
442
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
443
+ container.addChild(new Text(theme.fg("accent", theme.bold(root.title)), 1, 0));
444
+ container.addChild(queryInput);
445
+ container.addChild(listHost);
446
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter open/select • backspace on empty returns • esc closes"), 1, 0));
454
447
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
455
448
 
456
449
  return {
450
+ get focused() {
451
+ return focused;
452
+ },
453
+ set focused(value: boolean) {
454
+ focused = value;
455
+ queryInput.focused = value;
456
+ },
457
457
  render(w: number) {
458
- return container.render(w);
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;
459
462
  },
460
463
  invalidate() {
461
464
  container.invalidate();
465
+ queryInput.invalidate();
466
+ selectList.invalidate();
462
467
  },
463
468
  handleInput(data: string) {
464
- // Backspace trim query
465
- if (matchesKey(data, Key.backspace)) {
466
- if (query.length > 0) {
467
- query = query.slice(0, -1);
468
- applyQuery();
469
- }
469
+ if (matchesKey(data, Key.escape)) {
470
+ done(null);
470
471
  return;
471
472
  }
472
- // Printable character append to query
473
- if (data.length === 1 && data.charCodeAt(0) >= 32) {
474
- query += data;
475
- applyQuery();
473
+ if (matchesKey(data, Key.backspace) && queryInput.getValue().length === 0) {
474
+ popPage();
476
475
  return;
477
476
  }
478
- // Navigation / confirm / cancel → pass to SelectList
479
- selectList.handleInput(data);
477
+ if (
478
+ matchesKey(data, Key.up) ||
479
+ matchesKey(data, Key.down) ||
480
+ matchesKey(data, Key.enter)
481
+ ) {
482
+ selectList.handleInput(data);
483
+ } else {
484
+ const before = queryInput.getValue();
485
+ queryInput.handleInput(data);
486
+ if (queryInput.getValue() !== before) {
487
+ current().selectedValue = undefined;
488
+ rebuild();
489
+ }
490
+ }
480
491
  tui.requestRender();
481
492
  },
482
493
  };
483
494
  },
484
- { overlay: true },
495
+ { overlay: true, overlayOptions: { width: "70%", maxHeight: "80%", minWidth: 50 } },
485
496
  );
486
497
 
487
498
  if (!result) return;
@@ -503,6 +514,10 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
503
514
  }
504
515
  break;
505
516
  }
517
+ case "model-select": {
518
+ // Kept for compatibility with callers that may construct this action.
519
+ break;
520
+ }
506
521
  case "restore": {
507
522
  if (savedEditorText !== null) {
508
523
  ctx.ui.setEditorText(savedEditorText);
@@ -519,8 +534,15 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
519
534
  ctx.ui.setEditorText(action.text);
520
535
  break;
521
536
  }
522
- case "model-select": {
523
- await showModelSelector(pi, ctx);
537
+ case "model": {
538
+ const model = ctx.modelRegistry.find(action.provider, action.modelId);
539
+ if (model) {
540
+ const success = await pi.setModel(model);
541
+ ctx.ui.notify(
542
+ success ? `Model: ${action.provider}/${action.modelId}` : `No API key for ${action.provider}/${action.modelId}`,
543
+ success ? "info" : "error",
544
+ );
545
+ }
524
546
  break;
525
547
  }
526
548
  case "compact": {