@oh-my-pi/pi-coding-agent 16.1.17 → 16.1.19

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/cli.js +17872 -17571
  3. package/dist/types/cli/usage-cli.d.ts +14 -0
  4. package/dist/types/commands/token.d.ts +9 -0
  5. package/dist/types/edit/hashline/filesystem.d.ts +1 -18
  6. package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +10 -0
  7. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +10 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +43 -27
  9. package/dist/types/internal-urls/skill-protocol.d.ts +2 -2
  10. package/dist/types/internal-urls/types.d.ts +3 -0
  11. package/dist/types/modes/components/custom-editor.d.ts +0 -2
  12. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +26 -10
  13. package/dist/types/modes/components/extensions/extension-list.d.ts +13 -0
  14. package/dist/types/modes/controllers/input-controller.d.ts +0 -1
  15. package/dist/types/tools/path-utils.d.ts +3 -0
  16. package/package.json +12 -12
  17. package/scripts/build-binary.ts +29 -16
  18. package/scripts/generate-legacy-pi-bundled-registry.ts +404 -0
  19. package/src/cli/usage-cli.ts +35 -4
  20. package/src/commands/token.ts +54 -0
  21. package/src/edit/hashline/filesystem.ts +14 -0
  22. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +987 -0
  23. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +3352 -0
  24. package/src/extensibility/plugins/legacy-pi-compat.ts +264 -131
  25. package/src/internal-urls/local-protocol.ts +116 -9
  26. package/src/internal-urls/skill-protocol.ts +3 -3
  27. package/src/internal-urls/types.ts +3 -0
  28. package/src/modes/components/custom-editor.test.ts +7 -5
  29. package/src/modes/components/custom-editor.ts +6 -16
  30. package/src/modes/components/extensions/extension-dashboard.ts +221 -165
  31. package/src/modes/components/extensions/extension-list.ts +66 -33
  32. package/src/modes/controllers/input-controller.ts +0 -71
  33. package/src/modes/controllers/selector-controller.ts +4 -0
  34. package/src/session/messages.ts +70 -47
  35. package/src/tools/ast-edit.ts +1 -0
  36. package/src/tools/ast-grep.ts +1 -0
  37. package/src/tools/find.ts +1 -0
  38. package/src/tools/path-utils.ts +4 -0
  39. package/src/tools/read.ts +43 -17
  40. package/src/tools/search.ts +4 -0
@@ -93,15 +93,17 @@ describe("CustomEditor bracketed path paste", () => {
93
93
  expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/report.csv"))).toBeUndefined();
94
94
  });
95
95
 
96
- it("routes non-image path pastes through the file-path hook", async () => {
96
+ it("inserts non-image path pastes as literal text instead of attaching them", () => {
97
97
  const { editor } = makeEditor();
98
- const pasted = Promise.withResolvers<string>();
99
- editor.onPasteFilePath = path => pasted.resolve(path);
98
+ let imagePathCalls = 0;
99
+ editor.onPasteImagePath = () => {
100
+ imagePathCalls++;
101
+ };
100
102
 
101
103
  editor.handleInput(bracketedPaste("/tmp/report.csv"));
102
104
 
103
- expect(await pasted.promise).toBe("/tmp/report.csv");
104
- expect(editor.getText()).toBe("");
105
+ expect(editor.getText()).toBe("/tmp/report.csv");
106
+ expect(imagePathCalls).toBe(0);
105
107
  });
106
108
  });
107
109
 
