@d3ara1n/pi-command-palette 0.5.2 → 0.6.0
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 +30 -5
- package/package.json +4 -1
- package/src/index.test.ts +62 -6
- package/src/index.ts +265 -204
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# pi-command-palette
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@d3ara1n/pi-command-palette) [](https://www.npmjs.com/package/@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
|
-
|
|
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
|
|
|
@@ -32,11 +34,17 @@ Or add to `~/.pi/agent/settings.json`:
|
|
|
32
34
|
|----------|--------|
|
|
33
35
|
| `Ctrl+Shift+P` _(default, configurable)_ | Open command palette |
|
|
34
36
|
|
|
37
|
+
The palette opens as a single macOS-launcher-style overlay with nested pages. Selecting a category with **Enter** replaces the current list in the same overlay instead of opening a second overlay. Press **Backspace** with an empty search field to return to the parent page; press **Esc** to close the palette immediately.
|
|
38
|
+
|
|
35
39
|
The palette lists:
|
|
36
40
|
|
|
37
|
-
- **Built-in
|
|
38
|
-
- **Extension
|
|
39
|
-
- **
|
|
41
|
+
- **Built-in Actions** — curated shortcuts for common operations (detailed below)
|
|
42
|
+
- **Extension Actions** — entries registered by other extensions that run a callback directly (see below)
|
|
43
|
+
- **Commands** — all registered `/command` entries
|
|
44
|
+
- **Skills** — installed skill commands
|
|
45
|
+
- **Templates** — prompt templates
|
|
46
|
+
|
|
47
|
+
Use **↑/↓** to move through entries and **←/→** to edit the search cursor. Search is fuzzy within the current page and updates as you type; Backspace uses the normal text-editing behavior while the query is non-empty.
|
|
40
48
|
|
|
41
49
|
### Built-in actions
|
|
42
50
|
|
|
@@ -64,13 +72,30 @@ Built-in actions are grouped by how they run:
|
|
|
64
72
|
|
|
65
73
|
> 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
74
|
|
|
75
|
+
### Native commands from other extensions
|
|
76
|
+
|
|
77
|
+
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:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { paletteCommandRegistry } from "@d3ara1n/pi-command-palette-core";
|
|
81
|
+
|
|
82
|
+
paletteCommandRegistry.register({
|
|
83
|
+
id: "my-plugin:do-thing",
|
|
84
|
+
label: "My Plugin: Do the Thing",
|
|
85
|
+
description: "Runs immediately, without touching the editor",
|
|
86
|
+
run: (pi, ctx) => { /* ... */ },
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
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.
|
|
91
|
+
|
|
67
92
|
### Editor text preservation
|
|
68
93
|
|
|
69
94
|
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.
|
|
70
95
|
|
|
71
96
|
### Model selector
|
|
72
97
|
|
|
73
|
-
The "Model: Switch Model"
|
|
98
|
+
The "Model: Switch Model" entry opens a model page inside the same overlay. Models are loaded when the page is first entered, then can be searched and selected without stacking another overlay.
|
|
74
99
|
|
|
75
100
|
**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
101
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-command-palette",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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
|
|
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 {
|
|
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(
|
|
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(
|
|
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,14 +14,13 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import {
|
|
18
|
-
|
|
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,
|
|
24
22
|
fuzzyFilter,
|
|
23
|
+
Input,
|
|
25
24
|
Key,
|
|
26
25
|
matchesKey,
|
|
27
26
|
SelectList,
|
|
@@ -33,7 +32,9 @@ import { resolveShortcutKey } from "./config.ts";
|
|
|
33
32
|
|
|
34
33
|
type CommandAction =
|
|
35
34
|
| { type: "editor"; text: string }
|
|
35
|
+
| { type: "native"; id: string }
|
|
36
36
|
| { type: "model-select" }
|
|
37
|
+
| { type: "model"; provider: string; modelId: string }
|
|
37
38
|
| { type: "compact" }
|
|
38
39
|
| { type: "reload" }
|
|
39
40
|
| { type: "restore" }
|
|
@@ -67,7 +68,22 @@ const BUILTIN_ORDER: Record<string, number> = {
|
|
|
67
68
|
|
|
68
69
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
69
70
|
|
|
70
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Sort ranks: built-in actions first, then native commands registered by other
|
|
73
|
+
* extensions (direct callbacks), then everything that fills the editor with a
|
|
74
|
+
* `/command`. Lower rank = higher up in the palette.
|
|
75
|
+
*/
|
|
76
|
+
function paletteSortRank(item: PaletteItem): number {
|
|
77
|
+
if (item.category === "Built-in") return 0;
|
|
78
|
+
if (item.action.type === "native") return 1;
|
|
79
|
+
return 2;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @internal — exported for testing; builds the palette item list from
|
|
84
|
+
* built-ins, the native-command registry, and pi's command registry.
|
|
85
|
+
*/
|
|
86
|
+
export function buildPaletteItems(pi: ExtensionAPI): PaletteItem[] {
|
|
71
87
|
const items: PaletteItem[] = [];
|
|
72
88
|
|
|
73
89
|
// ── Restore option (if previous editor text was saved) ────────
|
|
@@ -156,6 +172,20 @@ function buildPaletteItems(pi: ExtensionAPI, ctx: ExtensionContext): PaletteItem
|
|
|
156
172
|
action: { type: "clear-editor" },
|
|
157
173
|
});
|
|
158
174
|
|
|
175
|
+
// ── Native commands from other extensions ────────────────────
|
|
176
|
+
// Direct callbacks registered via @d3ara1n/pi-command-palette-core —
|
|
177
|
+
// executed in place, never touching the editor. Read at palette-open time,
|
|
178
|
+
// so late registrations are visible the next time the palette opens.
|
|
179
|
+
for (const cmd of paletteCommandRegistry.getAll()) {
|
|
180
|
+
items.push({
|
|
181
|
+
value: `native:${cmd.id}`,
|
|
182
|
+
label: cmd.label,
|
|
183
|
+
description: cmd.description ?? "",
|
|
184
|
+
category: "Native",
|
|
185
|
+
action: { type: "native", id: cmd.id },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
159
189
|
// ── Extension commands, skills, templates ────────────────────
|
|
160
190
|
const commands = pi.getCommands();
|
|
161
191
|
for (const cmd of commands) {
|
|
@@ -172,13 +202,13 @@ function buildPaletteItems(pi: ExtensionAPI, ctx: ExtensionContext): PaletteItem
|
|
|
172
202
|
});
|
|
173
203
|
}
|
|
174
204
|
|
|
175
|
-
// Sort: built-in actions first
|
|
176
|
-
//
|
|
205
|
+
// Sort: built-in actions first (ordered by BUILTIN_ORDER, then alphabetical),
|
|
206
|
+
// then native commands, then editor-fill entries — each group alphabetical.
|
|
177
207
|
items.sort((a, b) => {
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
181
|
-
if (
|
|
208
|
+
const ar = paletteSortRank(a);
|
|
209
|
+
const br = paletteSortRank(b);
|
|
210
|
+
if (ar !== br) return ar - br;
|
|
211
|
+
if (ar === 0) {
|
|
182
212
|
const ai = BUILTIN_ORDER[a.value] ?? Number.MAX_SAFE_INTEGER;
|
|
183
213
|
const bi = BUILTIN_ORDER[b.value] ?? Number.MAX_SAFE_INTEGER;
|
|
184
214
|
if (ai !== bi) return ai - bi;
|
|
@@ -226,237 +256,243 @@ export function partitionedFuzzyFilter<T>(
|
|
|
226
256
|
getText: (item: T) => string,
|
|
227
257
|
): T[] {
|
|
228
258
|
if (!query.trim()) return [...primary, ...secondary];
|
|
229
|
-
return [
|
|
230
|
-
...fuzzyFilter(primary, query, getText),
|
|
231
|
-
...fuzzyFilter(secondary, query, getText),
|
|
232
|
-
];
|
|
259
|
+
return [...fuzzyFilter(primary, query, getText), ...fuzzyFilter(secondary, query, getText)];
|
|
233
260
|
}
|
|
234
261
|
|
|
235
|
-
|
|
236
|
-
let models: Awaited<ReturnType<typeof ctx.modelRegistry.getAvailable>>;
|
|
237
|
-
try {
|
|
238
|
-
models = await ctx.modelRegistry.getAvailable();
|
|
239
|
-
} catch {
|
|
240
|
-
ctx.ui.notify("Cannot enumerate models. Use Ctrl+L instead.", "warning");
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (models.length === 0) {
|
|
245
|
-
ctx.ui.notify("No models available.", "warning");
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const scopedIds = new Set(ctx.scopedModels.map((s) => `${s.model.provider}/${s.model.id}`));
|
|
250
|
-
|
|
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
|
-
});
|
|
272
|
-
|
|
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];
|
|
279
|
-
|
|
280
|
-
const result = await ctx.ui.custom<string | null>(
|
|
281
|
-
(tui, theme, _kb, done) => {
|
|
282
|
-
const container = new Container();
|
|
283
|
-
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
284
|
-
container.addChild(new Text(theme.fg("accent", theme.bold("Switch Model")), 1, 0));
|
|
285
|
-
|
|
286
|
-
const selectList = new SelectList(items, Math.min(items.length, 12), {
|
|
287
|
-
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
288
|
-
selectedText: (t: string) => theme.fg("accent", t),
|
|
289
|
-
description: (t: string) => theme.fg("muted", t),
|
|
290
|
-
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
291
|
-
noMatch: (t: string) => theme.fg("warning", t),
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
selectList.onSelect = (item) => done(item.value);
|
|
295
|
-
selectList.onCancel = () => done(null);
|
|
296
|
-
|
|
297
|
-
// Type-to-filter state
|
|
298
|
-
let query = "";
|
|
299
|
-
const queryText = new Text(theme.fg("accent", "> "), 1, 0);
|
|
300
|
-
|
|
301
|
-
function applyQuery() {
|
|
302
|
-
const filtered = partitionedFuzzyFilter(
|
|
303
|
-
scopedItems,
|
|
304
|
-
otherItems,
|
|
305
|
-
query,
|
|
306
|
-
(item: SelectItem) => `${item.label} ${item.description ?? ""}`,
|
|
307
|
-
);
|
|
308
|
-
// FRAGILE: SelectList has no public filter/setItems API, so we poke its
|
|
309
|
-
// private filteredItems directly. If pi-tui renames it, filtering breaks
|
|
310
|
-
// silently with no compile error.
|
|
311
|
-
(selectList as any).filteredItems = filtered;
|
|
312
|
-
selectList.setSelectedIndex(0);
|
|
313
|
-
queryText.setText(theme.fg("accent", `> ${query}▎`));
|
|
314
|
-
container.invalidate();
|
|
315
|
-
tui.requestRender();
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
container.addChild(queryText);
|
|
319
|
-
container.addChild(selectList);
|
|
320
|
-
container.addChild(
|
|
321
|
-
new Text(theme.fg("dim", "type to filter • ↑↓ navigate • enter select • esc cancel"), 1, 0),
|
|
322
|
-
);
|
|
323
|
-
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
324
|
-
|
|
325
|
-
return {
|
|
326
|
-
render(w: number) {
|
|
327
|
-
return container.render(w);
|
|
328
|
-
},
|
|
329
|
-
invalidate() {
|
|
330
|
-
container.invalidate();
|
|
331
|
-
},
|
|
332
|
-
handleInput(data: string) {
|
|
333
|
-
// Backspace → trim query
|
|
334
|
-
if (matchesKey(data, Key.backspace)) {
|
|
335
|
-
if (query.length > 0) {
|
|
336
|
-
query = query.slice(0, -1);
|
|
337
|
-
applyQuery();
|
|
338
|
-
}
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
// Printable character → append to query
|
|
342
|
-
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
343
|
-
query += data;
|
|
344
|
-
applyQuery();
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
// Navigation / confirm / cancel → pass to SelectList
|
|
348
|
-
selectList.handleInput(data);
|
|
349
|
-
tui.requestRender();
|
|
350
|
-
},
|
|
351
|
-
};
|
|
352
|
-
},
|
|
353
|
-
{ overlay: true },
|
|
354
|
-
);
|
|
355
|
-
|
|
356
|
-
if (!result) return;
|
|
262
|
+
// ── Command palette overlay ────────────────────────────────────────
|
|
357
263
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const model = ctx.modelRegistry.find(provider, modelId);
|
|
362
|
-
if (model) {
|
|
363
|
-
const success = await pi.setModel(model);
|
|
364
|
-
if (success) {
|
|
365
|
-
ctx.ui.notify(`Model: ${provider}/${modelId}`, "info");
|
|
366
|
-
} else {
|
|
367
|
-
ctx.ui.notify(`No API key for ${provider}/${modelId}`, "error");
|
|
368
|
-
}
|
|
369
|
-
}
|
|
264
|
+
interface PalettePage {
|
|
265
|
+
title: string;
|
|
266
|
+
items: Array<PaletteItem | { type: "page"; value: string; label: string; description: string; page: PalettePage }>;
|
|
370
267
|
}
|
|
371
268
|
|
|
372
|
-
|
|
269
|
+
function pageItem(
|
|
270
|
+
value: string,
|
|
271
|
+
label: string,
|
|
272
|
+
description: string,
|
|
273
|
+
page: PalettePage,
|
|
274
|
+
): PalettePage["items"][number] {
|
|
275
|
+
return { type: "page", value, label, description, page };
|
|
276
|
+
}
|
|
373
277
|
|
|
374
278
|
async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
375
|
-
if (
|
|
376
|
-
|
|
377
|
-
const paletteItems = buildPaletteItems(pi
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
279
|
+
if (ctx.mode !== "tui") return;
|
|
280
|
+
|
|
281
|
+
const paletteItems = buildPaletteItems(pi);
|
|
282
|
+
const leaves = (category: string) =>
|
|
283
|
+
paletteItems.filter((item) => item.category === category);
|
|
284
|
+
const leafPage = (title: string, items: PaletteItem[]): PalettePage => ({ title, items });
|
|
285
|
+
|
|
286
|
+
const builtins = leaves("Built-in").filter((item) => item.action.type !== "model-select");
|
|
287
|
+
const native = leaves("Native");
|
|
288
|
+
const commands = leaves("Command");
|
|
289
|
+
const skills = leaves("Skill");
|
|
290
|
+
const templates = leaves("Template");
|
|
291
|
+
const modelPage: PalettePage = { title: "Models", items: [] };
|
|
292
|
+
const rootItems: PalettePage["items"] = [
|
|
293
|
+
pageItem("models", "Model: Switch Model", "Choose a model", modelPage),
|
|
294
|
+
...(builtins.length ? [pageItem("builtins", "Built-in Actions", "Session and editor actions", leafPage("Built-in Actions", builtins))] : []),
|
|
295
|
+
...(native.length ? [pageItem("native", "Extension Actions", "Actions provided by extensions", leafPage("Extension Actions", native))] : []),
|
|
296
|
+
...(commands.length ? [pageItem("commands", "Commands", "Extension slash commands", leafPage("Commands", commands))] : []),
|
|
297
|
+
...(skills.length ? [pageItem("skills", "Skills", "Installed skills", leafPage("Skills", skills))] : []),
|
|
298
|
+
...(templates.length ? [pageItem("templates", "Templates", "Prompt templates", leafPage("Templates", templates))] : []),
|
|
299
|
+
];
|
|
300
|
+
const root: PalettePage = { title: "Command Palette", items: rootItems };
|
|
383
301
|
|
|
384
302
|
const result = await ctx.ui.custom<PaletteItem | null>(
|
|
385
303
|
(tui, theme, _kb, done) => {
|
|
386
304
|
const container = new Container();
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const
|
|
305
|
+
const listHost = new Container();
|
|
306
|
+
const queryInput = new Input();
|
|
307
|
+
let focused = true;
|
|
308
|
+
const stack: Array<{ page: PalettePage; input: string; selectedValue?: string }> = [
|
|
309
|
+
{ page: root, input: "" },
|
|
310
|
+
];
|
|
311
|
+
let selectList!: SelectList;
|
|
312
|
+
let visibleItems: PalettePage["items"] = [];
|
|
313
|
+
let modelLoading = false;
|
|
314
|
+
|
|
315
|
+
const listTheme = {
|
|
391
316
|
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
392
317
|
selectedText: (t: string) => theme.fg("accent", t),
|
|
393
318
|
description: (t: string) => theme.fg("muted", t),
|
|
394
319
|
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
395
320
|
noMatch: (t: string) => theme.fg("warning", t),
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
selectList.onSelect = (item) => {
|
|
399
|
-
const paletteItem = paletteItems.find((p) => p.value === item.value);
|
|
400
|
-
done(paletteItem ?? null);
|
|
401
321
|
};
|
|
402
|
-
selectList.onCancel = () => done(null);
|
|
403
322
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
323
|
+
function current() {
|
|
324
|
+
return stack[stack.length - 1]!;
|
|
325
|
+
}
|
|
407
326
|
|
|
408
|
-
function
|
|
327
|
+
function rebuild() {
|
|
328
|
+
const { page } = current();
|
|
329
|
+
const query = queryInput.getValue();
|
|
330
|
+
visibleItems =
|
|
331
|
+
page === root && query.trim()
|
|
332
|
+
? [
|
|
333
|
+
...root.items,
|
|
334
|
+
...paletteItems.filter((item) => item.action.type !== "model-select"),
|
|
335
|
+
]
|
|
336
|
+
: page.items;
|
|
337
|
+
const items = visibleItems.map((item) => ({
|
|
338
|
+
value: item.value,
|
|
339
|
+
label: item.label,
|
|
340
|
+
description:
|
|
341
|
+
page === root && query.trim() && !("page" in item)
|
|
342
|
+
? `${item.category} › ${item.description}`
|
|
343
|
+
: item.description,
|
|
344
|
+
}));
|
|
345
|
+
const getText = (item: SelectItem) => `${item.label} ${item.description ?? ""}`;
|
|
409
346
|
const filtered = query
|
|
410
|
-
?
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
347
|
+
? page === modelPage
|
|
348
|
+
? partitionedFuzzyFilter(
|
|
349
|
+
items.filter((item) => item.label.startsWith(STAR)),
|
|
350
|
+
items.filter((item) => !item.label.startsWith(STAR)),
|
|
351
|
+
query,
|
|
352
|
+
getText,
|
|
353
|
+
)
|
|
354
|
+
: fuzzyFilter(items, query, getText)
|
|
355
|
+
: items;
|
|
356
|
+
selectList = new SelectList(filtered, Math.min(Math.max(filtered.length, 1), 15), listTheme);
|
|
357
|
+
const restoredIndex = filtered.findIndex(
|
|
358
|
+
(item) => item.value === current().selectedValue,
|
|
359
|
+
);
|
|
360
|
+
if (restoredIndex >= 0) selectList.setSelectedIndex(restoredIndex);
|
|
361
|
+
listHost.clear();
|
|
362
|
+
listHost.addChild(selectList);
|
|
363
|
+
selectList.onSelect = (selected) => {
|
|
364
|
+
current().selectedValue = selected.value;
|
|
365
|
+
const item = visibleItems.find((candidate) => candidate.value === selected.value);
|
|
366
|
+
if (!item) return;
|
|
367
|
+
if ("page" in item) {
|
|
368
|
+
if (item.value === "models" && !modelLoading && item.page.items.length === 0) {
|
|
369
|
+
modelLoading = true;
|
|
370
|
+
try {
|
|
371
|
+
const models = ctx.modelRegistry.getAvailable();
|
|
372
|
+
const scopedIds = new Set(
|
|
373
|
+
ctx.scopedModels.map((s) => `${s.model.provider}/${s.model.id}`),
|
|
374
|
+
);
|
|
375
|
+
const decorated = models
|
|
376
|
+
.map((m) => {
|
|
377
|
+
const value = `${m.provider}/${m.id}`;
|
|
378
|
+
const scoped = scopedIds.has(value);
|
|
379
|
+
return {
|
|
380
|
+
scoped,
|
|
381
|
+
item: {
|
|
382
|
+
value,
|
|
383
|
+
label: scoped ? `${STAR}${m.name}` : m.name,
|
|
384
|
+
description: m.provider,
|
|
385
|
+
category: "Built-in",
|
|
386
|
+
action: { type: "model", provider: m.provider, modelId: m.id } as CommandAction,
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
})
|
|
390
|
+
.sort((a, b) =>
|
|
391
|
+
a.scoped === b.scoped
|
|
392
|
+
? a.item.label.localeCompare(b.item.label)
|
|
393
|
+
: a.scoped
|
|
394
|
+
? -1
|
|
395
|
+
: 1,
|
|
396
|
+
);
|
|
397
|
+
item.page.items = decorated.map((d) => d.item);
|
|
398
|
+
modelLoading = false;
|
|
399
|
+
if (item.page.items.length === 0) {
|
|
400
|
+
ctx.ui.notify("No models available.", "warning");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
pushPage(item.page);
|
|
404
|
+
} catch {
|
|
405
|
+
modelLoading = false;
|
|
406
|
+
ctx.ui.notify("Cannot enumerate models.", "warning");
|
|
407
|
+
}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
pushPage(item.page);
|
|
411
|
+
} else {
|
|
412
|
+
done(item);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
selectList.onSelectionChange = (selected) => {
|
|
416
|
+
current().selectedValue = selected.value;
|
|
417
|
+
};
|
|
418
|
+
selectList.onCancel = () => done(null);
|
|
419
|
+
if (modelLoading && page.title === "Models") {
|
|
420
|
+
listHost.clear();
|
|
421
|
+
listHost.addChild(new Text(theme.fg("muted", "Loading models…"), 1, 0));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function pushPage(page: PalettePage) {
|
|
426
|
+
current().input = queryInput.getValue();
|
|
427
|
+
stack.push({ page, input: "" });
|
|
428
|
+
queryInput.setValue("");
|
|
429
|
+
rebuild();
|
|
430
|
+
tui.requestRender();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function popPage() {
|
|
434
|
+
if (stack.length <= 1) return;
|
|
435
|
+
stack.pop();
|
|
436
|
+
queryInput.setValue(current().input);
|
|
437
|
+
rebuild();
|
|
421
438
|
tui.requestRender();
|
|
422
439
|
}
|
|
423
440
|
|
|
424
|
-
|
|
425
|
-
container.addChild(
|
|
426
|
-
container.addChild(
|
|
427
|
-
|
|
428
|
-
);
|
|
441
|
+
rebuild();
|
|
442
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
443
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(root.title)), 1, 0));
|
|
444
|
+
container.addChild(queryInput);
|
|
445
|
+
container.addChild(listHost);
|
|
446
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter open/select • backspace on empty returns • esc closes"), 1, 0));
|
|
429
447
|
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
430
448
|
|
|
431
449
|
return {
|
|
450
|
+
get focused() {
|
|
451
|
+
return focused;
|
|
452
|
+
},
|
|
453
|
+
set focused(value: boolean) {
|
|
454
|
+
focused = value;
|
|
455
|
+
queryInput.focused = value;
|
|
456
|
+
},
|
|
432
457
|
render(w: number) {
|
|
433
|
-
|
|
458
|
+
const lines = container.render(w);
|
|
459
|
+
const title = stack.map((frame) => frame.page.title).join(" › ");
|
|
460
|
+
lines[1] = theme.fg("accent", theme.bold(title));
|
|
461
|
+
return lines;
|
|
434
462
|
},
|
|
435
463
|
invalidate() {
|
|
436
464
|
container.invalidate();
|
|
465
|
+
queryInput.invalidate();
|
|
466
|
+
selectList.invalidate();
|
|
437
467
|
},
|
|
438
468
|
handleInput(data: string) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (query.length > 0) {
|
|
442
|
-
query = query.slice(0, -1);
|
|
443
|
-
applyQuery();
|
|
444
|
-
}
|
|
469
|
+
if (matchesKey(data, Key.escape)) {
|
|
470
|
+
done(null);
|
|
445
471
|
return;
|
|
446
472
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
query += data;
|
|
450
|
-
applyQuery();
|
|
473
|
+
if (matchesKey(data, Key.backspace) && queryInput.getValue().length === 0) {
|
|
474
|
+
popPage();
|
|
451
475
|
return;
|
|
452
476
|
}
|
|
453
|
-
|
|
454
|
-
|
|
477
|
+
if (
|
|
478
|
+
matchesKey(data, Key.up) ||
|
|
479
|
+
matchesKey(data, Key.down) ||
|
|
480
|
+
matchesKey(data, Key.enter)
|
|
481
|
+
) {
|
|
482
|
+
selectList.handleInput(data);
|
|
483
|
+
} else {
|
|
484
|
+
const before = queryInput.getValue();
|
|
485
|
+
queryInput.handleInput(data);
|
|
486
|
+
if (queryInput.getValue() !== before) {
|
|
487
|
+
current().selectedValue = undefined;
|
|
488
|
+
rebuild();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
455
491
|
tui.requestRender();
|
|
456
492
|
},
|
|
457
493
|
};
|
|
458
494
|
},
|
|
459
|
-
{ overlay: true },
|
|
495
|
+
{ overlay: true, overlayOptions: { width: "70%", maxHeight: "80%", minWidth: 50 } },
|
|
460
496
|
);
|
|
461
497
|
|
|
462
498
|
if (!result) return;
|
|
@@ -464,6 +500,24 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
464
500
|
// Execute the selected action
|
|
465
501
|
const action = result.action;
|
|
466
502
|
switch (action.type) {
|
|
503
|
+
case "native": {
|
|
504
|
+
const cmd = paletteCommandRegistry.get(action.id);
|
|
505
|
+
if (!cmd) {
|
|
506
|
+
ctx.ui.notify(`Palette command not found: ${action.id}`, "warning");
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
await cmd.run(pi, ctx);
|
|
511
|
+
} catch (err) {
|
|
512
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
513
|
+
ctx.ui.notify(`Palette command "${cmd.label}" failed: ${message}`, "error");
|
|
514
|
+
}
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
case "model-select": {
|
|
518
|
+
// Kept for compatibility with callers that may construct this action.
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
467
521
|
case "restore": {
|
|
468
522
|
if (savedEditorText !== null) {
|
|
469
523
|
ctx.ui.setEditorText(savedEditorText);
|
|
@@ -480,8 +534,15 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
480
534
|
ctx.ui.setEditorText(action.text);
|
|
481
535
|
break;
|
|
482
536
|
}
|
|
483
|
-
case "model
|
|
484
|
-
|
|
537
|
+
case "model": {
|
|
538
|
+
const model = ctx.modelRegistry.find(action.provider, action.modelId);
|
|
539
|
+
if (model) {
|
|
540
|
+
const success = await pi.setModel(model);
|
|
541
|
+
ctx.ui.notify(
|
|
542
|
+
success ? `Model: ${action.provider}/${action.modelId}` : `No API key for ${action.provider}/${action.modelId}`,
|
|
543
|
+
success ? "info" : "error",
|
|
544
|
+
);
|
|
545
|
+
}
|
|
485
546
|
break;
|
|
486
547
|
}
|
|
487
548
|
case "compact": {
|