@d3ara1n/pi-command-palette 0.1.2 → 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
@@ -24,7 +28,7 @@ Or add to `~/.pi/agent/settings.json`:
24
28
 
25
29
  | Shortcut | Action |
26
30
  |----------|--------|
27
- | `Ctrl+Shift+P` | Open command palette |
31
+ | `Ctrl+Shift+P` _(default, configurable)_ | Open command palette |
28
32
 
29
33
  The palette lists:
30
34
 
@@ -42,4 +46,28 @@ The "Model: Switch Model" action opens a secondary overlay listing all models wi
42
46
 
43
47
  ## Configuration
44
48
 
45
- No configuration needed. Works out of the box.
49
+ The default shortcut is `Ctrl+Shift+P`. If it conflicts with your terminal, override it via either of the following (evaluated in order, first match wins).
50
+
51
+ ### 1. Environment variable
52
+
53
+ Useful for terminals that intercept `Ctrl+Shift+<key>` before it reaches the session (e.g. Termius on Windows/WSL2):
54
+
55
+ ```bash
56
+ export PI_COMMAND_PALETTE_KEY=ctrl+alt+k
57
+ ```
58
+
59
+ Add it to your shell profile to persist (`~/.zshrc` on macOS, `~/.bashrc` on bash).
60
+
61
+ ### 2. settings.json
62
+
63
+ Set `commandPalette.shortcut` in `~/.pi/agent/settings.json` (global) or `.pi/settings.json` in your project (project overrides global):
64
+
65
+ ```jsonc
66
+ {
67
+ "commandPalette": {
68
+ "shortcut": "ctrl+alt+k"
69
+ }
70
+ }
71
+ ```
72
+
73
+ Any valid pi keybinding string works (e.g. `ctrl+shift+k`, `ctrl+alt+p`, `ctrl+k`). Restart pi (or run `/reload`) after changing the shortcut.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-command-palette",
3
- "version": "0.1.2",
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 ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Resolve the command-palette shortcut key from configuration sources.
3
+ *
4
+ * Priority (highest wins):
5
+ * 1. `PI_COMMAND_PALETTE_KEY` env var — works even when the terminal intercepts
6
+ * `Ctrl+Shift+P` before it reaches the session (e.g. Termius on Windows/WSL2).
7
+ * 2. `settings.json` `commandPalette.shortcut` (project `.pi/settings.json`
8
+ * overrides global `~/.pi/agent/settings.json`)
9
+ * 3. default `"ctrl+shift+p"`
10
+ *
11
+ * Why not a CLI flag? Flags are applied to the extension runtime AFTER extensions
12
+ * load, so `pi.getFlag()` only returns the registered default at `registerShortcut()`
13
+ * time. Env vars and settings files are both available immediately at process
14
+ * start, so they are the correct mechanisms here.
15
+ */
16
+
17
+ import * as fs from "node:fs";
18
+ import * as os from "node:os";
19
+ import * as path from "node:path";
20
+ import type { KeyId } from "@earendil-works/pi-tui";
21
+
22
+ export const DEFAULT_SHORTCUT = "ctrl+shift+p";
23
+
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");
28
+ }
29
+
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
+ }
37
+ }
38
+
39
+ /** Lowercase + trim; returns undefined for empty/non-string input. */
40
+ function normalizeKey(raw: unknown): string | undefined {
41
+ if (typeof raw !== "string") return undefined;
42
+ const trimmed = raw.trim().toLowerCase();
43
+ return trimmed || undefined;
44
+ }
45
+
46
+ /**
47
+ * Resolve the command-palette shortcut key from env var → settings → default.
48
+ *
49
+ * @param cwd - Project working directory, for project-level settings override.
50
+ * Defaults to `process.cwd()` (accurate at pi startup when shortcuts register).
51
+ */
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;
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;
64
+
65
+ // 3. default
66
+ return DEFAULT_SHORTCUT as KeyId;
67
+ }
package/src/index.ts CHANGED
@@ -15,30 +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
+ import { resolveShortcutKey } from "./config.ts";
26
27
 
27
28
  // ── Types ──────────────────────────────────────────────────────────
28
29
 
29
30
  type CommandAction =
30
- | { type: "editor"; text: string }
31
- | { type: "model-select" }
32
- | { type: "compact" }
33
- | { type: "reload" }
34
- | { type: "restore" };
31
+ | { type: "editor"; text: string }
32
+ | { type: "model-select" }
33
+ | { type: "compact" }
34
+ | { type: "reload" }
35
+ | { type: "restore" };
35
36
 
36
37
  interface PaletteItem {
37
- value: string;
38
- label: string;
39
- description: string;
40
- category: string;
41
- action: CommandAction;
38
+ value: string;
39
+ label: string;
40
+ description: string;
41
+ category: string;
42
+ action: CommandAction;
42
43
  }
43
44
 
44
45
  // ── Module state ───────────────────────────────────────────────────
