@d3ara1n/pi-command-palette 0.5.0 → 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 +1 -1
- package/package.json +1 -1
- package/src/index.test.ts +56 -3
- package/src/index.ts +55 -49
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
package/src/index.test.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Regression tests for model reference parsing
|
|
3
|
-
*
|
|
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
|
-
*
|
|
213
|
-
*
|
|
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
|
-
*
|
|
216
|
-
*
|
|
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
|
-
*
|
|
222
|
-
* the scoped grouping.
|
|
220
|
+
* @internal — exported for testing; the model selector is the only caller.
|
|
223
221
|
*/
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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 =
|
|
249
|
+
const scopedIds = new Set(ctx.scopedModels.map((s) => `${s.model.provider}/${s.model.id}`));
|
|
254
250
|
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 =
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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.
|