@d3ara1n/pi-command-palette 0.4.2 → 0.5.1

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,19 +34,45 @@ Or add to `~/.pi/agent/settings.json`:
34
34
 
35
35
  The palette lists:
36
36
 
37
- - **Built-in actions** — Model selector, New session, Compact, Reload, Fork, Tree, Resume
37
+ - **Built-in actions** — curated shortcuts for common operations (detailed below)
38
38
  - **Extension commands** — All registered `/command` entries
39
39
  - **Skills & Templates** — Skill commands and prompt templates
40
40
 
41
+ ### Built-in actions
42
+
43
+ Built-in actions are grouped by how they run:
44
+
45
+ **Run immediately** — they call pi's API directly, no editor round-trip:
46
+
47
+ | Action | What it does |
48
+ |--------|--------------|
49
+ | Model: Switch Model | Open a model selector overlay; switch instantly |
50
+ | Session: Compact | Compact the conversation right away |
51
+ | Editor: Copy Content | Copy current editor text to the clipboard |
52
+ | Editor: Clear Content | Clear the editor, saving the current text to the restore buffer |
53
+ | Restore: Previous Editor Text | Bring back text saved before the last command _(appears only when available)_ |
54
+
55
+ **Fill the editor** — they insert the matching `/command` for you to submit, just like any extension command:
56
+
57
+ | Action | Inserts |
58
+ |--------|---------|
59
+ | Session: New | `/new` |
60
+ | Session: Reload | `/reload` |
61
+ | Session: Fork | `/fork` |
62
+ | Session: Tree | `/tree` |
63
+ | Session: Resume | `/resume` |
64
+
65
+ > 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
+
41
67
  ### Editor text preservation
42
68
 
43
- 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.
69
+ 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.
44
70
 
45
71
  ### Model selector
46
72
 