@@ -318,8 +318,6 @@ export class CustomEditor extends Editor {
318
318
  onPasteImage?: () => Promise<boolean>;
319
319
  /** Called when a bracketed paste contains one or more image-file paths. */
320
320
  onPasteImagePath?: (path: string) => void | Promise<void>;
321
- /** Called when a bracketed paste contains one or more non-image file paths. */
322
- onPasteFilePath?: (path: string) => void | Promise<void>;
323
321
  /** Called when the configured raw text-paste shortcut is pressed. */
324
322
  onPasteTextRaw?: () => void;
325
323
  /** Called when the configured dequeue shortcut is pressed. */
@@ -505,20 +503,12 @@ export class CustomEditor extends Editor {
505
503
  return;
506
504
  }
507
505
 
508
- const pastedPaths = extractBracketedPastePaths(data);
509
- if (pastedPaths) {
510
- const canHandlePaths = pastedPaths.every(path =>
511
- isImagePath(path) ? this.onPasteImagePath !== undefined : this.onPasteFilePath !== undefined,
512
- );
513
- if (canHandlePaths) {
514
- void (async () => {
515
- for (const path of pastedPaths) {
516
- if (isImagePath(path)) await this.onPasteImagePath?.(path);
517
- else await this.onPasteFilePath?.(path);
518
- }
519
- })();
520
- return;
521
- }
506
+ const pastedImagePaths = extractBracketedImagePastePaths(data);
507
+ if (pastedImagePaths && this.onPasteImagePath) {
508
+ void (async () => {
509
+ for (const path of pastedImagePaths) await this.onPasteImagePath?.(path);
510
+ })();
511
+ return;
522
512
  }
523
513
 
524
514
  const parsedKey = parseKey(data);
@@ -1,32 +1,34 @@
1
1
  /**
2
- * ExtensionDashboard - Tabbed layout for the Extension Control Center.
2
+ * ExtensionDashboard - Fullscreen alternate-screen control center for extensions.
3
3
  *
4
- * Layout:
5
- * - Top: Horizontal tab bar for provider selection
6
- * - Body: 2-column grid (inventory list | preview panel)
4
+ * Chrome mirrors the `/settings` overlay: a titled rounded box, a shared
5
+ * {@link TabBar} for provider selection, and a two-column body (inventory list |
6
+ * inspector). Both panes are mouse-aware wheel scrolls, hover highlights, and
7
+ * clicks select/activate — routed from a single SGR-mouse handler.
7
8
  *
8
9
  * 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)
10
+ * - Tab/Shift+Tab or ←/→: switch provider tab
11
+ * - Up/Down/j/k or wheel: move list selection
12
+ * - Space/Enter or click: toggle selected item (or provider master switch)
13
+ * - Wheel over the inspector: scroll the detail pane
14
+ * - Esc: clear search (if active) then close
13
15
  */
14
16
  import {
15
17
  type Component,
16
- Container,
17
18
  matchesKey,
18
19
  padding,
19
- Spacer,
20
- Text,
20
+ parseSgrMouse,
21
+ ScrollView,
22
+ type Tab,
23
+ TabBar,
21
24
  truncateToWidth,
22
25
  visibleWidth,
23
- wrapTextWithAnsi,
24
26
  } from "@oh-my-pi/pi-tui";
25
27
  import { Settings } from "../../../config/settings";
26
- import { DynamicBorder } from "../../../modes/components/dynamic-border";
28
+ import { getTabBarTheme } from "../../../modes/shared";
27
29
  import { theme } from "../../../modes/theme/theme";
28
30
  import { matchesAppInterrupt } from "../../../modes/utils/keybinding-matchers";
29
- import { handleTabSwitchKey, padLinesToHeight } from "../selector-helpers";
31
+ import { bottomBorder, divider, row, topBorder } from "../overlay-box";
30
32
  import { ExtensionList } from "./extension-list";
31
33
  import { InspectorPanel } from "./inspector-panel";
32
34
  import {
@@ -37,17 +39,41 @@ import {
37
39
  refreshState,
38
40
  toggleProvider,
39
41
  } from "./state-manager";
40
- import type { DashboardState } from "./types";
42
+ import type { DashboardState, ProviderTab } from "./types";
41
43
 
42
- const EXT_FOOTER = " ↑/↓: navigate Space: toggle ←/→: provider Esc: close";
44
+ const EXT_FOOTER = " ↑/↓: navigate · Space: toggle · ←/→: provider · Esc: close";
43
45
 
44
- export class ExtensionDashboard extends Container {
46
+ /**
47
+ * Map dashboard provider tabs to {@link TabBar} tabs. Empty *enabled* providers
48
+ * are muted — skipped by keyboard nav and unclickable; disabled providers stay
49
+ * selectable (with a leading disabled glyph) so their master switch can be
50
+ * re-enabled from the list. The "all" tab is never muted or marked.
51
+ */
52
+ export function buildTabBarTabs(tabs: ProviderTab[]): Tab[] {
53
+ return tabs.map(tab => {
54
+ const isAll = tab.id === "all";
55
+ const isEmptyEnabled = tab.count === 0 && tab.enabled && !isAll;
56
+ const isDisabled = !tab.enabled && !isAll;
57
+ let label = tab.label;
58
+ if (tab.count > 0) label += ` (${tab.count})`;
59
+ if (isDisabled) label = `${theme.status.disabled} ${label}`;
60
+ return { id: tab.id, label, short: tab.label, muted: isEmptyEnabled };
61
+ });
62
+ }
63
+
64
+ export class ExtensionDashboard implements Component {
45
65
  #state!: DashboardState;
46
66
  #mainList!: ExtensionList;
47
67
  #inspector!: InspectorPanel;
68
+ #tabBar!: TabBar;
69
+ #body!: TwoColumnBody;
48
70
  #refreshToken = 0;
49
- #builtRows = -1;
50
- #builtCols = -1;
71
+ // Frame geometry from the last render, for SGR mouse hit-testing. The
72
+ // fullscreen overlay paints from screen row 0, so mouse rows map 1:1.
73
+ #tabRowStart = 0;
74
+ #tabRowCount = 0;
75
+ #bodyRowStart = 0;
76
+ #bodyRowCount = 0;
51
77
 
52
78
  onClose?: () => void;
53
79
  onRequestRender?: () => void;
@@ -56,9 +82,7 @@ export class ExtensionDashboard extends Container {
56
82
  private readonly cwd: string,
57
83
  private readonly settings: Settings | null,
58
84
  private readonly terminalHeight: number,
59
- ) {
60
- super();
61
- }
85
+ ) {}
62
86
 
63
87
  static async create(
64
88
  cwd: string,
@@ -75,37 +99,36 @@ export class ExtensionDashboard extends Container {
75
99
  const disabledIds = sm ? ((sm.get("disabledExtensions") as string[]) ?? []) : [];
76
100
  this.#state = await createInitialState(this.cwd, disabledIds);
77
101
 
78
- // Calculate max visible items based on terminal height
79
- // Reserve ~10 lines for header, tabs, help text, borders
80
- const maxVisible = this.#maxVisibleItems();
81
-
82
- // Create main list - always focused
102
+ const initialMaxVisible = Math.max(3, this.terminalHeight - 9);
83
103
  this.#mainList = new ExtensionList(
84
104
  this.#state.searchFiltered,
85
105
  {
86
106
  onSelectionChange: ext => {
87
107
  this.#state.selected = ext;
88
108
  this.#inspector.setExtension(ext);
109
+ // A fresh selection resets the inspector to the top.
110
+ this.#body.resetInspectorScroll();
89
111
  },
90
- onToggle: (extensionId, enabled) => {
91
- this.#handleExtensionToggle(extensionId, enabled);
92
- },
93
- onMasterToggle: providerId => {
94
- this.#handleProviderToggle(providerId);
95
- },
112
+ onToggle: (extensionId, enabled) => this.#handleExtensionToggle(extensionId, enabled),
113
+ onMasterToggle: providerId => this.#handleProviderToggle(providerId),
96
114
  masterSwitchProvider: this.#getActiveProviderId(),
97
115
  },
98
- maxVisible,
116
+ initialMaxVisible,
99
117
  );
100
118
  this.#mainList.setFocused(true);
101
119
 
102
- // Create inspector
103
120
  this.#inspector = new InspectorPanel();
104
121
  if (this.#state.selected) {
105
122
  this.#inspector.setExtension(this.#state.selected);
106
123
  }
107
124
 
108
- this.#buildLayout();
125
+ this.#body = new TwoColumnBody(this.#mainList, this.#inspector, this.terminalHeight);
126
+
127
+ this.#tabBar = new TabBar("", buildTabBarTabs(this.#state.tabs), getTabBarTheme());
128
+ this.#tabBar.showHint = false;
129
+ this.#tabBar.onTabChange = tab => this.#selectProviderById(tab.id);
130
+ const activeId = this.#state.tabs[this.#state.activeTabIndex]?.id;
131
+ if (activeId) this.#tabBar.setActiveById(activeId);
109
132
  }
110
133
 
111
134
  #getActiveProviderId(): string | null {
@@ -118,98 +141,117 @@ export class ExtensionDashboard extends Container {
118
141
  return process.stdout.rows || this.terminalHeight || 24;
119
142
  }
120
143
 
121
- #uiWidth(): number {
122
- return Math.max(20, process.stdout.columns || 80);
123
- }
124
-
125
- #footerLines(): number {
126
- return Math.max(1, wrapTextWithAnsi(theme.fg("dim", EXT_FOOTER), this.#uiWidth()).length);
127
- }
128
-
129
- /** Height budget for the two-column body, sized to the live terminal. */
130
- #computeBodyHeight(): number {
131
- // Chrome: top border + title + tab bar + spacer (4), then spacer + footer + bottom border.
132
- const chrome = 4 + 1 + this.#footerLines() + 1;
133
- return Math.max(5, this.#terminalRows() - chrome);
144
+ /**
145
+ * Fullscreen frame: titled top border, the tab row(s), a divider, the
146
+ * two-column body sized to fill the viewport, a divider, the footer hint, and
147
+ * the bottom border. Records row geometry for mouse hit-testing.
148
+ */
149
+ render(width: number): readonly string[] {
150
+ const height = Math.max(14, this.#terminalRows());
151
+ const innerWidth = Math.max(1, width - 4);
152
+
153
+ const tabLines = this.#tabBar.render(innerWidth);
154
+ // Fixed chrome: top border + tab rows + divider + divider + footer + bottom border.
155
+ const fixedRows = 1 + tabLines.length + 1 + 1 + 1 + 1;
156
+ const contentRows = Math.max(5, height - fixedRows);
157
+
158
+ this.#mainList.setMaxVisible(Math.max(3, contentRows - 2));
159
+ this.#body.setMaxHeight(contentRows);
160
+ const bodyLines = this.#body.render(innerWidth);
161
+
162
+ const out: string[] = [];
163
+ out.push(topBorder(width, "Extension Control Center"));
164
+ this.#tabRowStart = out.length;
165
+ this.#tabRowCount = tabLines.length;
166
+ for (const line of tabLines) out.push(row(line, width));
167
+ out.push(divider(width));
168
+ this.#bodyRowStart = out.length;
169
+ this.#bodyRowCount = contentRows;
170
+ for (let i = 0; i < contentRows; i++) out.push(row(bodyLines[i] ?? "", width));
171
+ out.push(divider(width));
172
+ out.push(row(theme.fg("dim", EXT_FOOTER), width));
173
+ out.push(bottomBorder(width));
174
+ return out;
134
175
  }
135
176
 
136
- #maxVisibleItems(): number {
137
- // List chrome inside the body: search line, blank line, scroll indicator.
138
- return Math.max(3, this.#computeBodyHeight() - 3);
177
+ invalidate(): void {
178
+ this.#tabBar.invalidate();
179
+ this.#mainList.invalidate();
180
+ this.#inspector.invalidate();
139
181
  }
140
182
 
141
- override render(width: number): readonly string[] {
142
- // Rebuild when terminal geometry changes so the full-screen overlay
143
- // re-fits on resize.
144
- if (this.#terminalRows() !== this.#builtRows || this.#uiWidth() !== this.#builtCols) {
145
- this.#buildLayout();
183
+ /**
184
+ * Route an SGR mouse report against the last render's geometry. Wheel scrolls
185
+ * the pane under the pointer, motion drives hover highlights (tabs + rows),
186
+ * and a left click switches tabs or selects/activates a list row.
187
+ */
188
+ #handleMouse(data: string): void {
189
+ const event = parseSgrMouse(data);
190
+ if (!event) return;
191
+
192
+ // row() insets content by two columns (border + space).
193
+ const innerCol = event.col - 2;
194
+ const tabLine = event.row - this.#tabRowStart;
195
+ const overTabs = tabLine >= 0 && tabLine < this.#tabRowCount;
196
+ const bodyLine = event.row - this.#bodyRowStart;
197
+ const overBody = bodyLine >= 0 && bodyLine < this.#bodyRowCount;
198
+ const leftWidth = this.#body.leftWidth;
199
+ const overList = overBody && innerCol < leftWidth;
200
+ const overInspector = overBody && innerCol >= leftWidth + 3;
201
+
202
+ if (event.wheel !== null) {
203
+ if (overList) {
204
+ this.#mainList.handleWheel(event.wheel);
205
+ this.onRequestRender?.();
206
+ } else if (overInspector) {
207
+ this.#body.scrollInspector(event.wheel);
208
+ this.onRequestRender?.();
209
+ }
210
+ return;
146
211
  }
147
- const lines = super.render(width);
148
- // Pad to the full viewport so the dashboard covers the screen instead of
149
- // letting the transcript peek through below it.
150
- return padLinesToHeight(lines, this.#terminalRows());
151
- }
152
212
 
153
- #buildLayout(): void {
154
- this.clear();
155
-
156
- // Top border
157
- this.addChild(new DynamicBorder());
158
-
159
- // Title
160
- this.addChild(new Text(theme.bold(theme.fg("accent", " Extension Control Center")), 0, 0));
161
-
162
- // Tab bar
163
- this.addChild(new Text(this.#renderTabBar(), 0, 0));
164
- this.addChild(new Spacer(1));
165
-
166
- // 2-column body sized to fill the live terminal viewport.
167
- const bodyMaxHeight = this.#computeBodyHeight();
168
- this.#mainList.setMaxVisible(this.#maxVisibleItems());
169
- this.addChild(new TwoColumnBody(this.#mainList, this.#inspector, bodyMaxHeight));
213
+ if (event.motion) {
214
+ const hoveredTab = overTabs ? this.#tabBar.tabAt(tabLine, innerCol) : undefined;
215
+ this.#tabBar.setHoverTab(hoveredTab && !hoveredTab.muted ? hoveredTab.id : null);
216
+ this.#mainList.setHoverIndex(overList ? this.#mainList.hitTest(bodyLine) : null);
217
+ this.onRequestRender?.();
218
+ return;
219
+ }
170
220
 
171
- this.addChild(new Spacer(1));
172
- this.addChild(new Text(theme.fg("dim", EXT_FOOTER), 0, 0));
221
+ if (!event.leftClick) return;
173
222
 
174
- // Bottom border
175
- this.addChild(new DynamicBorder());
176
- this.#builtRows = this.#terminalRows();
177
- this.#builtCols = this.#uiWidth();
223
+ if (overTabs) {
224
+ const tab = this.#tabBar.tabAt(tabLine, innerCol);
225
+ if (tab) this.#tabBar.selectTab(tab.id);
226
+ return;
227
+ }
228
+ if (overList) {
229
+ this.#mainList.handleClick(bodyLine);
230
+ this.onRequestRender?.();
231
+ }
178
232
  }
179
233
 
180
- #renderTabBar(): string {
181
- const parts: string[] = [" "];
182
-
183
- for (let i = 0; i < this.#state.tabs.length; i++) {
184
- const tab = this.#state.tabs[i];
185
- const isActive = i === this.#state.activeTabIndex;
186
- const isEmpty = tab.count === 0 && tab.id !== "all";
187
- const isDisabled = !tab.enabled && tab.id !== "all";
234
+ /** Switch to the provider tab with `id`, re-filtering the list around it. */
235
+ #selectProviderById(id: string): void {
236
+ const index = this.#state.tabs.findIndex(t => t.id === id);
237
+ if (index < 0) return;
238
+ this.#state.activeTabIndex = index;
188
239
 
189
- // Build label with count
190
- let label = tab.label;
191
- if (tab.count > 0) {
192
- label += ` (${tab.count})`;
193
- }
240
+ const tab = this.#state.tabs[index];
241
+ this.#state.tabFiltered = filterByProvider(this.#state.extensions, tab.id);
242
+ this.#state.searchFiltered = applyFilter(this.#state.tabFiltered, this.#state.searchQuery);
243
+ this.#state.listIndex = 0;
244
+ this.#state.scrollOffset = 0;
245
+ this.#state.selected = this.#state.searchFiltered[0] ?? null;
194
246
 
195
- const displayLabel = isDisabled ? `${theme.status.disabled} ${label}` : label;
196
-
197
- if (isActive) {
198
- // Active tab: background highlight
199
- parts.push(theme.bg("selectedBg", ` ${displayLabel} `));
200
- } else if (isDisabled) {
201
- // Disabled provider: dim
202
- parts.push(theme.fg("dim", ` ${displayLabel} `));
203
- } else if (isEmpty) {
204
- // Empty enabled provider: very dim, unselectable
205
- parts.push(theme.fg("dim", ` ${label} `));
206
- } else {
207
- // Normal enabled provider
208
- parts.push(theme.fg("muted", ` ${label} `));
209
- }
247
+ this.#mainList.setExtensions(this.#state.searchFiltered);
248
+ this.#mainList.setMasterSwitchProvider(this.#getActiveProviderId());
249
+ this.#mainList.resetSelection();
250
+ if (this.#state.selected) {
251
+ this.#inspector.setExtension(this.#state.selected);
210
252
  }
211
-
212
- return parts.join("");
253
+ this.#body.resetInspectorScroll();
254
+ this.onRequestRender?.();
213
255
  }
214
256
 
215
257
  #handleProviderToggle(providerId: string): void {
@@ -241,7 +283,7 @@ export class ExtensionDashboard extends Container {
241
283
 
242
284
  async #refreshFromState(): Promise<void> {
243
285
  const refreshToken = ++this.#refreshToken;
244
- // Remember current tab ID before refresh
286
+ // Remember the current tab so it survives the re-sort.
245
287
  const currentTabId = this.#state.tabs[this.#state.activeTabIndex]?.id;
246
288
 
247
289
  const sm = this.settings ?? Settings.instance;
@@ -250,7 +292,7 @@ export class ExtensionDashboard extends Container {
250
292
  if (refreshToken !== this.#refreshToken) return;
251
293
  this.#state = nextState;
252
294
 
253
- // Find the same tab in the new (re-sorted) list
295
+ // Re-anchor on the same tab id in the (re-sorted) list.
254
296
  if (currentTabId) {
255
297
  const newIndex = this.#state.tabs.findIndex(t => t.id === currentTabId);
256
298
  if (newIndex >= 0) {
@@ -260,12 +302,11 @@ export class ExtensionDashboard extends Container {
260
302
 
261
303
  this.#mainList.setExtensions(this.#state.searchFiltered);
262
304
  this.#mainList.setMasterSwitchProvider(this.#getActiveProviderId());
263
-
264
305
  if (this.#state.selected) {
265
306
  this.#inspector.setExtension(this.#state.selected);
266
307
  }
267
308
 
268
- this.#buildLayout();
309
+ this.#tabBar.setTabs(buildTabBarTabs(this.#state.tabs), currentTabId);
269
310
  this.onRequestRender?.();
270
311
  }
271
312
 
@@ -275,45 +316,17 @@ export class ExtensionDashboard extends Container {
275
316
  if (this.#state.selected) {
276
317
  this.#inspector.setExtension(this.#state.selected);
277
318
  }
278
- this.#buildLayout();
319
+ this.#tabBar.setTabs(buildTabBarTabs(this.#state.tabs), this.#state.tabs[this.#state.activeTabIndex]?.id);
279
320
  this.onRequestRender?.();
280
321
  }
281
322
 
282
- #switchTab(direction: 1 | -1): void {
283
- const numTabs = this.#state.tabs.length;
284
- if (numTabs === 0) return;
285
-
286
- // Find next selectable tab (skip empty+enabled providers)
287
- let nextIndex = this.#state.activeTabIndex;
288
- for (let i = 0; i < numTabs; i++) {
289
- nextIndex = (nextIndex + direction + numTabs) % numTabs;
290
- const tab = this.#state.tabs[nextIndex];
291
- const isEmptyEnabled = tab.count === 0 && tab.enabled && tab.id !== "all";
292
- if (!isEmptyEnabled) break;
293
- }
294
- this.#state.activeTabIndex = nextIndex;
295
-
296
- // Re-filter for new tab
297
- const tab = this.#state.tabs[this.#state.activeTabIndex];
298
- this.#state.tabFiltered = filterByProvider(this.#state.extensions, tab.id);
299
- this.#state.searchFiltered = applyFilter(this.#state.tabFiltered, this.#state.searchQuery);
300
- this.#state.listIndex = 0;
301
- this.#state.scrollOffset = 0;
302
- this.#state.selected = this.#state.searchFiltered[0] ?? null;
303
-
304
- // Update list
305
- this.#mainList.setExtensions(this.#state.searchFiltered);
306
- this.#mainList.setMasterSwitchProvider(this.#getActiveProviderId());
307
- this.#mainList.resetSelection();
308
-
309
- if (this.#state.selected) {
310
- this.#inspector.setExtension(this.#state.selected);
323
+ handleInput(data: string): void {
324
+ // SGR mouse reports (the fullscreen overlay enables tracking).
325
+ if (data.startsWith("\x1b[<")) {
326
+ this.#handleMouse(data);
327
+ return;
311
328
  }
312
329
 
313
- this.#buildLayout();
314
- }
315
-
316
- handleInput(data: string): void {
317
330
  // Ctrl+C - close immediately
318
331
  if (matchesKey(data, "ctrl+c")) {
319
332
  this.onClose?.();
@@ -327,56 +340,99 @@ export class ExtensionDashboard extends Container {
327
340
  this.#state.searchFiltered = this.#state.tabFiltered;
328
341
  this.#mainList.setExtensions(this.#state.searchFiltered);
329
342
  this.#mainList.clearSearch();
330
- this.#buildLayout();
343
+ this.onRequestRender?.();
331
344
  return;
332
345
  }
333
346
  this.onClose?.();
334
347
  return;
335
348
  }
336
349
 
337
- // Tab/Shift+Tab or Left/Right: Cycle through tabs
338
- if (handleTabSwitchKey(data, direction => this.#switchTab(direction))) {
350
+ // Tab/Shift+Tab or ←/→: switch provider tabs (fires onTabChange).
351
+ if (this.#tabBar.handleInput(data)) {
339
352
  return;
340
353
  }
341
354
 
342
- // All other input goes to the list
355
+ // All other input goes to the list.
343
356
  this.#mainList.handleInput(data);
344
357
 
345
- // Sync search query back to state
358
+ // Sync search query back to state.
346
359
  const query = this.#mainList.getSearchQuery();
347
360
  if (query !== this.#state.searchQuery) {
348
361
  this.#state.searchQuery = query;
349
362
  this.#state.searchFiltered = applyFilter(this.#state.tabFiltered, query);
350
363
  }
364
+ this.onRequestRender?.();
351
365
  }
352
366
  }
353
367
 
354
368
  /**
355
- * Two-column body component for side-by-side rendering.
369
+ * Two-column body: inventory list on the left, inspector on the right, split by
370
+ * a vertical rule. The inspector is a {@link ScrollView} viewport so long detail
371
+ * panes scroll (wheel) with an auto scrollbar; the left list manages its own
372
+ * windowing. Records the left-column width so the host can hit-test panes.
356
373
  */
357
374
  class TwoColumnBody implements Component {
375
+ #maxHeight: number;
376
+ #rightScroll = 0;
377
+ #rightTotal = 0;
378
+ #leftWidth = 0;
379
+
358
380
  constructor(
359
381
  private readonly leftPane: ExtensionList,
360
382
  private readonly rightPane: InspectorPanel,
361
- private readonly maxHeight: number,
362
- ) {}
383
+ maxHeight: number,
384
+ ) {
385
+ this.#maxHeight = maxHeight;
386
+ }
387
+
388
+ setMaxHeight(maxHeight: number): void {
389
+ this.#maxHeight = maxHeight;
390
+ }
391
+
392
+ /** Content width of the left (list) column from the last render. */
393
+ get leftWidth(): number {
394
+ return this.#leftWidth;
395
+ }
396
+
397
+ resetInspectorScroll(): void {
398
+ this.#rightScroll = 0;
399
+ }
400
+
401
+ /** Wheel notch over the inspector pane: scroll its content, clamped. */
402
+ scrollInspector(delta: -1 | 1): void {
403
+ const max = Math.max(0, this.#rightTotal - this.#maxHeight);
404
+ this.#rightScroll = Math.max(0, Math.min(this.#rightScroll + delta, max));
405
+ }
363
406
 
364
407
  render(width: number): readonly string[] {
365
408
  const leftWidth = Math.floor(width * 0.5);
409
+ this.#leftWidth = leftWidth;
366
410
  const rightWidth = Math.max(0, width - leftWidth - 3);
411
+ const numLines = this.#maxHeight;
367
412
 
368
413
  const leftLines = this.leftPane.render(leftWidth);
369
414
  const rightLines = this.rightPane.render(rightWidth);
415
+ this.#rightTotal = rightLines.length;
416
+ const maxScroll = Math.max(0, this.#rightTotal - numLines);
417
+ if (this.#rightScroll > maxScroll) this.#rightScroll = maxScroll;
418
+
419
+ // `totalRows` omitted so the ScrollView windows `rightLines` by the scroll
420
+ // offset (rather than treating them as a pre-windowed slice) and pads short
421
+ // content to exactly `numLines`.
422
+ const rightView = new ScrollView(rightLines, {
423
+ height: numLines,
424
+ scrollbar: "auto",
425
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
426
+ });
427
+ rightView.setScrollOffset(this.#rightScroll);
428
+ const rightRendered = rightView.render(rightWidth);
370
429
 
371
- // Fill the full body height so the dashboard reads as a full-screen view.
372
- const numLines = this.maxHeight;
373
430
  const combined: string[] = [];
374
431
  const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `);
375
-
376
432
  for (let i = 0; i < numLines; i++) {
377
433
  const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
378
434
  const leftPadded = left + padding(Math.max(0, leftWidth - visibleWidth(left)));
379
- const right = truncateToWidth(rightLines[i] ?? "", rightWidth);
435
+ const right = rightRendered[i] ?? "";
380
436
  combined.push(leftPadded + separator + right);
381
437
  }
382
438