@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.
@@ -0,0 +1,297 @@
1
+ /**
2
+ * ExtensionDashboard - Tabbed layout for the Extension Control Center.
3
+ *
4
+ * Layout:
5
+ * - Top: Horizontal tab bar for provider selection
6
+ * - Body: 2-column grid (inventory list | preview panel)
7
+ *
8
+ * Navigation:
9
+ * - TAB/Shift+TAB: Cycle through provider tabs
10
+ * - Up/Down/j/k: Navigate list
11
+ * - Space: Toggle selected item (or master switch)
12
+ * - Esc: Close dashboard (clears search first if active)
13
+ */
14
+
15
+ import {
16
+ type Component,
17
+ Container,
18
+ isCtrlC,
19
+ isEscape,
20
+ isShiftTab,
21
+ isTab,
22
+ Spacer,
23
+ Text,
24
+ truncateToWidth,
25
+ visibleWidth,
26
+ } from "@oh-my-pi/pi-tui";
27
+ import type { SettingsManager } from "../../../../core/settings-manager";
28
+ import { theme } from "../../theme/theme";
29
+ import { DynamicBorder } from "../dynamic-border";
30
+ import { ExtensionList } from "./extension-list";
31
+ import { InspectorPanel } from "./inspector-panel";
32
+ import { applyFilter, createInitialState, filterByProvider, refreshState, toggleProvider } from "./state-manager";
33
+ import type { DashboardState } from "./types";
34
+
35
+ export class ExtensionDashboard extends Container {
36
+ private state: DashboardState;
37
+ private mainList: ExtensionList;
38
+ private inspector: InspectorPanel;
39
+ private settingsManager: SettingsManager | null;
40
+ private cwd: string;
41
+
42
+ public onClose?: () => void;
43
+
44
+ constructor(cwd: string, settingsManager: SettingsManager | null = null) {
45
+ super();
46
+ this.cwd = cwd;
47
+ this.settingsManager = settingsManager;
48
+ const disabledIds = settingsManager?.getDisabledExtensions() ?? [];
49
+ this.state = createInitialState(cwd, disabledIds);
50
+
51
+ // Create main list - always focused
52
+ this.mainList = new ExtensionList(this.state.searchFiltered, {
53
+ onSelectionChange: (ext) => {
54
+ this.state.selected = ext;
55
+ this.inspector.setExtension(ext);
56
+ },
57
+ onToggle: (extensionId, enabled) => {
58
+ this.handleExtensionToggle(extensionId, enabled);
59
+ },
60
+ onMasterToggle: (providerId) => {
61
+ this.handleProviderToggle(providerId);
62
+ },
63
+ masterSwitchProvider: this.getActiveProviderId(),
64
+ });
65
+ this.mainList.setFocused(true);
66
+
67
+ // Create inspector
68
+ this.inspector = new InspectorPanel();
69
+ if (this.state.selected) {
70
+ this.inspector.setExtension(this.state.selected);
71
+ }
72
+
73
+ this.buildLayout();
74
+ }
75
+
76
+ private getActiveProviderId(): string | null {
77
+ const tab = this.state.tabs[this.state.activeTabIndex];
78
+ return tab && tab.id !== "all" ? tab.id : null;
79
+ }
80
+
81
+ private buildLayout(): void {
82
+ this.clear();
83
+
84
+ // Top border
85
+ this.addChild(new DynamicBorder());
86
+
87
+ // Title
88
+ this.addChild(new Text(theme.bold(theme.fg("accent", " Extension Control Center")), 0, 0));
89
+
90
+ // Tab bar
91
+ this.addChild(new Text(this.renderTabBar(), 0, 0));
92
+ this.addChild(new Spacer(1));
93
+
94
+ // Help text
95
+ // 2-column body
96
+ this.addChild(new TwoColumnBody(this.mainList, this.inspector));
97
+
98
+ this.addChild(new Spacer(1));
99
+ this.addChild(new Text(theme.fg("dim", " ↑/↓: navigate Space: toggle Tab: next provider Esc: close"), 0, 0));
100
+
101
+ // Bottom border
102
+ this.addChild(new DynamicBorder());
103
+ }
104
+
105
+ private renderTabBar(): string {
106
+ const parts: string[] = [" "];
107
+
108
+ for (let i = 0; i < this.state.tabs.length; i++) {
109
+ const tab = this.state.tabs[i];
110
+ const isActive = i === this.state.activeTabIndex;
111
+ const isEmpty = tab.count === 0 && tab.id !== "all";
112
+ const isDisabled = !tab.enabled && tab.id !== "all";
113
+
114
+ // Build label with count
115
+ let label = tab.label;
116
+ if (tab.count > 0) {
117
+ label += ` (${tab.count})`;
118
+ }
119
+
120
+ // Apply strikethrough for disabled providers
121
+ const displayLabel = isDisabled ? `${label.split("").join("\u0336")}\u0336` : label;
122
+
123
+ if (isActive) {
124
+ // Active tab: background highlight
125
+ parts.push(theme.bg("selectedBg", ` ${displayLabel} `));
126
+ } else if (isDisabled) {
127
+ // Disabled provider: strikethrough + dim
128
+ parts.push(theme.fg("dim", ` ${displayLabel} `));
129
+ } else if (isEmpty) {
130
+ // Empty enabled provider: very dim, unselectable
131
+ parts.push(`\x1b[38;5;238m ${label} \x1b[0m`);
132
+ } else {
133
+ // Normal enabled provider
134
+ parts.push(theme.fg("muted", ` ${label} `));
135
+ }
136
+ }
137
+
138
+ return parts.join("");
139
+ }
140
+
141
+ private handleProviderToggle(providerId: string): void {
142
+ toggleProvider(providerId);
143
+ this.refreshFromState();
144
+ }
145
+
146
+ private handleExtensionToggle(extensionId: string, enabled: boolean): void {
147
+ if (!this.settingsManager) return;
148
+
149
+ if (enabled) {
150
+ this.settingsManager.enableExtension(extensionId);
151
+ } else {
152
+ this.settingsManager.disableExtension(extensionId);
153
+ }
154
+
155
+ this.refreshFromState();
156
+ }
157
+
158
+ private refreshFromState(): void {
159
+ // Remember current tab ID before refresh
160
+ const currentTabId = this.state.tabs[this.state.activeTabIndex]?.id;
161
+
162
+ const disabledIds = this.settingsManager?.getDisabledExtensions() ?? [];
163
+ this.state = refreshState(this.state, this.cwd, disabledIds);
164
+
165
+ // Find the same tab in the new (re-sorted) list
166
+ if (currentTabId) {
167
+ const newIndex = this.state.tabs.findIndex((t) => t.id === currentTabId);
168
+ if (newIndex >= 0) {
169
+ this.state.activeTabIndex = newIndex;
170
+ }
171
+ }
172
+
173
+ this.mainList.setExtensions(this.state.searchFiltered);
174
+ this.mainList.setMasterSwitchProvider(this.getActiveProviderId());
175
+
176
+ if (this.state.selected) {
177
+ this.inspector.setExtension(this.state.selected);
178
+ }
179
+
180
+ this.buildLayout();
181
+ }
182
+
183
+ private switchTab(direction: 1 | -1): void {
184
+ const numTabs = this.state.tabs.length;
185
+ if (numTabs === 0) return;
186
+
187
+ // Find next selectable tab (skip empty+enabled providers)
188
+ let nextIndex = this.state.activeTabIndex;
189
+ for (let i = 0; i < numTabs; i++) {
190
+ nextIndex = (nextIndex + direction + numTabs) % numTabs;
191
+ const tab = this.state.tabs[nextIndex];
192
+ const isEmptyEnabled = tab.count === 0 && tab.enabled && tab.id !== "all";
193
+ if (!isEmptyEnabled) break;
194
+ }
195
+ this.state.activeTabIndex = nextIndex;
196
+
197
+ // Re-filter for new tab
198
+ const tab = this.state.tabs[this.state.activeTabIndex];
199
+ this.state.tabFiltered = filterByProvider(this.state.extensions, tab.id);
200
+ this.state.searchFiltered = applyFilter(this.state.tabFiltered, this.state.searchQuery);
201
+ this.state.listIndex = 0;
202
+ this.state.scrollOffset = 0;
203
+ this.state.selected = this.state.searchFiltered[0] ?? null;
204
+
205
+ // Update list
206
+ this.mainList.setExtensions(this.state.searchFiltered);
207
+ this.mainList.setMasterSwitchProvider(this.getActiveProviderId());
208
+ this.mainList.resetSelection();
209
+
210
+ if (this.state.selected) {
211
+ this.inspector.setExtension(this.state.selected);
212
+ }
213
+
214
+ this.buildLayout();
215
+ }
216
+
217
+ handleInput(data: string): void {
218
+ // Ctrl+C - close immediately
219
+ if (isCtrlC(data)) {
220
+ this.onClose?.();
221
+ return;
222
+ }
223
+
224
+ // Escape - clear search first, then close
225
+ if (isEscape(data)) {
226
+ if (this.state.searchQuery.length > 0) {
227
+ this.state.searchQuery = "";
228
+ this.state.searchFiltered = this.state.tabFiltered;
229
+ this.mainList.setExtensions(this.state.searchFiltered);
230
+ this.mainList.clearSearch();
231
+ this.buildLayout();
232
+ return;
233
+ }
234
+ this.onClose?.();
235
+ return;
236
+ }
237
+
238
+ // Tab/Shift+Tab: Cycle through tabs
239
+ if (isTab(data)) {
240
+ this.switchTab(1);
241
+ return;
242
+ }
243
+ if (isShiftTab(data)) {
244
+ this.switchTab(-1);
245
+ return;
246
+ }
247
+
248
+ // All other input goes to the list
249
+ this.mainList.handleInput(data);
250
+
251
+ // Sync search query back to state
252
+ const query = this.mainList.getSearchQuery();
253
+ if (query !== this.state.searchQuery) {
254
+ this.state.searchQuery = query;
255
+ this.state.searchFiltered = applyFilter(this.state.tabFiltered, query);
256
+ }
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Two-column body component for side-by-side rendering.
262
+ */
263
+ class TwoColumnBody implements Component {
264
+ private leftPane: ExtensionList;
265
+ private rightPane: InspectorPanel;
266
+
267
+ constructor(left: ExtensionList, right: InspectorPanel) {
268
+ this.leftPane = left;
269
+ this.rightPane = right;
270
+ }
271
+
272
+ render(width: number): string[] {
273
+ const leftWidth = Math.floor(width * 0.5);
274
+ const rightWidth = width - leftWidth - 3;
275
+
276
+ const leftLines = this.leftPane.render(leftWidth);
277
+ const rightLines = this.rightPane.render(rightWidth);
278
+
279
+ const maxLines = Math.max(leftLines.length, rightLines.length);
280
+ const combined: string[] = [];
281
+ const separator = theme.fg("dim", " │ ");
282
+
283
+ for (let i = 0; i < maxLines; i++) {
284
+ const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
285
+ const leftPadded = left + " ".repeat(Math.max(0, leftWidth - visibleWidth(left)));
286
+ const right = truncateToWidth(rightLines[i] ?? "", rightWidth);
287
+ combined.push(leftPadded + separator + right);
288
+ }
289
+
290
+ return combined;
291
+ }
292
+
293
+ invalidate(): void {
294
+ this.leftPane.invalidate?.();
295
+ this.rightPane.invalidate?.();
296
+ }
297
+ }