@d3ara1n/pi-command-palette 0.5.3 → 0.7.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 +10 -7
- package/package.json +1 -1
- package/src/index.test.ts +16 -23
- package/src/index.ts +228 -214
package/README.md
CHANGED
|
@@ -34,12 +34,16 @@ Or add to `~/.pi/agent/settings.json`:
|
|
|
34
34
|
|----------|--------|
|
|
35
35
|
| `Ctrl+Shift+P` _(default, configurable)_ | Open command palette |
|
|
36
36
|
|
|
37
|
-
The palette
|
|
37
|
+
The palette opens as a single macOS-launcher-style overlay with nested pages. The root page mixes leaves and sub-pages: built-in actions and the model selector sit directly on the root, while the remaining categories open as sub-pages — selecting one 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
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
- **
|
|
42
|
-
- **
|
|
39
|
+
The root page lists:
|
|
40
|
+
|
|
41
|
+
- **Built-in actions** — curated shortcuts for common operations, shown directly on the root page so urgent entries like Restore never hide behind a sub-page (detailed below)
|
|
42
|
+
- **Models** — a sub-page listing every model with a configured API key (see below)
|
|
43
|
+
- **Extension Actions** — a sub-page of entries registered by other extensions that run a callback directly (see below)
|
|
44
|
+
- **Commands** / **Skills** / **Templates** — sub-pages for all registered `/command` entries, installed skills, and prompt templates; entries are labeled with their bare `/name` since the breadcrumb already names the category
|
|
45
|
+
|
|
46
|
+
Use **↑/↓** to move through entries and **←/→** to edit the search cursor. Search is fuzzy within the current page and updates as you type; searching the root page also matches entries from every sub-page, with each match's category shown next to its description. Backspace uses the normal text-editing behavior while the query is non-empty.
|
|
43
47
|
|
|
44
48
|
### Built-in actions
|
|
45
49
|
|
|
@@ -49,7 +53,6 @@ Built-in actions are grouped by how they run:
|
|
|
49
53
|
|
|
50
54
|
| Action | What it does |
|
|
51
55
|
|--------|--------------|
|
|
52
|
-
| Model: Switch Model | Open a model selector overlay; switch instantly |
|
|
53
56
|
| Session: Compact | Compact the conversation right away |
|
|
54
57
|
| Editor: Copy Content | Copy current editor text to the clipboard |
|
|
55
58
|
| Editor: Clear Content | Clear the editor, saving the current text to the restore buffer |
|
|
@@ -90,7 +93,7 @@ When a command replaces your editor text, or you run **Editor: Clear Content**,
|
|
|
90
93
|
|
|
91
94
|
### Model selector
|
|
92
95
|
|
|
93
|
-
The "
|
|
96
|
+
The "Models" 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.
|
|
94
97
|
|
|
95
98
|
**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.
|
|
96
99
|
|
package/package.json
CHANGED
package/src/index.test.ts
CHANGED
|
@@ -1,37 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Regression tests for
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Regression tests for the partitioned fuzzy filter that keeps scoped
|
|
3
|
+
* models on top while searching, and the palette item ordering that keeps
|
|
4
|
+
* built-ins → native commands → editor-fill entries.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import { after, test } from "node:test";
|
|
9
9
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { paletteCommandRegistry } from "@d3ara1n/pi-command-palette-core";
|
|
11
|
-
import { buildPaletteItems,
|
|
11
|
+
import { buildPaletteItems, partitionedFuzzyFilter } from "./index.ts";
|
|
12
12
|
|
|
13
13
|
/** Minimal fake of the pi API surface buildPaletteItems uses. */
|
|
14
|
-
function fakePi(
|
|
14
|
+
function fakePi(
|
|
15
|
+
commands: { name: string; description?: string; source?: "extension" | "skill" | "template" }[],
|
|
16
|
+
): ExtensionAPI {
|
|
15
17
|
return { getCommands: () => commands } as unknown as ExtensionAPI;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
test("parseModelRef splits provider and model at the first slash", () => {
|
|
19
|
-
assert.deepEqual(parseModelRef("anthropic/claude-sonnet"), {
|
|
20
|
-
provider: "anthropic",
|
|
21
|
-
modelId: "claude-sonnet",
|
|
22
|
-
});
|
|
23
|
-
assert.deepEqual(parseModelRef("openrouter/vendor/model/with/slashes"), {
|
|
24
|
-
provider: "openrouter",
|
|
25
|
-
modelId: "vendor/model/with/slashes",
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("parseModelRef preserves empty provider or model segments", () => {
|
|
30
|
-
assert.equal(parseModelRef("model-without-provider"), undefined);
|
|
31
|
-
assert.deepEqual(parseModelRef("/model"), { provider: "", modelId: "model" });
|
|
32
|
-
assert.deepEqual(parseModelRef("provider/"), { provider: "provider", modelId: "" });
|
|
33
|
-
});
|
|
34
|
-
|
|
35
20
|
// ── partitionedFuzzyFilter ─────────────────────────────────────────
|
|
36
21
|
|
|
37
22
|
test("partitionedFuzzyFilter concatenates partitions unchanged for empty query", () => {
|
|
@@ -108,7 +93,7 @@ test("buildPaletteItems orders built-ins above native commands above editor fill
|
|
|
108
93
|
});
|
|
109
94
|
|
|
110
95
|
const items = buildPaletteItems(
|
|
111
|
-
fakePi([{ name: "some-command", description: "extension command" }]),
|
|
96
|
+
fakePi([{ name: "some-command", description: "extension command", source: "extension" }]),
|
|
112
97
|
);
|
|
113
98
|
|
|
114
99
|
const ranks = items.map((item) =>
|
|
@@ -122,6 +107,14 @@ test("buildPaletteItems orders built-ins above native commands above editor fill
|
|
|
122
107
|
assert.ok(native);
|
|
123
108
|
assert.equal(native.label, "Peek: Ask This Session");
|
|
124
109
|
assert.equal(native.action.type, "native");
|
|
110
|
+
|
|
111
|
+
// Command/skill/template labels carry no category prefix — the page
|
|
112
|
+
// breadcrumb already names the category, and root search shows it via
|
|
113
|
+
// the description decoration.
|
|
114
|
+
const cmd = items.find((item) => item.value === "cmd:some-command");
|
|
115
|
+
assert.ok(cmd);
|
|
116
|
+
assert.equal(cmd.label, "/some-command");
|
|
117
|
+
assert.equal(cmd.category, "Command");
|
|
125
118
|
});
|
|
126
119
|
|
|
127
120
|
test("buildPaletteItems picks up native commands registered after load", () => {
|
package/src/index.ts
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* regardless of whether the editor has content.
|
|
6
6
|
*
|
|
7
7
|
* Features:
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* - Single overlay with nested pages: built-in actions on the root page,
|
|
9
|
+
* extension actions / commands / skills / templates as sub-pages, models
|
|
10
|
+
* loaded on first visit
|
|
11
|
+
* - Fuzzy search within the current page; searching the root page matches
|
|
12
|
+
* every leaf across sub-pages
|
|
11
13
|
* - Floating overlay on top of existing content
|
|
12
14
|
* - Saves editor text before overwriting; offers "Restore" in palette
|
|
13
15
|
* - Clear editor into the restore buffer
|
|
@@ -20,10 +22,12 @@ import {
|
|
|
20
22
|
Container,
|
|
21
23
|
type SelectItem,
|
|
22
24
|
fuzzyFilter,
|
|
25
|
+
Input,
|
|
23
26
|
Key,
|
|
24
27
|
matchesKey,
|
|
25
28
|
SelectList,
|
|
26
29
|
Text,
|
|
30
|
+
type TUI,
|
|
27
31
|
} from "@earendil-works/pi-tui";
|
|
28
32
|
import { resolveShortcutKey } from "./config.ts";
|
|
29
33
|
|
|
@@ -32,7 +36,7 @@ import { resolveShortcutKey } from "./config.ts";
|
|
|
32
36
|
type CommandAction =
|
|
33
37
|
| { type: "editor"; text: string }
|
|
34
38
|
| { type: "native"; id: string }
|
|
35
|
-
| { type: "model
|
|
39
|
+
| { type: "model"; provider: string; modelId: string }
|
|
36
40
|
| { type: "compact" }
|
|
37
41
|
| { type: "reload" }
|
|
38
42
|
| { type: "restore" }
|
|
@@ -58,10 +62,9 @@ let savedEditorText: string | null = null;
|
|
|
58
62
|
* non-built-in entries always sort after built-ins.
|
|
59
63
|
*/
|
|
60
64
|
const BUILTIN_ORDER: Record<string, number> = {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
__clear_editor: 3,
|
|
65
|
+
__restore: 0,
|
|
66
|
+
__copy_editor: 1,
|
|
67
|
+
__clear_editor: 2,
|
|
65
68
|
};
|
|
66
69
|
|
|
67
70
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
@@ -98,14 +101,6 @@ export function buildPaletteItems(pi: ExtensionAPI): PaletteItem[] {
|
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
// ── Built-in actions ──────────────────────────────────────────
|
|
101
|
-
items.push({
|
|
102
|
-
value: "__model_select",
|
|
103
|
-
label: "Model: Switch Model",
|
|
104
|
-
description: "Select a model from the registry",
|
|
105
|
-
category: "Built-in",
|
|
106
|
-
action: { type: "model-select" },
|
|
107
|
-
});
|
|
108
|
-
|
|
109
104
|
items.push({
|
|
110
105
|
value: "__new_session",
|
|
111
106
|
label: "Session: New",
|
|
@@ -193,7 +188,7 @@ export function buildPaletteItems(pi: ExtensionAPI): PaletteItem[] {
|
|
|
193
188
|
|
|
194
189
|
items.push({
|
|
195
190
|
value: `cmd:${cmd.name}`,
|
|
196
|
-
label:
|
|
191
|
+
label: `/${cmd.name}`,
|
|
197
192
|
description: cmd.description ?? "",
|
|
198
193
|
category: sourceLabel,
|
|
199
194
|
action: { type: "editor", text: editorText },
|
|
@@ -217,22 +212,6 @@ export function buildPaletteItems(pi: ExtensionAPI): PaletteItem[] {
|
|
|
217
212
|
return items;
|
|
218
213
|
}
|
|
219
214
|
|
|
220
|
-
// ── Model selector ─────────────────────────────────────────────────
|
|
221
|
-
|
|
222
|
-
const STAR = "★ ";
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* @internal — exported for testing; parses the selector's `provider/model-id` values.
|
|
226
|
-
*/
|
|
227
|
-
export function parseModelRef(modelRef: string): { provider: string; modelId: string } | undefined {
|
|
228
|
-
const slash = modelRef.indexOf("/");
|
|
229
|
-
if (slash === -1) return undefined;
|
|
230
|
-
return {
|
|
231
|
-
provider: modelRef.slice(0, slash),
|
|
232
|
-
modelId: modelRef.slice(slash + 1),
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
215
|
// ── Partitioned fuzzy filter ───────────────────────────────────────
|
|
237
216
|
|
|
238
217
|
/**
|
|
@@ -257,231 +236,254 @@ export function partitionedFuzzyFilter<T>(
|
|
|
257
236
|
return [...fuzzyFilter(primary, query, getText), ...fuzzyFilter(secondary, query, getText)];
|
|
258
237
|
}
|
|
259
238
|
|
|
260
|
-
|
|
261
|
-
let models: Awaited<ReturnType<typeof ctx.modelRegistry.getAvailable>>;
|
|
262
|
-
try {
|
|
263
|
-
models = await ctx.modelRegistry.getAvailable();
|
|
264
|
-
} catch {
|
|
265
|
-
ctx.ui.notify("Cannot enumerate models. Use Ctrl+L instead.", "warning");
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
239
|
+
// ── Command palette overlay ────────────────────────────────────────
|
|
268
240
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
241
|
+
interface PageEntry {
|
|
242
|
+
type: "page";
|
|
243
|
+
value: string;
|
|
244
|
+
label: string;
|
|
245
|
+
description: string;
|
|
246
|
+
page: PalettePage;
|
|
247
|
+
}
|
|
273
248
|
|
|
274
|
-
|
|
249
|
+
interface PalettePage {
|
|
250
|
+
title: string;
|
|
251
|
+
items: Array<PaletteItem | PageEntry>;
|
|
252
|
+
}
|
|
275
253
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
254
|
+
function pageItem(value: string, label: string, description: string, page: PalettePage): PageEntry {
|
|
255
|
+
return { type: "page", value, label, description, page };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
259
|
+
if (ctx.mode !== "tui") return;
|
|
260
|
+
|
|
261
|
+
const paletteItems = buildPaletteItems(pi);
|
|
262
|
+
const leaves = (category: string) =>
|
|
263
|
+
paletteItems.filter((item) => item.category === category);
|
|
264
|
+
const leafPage = (title: string, items: PaletteItem[]): PalettePage => ({ title, items });
|
|
265
|
+
|
|
266
|
+
const builtins = leaves("Built-in");
|
|
267
|
+
const native = leaves("Native");
|
|
268
|
+
const commands = leaves("Command");
|
|
269
|
+
const skills = leaves("Skill");
|
|
270
|
+
const templates = leaves("Template");
|
|
271
|
+
// Leaves reachable only through sub-pages — merged into the root list
|
|
272
|
+
// while searching there. Built-in leaves live on the root page itself.
|
|
273
|
+
const subLeaves = paletteItems.filter((item) => item.category !== "Built-in");
|
|
274
|
+
const modelPage: PalettePage = { title: "Models", items: [] };
|
|
275
|
+
const rootItems: PalettePage["items"] = [
|
|
276
|
+
pageItem("models", "Models", "Switch the active model", modelPage),
|
|
277
|
+
// Built-in actions sit directly on the root page so urgent entries like
|
|
278
|
+
// Restore are visible without descending into a sub-page.
|
|
279
|
+
...builtins,
|
|
280
|
+
...(native.length ? [pageItem("native", "Extension Actions", "Actions provided by extensions", leafPage("Extension Actions", native))] : []),
|
|
281
|
+
...(commands.length ? [pageItem("commands", "Commands", "Extension slash commands", leafPage("Commands", commands))] : []),
|
|
282
|
+
...(skills.length ? [pageItem("skills", "Skills", "Installed skills", leafPage("Skills", skills))] : []),
|
|
283
|
+
...(templates.length ? [pageItem("templates", "Templates", "Prompt templates", leafPage("Templates", templates))] : []),
|
|
284
|
+
];
|
|
285
|
+
const root: PalettePage = { title: "Command Palette", items: rootItems };
|
|
286
|
+
|
|
287
|
+
// Captured from the overlay factory so palette actions can request a
|
|
288
|
+
// render after mutating editor state (see the action switch below).
|
|
289
|
+
let tuiRef: TUI | undefined;
|
|
290
|
+
|
|
291
|
+
/** Scoped-model marker prefix (★). */
|
|
292
|
+
const STAR = "★ ";
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Build the model list for the Models page: scoped models (★) first, then
|
|
296
|
+
* the rest — alphabetical within each group. Reads the registry
|
|
297
|
+
* synchronously; throws if enumeration fails.
|
|
298
|
+
*/
|
|
299
|
+
function buildModelItems(): PaletteItem[] {
|
|
300
|
+
const scopedIds = new Set(ctx.scopedModels.map((s) => `${s.model.provider}/${s.model.id}`));
|
|
301
|
+
return ctx.modelRegistry
|
|
302
|
+
.getAvailable()
|
|
303
|
+
.map((m): PaletteItem => {
|
|
304
|
+
const scoped = scopedIds.has(`${m.provider}/${m.id}`);
|
|
305
|
+
return {
|
|
306
|
+
value: `${m.provider}/${m.id}`,
|
|
288
307
|
label: scoped ? `${STAR}${m.name}` : m.name,
|
|
289
308
|
description: m.provider,
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const scopedItems: SelectItem[] = decorated.filter((d) => d.scoped).map((d) => d.item);
|
|
302
|
-
const otherItems: SelectItem[] = decorated.filter((d) => !d.scoped).map((d) => d.item);
|
|
303
|
-
const items: SelectItem[] = [...scopedItems, ...otherItems];
|
|
309
|
+
category: "Models",
|
|
310
|
+
action: { type: "model", provider: m.provider, modelId: m.id },
|
|
311
|
+
};
|
|
312
|
+
})
|
|
313
|
+
.sort((a, b) => {
|
|
314
|
+
const aScoped = scopedIds.has(a.value);
|
|
315
|
+
const bScoped = scopedIds.has(b.value);
|
|
316
|
+
if (aScoped !== bScoped) return aScoped ? -1 : 1;
|
|
317
|
+
return a.label.localeCompare(b.label);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
304
320
|
|
|
305
|
-
const result = await ctx.ui.custom<
|
|
321
|
+
const result = await ctx.ui.custom<PaletteItem | null>(
|
|
306
322
|
(tui, theme, _kb, done) => {
|
|
323
|
+
tuiRef = tui;
|
|
307
324
|
const container = new Container();
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
325
|
+
const listHost = new Container();
|
|
326
|
+
const queryInput = new Input();
|
|
327
|
+
let focused = true;
|
|
328
|
+
const stack: Array<{ page: PalettePage; input: string; selectedValue?: string }> = [
|
|
329
|
+
{ page: root, input: "" },
|
|
330
|
+
];
|
|
331
|
+
let selectList!: SelectList;
|
|
332
|
+
let visibleItems: Array<PaletteItem | PageEntry> = [];
|
|
333
|
+
|
|
334
|
+
const listTheme = {
|
|
312
335
|
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
313
336
|
selectedText: (t: string) => theme.fg("accent", t),
|
|
314
337
|
description: (t: string) => theme.fg("muted", t),
|
|
315
338
|
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
316
339
|
noMatch: (t: string) => theme.fg("warning", t),
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
selectList.onSelect = (item) => done(item.value);
|
|
320
|
-
selectList.onCancel = () => done(null);
|
|
321
|
-
|
|
322
|
-
// Type-to-filter state
|
|
323
|
-
let query = "";
|
|
324
|
-
const queryText = new Text(theme.fg("accent", "> "), 1, 0);
|
|
340
|
+
};
|
|
325
341
|
|
|
326
|
-
function
|
|
327
|
-
|
|
328
|
-
scopedItems,
|
|
329
|
-
otherItems,
|
|
330
|
-
query,
|
|
331
|
-
(item: SelectItem) => `${item.label} ${item.description ?? ""}`,
|
|
332
|
-
);
|
|
333
|
-
// FRAGILE: SelectList has no public filter/setItems API, so we poke its
|
|
334
|
-
// private filteredItems directly. If pi-tui renames it, filtering breaks
|
|
335
|
-
// silently with no compile error.
|
|
336
|
-
(selectList as any).filteredItems = filtered;
|
|
337
|
-
selectList.setSelectedIndex(0);
|
|
338
|
-
queryText.setText(theme.fg("accent", `> ${query}▎`));
|
|
339
|
-
container.invalidate();
|
|
340
|
-
tui.requestRender();
|
|
342
|
+
function current() {
|
|
343
|
+
return stack[stack.length - 1]!;
|
|
341
344
|
}
|
|
342
345
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
346
|
+
function rebuild() {
|
|
347
|
+
const { page } = current();
|
|
348
|
+
const query = queryInput.getValue();
|
|
349
|
+
visibleItems =
|
|
350
|
+
page === root && query.trim() ? [...root.items, ...subLeaves] : page.items;
|
|
351
|
+
const items = visibleItems.map((item) => ({
|
|
352
|
+
value: item.value,
|
|
353
|
+
label: item.label,
|
|
354
|
+
description:
|
|
355
|
+
page === root && query.trim() && !("page" in item)
|
|
356
|
+
? `${item.category} › ${item.description}`
|
|
357
|
+
: item.description,
|
|
358
|
+
}));
|
|
359
|
+
const getText = (item: SelectItem) => `${item.label} ${item.description ?? ""}`;
|
|
360
|
+
const filtered = query
|
|
361
|
+
? page === modelPage
|
|
362
|
+
? partitionedFuzzyFilter(
|
|
363
|
+
items.filter((item) => item.label.startsWith(STAR)),
|
|
364
|
+
items.filter((item) => !item.label.startsWith(STAR)),
|
|
365
|
+
query,
|
|
366
|
+
getText,
|
|
367
|
+
)
|
|
368
|
+
: fuzzyFilter(items, query, getText)
|
|
369
|
+
: items;
|
|
370
|
+
selectList = new SelectList(filtered, Math.min(Math.max(filtered.length, 1), 15), listTheme);
|
|
371
|
+
const restoredIndex = filtered.findIndex(
|
|
372
|
+
(item) => item.value === current().selectedValue,
|
|
373
|
+
);
|
|
374
|
+
if (restoredIndex >= 0) selectList.setSelectedIndex(restoredIndex);
|
|
375
|
+
listHost.clear();
|
|
376
|
+
listHost.addChild(selectList);
|
|
377
|
+
selectList.onSelect = (selected) => {
|
|
378
|
+
current().selectedValue = selected.value;
|
|
379
|
+
const item = visibleItems.find((candidate) => candidate.value === selected.value);
|
|
380
|
+
if (!item) return;
|
|
381
|
+
if (!("page" in item)) {
|
|
382
|
+
done(item);
|
|
364
383
|
return;
|
|
365
384
|
}
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
385
|
+
// Entering the Models page loads the registry once per palette
|
|
386
|
+
// session; later visits reuse the cached list.
|
|
387
|
+
if (item.page === modelPage && item.page.items.length === 0) {
|
|
388
|
+
try {
|
|
389
|
+
item.page.items = buildModelItems();
|
|
390
|
+
} catch {
|
|
391
|
+
ctx.ui.notify("Cannot enumerate models. Use Ctrl+L instead.", "warning");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (item.page.items.length === 0) {
|
|
395
|
+
ctx.ui.notify("No models available.", "warning");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
371
398
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
{ overlay: true },
|
|
379
|
-
);
|
|
380
|
-
|
|
381
|
-
if (!result) return;
|
|
382
|
-
|
|
383
|
-
const parsed = parseModelRef(result);
|
|
384
|
-
if (!parsed) return;
|
|
385
|
-
const { provider, modelId } = parsed;
|
|
386
|
-
const model = ctx.modelRegistry.find(provider, modelId);
|
|
387
|
-
if (model) {
|
|
388
|
-
const success = await pi.setModel(model);
|
|
389
|
-
if (success) {
|
|
390
|
-
ctx.ui.notify(`Model: ${provider}/${modelId}`, "info");
|
|
391
|
-
} else {
|
|
392
|
-
ctx.ui.notify(`No API key for ${provider}/${modelId}`, "error");
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// ── Command palette overlay ────────────────────────────────────────
|
|
398
|
-
|
|
399
|
-
async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
400
|
-
if (!ctx.hasUI) return;
|
|
401
|
-
|
|
402
|
-
const paletteItems = buildPaletteItems(pi);
|
|
403
|
-
const selectItems: SelectItem[] = paletteItems.map((item) => ({
|
|
404
|
-
value: item.value,
|
|
405
|
-
label: item.label,
|
|
406
|
-
description: item.description,
|
|
407
|
-
}));
|
|
408
|
-
|
|
409
|
-
const result = await ctx.ui.custom<PaletteItem | null>(
|
|
410
|
-
(tui, theme, _kb, done) => {
|
|
411
|
-
const container = new Container();
|
|
412
|
-
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
413
|
-
container.addChild(new Text(theme.fg("accent", theme.bold("Command Palette")), 1, 0));
|
|
399
|
+
pushPage(item.page);
|
|
400
|
+
};
|
|
401
|
+
selectList.onSelectionChange = (selected) => {
|
|
402
|
+
current().selectedValue = selected.value;
|
|
403
|
+
};
|
|
404
|
+
}
|
|
414
405
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
selectedText: (t: string) => theme.fg("accent", t),
|
|
418
|
-
description: (t: string) => theme.fg("muted", t),
|
|
419
|
-
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
420
|
-
noMatch: (t: string) => theme.fg("warning", t),
|
|
421
|
-
});
|
|
406
|
+
// Breadcrumb title: updated in place whenever the page stack changes.
|
|
407
|
+
const titleText = new Text(theme.fg("accent", theme.bold(root.title)), 1, 0);
|
|
422
408
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
409
|
+
function updateTitle() {
|
|
410
|
+
titleText.setText(
|
|
411
|
+
theme.fg("accent", theme.bold(stack.map((frame) => frame.page.title).join(" › "))),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
428
414
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
415
|
+
function pushPage(page: PalettePage) {
|
|
416
|
+
current().input = queryInput.getValue();
|
|
417
|
+
stack.push({ page, input: "" });
|
|
418
|
+
queryInput.setValue("");
|
|
419
|
+
updateTitle();
|
|
420
|
+
rebuild();
|
|
421
|
+
tui.requestRender();
|
|
422
|
+
}
|
|
432
423
|
|
|
433
|
-
function
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
)
|
|
440
|
-
: selectItems;
|
|
441
|
-
// FRAGILE: see model selector — depends on SelectList.filteredItems.
|
|
442
|
-
(selectList as any).filteredItems = filtered;
|
|
443
|
-
selectList.setSelectedIndex(0);
|
|
444
|
-
queryText.setText(theme.fg("accent", `> ${query}▎`));
|
|
445
|
-
container.invalidate();
|
|
424
|
+
function popPage() {
|
|
425
|
+
if (stack.length <= 1) return;
|
|
426
|
+
stack.pop();
|
|
427
|
+
queryInput.setValue(current().input);
|
|
428
|
+
updateTitle();
|
|
429
|
+
rebuild();
|
|
446
430
|
tui.requestRender();
|
|
447
431
|
}
|
|
448
432
|
|
|
449
|
-
|
|
450
|
-
container.addChild(
|
|
433
|
+
rebuild();
|
|
434
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
435
|
+
container.addChild(titleText);
|
|
436
|
+
container.addChild(queryInput);
|
|
437
|
+
container.addChild(listHost);
|
|
451
438
|
container.addChild(
|
|
452
|
-
new Text(theme.fg("dim", "
|
|
439
|
+
new Text(theme.fg("dim", "↑↓ navigate • enter open/select • backspace go back • esc close"), 1, 0),
|
|
453
440
|
);
|
|
454
441
|
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
455
442
|
|
|
456
443
|
return {
|
|
444
|
+
get focused() {
|
|
445
|
+
return focused;
|
|
446
|
+
},
|
|
447
|
+
set focused(value: boolean) {
|
|
448
|
+
focused = value;
|
|
449
|
+
queryInput.focused = value;
|
|
450
|
+
},
|
|
457
451
|
render(w: number) {
|
|
458
452
|
return container.render(w);
|
|
459
453
|
},
|
|
460
454
|
invalidate() {
|
|
461
455
|
container.invalidate();
|
|
456
|
+
queryInput.invalidate();
|
|
457
|
+
selectList.invalidate();
|
|
462
458
|
},
|
|
463
459
|
handleInput(data: string) {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if (query.length > 0) {
|
|
467
|
-
query = query.slice(0, -1);
|
|
468
|
-
applyQuery();
|
|
469
|
-
}
|
|
460
|
+
if (matchesKey(data, Key.escape)) {
|
|
461
|
+
done(null);
|
|
470
462
|
return;
|
|
471
463
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
query += data;
|
|
475
|
-
applyQuery();
|
|
464
|
+
if (matchesKey(data, Key.backspace) && queryInput.getValue().length === 0) {
|
|
465
|
+
popPage();
|
|
476
466
|
return;
|
|
477
467
|
}
|
|
478
|
-
|
|
479
|
-
|
|
468
|
+
if (
|
|
469
|
+
matchesKey(data, Key.up) ||
|
|
470
|
+
matchesKey(data, Key.down) ||
|
|
471
|
+
matchesKey(data, Key.enter)
|
|
472
|
+
) {
|
|
473
|
+
selectList.handleInput(data);
|
|
474
|
+
} else {
|
|
475
|
+
const before = queryInput.getValue();
|
|
476
|
+
queryInput.handleInput(data);
|
|
477
|
+
if (queryInput.getValue() !== before) {
|
|
478
|
+
current().selectedValue = undefined;
|
|
479
|
+
rebuild();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
480
482
|
tui.requestRender();
|
|
481
483
|
},
|
|
482
484
|
};
|
|
483
485
|
},
|
|
484
|
-
{ overlay: true },
|
|
486
|
+
{ overlay: true, overlayOptions: { width: "70%", maxHeight: "80%", minWidth: 50 } },
|
|
485
487
|
);
|
|
486
488
|
|
|
487
489
|
if (!result) return;
|
|
@@ -519,8 +521,15 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
519
521
|
ctx.ui.setEditorText(action.text);
|
|
520
522
|
break;
|
|
521
523
|
}
|
|
522
|
-
case "model
|
|
523
|
-
|
|
524
|
+
case "model": {
|
|
525
|
+
const model = ctx.modelRegistry.find(action.provider, action.modelId);
|
|
526
|
+
if (model) {
|
|
527
|
+
const success = await pi.setModel(model);
|
|
528
|
+
ctx.ui.notify(
|
|
529
|
+
success ? `Model: ${action.provider}/${action.modelId}` : `No API key for ${action.provider}/${action.modelId}`,
|
|
530
|
+
success ? "info" : "error",
|
|
531
|
+
);
|
|
532
|
+
}
|
|
524
533
|
break;
|
|
525
534
|
}
|
|
526
535
|
case "compact": {
|
|
@@ -562,6 +571,11 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
562
571
|
break;
|
|
563
572
|
}
|
|
564
573
|
}
|
|
574
|
+
|
|
575
|
+
// The overlay close renders the underlying UI before this promise
|
|
576
|
+
// resolves, and setEditorText mutates state without requesting a render —
|
|
577
|
+
// without this, the editor shows stale text until the next keypress.
|
|
578
|
+
tuiRef?.requestRender();
|
|
565
579
|
}
|
|
566
580
|
|
|
567
581
|
// ── Extension entry point ──────────────────────────────────────────
|