@oh-my-pi/pi-tui 18.4.8 → 18.4.9

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 +18 -0
  2. package/dist/types/apps/debug/log-viewer.d.ts +17 -0
  3. package/dist/types/apps/debug/raw-sse.d.ts +14 -0
  4. package/dist/types/native/backend.d.ts +8 -0
  5. package/dist/types/native/encode.d.ts +5 -1
  6. package/dist/types/native/node.d.ts +14 -1
  7. package/dist/types/native/reconcile.d.ts +5 -0
  8. package/dist/types/overlays/extensions/extension-dashboard.d.ts +31 -0
  9. package/dist/types/overlays/extensions/extension-list.d.ts +37 -0
  10. package/dist/types/overlays/extensions/inspector-panel.d.ts +8 -0
  11. package/dist/types/overlays/jobs-panel.d.ts +35 -9
  12. package/dist/types/prompt/word-completion.d.ts +12 -0
  13. package/dist/types/terminal.d.ts +7 -0
  14. package/dist/types/tools/bash-interactive.d.ts +15 -0
  15. package/dist/types/tools/output-meta.d.ts +8 -2
  16. package/dist/types/tools/streaming-output.d.ts +15 -10
  17. package/dist/types/tui.d.ts +5 -0
  18. package/dist/types/windows-input-mode.d.ts +26 -0
  19. package/package.json +9 -9
  20. package/src/apps/debug/log-viewer.ts +236 -2
  21. package/src/apps/debug/protocol-probe.ts +10 -1
  22. package/src/apps/debug/raw-sse.ts +210 -28
  23. package/src/native/apply.ts +2 -1
  24. package/src/native/backend.ts +22 -0
  25. package/src/native/encode.ts +7 -2
  26. package/src/native/node.ts +15 -1
  27. package/src/native/reconcile.ts +28 -1
  28. package/src/overlays/extensions/extension-dashboard.ts +275 -23
  29. package/src/overlays/extensions/extension-list.ts +274 -14
  30. package/src/overlays/extensions/inspector-panel.ts +368 -0
  31. package/src/overlays/hook-selector.ts +1 -1
  32. package/src/overlays/jobs-panel.ts +222 -49
  33. package/src/prompt/composer-cache.ts +26 -3
  34. package/src/prompt/word-completion.ts +31 -13
  35. package/src/terminal.ts +37 -1
  36. package/src/tools/bash-interactive.ts +85 -16
  37. package/src/tools/output-meta.ts +13 -5
  38. package/src/tools/streaming-output.ts +48 -23
  39. package/src/tui.ts +52 -14
  40. package/src/windows-input-mode.ts +206 -0
@@ -106,9 +106,13 @@ export function encodeTspJson(verb: TspVerb, value: unknown, params?: TspParams,
106
106
  return encodeTspMessage(verb, JSON.stringify(value), params, limit);
107
107
  }
108
108
 
109
- /** The `hello` query; callers follow it with a DA1 sentinel. */
109
+ /**
110
+ * The `hello` query; callers follow it with a DA1 sentinel. `features: ["edit"]`
111
+ * tells the terminal that omp applies its `edit` events (TSP §8.5), so it may keep a
112
+ * native selection in omp's editors; without it, every key stays omp's.
113
+ */
110
114
  export function encodeTspHelloQuery(version?: string): string {
111
- return encodeTspJson("q", { q: "hello", v: [TSP_VERSION], app: "omp", ver: version });
115
+ return encodeTspJson("q", { q: "hello", v: [TSP_VERSION], app: "omp", features: ["edit"], ver: version });
112
116
  }
113
117
 
114
118
  /** One decoded APC message: verb, parameters and raw body. */
@@ -166,6 +170,7 @@ const EVENT_REQUIRED: Readonly<Record<string, Readonly<Record<string, "string" |
166
170
  activate: { id: "string", item: "string" },
167
171
  action: { id: "string", act: "string" },
168
172
  edit: { id: "string", from: "number", to: "number", text: "string", cursor: "number", len: "number" },
173
+ focus: { id: "string" },
169
174
  error: { msg: "string" },
170
175
  gone: { ids: "array" },
171
176
  };
@@ -13,7 +13,7 @@
13
13
  * See `packages/wire/src/tsp.ts` for the wire vocabulary and
14
14
  * `crates/tern/SURFACE_PROTOCOL.md` (Stencil repository) for the spec.
15
15
  */
