@oh-my-pi/pi-coding-agent 3.4.1337 → 3.6.1337

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.
@@ -0,0 +1,477 @@
1
+ /**
2
+ * ExtensionList - Inventory list with Master Switch and fuzzy search.
3
+ *
4
+ * When viewing a specific provider (not "ALL"), Row #0 is the Master Switch
5
+ * that toggles the entire provider. All items below are dimmed when the
6
+ * master switch is off.
7
+ */
8
+
9
+ import {
10
+ type Component,
11
+ isArrowDown,
12
+ isArrowUp,
13
+ isBackspace,
14
+ isEnter,
15
+ truncateToWidth,
16
+ visibleWidth,
17
+ } from "@oh-my-pi/pi-tui";
18
+ import { isProviderEnabled } from "../../../../discovery";
19
+ import { theme } from "../../theme/theme";
20
+ import { applyFilter } from "./state-manager";
21
+ import type { Extension, ExtensionKind, ExtensionState } from "./types";
22
+
23
+ export interface ExtensionListCallbacks {
24
+ /** Called when selection changes */
25
+ onSelectionChange?: (extension: Extension | null) => void;
26
+ /** Called when extension is toggled */
27
+ onToggle?: (extensionId: string, enabled: boolean) => void;
28
+ /** Called when master switch is toggled */
29
+ onMasterToggle?: (providerId: string) => void;
30
+ /** Provider ID for master switch (null = no master switch) */
31
+ masterSwitchProvider?: string | null;
32
+ }
33
+
34
+ const MAX_VISIBLE = 25;
35
+
36
+ /** Flattened list item for rendering */
37
+ type ListItem =
38
+ | { type: "master"; providerId: string; providerName: string; enabled: boolean }
39
+ | { type: "kind-header"; kind: ExtensionKind; label: string; icon: string; count: number }
40
+ | { type: "extension"; item: Extension };
41
+
42
+ export class ExtensionList implements Component {
43
+ private extensions: Extension[] = [];
44
+ private listItems: ListItem[] = [];
45
+ private selectedIndex = 0;
46
+ private scrollOffset = 0;
47
+ private searchQuery = "";
48
+ private focused = false;
49
+ private callbacks: ExtensionListCallbacks;
50
+ private masterSwitchProvider: string | null = null;
51
+
52
+ constructor(extensions: Extension[], callbacks: ExtensionListCallbacks = {}) {
53
+ this.extensions = extensions;
54
+ this.callbacks = callbacks;
55
+ this.masterSwitchProvider = callbacks.masterSwitchProvider ?? null;
56
+ this.rebuildList();
57
+ }
58
+
59
+ setExtensions(extensions: Extension[]): void {
60
+ this.extensions = extensions;
61
+ this.rebuildList();
62
+ this.clampSelection();
63
+ }
64
+
65
+ setFocused(focused: boolean): void {
66
+ this.focused = focused;
67
+ }
68
+
69
+ setMasterSwitchProvider(providerId: string | null): void {
70
+ this.masterSwitchProvider = providerId;
71
+ this.rebuildList();
72
+ }
73
+
74
+ getSearchQuery(): string {
75
+ return this.searchQuery;
76
+ }
77
+
78
+ resetSelection(): void {
79
+ this.selectedIndex = 0;
80
+ this.scrollOffset = 0;
81
+ this.notifySelectionChange();
82
+ }
83
+
84
+ getSelectedExtension(): Extension | null {
85
+ const item = this.listItems[this.selectedIndex];
86
+ return item?.type === "extension" ? item.item : null;
87
+ }
88
+
89
+ /** Get the currently selected kind header (for preview purposes) */
90
+ getSelectedKind(): ExtensionKind | null {
91
+ const item = this.listItems[this.selectedIndex];
92
+ return item?.type === "kind-header" ? item.kind : null;
93
+ }
94
+
95
+ setSearchQuery(query: string): void {
96
+ this.searchQuery = query;
97
+ this.rebuildList();
98
+ this.selectedIndex = 0;
99
+ this.scrollOffset = 0;
100
+ this.notifySelectionChange();
101
+ }
102
+
103
+ clearSearch(): void {
104
+ this.setSearchQuery("");
105
+ }
106
+
107
+ invalidate(): void {}
108
+
109
+ render(width: number): string[] {
110
+ const lines: string[] = [];
111
+
112
+ // Search bar
113
+ const searchPrefix = theme.fg("muted", "Search: ");
114
+ const searchText = this.searchQuery || (this.focused ? "" : theme.fg("dim", "type to filter"));
115
+ const cursor = this.focused ? theme.fg("accent", "_") : "";
116
+ lines.push(searchPrefix + searchText + cursor);
117
+ lines.push("");
118
+
119
+ if (this.listItems.length === 0) {
120
+ lines.push(theme.fg("muted", " No extensions found for this provider."));
121
+ return lines;
122
+ }
123
+
124
+ // Determine if master switch is off (for dimming child items)
125
+ const masterDisabled = this.masterSwitchProvider !== null && !isProviderEnabled(this.masterSwitchProvider);
126
+
127
+ // Calculate visible range
128
+ const startIdx = this.scrollOffset;
129
+ const endIdx = Math.min(startIdx + MAX_VISIBLE, this.listItems.length);
130
+
131
+ // Render visible items
132
+ for (let i = startIdx; i < endIdx; i++) {
133
+ const listItem = this.listItems[i];
134
+ const isSelected = this.focused && i === this.selectedIndex;
135
+
136
+ if (listItem.type === "master") {
137
+ lines.push(this.renderMasterSwitch(listItem, isSelected, width));
138
+ } else if (listItem.type === "kind-header") {
139
+ lines.push(this.renderKindHeader(listItem, isSelected, width));
140
+ } else {
141
+ lines.push(this.renderExtensionRow(listItem.item, isSelected, width, masterDisabled));
142
+ }
143
+ }
144
+
145
+ // Scroll indicator
146
+ if (this.listItems.length > MAX_VISIBLE) {
147
+ const indicator = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.listItems.length})`);
148
+ lines.push(indicator);
149
+ }
150
+
151
+ return lines;
152
+ }
153
+
154
+ private renderMasterSwitch(item: ListItem & { type: "master" }, isSelected: boolean, width: number): string {
155
+ const checkbox = item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
156
+ const icon = "📦";
157
+ const label = `Enable ${item.providerName}`;
158
+ const badge = theme.fg("warning", "(Master Switch)");
159
+
160
+ let line = `${checkbox} ${icon} ${label} ${badge}`;
161
+
162
+ if (isSelected) {
163
+ line = theme.bold(theme.fg("accent", line));
164
+ line = theme.bg("selectedBg", line);
165
+ } else if (!item.enabled) {
166
+ line = theme.fg("dim", line);
167
+ }
168
+
169
+ return truncateToWidth(line, width);
170
+ }
171
+
172
+ private renderKindHeader(item: ListItem & { type: "kind-header" }, isSelected: boolean, width: number): string {
173
+ const countBadge = theme.fg("muted", `(${item.count})`);
174
+ let line = `${item.icon} ${item.label} ${countBadge}`;
175
+
176
+ if (isSelected) {
177
+ line = theme.bold(theme.fg("accent", line));
178
+ line = theme.bg("selectedBg", line);
179
+ } else {
180
+ line = theme.fg("muted", line);
181
+ }
182
+
183
+ return truncateToWidth(line, width);
184
+ }
185
+
186
+ private renderExtensionRow(ext: Extension, isSelected: boolean, width: number, masterDisabled: boolean): string {
187
+ // When master is disabled, all items appear dimmed
188
+ const effectivelyDisabled = masterDisabled || ext.state === "disabled";
189
+
190
+ // Status icon
191
+ const stateIcon = this.getStateIcon(ext.state, masterDisabled);
192
+
193
+ // Name
194
+ let name = ext.displayName;
195
+ const nameWidth = Math.min(24, width - 16);
196
+
197
+ // Build the line with indentation (visually "inside" the master switch)
198
+ let line = ` ${stateIcon} `;
199
+
200
+ if (isSelected && !masterDisabled) {
201
+ name = theme.bold(theme.fg("accent", name));
202
+ } else if (effectivelyDisabled) {
203
+ name = theme.fg("dim", name);
204
+ } else if (ext.state === "shadowed") {
205
+ name = theme.fg("warning", name);
206
+ }
207
+
208
+ // Pad name
209
+ const namePadded = this.padText(name, nameWidth);
210
+ line += namePadded;
211
+
212
+ // Trigger hint
213
+ if (ext.trigger) {
214
+ const triggerStyle = effectivelyDisabled ? "dim" : "muted";
215
+ const remainingWidth = width - visibleWidth(line) - 2;
216
+ if (remainingWidth > 5) {
217
+ line += ` ${truncateToWidth(theme.fg(triggerStyle as "dim" | "muted", ext.trigger), remainingWidth)}`;
218
+ }
219
+ }
220
+
221
+ // Apply selection background
222
+ if (isSelected) {
223
+ line = theme.bg("selectedBg", line);
224
+ }
225
+
226
+ return truncateToWidth(line, width);
227
+ }
228
+
229
+ private getKindIcon(kind: ExtensionKind): string {
230
+ switch (kind) {
231
+ case "skill":
232
+ return "⚡";
233
+ case "tool":
234
+ return "🔧";
235
+ case "slash-command":
236
+ return "🔗";
237
+ case "mcp":
238
+ return "🔄";
239
+ case "rule":
240
+ return "📋";
241
+ case "hook":
242
+ return "🪝";
243
+ case "prompt":
244
+ return "💬";
245
+ case "context-file":
246
+ return "📄";
247
+ case "instruction":
248
+ return "📌";
249
+ default:
250
+ return "•";
251
+ }
252
+ }
253
+
254
+ private getStateIcon(state: ExtensionState, masterDisabled: boolean): string {
255
+ if (masterDisabled) {
256
+ return theme.fg("dim", "○");
257
+ }
258
+ switch (state) {
259
+ case "active":
260
+ return theme.fg("success", "●");
261
+ case "disabled":
262
+ return theme.fg("dim", "⊘");
263
+ case "shadowed":
264
+ return theme.fg("warning", "◐");
265
+ }
266
+ }
267
+
268
+ private padText(text: string, targetWidth: number): string {
269
+ const width = visibleWidth(text);
270
+ if (width >= targetWidth) {
271
+ return truncateToWidth(text, targetWidth);
272
+ }
273
+ return text + " ".repeat(targetWidth - width);
274
+ }
275
+
276
+ private rebuildList(): void {
277
+ this.listItems = [];
278
+
279
+ // Apply search filter
280
+ const filtered = this.searchQuery.length > 0 ? applyFilter(this.extensions, this.searchQuery) : this.extensions;
281
+
282
+ // When searching, show flat list
283
+ if (this.searchQuery.length > 0) {
284
+ for (const ext of filtered) {
285
+ this.listItems.push({ type: "extension", item: ext });
286
+ }
287
+ return;
288
+ }
289
+
290
+ // Provider-specific view: Master switch + flat list
291
+ if (this.masterSwitchProvider) {
292
+ const providerName = filtered[0]?.source.providerName ?? this.masterSwitchProvider;
293
+ const enabled = isProviderEnabled(this.masterSwitchProvider);
294
+
295
+ this.listItems.push({
296
+ type: "master",
297
+ providerId: this.masterSwitchProvider,
298
+ providerName,
299
+ enabled,
300
+ });
301
+
302
+ for (const ext of filtered) {
303
+ this.listItems.push({ type: "extension", item: ext });
304
+ }
305
+ return;
306
+ }
307
+
308
+ // ALL view: Group by kind with headers
309
+ const byKind = new Map<ExtensionKind, Extension[]>();
310
+ for (const ext of filtered) {
311
+ const list = byKind.get(ext.kind) ?? [];
312
+ list.push(ext);
313
+ byKind.set(ext.kind, list);
314
+ }
315
+
316
+ const kindOrder: ExtensionKind[] = [
317
+ "skill",
318
+ "tool",
319
+ "slash-command",
320
+ "rule",
321
+ "mcp",
322
+ "hook",
323
+ "prompt",
324
+ "context-file",
325
+ "instruction",
326
+ ];
327
+
328
+ for (const kind of kindOrder) {
329
+ const items = byKind.get(kind);
330
+ if (!items || items.length === 0) continue;
331
+
332
+ this.listItems.push({
333
+ type: "kind-header",
334
+ kind,
335
+ label: this.getKindLabel(kind),
336
+ icon: this.getKindIcon(kind),
337
+ count: items.length,
338
+ });
339
+
340
+ for (const ext of items) {
341
+ this.listItems.push({ type: "extension", item: ext });
342
+ }
343
+ }
344
+ }
345
+
346
+ private getKindLabel(kind: ExtensionKind): string {
347
+ switch (kind) {
348
+ case "skill":
349
+ return "Skills";
350
+ case "tool":
351
+ return "Tools";
352
+ case "slash-command":
353
+ return "Commands";
354
+ case "rule":
355
+ return "Rules";
356
+ case "mcp":
357
+ return "MCP Servers";
358
+ case "hook":
359
+ return "Hooks";
360
+ case "prompt":
361
+ return "Prompts";
362
+ case "context-file":
363
+ return "Context";
364
+ case "instruction":
365
+ return "Instructions";
366
+ default:
367
+ return kind;
368
+ }
369
+ }
370
+
371
+ private clampSelection(): void {
372
+ if (this.listItems.length === 0) {
373
+ this.selectedIndex = 0;
374
+ this.scrollOffset = 0;
375
+ return;
376
+ }
377
+
378
+ this.selectedIndex = Math.min(this.selectedIndex, this.listItems.length - 1);
379
+ this.selectedIndex = Math.max(0, this.selectedIndex);
380
+
381
+ // Adjust scroll offset
382
+ if (this.selectedIndex < this.scrollOffset) {
383
+ this.scrollOffset = this.selectedIndex;
384
+ } else if (this.selectedIndex >= this.scrollOffset + MAX_VISIBLE) {
385
+ this.scrollOffset = this.selectedIndex - MAX_VISIBLE + 1;
386
+ }
387
+ }
388
+
389
+ handleInput(data: string): void {
390
+ const charCode = data.length === 1 ? data.charCodeAt(0) : -1;
391
+
392
+ // Navigation
393
+ if (isArrowUp(data) || data === "k") {
394
+ this.moveSelectionUp();
395
+ return;
396
+ }
397
+
398
+ if (isArrowDown(data) || data === "j") {
399
+ this.moveSelectionDown();
400
+ return;
401
+ }
402
+
403
+ // Space: Toggle selected item
404
+ if (data === " ") {
405
+ const item = this.listItems[this.selectedIndex];
406
+ if (item?.type === "master") {
407
+ this.callbacks.onMasterToggle?.(item.providerId);
408
+ } else if (item?.type === "extension") {
409
+ // Only allow toggling if master is enabled
410
+ const masterDisabled = this.masterSwitchProvider !== null && !isProviderEnabled(this.masterSwitchProvider);
411
+ if (!masterDisabled) {
412
+ const newEnabled = item.item.state === "disabled";
413
+ this.callbacks.onToggle?.(item.item.id, newEnabled);
414
+ }
415
+ }
416
+ return;
417
+ }
418
+
419
+ // Enter: Same as space - toggle selected item
420
+ if (isEnter(data)) {
421
+ const item = this.listItems[this.selectedIndex];
422
+ if (item?.type === "master") {
423
+ this.callbacks.onMasterToggle?.(item.providerId);
424
+ } else if (item?.type === "extension") {
425
+ const masterDisabled = this.masterSwitchProvider !== null && !isProviderEnabled(this.masterSwitchProvider);
426
+ if (!masterDisabled) {
427
+ const newEnabled = item.item.state === "disabled";
428
+ this.callbacks.onToggle?.(item.item.id, newEnabled);
429
+ }
430
+ }
431
+ return;
432
+ }
433
+
434
+ // Backspace: Delete from search query
435
+ if (isBackspace(data)) {
436
+ if (this.searchQuery.length > 0) {
437
+ this.setSearchQuery(this.searchQuery.slice(0, -1));
438
+ }
439
+ return;
440
+ }
441
+
442
+ // Printable characters -> search
443
+ if (data.length === 1 && charCode > 32 && charCode < 127) {
444
+ // Skip j/k as they're navigation
445
+ if (data === "j" || data === "k") {
446
+ return;
447
+ }
448
+ this.setSearchQuery(this.searchQuery + data);
449
+ return;
450
+ }
451
+ }
452
+
453
+ private moveSelectionUp(): void {
454
+ if (this.selectedIndex > 0) {
455
+ this.selectedIndex--;
456
+ if (this.selectedIndex < this.scrollOffset) {
457
+ this.scrollOffset = this.selectedIndex;
458
+ }
459
+ this.notifySelectionChange();
460
+ }
461
+ }
462
+
463
+ private moveSelectionDown(): void {
464
+ if (this.selectedIndex < this.listItems.length - 1) {
465
+ this.selectedIndex++;
466
+ if (this.selectedIndex >= this.scrollOffset + MAX_VISIBLE) {
467
+ this.scrollOffset = this.selectedIndex - MAX_VISIBLE + 1;
468
+ }
469
+ this.notifySelectionChange();
470
+ }
471
+ }
472
+
473
+ private notifySelectionChange(): void {
474
+ const ext = this.getSelectedExtension();
475
+ this.callbacks.onSelectionChange?.(ext);
476
+ }
477
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Extension Control Center exports.
3
+ */
4
+
5
+ export { ExtensionDashboard } from "./extension-dashboard";
6
+ export { ExtensionList } from "./extension-list";
7
+ export { InspectorPanel } from "./inspector-panel";
8
+ export * from "./state-manager";
9
+ export * from "./types";