47
73
  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`.
48
74
 
49
- **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. If no scope is configured, the list is a plain alphabetical roster — nothing breaks.
75
+ **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.
50
76
 
51
77
  ## Configuration
52
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-command-palette",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
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,11 +1,11 @@
1
1
  /**
2
- * Regression tests for model reference parsing.
3
- * Run: node --test packages/pi-command-palette/src/index.test.ts
2
+ * Regression tests for model reference parsing and the partitioned fuzzy
3
+ * filter that keeps scoped models on top while searching.
4
4
  */
5
5
 
6
6
  import assert from "node:assert/strict";
7
7
  import { test } from "node:test";
8
- import { parseModelRef } from "./index.ts";
8
+ import { parseModelRef, partitionedFuzzyFilter } from "./index.ts";
9
9
 
10
10
  test("parseModelRef splits provider and model at the first slash", () => {
11
11
  assert.deepEqual(parseModelRef("anthropic/claude-sonnet"), {
@@ -23,3 +23,56 @@ test("parseModelRef preserves empty provider or model segments", () => {
23
23
  assert.deepEqual(parseModelRef("/model"), { provider: "", modelId: "model" });
24
24
  assert.deepEqual(parseModelRef("provider/"), { provider: "provider", modelId: "" });
25
25
  });
26
+
27
+ // ── partitionedFuzzyFilter ─────────────────────────────────────────
28
+
29
+ test("partitionedFuzzyFilter concatenates partitions unchanged for empty query", () => {
30
+ const primary = [{ label: "A" }, { label: "B" }];
31
+ const secondary = [{ label: "C" }, { label: "D" }];
32
+ const getText = (m: { label: string }) => m.label;
33
+
34
+ assert.deepEqual(
35
+ partitionedFuzzyFilter(primary, secondary, "", getText).map((m) => m.label),
36
+ ["A", "B", "C", "D"],
37
+ );
38
+ // Whitespace-only is treated as no query.
39
+ assert.deepEqual(
40
+ partitionedFuzzyFilter(primary, secondary, " ", getText).map((m) => m.label),
41
+ ["A", "B", "C", "D"],
42
+ );
43
+ });
44
+
45
+ test("partitionedFuzzyFilter keeps the primary partition on top while filtering", () => {
46
+ const primary = [{ label: "alpha-scoped" }, { label: "beta-scoped" }];
47
+ const secondary = [{ label: "alpha-other" }, { label: "beta-other" }];
48
+ const getText = (m: { label: string }) => m.label;
49
+
50
+ const result = partitionedFuzzyFilter(primary, secondary, "alpha", getText);
51
+
52
+ // Both groups match, but the scoped (primary) match must come first — a
53
+ // single fuzzyFilter pass would have ranked them by score and could flip
54
+ // the order.
55
+ assert.equal(result.length, 2);
56
+ assert.equal(result[0].label, "alpha-scoped");
57
+ assert.equal(result[1].label, "alpha-other");
58
+ });
59
+
60
+ test("partitionedFuzzyFilter drops non-matches independently per partition", () => {
61
+ const primary = [{ label: "keep-scoped" }, { label: "drop-scoped" }];
62
+ const secondary = [{ label: "keep-other" }, { label: "drop-other" }];
63
+ const getText = (m: { label: string }) => m.label;
64
+
65
+ const result = partitionedFuzzyFilter(primary, secondary, "keep", getText);
66
+
67
+ assert.deepEqual(result.map((m) => m.label), ["keep-scoped", "keep-other"]);
68
+ });
69
+
70
+ test("partitionedFuzzyFilter returns only primary matches when secondary has none", () => {
71
+ const primary = [{ label: "sonnet" }];
72
+ const secondary = [{ label: "gpt-4o" }, { label: "gemini" }];
73
+ const getText = (m: { label: string }) => m.label;
74
+
75
+ const result = partitionedFuzzyFilter(primary, secondary, "son", getText);
76
+
77
+ assert.deepEqual(result.map((m) => m.label), ["sonnet"]);
78
+ });
package/src/index.ts CHANGED
@@ -10,14 +10,13 @@
10
10
  * - Fuzzy search via SelectList
11
11
  * - Floating overlay on top of existing content
12
12
  * - Saves editor text before overwriting; offers "Restore" in palette
13
+ * - Clear editor into the restore buffer
13
14
  */
14
15
 
15
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
17
  import {
18
+ copyToClipboard,
17
19
  DynamicBorder,
18
- getAgentDir,
19
- resolveModelScopeWithDiagnostics,
20
- SettingsManager,
21
20
  } from "@earendil-works/pi-coding-agent";
22
21
  import {
23
22
  Container,
@@ -37,7 +36,9 @@ type CommandAction =
37
36
  | { type: "model-select" }
38
37
  | { type: "compact" }
39
38
  | { type: "reload" }
40
- | { type: "restore" };
39
+ | { type: "restore" }
40
+ | { type: "copy-editor" }
41
+ | { type: "clear-editor" };
41
42
 
42
43
  interface PaletteItem {
43
44
  value: string;
@@ -52,6 +53,18 @@ interface PaletteItem {
52
53
  /** Editor text saved before the palette overwrites it. */
53
54
  let savedEditorText: string | null = null;
54
55
 
56
+ /**
57
+ * Explicit display order for built-in palette entries (lower = higher up).
58
+ * Unlisted built-ins fall back to alphabetical, after the listed ones;
59
+ * non-built-in entries always sort after built-ins.
60
+ */
61
+ const BUILTIN_ORDER: Record<string, number> = {
62
+ __model_select: 0,
63
+ __restore: 1,
64
+ __copy_editor: 2,
65
+ __clear_editor: 3,
66
+ };
67
+
55
68
  // ── Helpers ────────────────────────────────────────────────────────
56
69
 
57
70
  function buildPaletteItems(pi: ExtensionAPI, ctx: ExtensionContext): PaletteItem[] {
@@ -127,6 +140,22 @@ function buildPaletteItems(pi: ExtensionAPI, ctx: ExtensionContext): PaletteItem
127
140
  action: { type: "editor", text: "/resume" },
128
141
  });
129
142
 
143
+ items.push({
144
+ value: "__copy_editor",
145
+ label: "Editor: Copy Content",
146
+ description: "Copy current editor text to clipboard",
147
+ category: "Built-in",
148
+ action: { type: "copy-editor" },
149
+ });
150
+
151
+ items.push({
152
+ value: "__clear_editor",
153
+ label: "Editor: Clear Content",
154
+ description: "Clear editor (recover via Restore)",
155
+ category: "Built-in",
156
+ action: { type: "clear-editor" },
157
+ });
158
+
130
159
  // ── Extension commands, skills, templates ────────────────────
131
160
  const commands = pi.getCommands();
132
161
  for (const cmd of commands) {
@@ -143,10 +172,17 @@ function buildPaletteItems(pi: ExtensionAPI, ctx: ExtensionContext): PaletteItem
143
172
  });
144
173
  }
145
174
 
146
- // Sort: built-in first, then alphabetically within category
175
+ // Sort: built-in actions first, ordered by BUILTIN_ORDER (then alphabetical
176
+ // for unlisted built-ins); extension commands follow alphabetically.
147
177
  items.sort((a, b) => {
148
- if (a.category === "Built-in" && b.category !== "Built-in") return -1;
149
- if (a.category !== "Built-in" && b.category === "Built-in") return 1;
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) {
182
+ const ai = BUILTIN_ORDER[a.value] ?? Number.MAX_SAFE_INTEGER;
183
+ const bi = BUILTIN_ORDER[b.value] ?? Number.MAX_SAFE_INTEGER;
184
+ if (ai !== bi) return ai - bi;
185
+ }
150
186
  return a.label.localeCompare(b.label);
151
187
  });
152
188
 
@@ -169,32 +205,31 @@ export function parseModelRef(modelRef: string): { provider: string; modelId: st
169
205
  };
170
206
  }
171
207
 
208
+ // ── Partitioned fuzzy filter ───────────────────────────────────────
209
+
172
210
  /**
173
- * Resolve the set of "scoped" model full-ids (`provider/id`) the same models
174
- * pi surfaces in its built-in selector's "scoped" tab and Ctrl+P cycling.
211
+ * Filter two partitions independently and concatenate them in a stable order:
212
+ * every matching item from `primary`, then every matching item from
213
+ * `secondary`. Each group is ranked by fuzzy score on its own, so `primary`
214
+ * always stays on top — a single {@link fuzzyFilter} call flattens both groups
215
+ * into one score-ordered list and erases the boundary between them.
175
216
  *
176
- * Fully official-API driven, no manual settings parsing:
177
- * - `SettingsManager.getEnabledModels()` reads the `enabledModels` scope
178
- * patterns (global + project merge handled by pi).
179
- * - `resolveModelScopeWithDiagnostics()` expands those patterns (globs, aliases,
180
- * thinking-level suffixes) into concrete models — identical to pi's scope tab.
217
+ * An empty (or whitespace-only) query returns `[...primary, ...secondary]`
218
+ * unchanged.
181
219
  *
182
- * Any failure degrades to an empty set: the selector still works, just without
183
- * the scoped grouping.
220
+ * @internal exported for testing; the model selector is the only caller.
184
221
  */
185
- async function resolveScopedModelIds(
186
- modelRegistry: ExtensionContext["modelRegistry"],
187
- cwd: string,
188
- ): Promise<Set<string>> {
189
- try {
190
- const settings = SettingsManager.create(cwd, getAgentDir());
191
- const patterns = settings.getEnabledModels();
192
- if (!patterns || patterns.length === 0) return new Set();
193
- const { scopedModels } = await resolveModelScopeWithDiagnostics(patterns, modelRegistry);
194
- return new Set(scopedModels.map((s) => `${s.model.provider}/${s.model.id}`));
195
- } catch {
196
- return new Set();
197
- }
222
+ export function partitionedFuzzyFilter<T>(
223
+ primary: T[],
224
+ secondary: T[],
225
+ query: string,
226
+ getText: (item: T) => string,
227
+ ): T[] {
228
+ if (!query.trim()) return [...primary, ...secondary];
229
+ return [
230
+ ...fuzzyFilter(primary, query, getText),
231
+ ...fuzzyFilter(secondary, query, getText),
232
+ ];
198
233
  }
199
234
 
200
235
  async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
@@ -211,25 +246,36 @@ async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promi
211
246
  return;
212
247
  }
213
248
 
214
- const scopedIds = await resolveScopedModelIds(ctx.modelRegistry, ctx.cwd);
249
+ const scopedIds = new Set(ctx.scopedModels.map((s) => `${s.model.provider}/${s.model.id}`));
215
250
 
216
- // Scoped models float to the top with a ★ prefix (favorites); the rest follow
217
- // alphabetically. Within the scoped group we also sort alphabetically so the
218
- // ordering is stable and predictable regardless of registry order.
219
- const decorated = models.map((m) => {
220
- const value = `${m.provider}/${m.id}`;
221
- return { model: m, value, scoped: scopedIds.has(value) };
222
- });
223
- decorated.sort((a, b) => {
224
- if (a.scoped !== b.scoped) return a.scoped ? -1 : 1;
225
- return a.model.name.localeCompare(b.model.name);
226
- });
251
+ // Build each model's SelectItem alongside whether it's scope-pinned, then
252
+ // sort into two stable groups scoped first (★), then the rest —
253
+ // alphabetical within each. We keep the groups as separate arrays so the
254
+ // search pipeline below can re-apply the same partitioning.
255
+ const decorated = models
256
+ .map((m) => {
257
+ const value = `${m.provider}/${m.id}`;
258
+ const scoped = scopedIds.has(value);
259
+ return {
260
+ scoped,
261
+ item: {
262
+ value,
263
+ label: scoped ? `${STAR}${m.name}` : m.name,
264
+ description: m.provider,
265
+ } satisfies SelectItem,
266
+ };
267
+ })
268
+ .sort((a, b) => {
269
+ if (a.scoped !== b.scoped) return a.scoped ? -1 : 1;
270
+ return a.item.label.localeCompare(b.item.label);
271
+ });
227
272
 
228
- const items: SelectItem[] = decorated.map((d) => ({
229
- value: d.value,
230
- label: d.scoped ? `${STAR}${d.model.name}` : d.model.name,
231
- description: d.model.provider,
232
- }));
273
+ // Scoped models () stay above the rest whether browsing or filtering: each
274
+ // group is filtered and ranked independently, then concatenated, so a search
275
+ // never merges the two into one score-ordered list.
276
+ const scopedItems: SelectItem[] = decorated.filter((d) => d.scoped).map((d) => d.item);
277
+ const otherItems: SelectItem[] = decorated.filter((d) => !d.scoped).map((d) => d.item);
278
+ const items: SelectItem[] = [...scopedItems, ...otherItems];
233
279
 
234
280
  const result = await ctx.ui.custom<string | null>(
235
281
  (tui, theme, _kb, done) => {
@@ -253,13 +299,12 @@ async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promi
253
299
  const queryText = new Text(theme.fg("accent", "> "), 1, 0);
254
300
 
255
301
  function applyQuery() {
256
- const filtered = query
257
- ? fuzzyFilter(
258
- items,
259
- query,
260
- (item: SelectItem) => `${item.label} ${item.description ?? ""}`,
261
- )
262
- : items;
302
+ const filtered = partitionedFuzzyFilter(
303
+ scopedItems,
304
+ otherItems,
305
+ query,
306
+ (item: SelectItem) => `${item.label} ${item.description ?? ""}`,
307
+ );
263
308
  // FRAGILE: SelectList has no public filter/setItems API, so we poke its
264
309
  // private filteredItems directly. If pi-tui renames it, filtering breaks
265
310
  // silently with no compile error.
@@ -454,6 +499,29 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
454
499
  ctx.ui.setEditorText("/reload");
455
500
  break;
456
501
  }
502
+ case "copy-editor": {
503
+ const text = ctx.ui.getEditorText();
504
+ if (text && text.trim()) {
505
+ await copyToClipboard(text);
506
+ ctx.ui.notify("Copied editor text to clipboard", "info");
507
+ } else {
508
+ ctx.ui.notify("Editor is empty", "warning");
509
+ }
510
+ break;
511
+ }
512
+ case "clear-editor": {
513
+ // Save current editor text to the restore buffer before clearing, so
514
+ // the built-in Restore action can bring it back.
515
+ const currentText = ctx.ui.getEditorText();
516
+ if (currentText && currentText.trim()) {
517
+ savedEditorText = currentText;
518
+ ctx.ui.setEditorText("");
519
+ ctx.ui.notify("Cleared editor — use Restore to recover", "info");
520
+ } else {
521
+ ctx.ui.notify("Editor is empty", "warning");
522
+ }
523
+ break;
524
+ }
457
525
  }
458
526
  }
459
527