@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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # pi-command-palette
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@d3ara1n/pi-command-palette)](https://www.npmjs.com/package/@d3ara1n/pi-command-palette) [![npm downloads](https://img.shields.io/npm/dm/@d3ara1n/pi-command-palette)](https://www.npmjs.com/package/@d3ara1n/pi-command-palette) [![license](https://img.shields.io/npm/l/@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
- None.
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 actions** — curated shortcuts for common operations (detailed below)
38
- - **Extension commands** — All registered `/command` entries
39
- - **Skills & Templates** — Skill commands and prompt templates
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" 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`.
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.5.2",
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 and the partitioned fuzzy
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 { parseModelRef, partitionedFuzzyFilter } from "./index.ts";
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(result.map((m) => m.label), ["keep-scoped", "keep-other"]);
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(result.map((m) => m.label), ["sonnet"]);
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
- copyToClipboard,
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
- function buildPaletteItems(pi: ExtensionAPI, ctx: ExtensionContext): PaletteItem[] {
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, ordered by BUILTIN_ORDER (then alphabetical
176
- // for unlisted built-ins); extension commands follow alphabetically.
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 aBuilt = a.category === "Built-in";
179
- const bBuilt = b.category === "Built-in";
180
- if (aBuilt !== bBuilt) return aBuilt ? -1 : 1;
181
- if (aBuilt) {
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
- async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
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
- const parsed = parseModelRef(result);
359
- if (!parsed) return;
360
- const { provider, modelId } = parsed;
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
- // ── Command palette overlay ────────────────────────────────────────
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 (!ctx.hasUI) return;
376
-
377
- const paletteItems = buildPaletteItems(pi, ctx);
378
- const selectItems: SelectItem[] = paletteItems.map((item) => ({
379
- value: item.value,
380
- label: item.label,
381
- description: item.description,
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
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
388
- container.addChild(new Text(theme.fg("accent", theme.bold("Command Palette")), 1, 0));
389
-
390
- const selectList = new SelectList(selectItems, Math.min(selectItems.length, 15), {
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
- // Type-to-filter state
405
- let query = "";
406
- const queryText = new Text(theme.fg("accent", "> "), 1, 0);
323
+ function current() {
324
+ return stack[stack.length - 1]!;
325
+ }
407
326
 
408
- function applyQuery() {
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
- ? fuzzyFilter(
411
- selectItems,
412
- query,
413
- (item: SelectItem) => `${item.label} ${item.description ?? ""}`,
414
- )
415
- : selectItems;
416
- // FRAGILE: see model selector — depends on SelectList.filteredItems.
417
- (selectList as any).filteredItems = filtered;
418
- selectList.setSelectedIndex(0);
419
- queryText.setText(theme.fg("accent", `> ${query}▎`));
420
- container.invalidate();
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
- container.addChild(queryText);
425
- container.addChild(selectList);
426
- container.addChild(
427
- new Text(theme.fg("dim", "type to filter • ↑↓ navigate • enter select • esc cancel"), 1, 0),
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
- return container.render(w);
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
- // Backspace trim query
440
- if (matchesKey(data, Key.backspace)) {
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
- // Printable character append to query
448
- if (data.length === 1 && data.charCodeAt(0) >= 32) {
449
- query += data;
450
- applyQuery();
473
+ if (matchesKey(data, Key.backspace) && queryInput.getValue().length === 0) {
474
+ popPage();
451
475
  return;
452
476
  }
453
- // Navigation / confirm / cancel → pass to SelectList
454
- selectList.handleInput(data);
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-select": {
484
- await showModelSelector(pi, ctx);
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": {