16
- import type { TspEvent, TspKind, TspProps, TspSpan } from "@oh-my-pi/pi-wire";
16
+ import type { TspEvent, TspKind, TspProps, TspScrollBy, TspSpan } from "@oh-my-pi/pi-wire";
17
17
  import type { Component } from "../tui";
18
18
 
19
19
  /** A described node: a wire node minus its id, with components allowed as children. */
@@ -33,9 +33,23 @@ export type NativeNode = {
33
33
  * added. Key a node by what it points at to reveal it again on a move.
34
34
  */
35
35
  readonly reveal?: "start" | "end" | "nearest";
36
+ /**
37
+ * Keyboard scrolling forwarded to the terminal (PgUp/PgDn/End reach the
38
+ * program): moves the scroller at or above the node by `by` whenever
39
+ * `n` differs from the previous description of the same node. Bump `n`
40
+ * per key press; presses between two frames repeat the latest `by` once
41
+ * each (`start`/`end` once). A freshly added node never scrolls.
42
+ */
43
+ readonly scroll?: NativeScroll;
36
44
  };
37
45
  }[TspKind];
38
46
 
47
+ /** A {@link NativeNode.scroll} request. */
48
+ export interface NativeScroll {
49
+ readonly by: TspScrollBy;
50
+ readonly n: number;
51
+ }
52
+
39
53
  /** A child slot: a described node, or a component that describes itself. */
40
54
  export type NativeChild = NativeNode | Component;
41
55
 
@@ -28,7 +28,7 @@
28
28
  * into `layer`; their anchor keypaths are rewritten to wire ids.
29
29
  */
30
30
  import * as logger from "@oh-my-pi/pi-utils/logger";
31
- import { TSP_TEXT_KINDS, type TspKind, type TspNode, type TspOp } from "@oh-my-pi/pi-wire";
31
+ import { TSP_TEXT_KINDS, type TspKind, type TspNode, type TspOp, type TspScrollBy } from "@oh-my-pi/pi-wire";
32
32
  import { type Component, Container, CURSOR_MARKER } from "../tui";
33
33
  import { normalizeIconProps } from "./icons";
34
34
  import type { DescribeContext, NativeChild, NativeNode } from "./node";
@@ -58,6 +58,8 @@ const REGION_IDS = ["main", "dock", "layer"] as const;
58
58
  type RegionId = (typeof REGION_IDS)[number];
59
59
  const RESERVED_IDS: ReadonlySet<string> = new Set(REGION_IDS);
60
60
  const TEXT_KINDS: ReadonlySet<string> = new Set(TSP_TEXT_KINDS);
61
+ /** Most `scroll` ops one node's coalesced key presses send in a frame. */
62
+ const MAX_SCROLL_REPEAT = 32;
61
63
  let nextComponentId = 0;
62
64
 
63
65
  /** Stable base-36 wire id of a component instance (its root node's id). */