@@ -49,350 +50,353 @@ let savedEditorText: string | null = null;
49
50
  // ── Helpers ────────────────────────────────────────────────────────
50
51
 
51
52
  function buildPaletteItems(pi: ExtensionAPI, ctx: ExtensionContext): PaletteItem[] {
52
- const items: PaletteItem[] = [];
53
-
54
- // ── Restore option (if previous editor text was saved) ────────
55
- if (savedEditorText) {
56
- const preview =
57
- savedEditorText.length > 40
58
- ? `${savedEditorText.slice(0, 37)}...`
59
- : savedEditorText;
60
- items.push({
61
- value: "__restore",
62
- label: "Restore: Previous Editor Text",
63
- description: preview.replace(/\n/g, ""),
64
- category: "Built-in",
65
- action: { type: "restore" },
66
- });
67
- }
68
-
69
- // ── Built-in actions ──────────────────────────────────────────
70
- items.push({
71
- value: "__model_select",
72
- label: "Model: Switch Model",
73
- description: "Select a model from the registry",
74
- category: "Built-in",
75
- action: { type: "model-select" },
76
- });
77
-
78
- items.push({
79
- value: "__new_session",
80
- label: "Session: New",
81
- description: "Start a new session",
82
- category: "Built-in",
83
- action: { type: "editor", text: "/new" },
84
- });
85
-
86
- items.push({
87
- value: "__compact",
88
- label: "Session: Compact",
89
- description: "Compact conversation to free context",
90
- category: "Built-in",
91
- action: { type: "compact" },
92
- });
93
-
94
- items.push({
95
- value: "__reload",
96
- label: "Session: Reload",
97
- description: "Reload extensions, skills, and config",
98
- category: "Built-in",
99
- action: { type: "reload" },
100
- });
101
-
102
- items.push({
103
- value: "__fork",
104
- label: "Session: Fork",
105
- description: "Fork from selected entry",
106
- category: "Built-in",
107
- action: { type: "editor", text: "/fork" },
108
- });
109
-
110
- items.push({
111
- value: "__tree",
112
- label: "Session: Tree",
113
- description: "Navigate session tree",
114
- category: "Built-in",
115
- action: { type: "editor", text: "/tree" },
116
- });
117
-
118
- items.push({
119
- value: "__resume",
120
- label: "Session: Resume",
121
- description: "Resume a previous session",
122
- category: "Built-in",
123
- action: { type: "editor", text: "/resume" },
124
- });
125
-
126
- // ── Extension commands, skills, templates ────────────────────
127
- const commands = pi.getCommands();
128
- for (const cmd of commands) {
129
- const editorText = `/${cmd.name}`;
130
- const sourceLabel =
131
- cmd.source === "extension"
132
- ? "Command"
133
- : cmd.source === "skill"
134
- ? "Skill"
135
- : "Template";
136
-
137
- items.push({
138
- value: `cmd:${cmd.name}`,
139
- label: `${sourceLabel}: /${cmd.name}`,
140
- description: cmd.description ?? "",
141
- category: sourceLabel,
142
- action: { type: "editor", text: editorText },
143
- });
144
- }
145
-
146
- // Sort: built-in first, then alphabetically within category
147
- items.sort((a, b) => {
148
- if (a.category === "Built-in" && b.category !== "Built-in") return -1;
149
- if (a.category !== "Built-in" && b.category === "Built-in") return 1;
150
- return a.label.localeCompare(b.label);
151
- });
152
-
153
- 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;
154
149
  }
155
150
 
156
151
  // ── Model selector ─────────────────────────────────────────────────
157
152
 
158
153
  async function showModelSelector(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
159
- let models: Awaited<ReturnType<typeof ctx.modelRegistry.getAvailable>>;
160
- try {
161
- models = await ctx.modelRegistry.getAvailable();
162
- } catch {
163
- ctx.ui.notify("Cannot enumerate models. Use Ctrl+L instead.", "warning");
164
- return;
165
- }
166
-
167
- if (models.length === 0) {
168
- ctx.ui.notify("No models available.", "warning");
169
- return;
170
- }
171
-
172
- const items: SelectItem[] = models.map((m) => ({
173
- value: `${m.provider}/${m.id}`,
174
- label: m.name,
175
- description: m.provider,
176
- }));
177
-
178
- const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
179
- const container = new Container();
180
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
181
- container.addChild(new Text(theme.fg("accent", theme.bold("Switch Model")), 1, 0));
182
-
183
- const selectList = new SelectList(items, Math.min(items.length, 12), {
184
- selectedPrefix: (t: string) => theme.fg("accent", t),
185
- selectedText: (t: string) => theme.fg("accent", t),
186
- description: (t: string) => theme.fg("muted", t),
187
- scrollInfo: (t: string) => theme.fg("dim", t),
188
- noMatch: (t: string) => theme.fg("warning", t),
189
- });
190
-
191
- selectList.onSelect = (item) => done(item.value);
192
- selectList.onCancel = () => done(null);
193
-
194
- // Type-to-filter state
195
- let query = "";
196
- const queryText = new Text(theme.fg("accent", "> "), 1, 0);
197
-
198
- function applyQuery() {
199
- const filtered = query
200
- ? fuzzyFilter(items, query, (item: SelectItem) => `${item.label} ${item.description ?? ""}`)
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
- }, { overlay: true });
244
-
245
- if (!result) return;
246
-
247
- const [provider, modelId] = result.split("/");
248
- const model = ctx.modelRegistry.find(provider, modelId);
249
- if (model) {
250
- const success = await pi.setModel(model);
251
- if (success) {
252
- ctx.ui.notify(`Model: ${provider}/${modelId}`, "info");
253
- } else {
254
- ctx.ui.notify(`No API key for ${provider}/${modelId}`, "error");
255
- }
256
- }
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
+ }
257
259
  }
