@d3ara1n/pi-command-palette 0.5.2 → 0.5.3

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
@@ -1,5 +1,7 @@
1
1
  # pi-command-palette
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@d3ara1n/pi-command-palette)](https://www.npmjs.com/package/@d3ara1n/pi-command-palette) [![npm downloads](https://img.shields.io/npm/dm/@d3ara1n/pi-command-palette)](https://www.npmjs.com/package/@d3ara1n/pi-command-palette) [![license](https://img.shields.io/npm/l/@d3ara1n/pi-command-palette)](https://www.npmjs.com/package/@d3ara1n/pi-command-palette)
4
+
3
5
  Global command palette for [Pi Coding Agent](https://pi.dev) — press **Ctrl+Shift+P** to search and run commands from anywhere.
4
6
 
5
7
  ## Why?
@@ -8,7 +10,7 @@ Pi's slash commands (`/model`, `/compact`, extension commands, etc.) only work w
8
10
 
9
11
  ## Dependencies
10
12
 
11
- None.
13
+ - [`@d3ara1n/pi-command-palette-core`](../pi-command-palette-core) — shared registry for native palette commands (pure npm library, installed automatically)
12
14
 
13
15
  ## Installation
14
16
 
@@ -35,6 +37,7 @@ Or add to `~/.pi/agent/settings.json`:
35
37
  The palette lists:
36
38
 
37
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)
38
41
  - **Extension commands** — All registered `/command` entries
39
42
  - **Skills & Templates** — Skill commands and prompt templates
40
43
 
@@ -64,6 +67,23 @@ Built-in actions are grouped by how they run:
64
67
 
65
68
  > Pi ships with more built-in slash commands (e.g. `/export`, `/share`, `/name`, `/settings`). This palette only surfaces a curated subset above — for the rest, type them directly into the editor.
66
69
 
70
+ ### Native commands from other extensions
71
+
72
+ Extensions built on [`@d3ara1n/pi-command-palette-core`](../pi-command-palette-core) can register palette entries backed by a **direct callback** instead of a `/command` editor fill. They appear above the extension-command entries, and selecting one runs the callback in place — your editor text is never touched, saved, or restored:
73
+
74
+ ```ts
75
+ import { paletteCommandRegistry } from "@d3ara1n/pi-command-palette-core";
76
+
77
+ paletteCommandRegistry.register({
78
+ id: "my-plugin:do-thing",
79
+ label: "My Plugin: Do the Thing",
80
+ description: "Runs immediately, without touching the editor",
81
+ run: (pi, ctx) => { /* ... */ },
82
+ });
83
+ ```
84
+
85
+ The registry is read every time the palette opens, so commands can be registered and unregistered at any time. Failures inside `run` are caught and surfaced as an error notification. See the [core package](../pi-command-palette-core) for the full API.
86
+
67
87
  ### Editor text preservation
68
88
 
69
89
  When a command replaces your editor text, or you run **Editor: Clear Content**, 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.
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-command-palette",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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",
7
+ "dependencies": {
8
+ "@d3ara1n/pi-command-palette-core": "*"
9
+ },
7
10
  "keywords": [
8
11
  "pi-package",
9
12
  "pi",
package/src/index.test.ts CHANGED
@@ -1,11 +1,19 @@
1
1
  /**
2
- * Regression tests for model reference parsing and the partitioned fuzzy
3
- * filter that keeps scoped models on top while searching.
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.
4
5
  */
5
6
 
6
7
  import assert from "node:assert/strict";
7
- import { test } from "node:test";
8
- import { parseModelRef, partitionedFuzzyFilter } from "./index.ts";
8
+ import { after, test } from "node:test";
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { paletteCommandRegistry } from "@d3ara1n/pi-command-palette-core";
11
+ import { buildPaletteItems, parseModelRef, partitionedFuzzyFilter } from "./index.ts";
12
+
13
+ /** Minimal fake of the pi API surface buildPaletteItems uses. */
14
+ function fakePi(commands: { name: string; description?: string }[]): ExtensionAPI {
15
+ return { getCommands: () => commands } as unknown as ExtensionAPI;
16
+ }
9
17
 
10
18
  test("parseModelRef splits provider and model at the first slash", () => {
11
19
  assert.deepEqual(parseModelRef("anthropic/claude-sonnet"), {
@@ -64,7 +72,10 @@ test("partitionedFuzzyFilter drops non-matches independently per partition", ()
64
72
 
65
73
  const result = partitionedFuzzyFilter(primary, secondary, "keep", getText);
66
74
 
67
- assert.deepEqual(result.map((m) => m.label), ["keep-scoped", "keep-other"]);
75
+ assert.deepEqual(
76
+ result.map((m) => m.label),
77
+ ["keep-scoped", "keep-other"],
78
+ );
68
79
  });
69
80
 
70
81
  test("partitionedFuzzyFilter returns only primary matches when secondary has none", () => {
@@ -74,5 +85,50 @@ test("partitionedFuzzyFilter returns only primary matches when secondary has non
74
85
 
75
86
  const result = partitionedFuzzyFilter(primary, secondary, "son", getText);
76
87
 
77
- assert.deepEqual(result.map((m) => m.label), ["sonnet"]);
88
+ assert.deepEqual(
89
+ result.map((m) => m.label),
90
+ ["sonnet"],
91
+ );
92
+ });
93
+
94
+ // ── buildPaletteItems ordering ─────────────────────────────────────
95
+
96
+ const idsBefore = new Set(paletteCommandRegistry.getAll().map((c) => c.id));
97
+ after(() => {
98
+ for (const c of paletteCommandRegistry.getAll()) {
99
+ if (!idsBefore.has(c.id)) paletteCommandRegistry.unregister(c.id);
100
+ }
101
+ });
102
+
103
+ test("buildPaletteItems orders built-ins above native commands above editor fills", () => {
104
+ paletteCommandRegistry.register({
105
+ id: "test:peek",
106
+ label: "Peek: Ask This Session",
107
+ run: () => {},
108
+ });
109
+
110
+ const items = buildPaletteItems(
111
+ fakePi([{ name: "some-command", description: "extension command" }]),
112
+ );
113
+
114
+ const ranks = items.map((item) =>
115
+ item.category === "Built-in" ? 0 : item.action.type === "native" ? 1 : 2,
116
+ );
117
+ // Monotonically non-decreasing → no editor-fill entry sits above a native
118
+ // entry, and no native entry sits above a built-in.
119
+ assert.ok(ranks.every((r, i) => i === 0 || ranks[i - 1] <= r));
120
+
121
+ const native = items.find((item) => item.value === "native:test:peek");
122
+ assert.ok(native);
123
+ assert.equal(native.label, "Peek: Ask This Session");
124
+ assert.equal(native.action.type, "native");
125
+ });
126
+
127
+ test("buildPaletteItems picks up native commands registered after load", () => {
128
+ // The registry is read at palette-open time, so a late registration must
129
+ // show up on the next build without any re-init.
130
+ paletteCommandRegistry.register({ id: "test:late", label: "Registered Late", run: () => {} });
131
+
132
+ const items = buildPaletteItems(fakePi([]));
133
+ assert.ok(items.some((item) => item.value === "native:test:late"));
78
134
  });
package/src/index.ts CHANGED
@@ -14,10 +14,8 @@
14
14
  */
15
15
 
16
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
- import {
18
- copyToClipboard,
19
- DynamicBorder,
20
- } from "@earendil-works/pi-coding-agent";
17
+ import { copyToClipboard, DynamicBorder } from "@earendil-works/pi-coding-agent";
18
+ import { paletteCommandRegistry } from "@d3ara1n/pi-command-palette-core";
21
19
  import {
22
20
  Container,
23
21
  type SelectItem,
@@ -33,6 +31,7 @@ import { resolveShortcutKey } from "./config.ts";
33
31
 
34
32
  type CommandAction =
35
33
  | { type: "editor"; text: string }
34
+ | { type: "native"; id: string }
36
35
  | { type: "model-select" }
37
36
  | { type: "compact" }
38
37
  | { type: "reload" }
@@ -67,7 +66,22 @@ const BUILTIN_ORDER: Record<string, number> = {
67
66
 
68
67
  // ── Helpers ────────────────────────────────────────────────────────
69
68
 
70
- function buildPaletteItems(pi: ExtensionAPI, ctx: ExtensionContext): PaletteItem[] {
69
+ /**
70
+ * Sort ranks: built-in actions first, then native commands registered by other
71
+ * extensions (direct callbacks), then everything that fills the editor with a
72
+ * `/command`. Lower rank = higher up in the palette.
73
+ */
74
+ function paletteSortRank(item: PaletteItem): number {
75
+ if (item.category === "Built-in") return 0;
76
+ if (item.action.type === "native") return 1;
77
+ return 2;
78
+ }
79
+
80
+ /**
81
+ * @internal — exported for testing; builds the palette item list from
82
+ * built-ins, the native-command registry, and pi's command registry.
83
+ */
84
+ export function buildPaletteItems(pi: ExtensionAPI): PaletteItem[] {
71
85
  const items: PaletteItem[] = [];
72
86
 
73
87
  // ── Restore option (if previous editor text was saved) ────────
@@ -156,6 +170,20 @@ function buildPaletteItems(pi: ExtensionAPI, ctx: ExtensionContext): PaletteItem
156
170
  action: { type: "clear-editor" },
157
171
  });
158
172
 
173
+ // ── Native commands from other extensions ────────────────────
174
+ // Direct callbacks registered via @d3ara1n/pi-command-palette-core —
175
+ // executed in place, never touching the editor. Read at palette-open time,
176
+ // so late registrations are visible the next time the palette opens.
177
+ for (const cmd of paletteCommandRegistry.getAll()) {
178
+ items.push({
179
+ value: `native:${cmd.id}`,
180
+ label: cmd.label,
181
+ description: cmd.description ?? "",
182
+ category: "Native",
183
+ action: { type: "native", id: cmd.id },
184
+ });
185
+ }
186
+
159
187
  // ── Extension commands, skills, templates ────────────────────
160
188
  const commands = pi.getCommands();
161
189
  for (const cmd of commands) {
@@ -172,13 +200,13 @@ function buildPaletteItems(pi: ExtensionAPI, ctx: ExtensionContext): PaletteItem
172
200
  });
173
201
  }
174
202
 
175
- // Sort: built-in actions first, ordered by BUILTIN_ORDER (then alphabetical
176
- // for unlisted built-ins); extension commands follow alphabetically.
203
+ // Sort: built-in actions first (ordered by BUILTIN_ORDER, then alphabetical),
204
+ // then native commands, then editor-fill entries each group alphabetical.
177
205
  items.sort((a, b) => {
178
- const aBuilt = a.category === "Built-in";
179
- const bBuilt = b.category === "Built-in";
180
- if (aBuilt !== bBuilt) return aBuilt ? -1 : 1;
181
- if (aBuilt) {
206
+ const ar = paletteSortRank(a);
207
+ const br = paletteSortRank(b);
208
+ if (ar !== br) return ar - br;
209
+ if (ar === 0) {
182
210
  const ai = BUILTIN_ORDER[a.value] ?? Number.MAX_SAFE_INTEGER;
183
211
  const bi = BUILTIN_ORDER[b.value] ?? Number.MAX_SAFE_INTEGER;
184
212
  if (ai !== bi) return ai - bi;
@@ -226,10 +254,7 @@ export function partitionedFuzzyFilter<T>(
226
254
  getText: (item: T) => string,
227
255
  ): T[] {
228
256
  if (!query.trim()) return [...primary, ...secondary];
229
- return [
230
- ...fuzzyFilter(primary, query, getText),
231
- ...fuzzyFilter(secondary, query, getText),
232
- ];
257
+ return [...fuzzyFilter(primary, query, getText), ...fuzzyFilter(secondary, query, getText)];
233
258
  }
234
259
 
235
260
  async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
@@ -374,7 +399,7 @@ async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promi
374
399
  async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
375
400
  if (!ctx.hasUI) return;
376
401
 
377
- const paletteItems = buildPaletteItems(pi, ctx);
402
+ const paletteItems = buildPaletteItems(pi);
378
403
  const selectItems: SelectItem[] = paletteItems.map((item) => ({
379
404
  value: item.value,
380
405
  label: item.label,
@@ -464,6 +489,20 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
464
489
  // Execute the selected action
465
490
  const action = result.action;
466
491
  switch (action.type) {
492
+ case "native": {
493
+ const cmd = paletteCommandRegistry.get(action.id);
494
+ if (!cmd) {
495
+ ctx.ui.notify(`Palette command not found: ${action.id}`, "warning");
496
+ break;
497
+ }
498
+ try {
499
+ await cmd.run(pi, ctx);
500
+ } catch (err) {
501
+ const message = err instanceof Error ? err.message : String(err);
502
+ ctx.ui.notify(`Palette command "${cmd.label}" failed: ${message}`, "error");
503
+ }
504
+ break;
505
+ }
467
506
  case "restore": {
468
507
  if (savedEditorText !== null) {
469
508
  ctx.ui.setEditorText(savedEditorText);