@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 +29 -3
- package/package.json +1 -1
- package/src/index.test.ts +56 -3
- package/src/index.ts +121 -53
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** —
|
|
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
|
|
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
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
|
@@ -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,
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
*
|
|
174
|
-
*
|
|
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
|
-
*
|
|
177
|
-
*
|
|
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
|
-
*
|
|
183
|
-
* the scoped grouping.
|
|
220
|
+
* @internal — exported for testing; the model selector is the only caller.
|
|
184
221
|
*/
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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 =
|
|
249
|
+
const scopedIds = new Set(ctx.scopedModels.map((s) => `${s.model.provider}/${s.model.id}`));
|
|
215
250
|
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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 =
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|