258
260
 
259
261
  // ── Command palette overlay ────────────────────────────────────────
260
262
 
261
263
  async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
262
- if (!ctx.hasUI) return;
263
-
264
- const paletteItems = buildPaletteItems(pi, ctx);
265
- const selectItems: SelectItem[] = paletteItems.map((item) => ({
266
- value: item.value,
267
- label: item.label,
268
- description: item.description,
269
- }));
270
-
271
- const result = await ctx.ui.custom<PaletteItem | null>(
272
- (tui, theme, _kb, done) => {
273
- const container = new Container();
274
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
275
- container.addChild(
276
- new Text(theme.fg("accent", theme.bold("Command Palette")), 1, 0),
277
- );
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(selectItems, query, (item: SelectItem) => `${item.label} ${item.description ?? ""}`)
300
- : selectItems;
301
- (selectList as any).filteredItems = filtered;
302
- selectList.setSelectedIndex(0);
303
- queryText.setText(theme.fg("accent", `> ${query}▎`));
304
- container.invalidate();
305
- tui.requestRender();
306
- }
307
-
308
- container.addChild(queryText);
309
- container.addChild(selectList);
310
- container.addChild(
311
- new Text(theme.fg("dim", "type to filter • ↑↓ navigate • enter select • esc cancel"), 1, 0),
312
- );
313
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
314
-
315
- return {
316
- render(w: number) {
317
- return container.render(w);
318
- },
319
- invalidate() {
320
- container.invalidate();
321
- },
322
- handleInput(data: string) {
323
- // Backspace → trim query
324
- if (matchesKey(data, Key.backspace)) {
325
- if (query.length > 0) {
326
- query = query.slice(0, -1);
327
- applyQuery();
328
- }
329
- return;
330
- }
331
- // Printable character → append to query
332
- if (data.length === 1 && data.charCodeAt(0) >= 32) {
333
- query += data;
334
- applyQuery();
335
- return;
336
- }
337
- // Navigation / confirm / cancel → pass to SelectList
338
- selectList.handleInput(data);
339
- tui.requestRender();
340
- },
341
- };
342
- },
343
- { overlay: true },
344
- );
345
-
346
- if (!result) return;
347
-
348
- // Execute the selected action
349
- const action = result.action;
350
- switch (action.type) {
351
- case "restore": {
352
- if (savedEditorText !== null) {
353
- ctx.ui.setEditorText(savedEditorText);
354
- savedEditorText = null;
355
- }
356
- break;
357
- }
358
- case "editor": {
359
- // Save current editor text before overwriting, so user can restore
360
- const currentText = ctx.ui.getEditorText();
361
- if (currentText && currentText.trim()) {
362
- savedEditorText = currentText;
363
- }
364
- ctx.ui.setEditorText(action.text);
365
- break;
366
- }
367
- case "model-select": {
368
- await showModelSelector(pi, ctx);
369
- break;
370
- }
371
- case "compact": {
372
- ctx.compact({
373
- onComplete: () => ctx.ui.notify("Compaction completed", "info"),
374
- onError: (err) => ctx.ui.notify(`Compaction failed: ${err.message}`, "error"),
375
- });
376
- break;
377
- }
378
- case "reload": {
379
- const currentText = ctx.ui.getEditorText();
380
- if (currentText && currentText.trim()) {
381
- savedEditorText = currentText;
382
- }
383
- ctx.ui.setEditorText("/reload");
384
- break;
385
- }
386
- }
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
+ }
387
391
  }
388
392
 
389
393
  // ── Extension entry point ──────────────────────────────────────────
390
394
 
391
395
  export default function commandPaletteExtension(pi: ExtensionAPI) {
392
- pi.registerShortcut("ctrl+shift+p", {
393
- description: "Open command palette",
394
- handler: async (ctx) => {
395
- await showCommandPalette(pi, ctx);
396
- },
397
- });
396
+ pi.registerShortcut(resolveShortcutKey(), {
397
+ description: "Open command palette",
398
+ handler: async (ctx) => {
399
+ await showCommandPalette(pi, ctx);
400
+ },
401
+ });
398
402
  }