@@ -213,6 +215,8 @@ export class Reconciler {
213
215
  #settles: string[] = [];
214
216
  /** Selected list items and added `reveal` nodes sent this frame, revealed once the frame's adds have landed. */
215
217
  #reveals: [id: string, at: "start" | "end" | "nearest"][] = [];
218
+ /** `scroll` requests whose `n` moved this frame, sent after the reveals. */
219
+ #scrolls: [id: string, by: TspScrollBy][] = [];
216
220
  /** Wire ids `list.selected` keys resolved to (an unresolved key names no node to reveal). */
217
221
  #selectedIds = new Set<string>();
218
222
  #moves: PendingMove[] = [];
@@ -250,6 +254,7 @@ export class Reconciler {
250
254
  this.#dels = [];
251
255
  this.#settles = [];
252
256
  this.#reveals = [];
257
+ this.#scrolls = [];
253
258
  this.#selectedIds.clear();
254
259
  this.#framePortals = [];
255
260
  this.#rows = 0;
@@ -293,6 +298,7 @@ export class Reconciler {
293
298
  this.#parked = [];
294
299
  for (const id of this.#settles) this.#ops.push(["settle", id]);
295
300
  for (const [id, at] of this.#reveals) this.#ops.push(["reveal", id, at]);
301
+ if (cx.feature("scroll")) for (const [id, by] of this.#scrolls) this.#ops.push(["scroll", id, by]);
296
302
  const ops = this.#ops;
297
303
  this.#ops = [];
298
304
  return this.#gone.size === 0 ? ops : ops.filter(op => !this.#touchesGone(op));
@@ -339,6 +345,19 @@ export class Reconciler {
339
345
  return { component: state.comp, keypath };
340
346
  }
341
347
 
348
+ /**
349
+ * The component that described wire node `id`, then each component whose
350
+ * node contains it, innermost first (empty for an unknown id).
351
+ */
352
+ owners(id: string): Component[] {
353
+ const dot = id.indexOf(".");
354
+ const out: Component[] = [];
355
+ for (let state = this.#byId.get(dot === -1 ? id : id.slice(0, dot)) ?? null; state; state = state.container) {
356
+ out.push(state.comp);
357
+ }
358
+ return out;
359
+ }
360
+
342
361
  /** The key a list item node was described with (its key, else its child index). */
343
362
  itemKey(id: string): string {
344
363
  const dot = id.indexOf(".");
@@ -583,6 +602,14 @@ export class Reconciler {
583
602
  this.#flushMoves();
584
603
  return;
585
604
  }
605
+ const scroll = next.node.scroll;
606
+ if (scroll && scroll.n !== old.node.scroll?.n) {
607
+ // Presses described in one frame coalesce: repeat the latest step once per
608
+ // press (an end once), so key repeat keeps its distance.
609
+ const jump = scroll.by === "start" || scroll.by === "end";
610
+ const presses = jump ? 1 : Math.min(Math.max(scroll.n - (old.node.scroll?.n ?? 0), 1), MAX_SCROLL_REPEAT);
611
+ for (let i = 0; i < presses; i++) this.#scrolls.push([next.id, scroll.by]);
612
+ }
586
613
  const oldEntries = this.#entries(old.node.c, old.keypath, old.owner, old.hoist, null);
587
614
  const newEntries = this.#entries(next.node.c, next.keypath, next.owner, next.hoist, inner);
588
615
  this.#diffProps(
@@ -12,8 +12,19 @@
12
12
  * - Space/Enter or click: toggle selected item (or provider master switch)
13
13
  * - Wheel over the inspector, or PageUp/PageDown when the inspector overflows: scroll the detail pane
14
14
  * - Esc: clear search (if active) then close
15
+ *
16
+ * Natively (Tern) it is a `picker` sheet: provider scopes, the inventory as
17
+ * rows grouped by kind, the inspector as the side preview, and Toggle /
18
+ * Expand / Close actions; without `picker` it describes a page with the same
19
+ * parts. Pointer events run the key paths above.
15
20
  */
21
+ import type { TspPickerAction, TspPickerColumn, TspPickerScope } from "@oh-my-pi/pi-wire";
16
22
  import type { Component } from "../../tui";
23
+ import { col, keyed, node, span, text } from "../../native/describe";
24
+ import { Memo } from "../../native/memo";
25
+ import type { DescribeContext, NativeNode, NativeScroll, NativeUiEvent } from "../../native/node";
26
+ import { actionBar, actionButton } from "../../native/overlay";
27
+ import { CLOSE_ACTION, type PickerEvent, picker, pickerAction, pickerEvent } from "../../native/picker";
17
28
  import { matchesKey } from "../../keys";
18
29
  import { parseSgrMouse } from "../../mouse";
19
30
  import { SplitPane, type SplitPaneHit } from "../../components/layout/split-pane";
@@ -31,9 +42,9 @@ import {
31
42
  } from "../../keybinding-matchers";
32
43
  import { expandKeyHint } from "../../render/render-utils";
33
44
  import { formatKeyHint, formatKeyHints } from "../../app-keybindings";
34
- import { editorKeys, interruptKey } from "../../chrome/keybinding-hints";
45
+ import { boundKeys, editorKeys, interruptKey } from "../../chrome/keybinding-hints";
35
46
  import { bottomBorder, divider, PanelRows, row, topBorder } from "../../chrome/overlay-box";
36
- import { ExtensionList } from "./extension-list";
47
+ import { ExtensionList, type ExtensionListSwitch } from "./extension-list";
37
48
  import { InspectorPanel, type ToolRuntimeSource } from "./inspector-panel";
38
49
  import type { ExtensionInspectorSource } from "./inspector-model";
39
50
  import { snapshotToolRuntimeSource } from "./live-tool-session";
@@ -99,8 +110,50 @@ export function buildTabBarTabs(tabs: ProviderTab[]): Tab[] {
99
110
  });
100
111
  }
101
112
 
113
+ /** The sheet/page title, shared by the ANSI frame and the native views. */
114
+ const DASHBOARD_TITLE = "Extension Control Center";
115
+
116
+ /** The kind column shown when rows are not grouped under kind headers (a provider scope or a search). */
117
+ const KIND_COLUMNS: readonly TspPickerColumn[] = [{ id: "kind", format: "dim", priority: 1 }];
118
+
119
+ /** Up to two initials of a provider label, for its scope mark. */
120
+ function providerInitials(label: string): string {
121
+ const words = label.split(/[\s()_-]+/).filter(word => word.length > 0);
122
+ return words
123
+ .slice(0, 2)
124
+ .map(word => word[0]?.toUpperCase() ?? "")
125
+ .join("");
126
+ }
127
+
128
+ /**
129
+ * Picker scopes from the provider tabs: "all" first, then each provider with
130
+ * its mark and count. Empty enabled providers are disabled (muted in the
131
+ * {@link TabBar}); switched-off providers keep a muted dot and stay
132
+ * selectable so their master switch can be turned back on.
133
+ */
134
+ export function buildPickerScopes(tabs: ProviderTab[]): TspPickerScope[] {
135
+ return tabs.map(tab => {
136
+ if (tab.id === "all") return { id: tab.id, label: "All", icon: "extension", count: tab.count };
137
+ const emptyEnabled = tab.count === 0 && tab.enabled;
138
+ return {
139
+ id: tab.id,
140
+ label: tab.label,
141
+ mark: { text: providerInitials(tab.label), seed: tab.id },
142
+ count: tab.count,
143
+ group: "Providers",
144
+ dot: !tab.enabled ? "muted" : emptyEnabled ? "warning" : "success",
145
+ disabled: emptyEnabled ? "No extensions" : undefined,
146
+ };
147
+ });
148
+ }
149
+
102
150
  export class ExtensionDashboard implements Component {
103
151
  #state!: DashboardState;
152
+ /** Bumped by every state change that reaches the screen; keys {@link #native}. */
153
+ #nativeVersion = 0;
154
+ readonly #native = new Memo<NativeNode>();
155
+ /** The last inspector page key, forwarded to the native preview (see {@link NativeNode.scroll}). */
156
+ #inspectorScroll: NativeScroll | undefined;
104
157
  #mainList!: ExtensionList;
105
158
  #inspector!: InspectorPanel;
106
159
  #tabBar!: TabBar;
@@ -231,7 +284,7 @@ export class ExtensionDashboard implements Component {
231
284
  this.#mainList.setToolSource(toolFrame);
232
285
  this.#inspector.setToolSource(toolFrame);
233
286
 
234
- this.#frameTop.setLines([topBorder(width, "Extension Control Center")]);
287
+ this.#frameTop.setLines([topBorder(width, DASHBOARD_TITLE)]);
235
288
  this.#frameTabs.setLines(tabLines.map(line => row(line, width)));
236
289
  this.#frameUpperDivider.setLines([divider(width)]);
237
290
  this.#frameBody.setLines(this.#body.render(innerWidth).map(line => row(line, width)));
@@ -242,6 +295,7 @@ export class ExtensionDashboard implements Component {
242
295
  }
243
296
 
244
297
  invalidate(): void {
298
+ this.#nativeVersion++;
245
299
  this.#frame.invalidate();
246
300
  this.#tabBar.invalidate();
247
301
  this.#mainList.invalidate();
@@ -280,10 +334,10 @@ export class ExtensionDashboard implements Component {
280
334
  if (event.wheel !== null) {
281
335
  if (overList) {
282
336
  this.#mainList.handleWheel(event.wheel);
283
- this.onRequestRender?.();
337
+ this.#requestRender();
284
338
  } else if (overInspector) {
285
339
  this.#body.scrollInspector(event.wheel);
286
- this.onRequestRender?.();
340
+ this.#requestRender();
287
341
  }
288
342
  return;
289
343
  }
@@ -292,7 +346,7 @@ export class ExtensionDashboard implements Component {
292
346
  const hoveredTab = overTabs ? this.#tabBar.tabAt(tabLine, innerCol) : undefined;
293
347
  this.#tabBar.setHoverTab(hoveredTab && !hoveredTab.muted ? hoveredTab.id : null);
294
348
  this.#mainList.setHoverIndex(overList ? this.#mainList.hitTest(paneLine) : null);
295
- this.onRequestRender?.();
349
+ this.#requestRender();
296
350
  return;
297
351
  }
298
352
 
@@ -305,7 +359,7 @@ export class ExtensionDashboard implements Component {
305
359
  }
306
360
  if (overList) {
307
361
  this.#mainList.handleClick(paneLine);
308
- this.onRequestRender?.();
362
+ this.#requestRender();
309
363
  }
310
364
  }
311
365
 
@@ -329,7 +383,7 @@ export class ExtensionDashboard implements Component {
329
383
  this.#inspector.setExtension(this.#state.selected);
330
384
  }
331
385
  this.#body.resetInspectorScroll();
332
- this.onRequestRender?.();
386
+ this.#requestRender();
333
387
  }
334
388
 
335
389
  #handleProviderToggle(providerId: string): void {
@@ -479,7 +533,7 @@ export class ExtensionDashboard implements Component {
479
533
  }
480
534
 
481
535
  this.#tabBar.setTabs(buildTabBarTabs(this.#state.tabs), currentTabId);
482
- this.onRequestRender?.();
536
+ this.#requestRender();
483
537
  }
484
538
 
485
539
  #applyDisabledExtensions(disabledIds: string[]): void {
@@ -489,7 +543,7 @@ export class ExtensionDashboard implements Component {
489
543
  this.#inspector.setExtension(this.#state.selected);
490
544
  }
491
545
  this.#tabBar.setTabs(buildTabBarTabs(this.#state.tabs), this.#state.tabs[this.#state.activeTabIndex]?.id);
492
- this.onRequestRender?.();
546
+ this.#requestRender();
493
547
  }
494
548
 
495
549
  handleInput(data: string): void {
@@ -501,34 +555,32 @@ export class ExtensionDashboard implements Component {
501
555
 
502
556
  // Ctrl+C - close immediately
503
557
  if (matchesKey(data, "ctrl+c")) {
504
- this.dispose();
505
- this.onClose?.();
558
+ this.#close();
506
559
  return;
507
560
  }
508
561
 
509
562
  // Escape - clear search first, then close
510
563
  if (matchesAppInterrupt(data)) {
511
564
  if (this.#state.searchQuery.length > 0) {
512
- this.#state.searchQuery = "";
513
- this.#state.searchFiltered = this.#state.tabFiltered;
514
- this.#mainList.setExtensions(this.#state.searchFiltered);
515
- this.#mainList.clearSearch();
516
- this.onRequestRender?.();
565
+ this.#clearSearch();
517
566
  return;
518
567
  }
519
- this.dispose();
520
- this.onClose?.();
568
+ this.#close();
521
569
  return;
522
570
  }
523
571
 
524
572
  if (matchesAppToolsExpand(data)) {
525
573
  this.#inspector.toggleExpanded();
526
- this.onRequestRender?.();
574
+ this.#requestRender();
527
575
  return;
528
576
  }
529
577
 
530
- if (this.#body.pageInspector(matchesSelectPageUp(data) ? -1 : matchesSelectPageDown(data) ? 1 : 0)) {
531
- this.onRequestRender?.();
578
+ const page = matchesSelectPageUp(data) ? -1 : matchesSelectPageDown(data) ? 1 : 0;
579
+ if (page !== 0) {
580
+ this.#inspectorScroll = { by: page < 0 ? "page-up" : "page-down", n: (this.#inspectorScroll?.n ?? 0) + 1 };
581
+ }
582
+ if (this.#body.pageInspector(page)) {
583
+ this.#requestRender();
532
584
  return;
533
585
  }
534
586
 
@@ -546,16 +598,216 @@ export class ExtensionDashboard implements Component {
546
598
  this.#state.searchQuery = query;
547
599
  this.#state.searchFiltered = applyFilter(this.#state.tabFiltered, query);
548
600
  }
601
+ this.#requestRender();
602
+ }
603
+
604
+ #close(): void {
605
+ this.dispose();
606
+ this.onClose?.();
607
+ }
608
+
609
+ #clearSearch(): void {
610
+ this.#state.searchQuery = "";
611
+ this.#state.searchFiltered = this.#state.tabFiltered;
612
+ this.#mainList.setExtensions(this.#state.searchFiltered);
613
+ this.#mainList.clearSearch();
614
+ this.#requestRender();
615
+ }
616
+
617
+ /** Every repaint request: the native description is stale too. */
618
+ #requestRender(): void {
619
+ this.#nativeVersion++;
549
620
  this.onRequestRender?.();
550
621
  }
551
622
 
623
+ /** The dashboard is a `picker` sheet (scopes = providers, preview = inspector) wherever Tern draws pickers. */
624
+ nativeSheet(cx: DescribeContext): boolean {
625
+ return cx.supports("picker");
626
+ }
627
+
628
+ /**
629
+ * The picker sheet, or without `picker` a page: head, provider tabs,
630
+ * search, the inventory list beside the inspector, and an action bar.
631
+ * Rebuilt when state moves or the live tool set changes (MCP health
632
+ * changes arrive as render requests, which bump the version).
633
+ */
634
+ describe(cx: DescribeContext): NativeNode {
635
+ const sheet = this.nativeSheet(cx);
636
+ const toolFrame = snapshotToolRuntimeSource(this.#toolSource);
637
+ let tools = "";
638
+ for (const tool of toolFrame?.listLiveTools?.() ?? []) tools += `${tool.name}${tool.hidden ? "!" : ""}\0`;
639
+ return this.#native.get([this.#nativeVersion, sheet, tools, this.#inspectorScroll], () => {
640
+ this.#mainList.setToolSource(toolFrame);
641
+ this.#inspector.setToolSource(toolFrame);
642
+ return sheet ? this.#describePicker() : this.#describePage();
643
+ });
644
+ }
645
+
646
+ #describePicker(): NativeNode {
647
+ const view = this.#mainList.pickerView();
648
+ const activeTab = this.#state.tabs[this.#state.activeTabIndex];
649
+ return picker(
650
+ {
651
+ title: DASHBOARD_TITLE,
652
+ icon: "extension",
653
+ noun: "extensions",
654
+ size: "lg",
655
+ layout: "rows",
656
+ preview: "side",
657
+ ...this.#mainList.nativeQuery(),
658
+ placeholder: "Search extensions…",
659
+ scopes: buildPickerScopes(this.#state.tabs),
660
+ scope: activeTab?.id ?? "all",
661
+ columns: view.grouped ? undefined : KIND_COLUMNS,
662
+ items: view.items,
663
+ order: view.order,
664
+ hits: view.hits,
665
+ selected: view.selected,
666
+ total: this.#state.tabFiltered.length,
667
+ empty: "No extensions found for this provider.",
668
+ actions: this.#pickerActions(this.#mainList.selectedSwitch()),
669
+ focus: "list",
670
+ },
671
+ this.#inspectorPreview(),
672
+ );
673
+ }
674
+
675
+ /** The inspector's preview nodes, the first carrying the page-key scroll so Tern scrolls the pane holding it. */
676
+ #inspectorPreview(): NativeNode[] {
677
+ const preview = this.#inspector.describePreview();
678
+ const scroll = this.#inspectorScroll;
679
+ const [first, ...rest] = preview;
680
+ return scroll && first ? [{ ...first, scroll }, ...rest] : preview;
681
+ }
682
+
683
+ /** Action bar: Toggle (Space/Enter), Expand (the tools-expand key), Close (Esc). */
684
+ #pickerActions(selected: ExtensionListSwitch | undefined): TspPickerAction[] {
685
+ const expanded = this.#inspector.isExpanded();
686
+ return [
687
+ pickerAction("toggle", "Toggle", "space", {
688
+ primary: true,
689
+ on: selected?.on,
690
+ disabled: selected ? selected.blocked : "Select an extension",
691
+ }),
692
+ pickerAction("expand", expanded ? "Collapse" : "Expand", this.#expandKey(), { on: expanded }),
693
+ CLOSE_ACTION,
694
+ ];
695
+ }
696
+
697
+ #expandKey(): string {
698
+ return boundKeys("app.tools.expand", ["ctrl+o"])[0] ?? "ctrl+o";
699
+ }
700
+
701
+ #describePage(): NativeNode {
702
+ const selected = this.#mainList.selectedSwitch();
703
+ const query = this.#state.searchQuery;
704
+ const shown = this.#state.searchFiltered.length;
705
+ const total = this.#state.tabFiltered.length;
706
+ const head = node(
707
+ "row",
708
+ { justify: "between", align: "center", role: "omp.app.head" },
709
+ [
710
+ node("row", { gap: "sm", align: "center", role: "omp.app.where" }, [
711
+ text(DASHBOARD_TITLE, { role: "omp.app.title" }),
712
+ text([span(query ? `${shown} of ${total}` : `${total} extensions`, "muted")], { truncate: "end" }),
713
+ ]),
714
+ node("icon", {
715
+ name: "x",
716
+ role: "omp.app.ibtn",
717
+ title: `Close ${interruptKey()}`,
718
+ aria: "Close",
719
+ actions: { click: "close" },
720
+ }),
721
+ ],
722
+ "head",
723
+ );
724
+ const search = text([span("Search: ", "muted"), query ? span(query, "accent") : span("type to filter", "dim")], {
725
+ truncate: "end",
726
+ });
727
+ const body = node(
728
+ "row",
729
+ { gap: "md", grow: 1 },
730
+ [
731
+ this.#mainList.describeList("list"),
732
+ {
733
+ ...keyed(col(this.#inspector.describePreview(), { gap: "sm", grow: 1, basis: 0 }), "inspector"),
734
+ scroll: this.#inspectorScroll,
735
+ },
736
+ ],
737
+ "body",
738
+ );
739
+ const expanded = this.#inspector.isExpanded();
740
+ const buttons: (NativeNode | null)[] = [
741
+ actionButton(selected?.on === false ? "Enable" : selected ? "Disable" : "Toggle", "toggle", {
742
+ keys: "space",
743
+ tone: "accent",
744
+ title: selected?.blocked ?? (selected ? undefined : "Select an extension"),
745
+ }),
746
+ actionButton(expanded ? "Collapse" : "Expand", "expand", { keys: this.#expandKey() }),
747
+ ];
748
+ if (query) buttons.push(actionButton("Clear search", "clear", { keys: "escape" }));
749
+ buttons.push(null, actionButton("Close", "close", { keys: "escape" }));
750
+ return col([head, this.#tabBar, keyed(search, "search"), body, actionBar(buttons)], { gap: "md", grow: 1 });
751
+ }
752
+
753
+ /**
754
+ * Pointer input, each on the path of the key it stands for: a row click
755
+ * selects (click on the selected row or double click toggles, like
756
+ * Space/Enter), a scope switches provider (←/→), `toggle`/`expand` run
757
+ * Space and the expand key, `clear` empties the search (first Esc) and
758
+ * `close` closes. Provider tabs in the page route to the {@link TabBar}.
759
+ */
760
+ handleNativeEvent(event: NativeUiEvent): void {
761
+ const pick = pickerEvent(event);
762
+ if (pick) {
763
+ this.#handlePick(pick);
764
+ return;
765
+ }
766
+ if (event.type === "action") {
767
+ this.#handlePick({ kind: "action", act: event.act, value: event.value });
768
+ return;
769
+ }
770
+ if (
771
+ (event.type === "select" || event.type === "activate") &&
772
+ event.key.slice(event.key.lastIndexOf("/") + 1) === "list"
773
+ ) {
774
+ this.#handlePick({ kind: event.type, item: event.item });
775
+ }
776
+ }
777
+
778
+ #handlePick(pick: PickerEvent): void {
779
+ if (pick.kind !== "action") {
780
+ if (this.#mainList.pickNative(pick.item, pick.kind === "activate")) this.#requestRender();
781
+ return;
782
+ }
783
+ switch (pick.act) {
784
+ case "close":
785
+ this.#close();
786
+ return;
787
+ case "clear":
788
+ this.#clearSearch();
789
+ return;
790
+ case "scope":
791
+ if (pick.value) this.#tabBar.selectTab(pick.value);
792
+ return;
793
+ case "toggle":
794
+ this.#mainList.activateSelected();
795
+ this.#requestRender();
796
+ return;
797
+ case "expand":
798
+ this.#inspector.toggleExpanded();
799
+ this.#requestRender();
800
+ return;
801
+ }
802
+ }
803
+
552
804
  /**
553
805
  * Live MCP health is joined at render time. Connection-status events and
554
806
  * list-changed notifications only need to request a repaint — they must not
555
807
  * rewrite Extension.raw.
556
808
  */
557
809
  #subscribeMcpRuntime(): void {
558
- this.#unsubscribers.push(...this.#runtime.subscribeMcpChanges(() => this.onRequestRender?.()));
810
+ this.#unsubscribers.push(...this.#runtime.subscribeMcpChanges(() => this.#requestRender()));
559
811
  }
560
812
 
561
813
  dispose(): void {