@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 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 lists:
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
- - **Built-in actions** — curated shortcuts for common operations (detailed below)
40
- - **Native commands** — entries registered by other extensions that run a callback directly (see below)
41
- - **Extension commands** — All registered `/command` entries
42
- - **Skills & Templates** — Skill commands and prompt templates
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 "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`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-command-palette",
3
- "version": "0.5.3",
3
+ "version": "0.7.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",
package/src/index.test.ts CHANGED
@@ -1,37 +1,22 @@
1
1
  /**
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.
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, parseModelRef, partitionedFuzzyFilter } from "./index.ts";
11
+ import { buildPaletteItems, partitionedFuzzyFilter } from "./index.ts";
12
12
 
13
13
  /** Minimal fake of the pi API surface buildPaletteItems uses. */
14
- function fakePi(commands: { name: string; description?: string }[]): ExtensionAPI {
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
- * - Lists extension commands, skills, and prompt templates (from pi.getCommands())
9
- * - Built-in actions: model selector, new session, compact, reload
10
- * - Fuzzy search via SelectList
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-select" }
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
- __model_select: 0,
62
- __restore: 1,
63
- __copy_editor: 2,
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: `${sourceLabel}: /${cmd.name}`,
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
- async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
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
- if (models.length === 0) {
270
- ctx.ui.notify("No models available.", "warning");
271
- return;
272
- }
241
+ interface PageEntry {
242
+ type: "page";
243
+ value: string;
244
+ label: string;
245
+ description: string;
246
+ page: PalettePage;
247
+ }
273
248
 
274
- const scopedIds = new Set(ctx.scopedModels.map((s) => `${s.model.provider}/${s.model.id}`));
249
+ interface PalettePage {
250
+ title: string;
251
+ items: Array<PaletteItem | PageEntry>;
252
+ }
275
253
 
276
- // Build each model's SelectItem alongside whether it's scope-pinned, then
277
- // sort into two stable groups — scoped first (★), then the rest —
278
- // alphabetical within each. We keep the groups as separate arrays so the
279
- // search pipeline below can re-apply the same partitioning.
280
- const decorated = models
281
- .map((m) => {
282
- const value = `${m.provider}/${m.id}`;
283
- const scoped = scopedIds.has(value);
284
- return {
285
- scoped,
286
- item: {
287
- value,
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
- } satisfies SelectItem,
291
- };
292
- })
293
- .sort((a, b) => {
294
- if (a.scoped !== b.scoped) return a.scoped ? -1 : 1;
295
- return a.item.label.localeCompare(b.item.label);
296
- });
297
-
298
- // Scoped models () stay above the rest whether browsing or filtering: each
299
- // group is filtered and ranked independently, then concatenated, so a search
300
- // never merges the two into one score-ordered list.
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<string | null>(
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
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
309
- container.addChild(new Text(theme.fg("accent", theme.bold("Switch Model")), 1, 0));
310
-
311
- const selectList = new SelectList(items, Math.min(items.length, 12), {
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 applyQuery() {
327
- const filtered = partitionedFuzzyFilter(
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
- container.addChild(queryText);
344
- container.addChild(selectList);
345
- container.addChild(
346
- new Text(theme.fg("dim", "type to filter • ↑↓ navigate • enter select • esc cancel"), 1, 0),
347
- );
348
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
349
-
350
- return {
351
- render(w: number) {
352
- return container.render(w);
353
- },
354
- invalidate() {
355
- container.invalidate();
356
- },
357
- handleInput(data: string) {
358
- // Backspace trim query
359
- if (matchesKey(data, Key.backspace)) {
360
- if (query.length > 0) {
361
- query = query.slice(0, -1);
362
- applyQuery();
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
- // Printable character append to query
367
- if (data.length === 1 && data.charCodeAt(0) >= 32) {
368
- query += data;
369
- applyQuery();
370
- return;
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
- // Navigation / confirm / cancel → pass to SelectList
373
- selectList.handleInput(data);
374
- tui.requestRender();
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
- const selectList = new SelectList(selectItems, Math.min(selectItems.length, 15), {
416
- selectedPrefix: (t: string) => theme.fg("accent", t),
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
- selectList.onSelect = (item) => {
424
- const paletteItem = paletteItems.find((p) => p.value === item.value);
425
- done(paletteItem ?? null);
426
- };
427
- selectList.onCancel = () => done(null);
409
+ function updateTitle() {
410
+ titleText.setText(
411
+ theme.fg("accent", theme.bold(stack.map((frame) => frame.page.title).join(" › "))),
412
+ );
413
+ }
428
414
 
429
- // Type-to-filter state
430
- let query = "";
431
- const queryText = new Text(theme.fg("accent", "> "), 1, 0);
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 applyQuery() {
434
- const filtered = query
435
- ? fuzzyFilter(
436
- selectItems,
437
- query,
438
- (item: SelectItem) => `${item.label} ${item.description ?? ""}`,
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
- container.addChild(queryText);
450
- container.addChild(selectList);
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", "type to filter • ↑↓ navigate • enter select • esc cancel"), 1, 0),
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
- // Backspace trim query
465
- if (matchesKey(data, Key.backspace)) {
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
- // Printable character append to query
473
- if (data.length === 1 && data.charCodeAt(0) >= 32) {
474
- query += data;
475
- applyQuery();
464
+ if (matchesKey(data, Key.backspace) && queryInput.getValue().length === 0) {
465
+ popPage();
476
466
  return;
477
467
  }
478
- // Navigation / confirm / cancel → pass to SelectList
479
- selectList.handleInput(data);
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-select": {
523
- await showModelSelector(pi, ctx);
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 ──────────────────────────────────────────