@d3ara1n/pi-command-palette 0.5.0 → 0.5.2

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
@@ -72,7 +72,7 @@ When a command replaces your editor text, or you run **Editor: Clear Content**,
72
72
 
73
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`.
74
74
 
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. 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.
76
76
 
77
77
  ## Configuration
78
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-command-palette",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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/config.ts CHANGED
@@ -15,18 +15,12 @@
15
15
  */
16
16
 
17
17
  import * as fs from "node:fs";
18
- import * as os from "node:os";
19
18
  import * as path from "node:path";
19
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
20
20
  import type { KeyId } from "@earendil-works/pi-tui";
21
21
 
22
22
  export const DEFAULT_SHORTCUT = "ctrl+shift+p";
23
23
 
24
- function getAgentDir(): string {
25
- const envDir = process.env.PI_AGENT_DIR;
26
- if (envDir) return envDir;
27
- return path.join(os.homedir(), ".pi", "agent");
28
- }
29
-
30
24
  function readSettings(filePath: string): Record<string, unknown> {
31
25
  try {
32
26
  if (!fs.existsSync(filePath)) return {};
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
@@ -17,9 +17,6 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
17
17
  import {
18
18
  copyToClipboard,
19
19
  DynamicBorder,
20
- getAgentDir,
21
- resolveModelScopeWithDiagnostics,
22
- SettingsManager,
23
20
  } from "@earendil-works/pi-coding-agent";
24
21
  import {
25
22
  Container,
@@ -208,32 +205,31 @@ export function parseModelRef(modelRef: string): { provider: string; modelId: st
208
205
  };
209
206
  }
210
207
 
208
+ // ── Partitioned fuzzy filter ───────────────────────────────────────
209
+
211
210
  /**
212
- * Resolve the set of "scoped" model full-ids (`provider/id`) the same models
213
- * 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.
214
216
  *
215
- * Fully official-API driven, no manual settings parsing:
216
- * - `SettingsManager.getEnabledModels()` reads the `enabledModels` scope
217
- * patterns (global + project merge handled by pi).
218
- * - `resolveModelScopeWithDiagnostics()` expands those patterns (globs, aliases,
219
- * thinking-level suffixes) into concrete models — identical to pi's scope tab.
217
+ * An empty (or whitespace-only) query returns `[...primary, ...secondary]`
218
+ * unchanged.
220
219
  *
221
- * Any failure degrades to an empty set: the selector still works, just without
222
- * the scoped grouping.
220
+ * @internal exported for testing; the model selector is the only caller.
223
221
  */
224
- async function resolveScopedModelIds(
225
- modelRegistry: ExtensionContext["modelRegistry"],
226
- cwd: string,
227
- ): Promise<Set<string>> {
228
- try {
229
- const settings = SettingsManager.create(cwd, getAgentDir());
230
- const patterns = settings.getEnabledModels();
231
- if (!patterns || patterns.length === 0) return new Set();
232
- const { scopedModels } = await resolveModelScopeWithDiagnostics(patterns, modelRegistry);
233
- return new Set(scopedModels.map((s) => `${s.model.provider}/${s.model.id}`));
234
- } catch {
235
- return new Set();
236
- }
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
+ ];
237
233
  }
238
234
 
239
235
  async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
@@ -250,25 +246,36 @@ async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promi
250
246
  return;
251
247
  }
252
248
 
253
- const scopedIds = await resolveScopedModelIds(ctx.modelRegistry, ctx.cwd);
249
+ const scopedIds = new Set(ctx.scopedModels.map((s) => `${s.model.provider}/${s.model.id}`));
254
250
 
255
- // Scoped models float to the top with a ★ prefix (favorites); the rest follow
256
- // alphabetically. Within the scoped group we also sort alphabetically so the
257
- // ordering is stable and predictable regardless of registry order.
258
- const decorated = models.map((m) => {
259
- const value = `${m.provider}/${m.id}`;
260
- return { model: m, value, scoped: scopedIds.has(value) };
261
- });
262
- decorated.sort((a, b) => {
263
- if (a.scoped !== b.scoped) return a.scoped ? -1 : 1;
264
- return a.model.name.localeCompare(b.model.name);
265
- });
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
+ });
266
272
 
267
- const items: SelectItem[] = decorated.map((d) => ({
268
- value: d.value,
269
- label: d.scoped ? `${STAR}${d.model.name}` : d.model.name,
270
- description: d.model.provider,
271
- }));
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];
272
279
 
273
280
  const result = await ctx.ui.custom<string | null>(
274
281
  (tui, theme, _kb, done) => {
@@ -292,13 +299,12 @@ async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promi
292
299
  const queryText = new Text(theme.fg("accent", "> "), 1, 0);
293
300
 
294
301
  function applyQuery() {
295
- const filtered = query
296
- ? fuzzyFilter(
297
- items,
298
- query,
299
- (item: SelectItem) => `${item.label} ${item.description ?? ""}`,
300
- )
301
- : items;
302
+ const filtered = partitionedFuzzyFilter(
303
+ scopedItems,
304
+ otherItems,
305
+ query,
306
+ (item: SelectItem) => `${item.label} ${item.description ?? ""}`,
307
+ );
302
308
  // FRAGILE: SelectList has no public filter/setItems API, so we poke its
303
309
  // private filteredItems directly. If pi-tui renames it, filtering breaks
304
310
  // silently with no compile error.