@oh-my-pi/pi-coding-agent 3.4.1337 → 3.5.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,558 @@
1
+ /**
2
+ * State manager for the Extension Control Center.
3
+ * Handles data loading, tree building, filtering, and toggle persistence.
4
+ */
5
+
6
+ import type { ContextFile } from "../../../../capability/context-file";
7
+ import type { Hook } from "../../../../capability/hook";
8
+ import type { MCPServer } from "../../../../capability/mcp";
9
+ import type { Prompt } from "../../../../capability/prompt";
10
+ import type { Rule } from "../../../../capability/rule";
11
+ import type { Skill } from "../../../../capability/skill";
12
+ import type { CustomTool } from "../../../../capability/tool";
13
+ import type { SourceMeta } from "../../../../capability/types";
14
+ import {
15
+ disableProvider,
16
+ enableProvider,
17
+ getAllProvidersInfo,
18
+ isProviderEnabled,
19
+ loadSync,
20
+ } from "../../../../discovery";
21
+ import type {
22
+ DashboardState,
23
+ Extension,
24
+ ExtensionKind,
25
+ ExtensionState,
26
+ FlatTreeItem,
27
+ ProviderTab,
28
+ TreeNode,
29
+ } from "./types";
30
+ import { makeExtensionId, sourceFromMeta } from "./types";
31
+
32
+ /**
33
+ * Settings manager interface for granular toggle persistence.
34
+ */
35
+ export interface ExtensionSettingsManager {
36
+ getDisabledExtensions(): string[];
37
+ setDisabledExtensions(ids: string[]): void;
38
+ }
39
+
40
+ /**
41
+ * Load all extensions from all capabilities.
42
+ */
43
+ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extension[] {
44
+ const extensions: Extension[] = [];
45
+ const disabledExtensions = new Set<string>(disabledIds ?? []);
46
+
47
+ // Helper to convert capability items to extensions
48
+ function addItems<T extends { name: string; path: string; _source: SourceMeta }>(
49
+ items: T[],
50
+ kind: ExtensionKind,
51
+ opts?: {
52
+ getDescription?: (item: T) => string | undefined;
53
+ getTrigger?: (item: T) => string | undefined;
54
+ getShadowedBy?: (item: T) => string | undefined;
55
+ },
56
+ ): void {
57
+ for (const item of items) {
58
+ const id = makeExtensionId(kind, item.name);
59
+ const isDisabled = disabledExtensions.has(id);
60
+ const isShadowed = (item as { _shadowed?: boolean })._shadowed;
61
+ const providerEnabled = isProviderEnabled(item._source.provider);
62
+
63
+ let state: ExtensionState;
64
+ let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined;
65
+
66
+ // Item-disabled takes precedence over shadowed
67
+ if (isDisabled) {
68
+ state = "disabled";
69
+ disabledReason = "item-disabled";
70
+ } else if (isShadowed) {
71
+ state = "shadowed";
72
+ disabledReason = "shadowed";
73
+ } else if (!providerEnabled) {
74
+ state = "disabled";
75
+ disabledReason = "provider-disabled";
76
+ } else {
77
+ state = "active";
78
+ }
79
+
80
+ extensions.push({
81
+ id,
82
+ kind,
83
+ name: item.name,
84
+ displayName: item.name,
85
+ description: opts?.getDescription?.(item),
86
+ trigger: opts?.getTrigger?.(item),
87
+ path: item.path,
88
+ source: sourceFromMeta(item._source),
89
+ state,
90
+ disabledReason,
91
+ shadowedBy: opts?.getShadowedBy?.(item),
92
+ raw: item,
93
+ });
94
+ }
95
+ }
96
+
97
+ const loadOpts = cwd ? { cwd } : {};
98
+
99
+ // Load skills
100
+ try {
101
+ const skills = loadSync<Skill>("skills", loadOpts);
102
+ addItems(skills.all, "skill", {
103
+ getDescription: (s) => s.frontmatter?.description,
104
+ getTrigger: (s) => s.frontmatter?.globs?.join(", "),
105
+ });
106
+ } catch {
107
+ // Capability may not be registered
108
+ }
109
+
110
+ // Load rules
111
+ try {
112
+ const rules = loadSync<Rule>("rules", loadOpts);
113
+ addItems(rules.all, "rule", {
114
+ getDescription: (r) => r.description,
115
+ getTrigger: (r) => r.globs?.join(", ") || (r.alwaysApply ? "always" : undefined),
116
+ });
117
+ } catch {
118
+ // Capability may not be registered
119
+ }
120
+
121
+ // Load custom tools
122
+ try {
123
+ const tools = loadSync<CustomTool>("tools", loadOpts);
124
+ addItems(tools.all, "tool", {
125
+ getDescription: (t) => t.description,
126
+ });
127
+ } catch {
128
+ // Capability may not be registered
129
+ }
130
+
131
+ // Load MCP servers
132
+ try {
133
+ const mcps = loadSync<MCPServer>("mcps", loadOpts);
134
+ for (const server of mcps.all) {
135
+ const id = makeExtensionId("mcp", server.name);
136
+ const isDisabled = disabledExtensions.has(id);
137
+ const isShadowed = (server as { _shadowed?: boolean })._shadowed;
138
+ const providerEnabled = isProviderEnabled(server._source.provider);
139
+
140
+ let state: ExtensionState;
141
+ let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined;
142
+
143
+ if (isDisabled) {
144
+ state = "disabled";
145
+ disabledReason = "item-disabled";
146
+ } else if (isShadowed) {
147
+ state = "shadowed";
148
+ disabledReason = "shadowed";
149
+ } else if (!providerEnabled) {
150
+ state = "disabled";
151
+ disabledReason = "provider-disabled";
152
+ } else {
153
+ state = "active";
154
+ }
155
+
156
+ extensions.push({
157
+ id,
158
+ kind: "mcp",
159
+ name: server.name,
160
+ displayName: server.name,
161
+ description: server.command || server.url,
162
+ trigger: server.transport || "stdio",
163
+ path: server._source.path,
164
+ source: sourceFromMeta(server._source),
165
+ state,
166
+ disabledReason,
167
+ raw: server,
168
+ });
169
+ }
170
+ } catch {
171
+ // Capability may not be registered
172
+ }
173
+
174
+ // Load prompts
175
+ try {
176
+ const prompts = loadSync<Prompt>("prompts", loadOpts);
177
+ addItems(prompts.all, "prompt", {
178
+ getDescription: () => undefined,
179
+ getTrigger: (p) => `/prompts:${p.name}`,
180
+ });
181
+ } catch {
182
+ // Capability may not be registered
183
+ }
184
+
185
+ // Load hooks
186
+ try {
187
+ const hooks = loadSync<Hook>("hooks", loadOpts);
188
+ for (const hook of hooks.all) {
189
+ const id = makeExtensionId("hook", `${hook.type}:${hook.tool}:${hook.name}`);
190
+ const isDisabled = disabledExtensions.has(id);
191
+ const isShadowed = (hook as { _shadowed?: boolean })._shadowed;
192
+ const providerEnabled = isProviderEnabled(hook._source.provider);
193
+
194
+ let state: ExtensionState;
195
+ let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined;
196
+
197
+ if (isDisabled) {
198
+ state = "disabled";
199
+ disabledReason = "item-disabled";
200
+ } else if (isShadowed) {
201
+ state = "shadowed";
202
+ disabledReason = "shadowed";
203
+ } else if (!providerEnabled) {
204
+ state = "disabled";
205
+ disabledReason = "provider-disabled";
206
+ } else {
207
+ state = "active";
208
+ }
209
+
210
+ extensions.push({
211
+ id,
212
+ kind: "hook",
213
+ name: hook.name,
214
+ displayName: hook.name,
215
+ description: `${hook.type}-${hook.tool}`,
216
+ trigger: `${hook.type}:${hook.tool}`,
217
+ path: hook.path,
218
+ source: sourceFromMeta(hook._source),
219
+ state,
220
+ disabledReason,
221
+ raw: hook,
222
+ });
223
+ }
224
+ } catch {
225
+ // Capability may not be registered
226
+ }
227
+
228
+ // Load context files
229
+ try {
230
+ const contextFiles = loadSync<ContextFile>("context-files", loadOpts);
231
+ for (const file of contextFiles.all) {
232
+ // Extract filename from path for display
233
+ const name = file.path.split("/").pop() || file.path;
234
+ const id = makeExtensionId("context-file", `${file.level}:${name}`);
235
+ const isDisabled = disabledExtensions.has(id);
236
+ const isShadowed = (file as { _shadowed?: boolean })._shadowed;
237
+ const providerEnabled = isProviderEnabled(file._source.provider);
238
+
239
+ let state: ExtensionState;
240
+ let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined;
241
+
242
+ if (isDisabled) {
243
+ state = "disabled";
244
+ disabledReason = "item-disabled";
245
+ } else if (isShadowed) {
246
+ state = "shadowed";
247
+ disabledReason = "shadowed";
248
+ } else if (!providerEnabled) {
249
+ state = "disabled";
250
+ disabledReason = "provider-disabled";
251
+ } else {
252
+ state = "active";
253
+ }
254
+
255
+ extensions.push({
256
+ id,
257
+ kind: "context-file",
258
+ name,
259
+ displayName: name,
260
+ description: file.level === "user" ? "User-level context" : "Project-level context",
261
+ trigger: file.level,
262
+ path: file.path,
263
+ source: sourceFromMeta(file._source),
264
+ state,
265
+ disabledReason,
266
+ raw: file,
267
+ });
268
+ }
269
+ } catch {
270
+ // Capability may not be registered
271
+ }
272
+
273
+ return extensions;
274
+ }
275
+
276
+ /**
277
+ * Build sidebar tree from extensions.
278
+ * Groups by provider → kind.
279
+ */
280
+ export function buildSidebarTree(extensions: Extension[]): TreeNode[] {
281
+ const providers = getAllProvidersInfo();
282
+ const tree: TreeNode[] = [];
283
+
284
+ // Group extensions by provider and kind
285
+ const byProvider = new Map<string, Map<ExtensionKind, Extension[]>>();
286
+
287
+ for (const ext of extensions) {
288
+ const providerId = ext.source.provider;
289
+ if (!byProvider.has(providerId)) {
290
+ byProvider.set(providerId, new Map());
291
+ }
292
+ const byKind = byProvider.get(providerId)!;
293
+ if (!byKind.has(ext.kind)) {
294
+ byKind.set(ext.kind, []);
295
+ }
296
+ byKind.get(ext.kind)!.push(ext);
297
+ }
298
+
299
+ // Build tree nodes for each provider (show ALL providers, even if disabled/empty)
300
+ for (const provider of providers) {
301
+ // Skip the 'native' provider as it cannot be toggled
302
+ if (provider.id === "native") continue;
303
+
304
+ const byKind = byProvider.get(provider.id);
305
+ const kindNodes: TreeNode[] = [];
306
+ let totalCount = 0;
307
+
308
+ if (byKind && byKind.size > 0) {
309
+ for (const [kind, exts] of byKind) {
310
+ totalCount += exts.length;
311
+ kindNodes.push({
312
+ id: `${provider.id}:${kind}`,
313
+ label: getKindDisplayName(kind),
314
+ type: "kind",
315
+ enabled: provider.enabled,
316
+ collapsed: true,
317
+ children: [],
318
+ count: exts.length,
319
+ });
320
+ }
321
+
322
+ // Sort kind nodes by count (most items first)
323
+ kindNodes.sort((a, b) => (b.count || 0) - (a.count || 0));
324
+ }
325
+
326
+ tree.push({
327
+ id: provider.id,
328
+ label: provider.displayName,
329
+ type: "provider",
330
+ enabled: provider.enabled,
331
+ collapsed: false,
332
+ children: kindNodes,
333
+ count: totalCount,
334
+ });
335
+ }
336
+
337
+ return tree;
338
+ }
339
+
340
+ /**
341
+ * Flatten tree for keyboard navigation.
342
+ */
343
+ export function flattenTree(tree: TreeNode[]): FlatTreeItem[] {
344
+ const flat: FlatTreeItem[] = [];
345
+ let index = 0;
346
+
347
+ function walk(node: TreeNode, depth: number): void {
348
+ flat.push({ node, depth, index: index++ });
349
+ if (!node.collapsed) {
350
+ for (const child of node.children) {
351
+ walk(child, depth + 1);
352
+ }
353
+ }
354
+ }
355
+
356
+ for (const node of tree) {
357
+ walk(node, 0);
358
+ }
359
+
360
+ return flat;
361
+ }
362
+
363
+ /**
364
+ * Apply fuzzy filter to extensions.
365
+ */
366
+ export function applyFilter(extensions: Extension[], query: string): Extension[] {
367
+ if (!query.trim()) {
368
+ return extensions;
369
+ }
370
+
371
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
372
+ if (tokens.length === 0) {
373
+ return extensions;
374
+ }
375
+
376
+ return extensions.filter((ext) => {
377
+ const searchable = [
378
+ ext.name,
379
+ ext.displayName,
380
+ ext.description || "",
381
+ ext.trigger || "",
382
+ ext.source.providerName,
383
+ ext.kind,
384
+ ]
385
+ .join(" ")
386
+ .toLowerCase();
387
+
388
+ return tokens.every((token) => searchable.includes(token));
389
+ });
390
+ }
391
+
392
+ /**
393
+ * Get display name for extension kind.
394
+ */
395
+ function getKindDisplayName(kind: ExtensionKind): string {
396
+ switch (kind) {
397
+ case "skill":
398
+ return "Skills";
399
+ case "rule":
400
+ return "Rules";
401
+ case "tool":
402
+ return "Tools";
403
+ case "mcp":
404
+ return "MCP Servers";
405
+ case "prompt":
406
+ return "Prompts";
407
+ case "instruction":
408
+ return "Instructions";
409
+ case "context-file":
410
+ return "Context Files";
411
+ case "hook":
412
+ return "Hooks";
413
+ case "slash-command":
414
+ return "Slash Commands";
415
+ default:
416
+ return kind;
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Build provider tabs from extensions.
422
+ */
423
+ export function buildProviderTabs(extensions: Extension[]): ProviderTab[] {
424
+ const providers = getAllProvidersInfo();
425
+ const tabs: ProviderTab[] = [];
426
+
427
+ // Count extensions per provider
428
+ const countByProvider = new Map<string, number>();
429
+ for (const ext of extensions) {
430
+ const count = countByProvider.get(ext.source.provider) ?? 0;
431
+ countByProvider.set(ext.source.provider, count + 1);
432
+ }
433
+
434
+ // ALL tab first
435
+ tabs.push({
436
+ id: "all",
437
+ label: "ALL",
438
+ enabled: true,
439
+ count: extensions.length,
440
+ });
441
+
442
+ // Provider tabs (skip native)
443
+ for (const provider of providers) {
444
+ if (provider.id === "native") continue;
445
+ const count = countByProvider.get(provider.id) ?? 0;
446
+ tabs.push({
447
+ id: provider.id,
448
+ label: provider.displayName,
449
+ enabled: provider.enabled,
450
+ count,
451
+ });
452
+ }
453
+
454
+ // Sort: ALL first, then enabled by count, then disabled by count, then empty
455
+ tabs.sort((a, b) => {
456
+ if (a.id === "all") return -1;
457
+ if (b.id === "all") return 1;
458
+
459
+ // Categorize: 0 = enabled with content, 1 = disabled, 2 = empty+enabled
460
+ const category = (t: ProviderTab) => {
461
+ if (t.count === 0 && t.enabled) return 2; // empty
462
+ if (!t.enabled) return 1; // disabled
463
+ return 0; // enabled with content
464
+ };
465
+
466
+ const aCat = category(a);
467
+ const bCat = category(b);
468
+ if (aCat !== bCat) return aCat - bCat;
469
+
470
+ // Within same category, sort by count descending
471
+ return b.count - a.count;
472
+ });
473
+
474
+ return tabs;
475
+ }
476
+
477
+ /**
478
+ * Filter extensions by provider tab.
479
+ */
480
+ export function filterByProvider(extensions: Extension[], providerId: string): Extension[] {
481
+ if (providerId === "all") {
482
+ return extensions;
483
+ }
484
+ return extensions.filter((ext) => ext.source.provider === providerId);
485
+ }
486
+
487
+ /**
488
+ * Create initial dashboard state.
489
+ */
490
+ export function createInitialState(cwd?: string, disabledIds?: string[]): DashboardState {
491
+ const extensions = loadAllExtensions(cwd, disabledIds);
492
+ const tabs = buildProviderTabs(extensions);
493
+ const tabFiltered = extensions; // "all" tab by default
494
+ const searchFiltered = tabFiltered;
495
+
496
+ return {
497
+ tabs,
498
+ activeTabIndex: 0,
499
+ extensions,
500
+ tabFiltered,
501
+ searchFiltered,
502
+ searchQuery: "",
503
+ listIndex: 0,
504
+ scrollOffset: 0,
505
+ selected: searchFiltered[0] ?? null,
506
+ };
507
+ }
508
+
509
+ /**
510
+ * Toggle provider enabled state.
511
+ */
512
+ export function toggleProvider(providerId: string): boolean {
513
+ if (isProviderEnabled(providerId)) {
514
+ disableProvider(providerId);
515
+ return false;
516
+ } else {
517
+ enableProvider(providerId);
518
+ return true;
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Refresh state after toggle.
524
+ */
525
+ export function refreshState(state: DashboardState, cwd?: string, disabledIds?: string[]): DashboardState {
526
+ const extensions = loadAllExtensions(cwd, disabledIds);
527
+ const tabs = buildProviderTabs(extensions);
528
+
529
+ // Get current provider from tabs
530
+ const activeTab = state.tabs[state.activeTabIndex];
531
+ const providerId = activeTab?.id ?? "all";
532
+
533
+ // Re-apply filters
534
+ const tabFiltered = filterByProvider(extensions, providerId);
535
+ const searchFiltered = applyFilter(tabFiltered, state.searchQuery);
536
+
537
+ // Find new index for current provider (tabs may have reordered)
538
+ const newActiveTabIndex = tabs.findIndex((t) => t.id === providerId);
539
+ const activeTabIndex = newActiveTabIndex >= 0 ? newActiveTabIndex : 0;
540
+
541
+ // Try to preserve selection
542
+ const selectedId = state.selected?.id;
543
+ let selected = selectedId ? searchFiltered.find((e) => e.id === selectedId) : null;
544
+ if (!selected && searchFiltered.length > 0) {
545
+ selected = searchFiltered[Math.min(state.listIndex, searchFiltered.length - 1)];
546
+ }
547
+
548
+ return {
549
+ ...state,
550
+ tabs,
551
+ activeTabIndex,
552
+ extensions,
553
+ tabFiltered,
554
+ searchFiltered,
555
+ selected: selected ?? null,
556
+ listIndex: selected ? searchFiltered.indexOf(selected) : 0,
557
+ };
558
+ }