@oh-my-pi/pi-coding-agent 3.3.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.
- package/CHANGELOG.md +32 -0
- package/package.json +4 -4
- package/src/capability/rule.ts +4 -0
- package/src/core/agent-session.ts +92 -1
- package/src/core/sdk.ts +27 -0
- package/src/core/session-manager.ts +60 -4
- package/src/core/settings-manager.ts +101 -0
- package/src/core/system-prompt.ts +15 -0
- package/src/core/title-generator.ts +28 -6
- package/src/core/tools/index.ts +6 -0
- package/src/core/tools/rulebook.ts +124 -0
- package/src/core/ttsr.ts +211 -0
- package/src/discovery/builtin.ts +1 -0
- package/src/discovery/cline.ts +2 -0
- package/src/discovery/cursor.ts +2 -0
- package/src/discovery/windsurf.ts +3 -0
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +297 -0
- package/src/modes/interactive/components/extensions/extension-list.ts +477 -0
- package/src/modes/interactive/components/extensions/index.ts +9 -0
- package/src/modes/interactive/components/extensions/inspector-panel.ts +313 -0
- package/src/modes/interactive/components/extensions/state-manager.ts +558 -0
- package/src/modes/interactive/components/extensions/types.ts +191 -0
- package/src/modes/interactive/components/footer.ts +15 -1
- package/src/modes/interactive/components/settings-defs.ts +31 -31
- package/src/modes/interactive/components/settings-selector.ts +0 -1
- package/src/modes/interactive/components/ttsr-notification.ts +82 -0
- package/src/modes/interactive/interactive-mode.ts +54 -314
- package/src/modes/print-mode.ts +34 -0
|
@@ -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
|
+
}
|