@d3ara1n/pi-command-palette 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,11 @@ Global command palette for [Pi Coding Agent](https://pi.dev) — press **Ctrl+Sh
6
6
 
7
7
  Pi's slash commands (`/model`, `/compact`, extension commands, etc.) only work when the editor is empty. If you've typed something and want to switch models or run a command, you're stuck. This extension opens a floating command palette via keyboard shortcut, regardless of editor state.
8
8
 
9
- ## Install
9
+ ## Dependencies
10
+
11
+ None.
12
+
13
+ ## Installation
10
14
 
11
15
  ```bash
12
16
  pi install npm:@d3ara1n/pi-command-palette
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-command-palette",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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/config.ts CHANGED
@@ -22,25 +22,25 @@ import type { KeyId } from "@earendil-works/pi-tui";
22
22
  export const DEFAULT_SHORTCUT = "ctrl+shift+p";
23
23
 
24
24
  function getAgentDir(): string {
25
- const envDir = process.env.PI_AGENT_DIR;
26
- if (envDir) return envDir;
27
- return path.join(os.homedir(), ".pi", "agent");
25
+ const envDir = process.env.PI_AGENT_DIR;
26
+ if (envDir) return envDir;
27
+ return path.join(os.homedir(), ".pi", "agent");
28
28
  }
29
29
 
30
30
  function readSettings(filePath: string): Record<string, unknown> {
31
- try {
32
- if (!fs.existsSync(filePath)) return {};
33
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
34
- } catch {
35
- return {};
36
- }
31
+ try {
32
+ if (!fs.existsSync(filePath)) return {};
33
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
34
+ } catch {
35
+ return {};
36
+ }
37
37
  }
38
38
 
39
39
  /** Lowercase + trim; returns undefined for empty/non-string input. */
40
40
  function normalizeKey(raw: unknown): string | undefined {
41
- if (typeof raw !== "string") return undefined;
42
- const trimmed = raw.trim().toLowerCase();
43
- return trimmed || undefined;
41
+ if (typeof raw !== "string") return undefined;
42
+ const trimmed = raw.trim().toLowerCase();
43
+ return trimmed || undefined;
44
44
  }
45
45
 
46
46
  /**
@@ -50,18 +50,18 @@ function normalizeKey(raw: unknown): string | undefined {
50
50
  * Defaults to `process.cwd()` (accurate at pi startup when shortcuts register).
51
51
  */
52
52
  export function resolveShortcutKey(cwd: string = process.cwd()): KeyId {
53
- // 1. Env var — highest priority, for terminals that intercept the default combo.
54
- const envKey = normalizeKey(process.env.PI_COMMAND_PALETTE_KEY);
55
- if (envKey) return envKey as KeyId;
53
+ // 1. Env var — highest priority, for terminals that intercept the default combo.
54
+ const envKey = normalizeKey(process.env.PI_COMMAND_PALETTE_KEY);
55
+ if (envKey) return envKey as KeyId;
56
56
 
57
- // 2. settings.json — global ~/.pi/agent/settings.json; project overrides global.
58
- const globalSettings = readSettings(path.join(getAgentDir(), "settings.json"));
59
- const projectSettings = readSettings(path.join(cwd, ".pi", "settings.json"));
60
- const globalCfg = (globalSettings.commandPalette ?? {}) as { shortcut?: unknown };
61
- const projectCfg = (projectSettings.commandPalette ?? {}) as { shortcut?: unknown };
62
- const settingsKey = normalizeKey(projectCfg.shortcut ?? globalCfg.shortcut);
63
- if (settingsKey) return settingsKey as KeyId;
57
+ // 2. settings.json — global ~/.pi/agent/settings.json; project overrides global.
58
+ const globalSettings = readSettings(path.join(getAgentDir(), "settings.json"));
59
+ const projectSettings = readSettings(path.join(cwd, ".pi", "settings.json"));
60
+ const globalCfg = (globalSettings.commandPalette ?? {}) as { shortcut?: unknown };
61
+ const projectCfg = (projectSettings.commandPalette ?? {}) as { shortcut?: unknown };
62
+ const settingsKey = normalizeKey(projectCfg.shortcut ?? globalCfg.shortcut);
63
+ if (settingsKey) return settingsKey as KeyId;
64
64
 
65
- // 3. default
66
- return DEFAULT_SHORTCUT as KeyId;
65
+ // 3. default
66
+ return DEFAULT_SHORTCUT as KeyId;
67
67
  }
package/src/index.ts CHANGED
@@ -15,31 +15,31 @@
15
15
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
16
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
17
17
  import {
18
- Container,
19
- type SelectItem,
20
- fuzzyFilter,
21
- Key,
22
- matchesKey,
23
- SelectList,
24
- Text,
18
+ Container,
19
+ type SelectItem,
20
+ fuzzyFilter,
21
+ Key,
22
+ matchesKey,
23
+ SelectList,
24
+ Text,
25
25
  } from "@earendil-works/pi-tui";
26
26
  import { resolveShortcutKey } from "./config.ts";
27
27
 
28
28
  // ── Types ──────────────────────────────────────────────────────────
29
29
 
30
30
  type CommandAction =
31
- | { type: "editor"; text: string }
32
- | { type: "model-select" }
33
- | { type: "compact" }
34
- | { type: "reload" }
35
- | { type: "restore" };
31
+ | { type: "editor"; text: string }
32
+ | { type: "model-select" }
33
+ | { type: "compact" }
34
+ | { type: "reload" }
35
+ | { type: "restore" };
36
36
 
37
37
  interface PaletteItem {
38
- value: string;
39
- label: string;
40
- description: string;
41
- category: string;
42
- action: CommandAction;
38
+ value: string;
39
+ label: string;
40
+ description: string;
41
+ category: string;
42
+ action: CommandAction;
43
43
  }
44
44
 
45
45
  // ── Module state ───────────────────────────────────────────────────
@@ -50,350 +50,353 @@ let savedEditorText: string | null = null;
50
50
  // ── Helpers ────────────────────────────────────────────────────────
51
51
 
52
52
  function buildPaletteItems(pi: ExtensionAPI, ctx: ExtensionContext): PaletteItem[] {
53
- const items: PaletteItem[] = [];
54
-
55
- // ── Restore option (if previous editor text was saved) ────────
56
- if (savedEditorText) {
57
- const preview =
58
- savedEditorText.length > 40
59
- ? `${savedEditorText.slice(0, 37)}...`
60
- : savedEditorText;
61
- items.push({
62
- value: "__restore",
63
- label: "Restore: Previous Editor Text",
64
- description: preview.replace(/\n/g, ""),
65
- category: "Built-in",
66
- action: { type: "restore" },
67
- });
68
- }
69
-
70
- // ── Built-in actions ──────────────────────────────────────────
71
- items.push({
72
- value: "__model_select",
73
- label: "Model: Switch Model",
74
- description: "Select a model from the registry",
75
- category: "Built-in",
76
- action: { type: "model-select" },
77
- });
78
-
79
- items.push({
80
- value: "__new_session",
81
- label: "Session: New",
82
- description: "Start a new session",
83
- category: "Built-in",
84
- action: { type: "editor", text: "/new" },
85
- });
86
-
87
- items.push({
88
- value: "__compact",
89
- label: "Session: Compact",
90
- description: "Compact conversation to free context",
91
- category: "Built-in",
92
- action: { type: "compact" },
93
- });
94
-
95
- items.push({
96
- value: "__reload",
97
- label: "Session: Reload",
98
- description: "Reload extensions, skills, and config",
99
- category: "Built-in",
100
- action: { type: "reload" },
101
- });
102
-
103
- items.push({
104
- value: "__fork",
105
- label: "Session: Fork",
106
- description: "Fork from selected entry",
107
- category: "Built-in",
108
- action: { type: "editor", text: "/fork" },
109
- });
110
-
111
- items.push({
112
- value: "__tree",
113
- label: "Session: Tree",
114
- description: "Navigate session tree",
115
- category: "Built-in",
116
- action: { type: "editor", text: "/tree" },
117
- });
118
-
119
- items.push({
120
- value: "__resume",
121
- label: "Session: Resume",
122
- description: "Resume a previous session",
123
- category: "Built-in",
124
- action: { type: "editor", text: "/resume" },
125
- });
126
-
127
- // ── Extension commands, skills, templates ────────────────────
128
- const commands = pi.getCommands();
129
- for (const cmd of commands) {
130
- const editorText = `/${cmd.name}`;
131
- const sourceLabel =
132
- cmd.source === "extension"
133
- ? "Command"
134
- : cmd.source === "skill"
135
- ? "Skill"
136
- : "Template";
137
-
138
- items.push({
139
- value: `cmd:${cmd.name}`,
140
- label: `${sourceLabel}: /${cmd.name}`,
141
- description: cmd.description ?? "",
142
- category: sourceLabel,
143
- action: { type: "editor", text: editorText },
144
- });
145
- }
146
-
147
- // Sort: built-in first, then alphabetically within category
148
- items.sort((a, b) => {
149
- if (a.category === "Built-in" && b.category !== "Built-in") return -1;
150
- if (a.category !== "Built-in" && b.category === "Built-in") return 1;
151
- return a.label.localeCompare(b.label);
152
- });
153
-
154
- return items;
53
+ const items: PaletteItem[] = [];
54
+
55
+ // ── Restore option (if previous editor text was saved) ────────
56
+ if (savedEditorText) {
57
+ const preview =
58
+ savedEditorText.length > 40 ? `${savedEditorText.slice(0, 37)}...` : savedEditorText;
59
+ items.push({
60
+ value: "__restore",
61
+ label: "Restore: Previous Editor Text",
62
+ description: preview.replace(/\n/g, ""),
63
+ category: "Built-in",
64
+ action: { type: "restore" },
65
+ });
66
+ }
67
+
68
+ // ── Built-in actions ──────────────────────────────────────────
69
+ items.push({
70
+ value: "__model_select",
71
+ label: "Model: Switch Model",
72
+ description: "Select a model from the registry",
73
+ category: "Built-in",
74
+ action: { type: "model-select" },
75
+ });
76
+
77
+ items.push({
78
+ value: "__new_session",
79
+ label: "Session: New",
80
+ description: "Start a new session",
81
+ category: "Built-in",
82
+ action: { type: "editor", text: "/new" },
83
+ });
84
+
85
+ items.push({
86
+ value: "__compact",
87
+ label: "Session: Compact",
88
+ description: "Compact conversation to free context",
89
+ category: "Built-in",
90
+ action: { type: "compact" },
91
+ });
92
+
93
+ items.push({
94
+ value: "__reload",
95
+ label: "Session: Reload",
96
+ description: "Reload extensions, skills, and config",
97
+ category: "Built-in",
98
+ action: { type: "reload" },
99
+ });
100
+
101
+ items.push({
102
+ value: "__fork",
103
+ label: "Session: Fork",
104
+ description: "Fork from selected entry",
105
+ category: "Built-in",
106
+ action: { type: "editor", text: "/fork" },
107
+ });
108
+
109
+ items.push({
110
+ value: "__tree",
111
+ label: "Session: Tree",
112
+ description: "Navigate session tree",
113
+ category: "Built-in",
114
+ action: { type: "editor", text: "/tree" },
115
+ });
116
+
117
+ items.push({
118
+ value: "__resume",
119
+ label: "Session: Resume",
120
+ description: "Resume a previous session",
121
+ category: "Built-in",
122
+ action: { type: "editor", text: "/resume" },
123
+ });
124
+
125
+ // ── Extension commands, skills, templates ────────────────────
126
+ const commands = pi.getCommands();
127
+ for (const cmd of commands) {
128
+ const editorText = `/${cmd.name}`;
129
+ const sourceLabel =
130
+ cmd.source === "extension" ? "Command" : cmd.source === "skill" ? "Skill" : "Template";
131
+
132
+ items.push({
133
+ value: `cmd:${cmd.name}`,
134
+ label: `${sourceLabel}: /${cmd.name}`,
135
+ description: cmd.description ?? "",
136
+ category: sourceLabel,
137
+ action: { type: "editor", text: editorText },
138
+ });
139
+ }
140
+
141
+ // Sort: built-in first, then alphabetically within category
142
+ items.sort((a, b) => {
143
+ if (a.category === "Built-in" && b.category !== "Built-in") return -1;
144
+ if (a.category !== "Built-in" && b.category === "Built-in") return 1;
145
+ return a.label.localeCompare(b.label);
146
+ });
147
+
148
+ return items;
155
149
  }
156
150
 
157
151
  // ── Model selector ─────────────────────────────────────────────────
158
152
 
159
153
  async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
160
- let models: Awaited<ReturnType<typeof ctx.modelRegistry.getAvailable>>;
161
- try {
162
- models = await ctx.modelRegistry.getAvailable();
163
- } catch {
164
- ctx.ui.notify("Cannot enumerate models. Use Ctrl+L instead.", "warning");
165
- return;
166
- }
167
-
168
- if (models.length === 0) {
169
- ctx.ui.notify("No models available.", "warning");
170
- return;
171
- }
172
-
173
- const items: SelectItem[] = models.map((m) => ({
174
- value: `${m.provider}/${m.id}`,
175
- label: m.name,
176
- description: m.provider,
177
- }));
178
-
179
- const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
180
- const container = new Container();
181
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
182
- container.addChild(new Text(theme.fg("accent", theme.bold("Switch Model")), 1, 0));
183
-
184
- const selectList = new SelectList(items, Math.min(items.length, 12), {
185
- selectedPrefix: (t: string) => theme.fg("accent", t),
186
- selectedText: (t: string) => theme.fg("accent", t),
187
- description: (t: string) => theme.fg("muted", t),
188
- scrollInfo: (t: string) => theme.fg("dim", t),
189
- noMatch: (t: string) => theme.fg("warning", t),
190
- });
191
-
192
- selectList.onSelect = (item) => done(item.value);
193
- selectList.onCancel = () => done(null);
194
-
195
- // Type-to-filter state
196
- let query = "";
197
- const queryText = new Text(theme.fg("accent", "> "), 1, 0);
198
-
199
- function applyQuery() {
200
- const filtered = query
201
- ? fuzzyFilter(items, query, (item: SelectItem) => `${item.label} ${item.description ?? ""}`)
202
- : items;
203
- (selectList as any).filteredItems = filtered;
204
- selectList.setSelectedIndex(0);
205
- queryText.setText(theme.fg("accent", `> ${query}▎`));
206
- container.invalidate();
207
- tui.requestRender();
208
- }
209
-
210
- container.addChild(queryText);
211
- container.addChild(selectList);
212
- container.addChild(
213
- new Text(theme.fg("dim", "type to filter • ↑↓ navigate • enter select • esc cancel"), 1, 0),
214
- );
215
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
216
-
217
- return {
218
- render(w: number) {
219
- return container.render(w);
220
- },
221
- invalidate() {
222
- container.invalidate();
223
- },
224
- handleInput(data: string) {
225
- // Backspace → trim query
226
- if (matchesKey(data, Key.backspace)) {
227
- if (query.length > 0) {
228
- query = query.slice(0, -1);
229
- applyQuery();
230
- }
231
- return;
232
- }
233
- // Printable character → append to query
234
- if (data.length === 1 && data.charCodeAt(0) >= 32) {
235
- query += data;
236
- applyQuery();
237
- return;
238
- }
239
- // Navigation / confirm / cancel pass to SelectList
240
- selectList.handleInput(data);
241
- tui.requestRender();
242
- },
243
- };
244
- }, { overlay: true });
245
-
246
- if (!result) return;
247
-
248
- const [provider, modelId] = result.split("/");
249
- const model = ctx.modelRegistry.find(provider, modelId);
250
- if (model) {
251
- const success = await pi.setModel(model);
252
- if (success) {
253
- ctx.ui.notify(`Model: ${provider}/${modelId}`, "info");
254
- } else {
255
- ctx.ui.notify(`No API key for ${provider}/${modelId}`, "error");
256
- }
257
- }
154
+ let models: Awaited<ReturnType<typeof ctx.modelRegistry.getAvailable>>;
155
+ try {
156
+ models = await ctx.modelRegistry.getAvailable();
157
+ } catch {
158
+ ctx.ui.notify("Cannot enumerate models. Use Ctrl+L instead.", "warning");
159
+ return;
160
+ }
161
+
162
+ if (models.length === 0) {
163
+ ctx.ui.notify("No models available.", "warning");
164
+ return;
165
+ }
166
+
167
+ const items: SelectItem[] = models.map((m) => ({
168
+ value: `${m.provider}/${m.id}`,
169
+ label: m.name,
170
+ description: m.provider,
171
+ }));
172
+
173
+ const result = await ctx.ui.custom<string | null>(
174
+ (tui, theme, _kb, done) => {
175
+ const container = new Container();
176
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
177
+ container.addChild(new Text(theme.fg("accent", theme.bold("Switch Model")), 1, 0));
178
+
179
+ const selectList = new SelectList(items, Math.min(items.length, 12), {
180
+ selectedPrefix: (t: string) => theme.fg("accent", t),
181
+ selectedText: (t: string) => theme.fg("accent", t),
182
+ description: (t: string) => theme.fg("muted", t),
183
+ scrollInfo: (t: string) => theme.fg("dim", t),
184
+ noMatch: (t: string) => theme.fg("warning", t),
185
+ });
186
+
187
+ selectList.onSelect = (item) => done(item.value);
188
+ selectList.onCancel = () => done(null);
189
+
190
+ // Type-to-filter state
191
+ let query = "";
192
+ const queryText = new Text(theme.fg("accent", "> "), 1, 0);
193
+
194
+ function applyQuery() {
195
+ const filtered = query
196
+ ? fuzzyFilter(
197
+ items,
198
+ query,
199
+ (item: SelectItem) => `${item.label} ${item.description ?? ""}`,
200
+ )
201
+ : items;
202
+ (selectList as any).filteredItems = filtered;
203
+ selectList.setSelectedIndex(0);
204
+ queryText.setText(theme.fg("accent", `> ${query}▎`));
205
+ container.invalidate();
206
+ tui.requestRender();
207
+ }
208
+
209
+ container.addChild(queryText);
210
+ container.addChild(selectList);
211
+ container.addChild(
212
+ new Text(theme.fg("dim", "type to filter • ↑↓ navigate • enter select • esc cancel"), 1, 0),
213
+ );
214
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
215
+
216
+ return {
217
+ render(w: number) {
218
+ return container.render(w);
219
+ },
220
+ invalidate() {
221
+ container.invalidate();
222
+ },
223
+ handleInput(data: string) {
224
+ // Backspace → trim query
225
+ if (matchesKey(data, Key.backspace)) {
226
+ if (query.length > 0) {
227
+ query = query.slice(0, -1);
228
+ applyQuery();
229
+ }
230
+ return;
231
+ }
232
+ // Printable character → append to query
233
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
234
+ query += data;
235
+ applyQuery();
236
+ return;
237
+ }
238
+ // Navigation / confirm / cancel → pass to SelectList
239
+ selectList.handleInput(data);
240
+ tui.requestRender();
241
+ },
242
+ };
243
+ },
244
+ { overlay: true },
245
+ );
246
+
247
+ if (!result) return;
248
+
249
+ const [provider, modelId] = result.split("/");
250
+ const model = ctx.modelRegistry.find(provider, modelId);
251
+ if (model) {
252
+ const success = await pi.setModel(model);
253
+ if (success) {
254
+ ctx.ui.notify(`Model: ${provider}/${modelId}`, "info");
255
+ } else {
256
+ ctx.ui.notify(`No API key for ${provider}/${modelId}`, "error");
257
+ }
258
+ }
258
259
  }
259
260
 
260
261
  // ── Command palette overlay ────────────────────────────────────────
261
262
 
262
263
  async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
263
- if (!ctx.hasUI) return;
264
-
265
- const paletteItems = buildPaletteItems(pi, ctx);
266
- const selectItems: SelectItem[] = paletteItems.map((item) => ({
267
- value: item.value,
268
- label: item.label,
269
- description: item.description,
270
- }));
271
-
272
- const result = await ctx.ui.custom<PaletteItem | null>(
273
- (tui, theme, _kb, done) => {
274
- const container = new Container();
275
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
276
- container.addChild(
277
- new Text(theme.fg("accent", theme.bold("Command Palette")), 1, 0),
278
- );
279
-
280
- const selectList = new SelectList(selectItems, Math.min(selectItems.length, 15), {
281
- selectedPrefix: (t: string) => theme.fg("accent", t),
282
- selectedText: (t: string) => theme.fg("accent", t),
283
- description: (t: string) => theme.fg("muted", t),
284
- scrollInfo: (t: string) => theme.fg("dim", t),
285
- noMatch: (t: string) => theme.fg("warning", t),
286
- });
287
-
288
- selectList.onSelect = (item) => {
289
- const paletteItem = paletteItems.find((p) => p.value === item.value);
290
- done(paletteItem ?? null);
291
- };
292
- selectList.onCancel = () => done(null);
293
-
294
- // Type-to-filter state
295
- let query = "";
296
- const queryText = new Text(theme.fg("accent", "> "), 1, 0);
297
-
298
- function applyQuery() {
299
- const filtered = query
300
- ? fuzzyFilter(selectItems, query, (item: SelectItem) => `${item.label} ${item.description ?? ""}`)
301
- : selectItems;
302
- (selectList as any).filteredItems = filtered;
303
- selectList.setSelectedIndex(0);
304
- queryText.setText(theme.fg("accent", `> ${query}▎`));
305
- container.invalidate();
306
- tui.requestRender();
307
- }
308
-
309
- container.addChild(queryText);
310
- container.addChild(selectList);
311
- container.addChild(
312
- new Text(theme.fg("dim", "type to filter • ↑↓ navigate • enter select • esc cancel"), 1, 0),
313
- );
314
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
315
-
316
- return {
317
- render(w: number) {
318
- return container.render(w);
319
- },
320
- invalidate() {
321
- container.invalidate();
322
- },
323
- handleInput(data: string) {
324
- // Backspace → trim query
325
- if (matchesKey(data, Key.backspace)) {
326
- if (query.length > 0) {
327
- query = query.slice(0, -1);
328
- applyQuery();
329
- }
330
- return;
331
- }
332
- // Printable character → append to query
333
- if (data.length === 1 && data.charCodeAt(0) >= 32) {
334
- query += data;
335
- applyQuery();
336
- return;
337
- }
338
- // Navigation / confirm / cancel → pass to SelectList
339
- selectList.handleInput(data);
340
- tui.requestRender();
341
- },
342
- };
343
- },
344
- { overlay: true },
345
- );
346
-
347
- if (!result) return;
348
-
349
- // Execute the selected action
350
- const action = result.action;
351
- switch (action.type) {
352
- case "restore": {
353
- if (savedEditorText !== null) {
354
- ctx.ui.setEditorText(savedEditorText);
355
- savedEditorText = null;
356
- }
357
- break;
358
- }
359
- case "editor": {
360
- // Save current editor text before overwriting, so user can restore
361
- const currentText = ctx.ui.getEditorText();
362
- if (currentText && currentText.trim()) {
363
- savedEditorText = currentText;
364
- }
365
- ctx.ui.setEditorText(action.text);
366
- break;
367
- }
368
- case "model-select": {
369
- await showModelSelector(pi, ctx);
370
- break;
371
- }
372
- case "compact": {
373
- ctx.compact({
374
- onComplete: () => ctx.ui.notify("Compaction completed", "info"),
375
- onError: (err) => ctx.ui.notify(`Compaction failed: ${err.message}`, "error"),
376
- });
377
- break;
378
- }
379
- case "reload": {
380
- const currentText = ctx.ui.getEditorText();
381
- if (currentText && currentText.trim()) {
382
- savedEditorText = currentText;
383
- }
384
- ctx.ui.setEditorText("/reload");
385
- break;
386
- }
387
- }
264
+ if (!ctx.hasUI) return;
265
+
266
+ const paletteItems = buildPaletteItems(pi, ctx);
267
+ const selectItems: SelectItem[] = paletteItems.map((item) => ({
268
+ value: item.value,
269
+ label: item.label,
270
+ description: item.description,
271
+ }));
272
+
273
+ const result = await ctx.ui.custom<PaletteItem | null>(
274
+ (tui, theme, _kb, done) => {
275
+ const container = new Container();
276
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
277
+ container.addChild(new Text(theme.fg("accent", theme.bold("Command Palette")), 1, 0));
278
+
279
+ const selectList = new SelectList(selectItems, Math.min(selectItems.length, 15), {
280
+ selectedPrefix: (t: string) => theme.fg("accent", t),
281
+ selectedText: (t: string) => theme.fg("accent", t),
282
+ description: (t: string) => theme.fg("muted", t),
283
+ scrollInfo: (t: string) => theme.fg("dim", t),
284
+ noMatch: (t: string) => theme.fg("warning", t),
285
+ });
286
+
287
+ selectList.onSelect = (item) => {
288
+ const paletteItem = paletteItems.find((p) => p.value === item.value);
289
+ done(paletteItem ?? null);
290
+ };
291
+ selectList.onCancel = () => done(null);
292
+
293
+ // Type-to-filter state
294
+ let query = "";
295
+ const queryText = new Text(theme.fg("accent", "> "), 1, 0);
296
+
297
+ function applyQuery() {
298
+ const filtered = query
299
+ ? fuzzyFilter(
300
+ selectItems,
301
+ query,
302
+ (item: SelectItem) => `${item.label} ${item.description ?? ""}`,
303
+ )
304
+ : selectItems;
305
+ (selectList as any).filteredItems = filtered;
306
+ selectList.setSelectedIndex(0);
307
+ queryText.setText(theme.fg("accent", `> ${query}▎`));
308
+ container.invalidate();
309
+ tui.requestRender();
310
+ }
311
+
312
+ container.addChild(queryText);
313
+ container.addChild(selectList);
314
+ container.addChild(
315
+ new Text(theme.fg("dim", "type to filter • ↑↓ navigate • enter select • esc cancel"), 1, 0),
316
+ );
317
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
318
+
319
+ return {
320
+ render(w: number) {
321
+ return container.render(w);
322
+ },
323
+ invalidate() {
324
+ container.invalidate();
325
+ },
326
+ handleInput(data: string) {
327
+ // Backspace trim query
328
+ if (matchesKey(data, Key.backspace)) {
329
+ if (query.length > 0) {
330
+ query = query.slice(0, -1);
331
+ applyQuery();
332
+ }
333
+ return;
334
+ }
335
+ // Printable character → append to query
336
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
337
+ query += data;
338
+ applyQuery();
339
+ return;
340
+ }
341
+ // Navigation / confirm / cancel → pass to SelectList
342
+ selectList.handleInput(data);
343
+ tui.requestRender();
344
+ },
345
+ };
346
+ },
347
+ { overlay: true },
348
+ );
349
+
350
+ if (!result) return;
351
+
352
+ // Execute the selected action
353
+ const action = result.action;
354
+ switch (action.type) {
355
+ case "restore": {
356
+ if (savedEditorText !== null) {
357
+ ctx.ui.setEditorText(savedEditorText);
358
+ savedEditorText = null;
359
+ }
360
+ break;
361
+ }
362
+ case "editor": {
363
+ // Save current editor text before overwriting, so user can restore
364
+ const currentText = ctx.ui.getEditorText();
365
+ if (currentText && currentText.trim()) {
366
+ savedEditorText = currentText;
367
+ }
368
+ ctx.ui.setEditorText(action.text);
369
+ break;
370
+ }
371
+ case "model-select": {
372
+ await showModelSelector(pi, ctx);
373
+ break;
374
+ }
375
+ case "compact": {
376
+ ctx.compact({
377
+ onComplete: () => ctx.ui.notify("Compaction completed", "info"),
378
+ onError: (err) => ctx.ui.notify(`Compaction failed: ${err.message}`, "error"),
379
+ });
380
+ break;
381
+ }
382
+ case "reload": {
383
+ const currentText = ctx.ui.getEditorText();
384
+ if (currentText && currentText.trim()) {
385
+ savedEditorText = currentText;
386
+ }
387
+ ctx.ui.setEditorText("/reload");
388
+ break;
389
+ }
390
+ }
388
391
  }
389
392
 
390
393
  // ── Extension entry point ──────────────────────────────────────────
391
394
 
392
395
  export default function commandPaletteExtension(pi: ExtensionAPI) {
393
- pi.registerShortcut(resolveShortcutKey(), {
394
- description: "Open command palette",
395
- handler: async (ctx) => {
396
- await showCommandPalette(pi, ctx);
397
- },
398
- });
396
+ pi.registerShortcut(resolveShortcutKey(), {
397
+ description: "Open command palette",
398
+ handler: async (ctx) => {
399
+ await showCommandPalette(pi, ctx);
400
+ },
401
+ });
399
402
  }