pi-extension-utils 0.7.3 → 0.7.5

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-extension-utils
2
2
 
3
- Shared utilities for Pi extensions that need coordinated UI, fullscreen overlays, reminders, and logging.
3
+ Shared utilities for Pi extensions that need coordinated UI, fullscreen custom UIs, reminders, and logging.
4
4
 
5
5
  Two faces:
6
6
 
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Minimal theme shape for chrome helpers: only `fg(color, text)` is required.
3
- * Lets the same helpers work for both `@earendil-works/pi-coding-agent` Theme and
2
+ * Minimal theme shape for chrome helpers: only `fg(color, text)` is required;
3
+ * `bg` enables native-style scrollbar thumbs when the host exposes it. Lets the
4
+ * same helpers work for both `@earendil-works/pi-coding-agent` Theme and
4
5
  * any pi-tui-compatible theme.
5
6
  */
6
7
  export interface ChromeTheme {
7
8
  fg(color: string, text: string): string;
9
+ bg?(color: string, text: string): string;
8
10
  bold?(text: string): string;
9
11
  }
10
12
  export declare function fuzzyFilter<T extends {
@@ -23,6 +25,10 @@ export declare function renderFooter(text: string, width: number, theme: ChromeT
23
25
  export interface TitledTopSegmentOptions {
24
26
  width: number;
25
27
  label: string;
28
+ /** Pre-rendered label string when caller already applied ANSI; pair with `labelPlain` for length. */
29
+ labelRendered?: string;
30
+ /** Plain visible-width companion for `labelRendered` so truncation and dash math stay correct. */
31
+ labelPlain?: string;
26
32
  /** Plain-text tail; styled via `tailColor` (default "dim"). */
27
33
  tail?: string;
28
34
  /** Pre-rendered tail string when caller already applied ANSI; pair with `tailPlain` for length. */
@@ -103,13 +103,14 @@ export function titledTopSegment(theme, opts) {
103
103
  const tailLen = visibleWidth(tailPlain);
104
104
  const canShowTail = tailLen > 0 && width - tailLen >= 14;
105
105
  const labelBudget = Math.max(0, width - (canShowTail ? tailLen + 6 : 4));
106
- const labelText = clipText(normalizeRenderableText(opts.label ?? ""), labelBudget);
106
+ const labelText = clipText(normalizeRenderableText(opts.labelPlain ?? opts.label ?? ""), labelBudget);
107
107
  const labelLen = visibleWidth(labelText);
108
108
  if (labelLen === 0)
109
109
  return dash(width);
110
- const labelStyled = opts.labelBold && theme.bold
111
- ? theme.bold(theme.fg(labelColor, labelText))
110
+ const renderedLabel = opts.labelRendered !== undefined
111
+ ? clipStyled(opts.labelRendered, labelLen)
112
112
  : theme.fg(labelColor, labelText);
113
+ const labelStyled = opts.labelBold && theme.bold ? theme.bold(renderedLabel) : renderedLabel;
113
114
  if (canShowTail) {
114
115
  const fillDashes = Math.max(1, width - labelLen - tailLen - 6);
115
116
  return clipStyled(`${dash(1)} ${labelStyled} ${dash(fillDashes)} ${tailRendered} ${dash(1)}`, width);
@@ -133,12 +134,13 @@ export function titledTopSegment(theme, opts) {
133
134
  const labelBudget = tailFits ? Math.max(0, width - tailLen - 7) : Math.max(0, width - 3);
134
135
  // Defensive: callers occasionally pass undefined labels for transient/legacy
135
136
  // runs that lack agent+mode+label; treat as empty rather than crashing pi.
136
- const rawLabel = normalizeRenderableText(opts.label ?? "");
137
+ const rawLabel = normalizeRenderableText(opts.labelPlain ?? opts.label ?? "");
137
138
  const labelText = truncateToWidth(rawLabel, labelBudget, "").replace(/\u001b\[[0-9;]*m/g, "");
138
- const labelStyled = opts.labelBold && theme.bold
139
- ? theme.bold(theme.fg(labelColor, labelText))
140
- : theme.fg(labelColor, labelText);
141
139
  const labelLen = visibleWidth(labelText);
140
+ const renderedLabel = opts.labelRendered !== undefined
141
+ ? clipStyled(opts.labelRendered, labelLen)
142
+ : theme.fg(labelColor, labelText);
143
+ const labelStyled = opts.labelBold && theme.bold ? theme.bold(renderedLabel) : renderedLabel;
142
144
  // Layout with tail: `─ <label> ──…── <tail> ─`.
143
145
  // Layout without tail: `─ <label> ──…────`.
144
146
  if (tailFits) {
@@ -1,19 +1,18 @@
1
1
  /**
2
2
  * SGR (1006) mouse sequence parsing for pane overlays.
3
3
  *
4
- * In fullscreen TUI mode a focused overlay receives raw mouse input: pi's
5
- * alt-screen viewport handler defers wheel and SGR mouse sequences to the
6
- * focused component (see TuiAltScreen#shouldDeferViewportInputToOverlay).
7
- * These helpers turn those sequences into structured events. In regular TUI
8
- * mode no mouse sequences arrive, so pane overlays degrade gracefully to
9
- * keyboard-only input with zero changes at call sites.
4
+ * In fullscreen TUI mode Pi defers wheel input to a focused overlay. Pi 0.84
5
+ * still consumes non-wheel SGR mouse events before the overlay sees them, so
6
+ * click and drag paths require a host that forwards those events. These helpers
7
+ * turn forwarded sequences into structured events. Regular TUI mode remains
8
+ * keyboard-only with zero changes at call sites.
10
9
  */
11
10
  export interface SgrMouseEvent {
12
11
  /** 0 = left, 1 = middle, 2 = right, 64 = wheel up, 65 = wheel down. */
13
12
  button: number;
14
- /** 1-based terminal column. */
13
+ /** 0-based terminal column. */
15
14
  x: number;
16
- /** 1-based terminal row. */
15
+ /** 0-based terminal row. */
17
16
  y: number;
18
17
  /** Button press (not motion, not release). */
19
18
  press: boolean;
@@ -1,12 +1,11 @@
1
1
  /**
2
2
  * SGR (1006) mouse sequence parsing for pane overlays.
3
3
  *
4
- * In fullscreen TUI mode a focused overlay receives raw mouse input: pi's
5
- * alt-screen viewport handler defers wheel and SGR mouse sequences to the
6
- * focused component (see TuiAltScreen#shouldDeferViewportInputToOverlay).
7
- * These helpers turn those sequences into structured events. In regular TUI
8
- * mode no mouse sequences arrive, so pane overlays degrade gracefully to
9
- * keyboard-only input with zero changes at call sites.
4
+ * In fullscreen TUI mode Pi defers wheel input to a focused overlay. Pi 0.84
5
+ * still consumes non-wheel SGR mouse events before the overlay sees them, so
6
+ * click and drag paths require a host that forwards those events. These helpers
7
+ * turn forwarded sequences into structured events. Regular TUI mode remains
8
+ * keyboard-only with zero changes at call sites.
10
9
  */
11
10
  const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
12
11
  /** Parse a full SGR mouse sequence. Returns undefined for any other input. */
@@ -15,8 +14,8 @@ export function parseSgrMouseEvent(data) {
15
14
  if (!match)
16
15
  return undefined;
17
16
  const cb = Number(match[1]);
18
- const x = Number(match[2]);
19
- const y = Number(match[3]);
17
+ const x = Number(match[2]) - 1;
18
+ const y = Number(match[3]) - 1;
20
19
  const isRelease = match[4] === "m";
21
20
  const motion = (cb & 32) !== 0 && !isRelease;
22
21
  const wheel = (cb & 64) !== 0;
@@ -26,6 +26,8 @@ export interface PaneOverlaySeparatorRow {
26
26
  export type PaneOverlayPrimaryRow<Row = unknown> = Row | PaneOverlaySeparatorRow;
27
27
  export interface PaneOverlayTitle {
28
28
  label: string;
29
+ labelRendered?: string;
30
+ labelPlain?: string;
29
31
  tail?: string;
30
32
  tailRendered?: string;
31
33
  tailPlain?: string;
@@ -72,6 +74,10 @@ export interface PaneOverlaySplitOptions {
72
74
  export interface PaneCollapseOptions {
73
75
  key?: string;
74
76
  collapsedWidth?: number;
77
+ /** Use Left/Right to move the primary cursor while the collapsed detail pane stays focused. */
78
+ horizontalPrimaryNavigation?: boolean;
79
+ /** Collapsed-footer label for Left/Right navigation (default: "navigate"). */
80
+ horizontalPrimaryNavigationLabel?: string;
75
81
  /**
76
82
  * Legend/divider label for the collapse toggle. A function receives the
77
83
  * current collapsed state so the hint can read e.g. "hide sidebar" when open
@@ -60,6 +60,8 @@ function titleOptions(title, focused) {
60
60
  if (typeof title === "object" && title !== null) {
61
61
  return {
62
62
  label: title.label,
63
+ labelRendered: title.labelRendered,
64
+ labelPlain: title.labelPlain,
63
65
  tail: title.tail,
64
66
  tailRendered: title.tailRendered,
65
67
  tailPlain: title.tailPlain,
@@ -101,12 +103,54 @@ export function paneOverlay(options) {
101
103
  let lastRenderWidth = 80;
102
104
  let lastSelectedKey;
103
105
  let initialSelectionApplied = false;
106
+ const primaryScrollbar = { visible: false, timer: undefined };
107
+ const detailScrollbar = { visible: false, timer: undefined };
104
108
  const primaryState = { cursor: 0, scrollOffset: 0 };
105
109
  const detailState = { scrollOffset: 0, sticky: stickyBottom };
106
110
  const perSelectionOffsets = new Map();
107
111
  const requestRender = () => {
108
112
  tui?.requestRender?.();
109
113
  };
114
+ const markScrollbarActivity = (scrollbar) => {
115
+ scrollbar.visible = true;
116
+ if (scrollbar.timer)
117
+ clearTimeout(scrollbar.timer);
118
+ scrollbar.timer = setTimeout(() => {
119
+ scrollbar.timer = undefined;
120
+ scrollbar.visible = false;
121
+ requestRender();
122
+ }, 1_000);
123
+ scrollbar.timer.unref?.();
124
+ };
125
+ const paintScrollbarThumb = (text, width, row, viewportHeight, contentLength, offset, visible) => {
126
+ const cell = padRight(clipStyled(text, width), width);
127
+ if (!visible || width <= 0 || viewportHeight <= 0 || contentLength <= viewportHeight)
128
+ return cell;
129
+ const thumbHeight = Math.max(Math.min(2, viewportHeight), Math.min(viewportHeight, Math.round((viewportHeight * viewportHeight) / contentLength)));
130
+ const maxOffset = Math.max(0, contentLength - viewportHeight);
131
+ const maxThumbTop = viewportHeight - thumbHeight;
132
+ const thumbTop = maxOffset === 0 ? 0 : Math.round((offset / maxOffset) * maxThumbTop);
133
+ if (row < thumbTop || row >= thumbTop + thumbHeight)
134
+ return cell;
135
+ const prefix = padRight(clipStyled(cell, width - 1), width - 1);
136
+ let thumb;
137
+ try {
138
+ thumb = chromeTheme.bg?.("scrollbarThumb", " ");
139
+ }
140
+ catch {
141
+ // Older Pi themes expose selectedBg but not scrollbarThumb.
142
+ }
143
+ if (thumb === undefined) {
144
+ try {
145
+ thumb = chromeTheme.bg?.("selectedBg", " ");
146
+ }
147
+ catch {
148
+ // Minimal structural themes may not support background roles.
149
+ }
150
+ }
151
+ thumb ??= chromeTheme.fg("dim", "▐");
152
+ return prefix + thumb;
153
+ };
110
154
  const finish = (result) => {
111
155
  if (finished)
112
156
  return;
@@ -263,7 +307,12 @@ export function paneOverlay(options) {
263
307
  };
264
308
  const applyPrimaryNav = (bodyHeight, legendLineCount, mutator) => {
265
309
  const { primaryRows, viewportHeight } = withPrimaryViewport(bodyHeight, legendLineCount);
310
+ const previousCursor = primaryState.cursor;
311
+ const previousOffset = primaryState.scrollOffset;
266
312
  mutator(primaryState, primaryRows, viewportHeight);
313
+ if (primaryState.cursor !== previousCursor || primaryState.scrollOffset !== previousOffset) {
314
+ markScrollbarActivity(primaryScrollbar);
315
+ }
267
316
  };
268
317
  const movePrimary = (delta, bodyHeight, legendLineCount) => {
269
318
  applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
@@ -330,12 +379,26 @@ export function paneOverlay(options) {
330
379
  }
331
380
  });
332
381
  };
333
- const markDetailManual = () => {
334
- detailState.sticky = false;
382
+ const setDetailOffset = (offset, contentLength, viewportHeight) => {
383
+ const height = Math.max(1, viewportHeight);
384
+ const maxOffset = endScrollOffset(contentLength, height);
385
+ const previousOffset = detailState.scrollOffset;
386
+ detailState.scrollOffset = Math.max(0, Math.min(offset, maxOffset));
387
+ detailState.sticky = stickyBottom && detailState.scrollOffset === maxOffset;
388
+ if (detailState.scrollOffset !== previousOffset)
389
+ markScrollbarActivity(detailScrollbar);
335
390
  if (perSelectionScroll && lastSelectedKey !== undefined) {
336
- getDetailStateForKey(lastSelectedKey).sticky = false;
391
+ const state = getDetailStateForKey(lastSelectedKey);
392
+ state.offset = detailState.scrollOffset;
393
+ state.sticky = detailState.sticky;
337
394
  }
338
395
  };
396
+ const moveDetail = (delta, contentLength, viewportHeight) => {
397
+ const previousOffset = detailState.scrollOffset;
398
+ const nextOffset = moveScrollOffset({ offset: previousOffset, contentLength, viewportHeight: Math.max(1, viewportHeight) }, delta);
399
+ setDetailOffset(nextOffset, contentLength, viewportHeight);
400
+ return delta - (detailState.scrollOffset - previousOffset);
401
+ };
339
402
  const legendEntries = (ctx) => {
340
403
  const entries = [];
341
404
  entries.push({ key: "tab/←/→", label: "focus" });
@@ -366,8 +429,8 @@ export function paneOverlay(options) {
366
429
  return entries.map((entry) => chromeTheme.fg("dim", renderKeyRow(entry.key, entry.label, totalWidth, keyWidth)));
367
430
  };
368
431
  /**
369
- * Mouse support (fullscreen TUI only, where a focused overlay receives raw
370
- * SGR sequences). Wheel scrolls the pane under the pointer; click selects
432
+ * Mouse support for forwarded SGR sequences. Wheel scrolls the pane under
433
+ * the pointer; click selects
371
434
  * rows and focuses panes; double-click activates (synthesizes Enter);
372
435
  * dragging the divider resizes the split. All bounds are in overlay-local
373
436
  * coordinates: the overlay mounts top-left at 100% width, so terminal
@@ -392,37 +455,20 @@ export function paneOverlay(options) {
392
455
  : 0);
393
456
  const inPrimaryColumn = primaryWidth > 0 && event.x >= 1 && event.x <= primaryWidth;
394
457
  const onDivider = !collapsed && primaryWidth > 0 && event.x === primaryWidth + 1;
395
- const inDetailColumn = event.x > primaryWidth + 1;
396
- // Wheel: scroll the pane under the pointer (shift accelerates).
458
+ const inDetailColumn = event.x > primaryWidth + 1 && event.x < totalWidth - 1;
459
+ const inBodyRow = event.y >= 1 && event.y <= bodyHeight;
460
+ // Wheel: move exactly one logical row per terminal report, like TuiAltScreen.
397
461
  if (event.button === 64 || event.button === 65) {
398
462
  if (event.y > maxRow)
399
463
  return;
400
464
  const direction = event.button === 64 ? -1 : 1;
401
- if (inPrimaryColumn || (primaryWidth > 0 && event.x <= primaryWidth + 1)) {
402
- const lines = direction * (event.shift ? 5 : 1);
465
+ if (!inDetailColumn || (!inBodyRow && !collapsed)) {
403
466
  focus = "primary";
404
- applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
405
- if ((options.primary.mode ?? "scroll") === "cursor") {
406
- const indexes = selectableIndexes(rows);
407
- if (indexes.length === 0)
408
- return;
409
- const currentIndex = nearestSelectableIndex(rows, state.cursor);
410
- const currentOrdinal = Math.max(0, indexes.indexOf(currentIndex));
411
- const nextOrdinal = Math.max(0, Math.min(indexes.length - 1, currentOrdinal + lines));
412
- state.cursor = indexes[nextOrdinal] ?? currentIndex;
413
- const next = ensureCursorVisible({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
414
- state.scrollOffset = next.scroll;
415
- }
416
- else {
417
- state.scrollOffset = moveScrollOffset({ offset: state.scrollOffset, contentLength: rows.length, viewportHeight }, lines);
418
- }
419
- });
467
+ movePrimary(direction, bodyHeight, legendLineCount);
420
468
  }
421
469
  else {
422
- const lines = direction * (event.shift ? 5 : 3);
423
470
  focus = "detail";
424
- markDetailManual();
425
- detailState.scrollOffset = moveScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, lines);
471
+ moveDetail(direction, detailRows.length, bodyHeight);
426
472
  }
427
473
  requestRender();
428
474
  return;
@@ -496,7 +542,10 @@ export function paneOverlay(options) {
496
542
  };
497
543
  const component = {
498
544
  dispose() {
499
- // no-op is acceptable
545
+ if (primaryScrollbar.timer)
546
+ clearTimeout(primaryScrollbar.timer);
547
+ if (detailScrollbar.timer)
548
+ clearTimeout(detailScrollbar.timer);
500
549
  },
501
550
  render(width) {
502
551
  const totalWidth = Math.max(10, Math.floor(width));
@@ -597,8 +646,13 @@ export function paneOverlay(options) {
597
646
  // When the primary pane is collapsed its legend (and the collapse hint with
598
647
  // it) disappears, so the user can't see how to reopen it. Surface the
599
648
  // reopen hint in the always-visible detail footer instead.
649
+ const horizontalNavigationHint = collapsed && collapse?.horizontalPrimaryNavigation
650
+ ? `←/→ ${collapse.horizontalPrimaryNavigationLabel ?? "navigate"}`
651
+ : "";
600
652
  const detailFooterText = collapsed && collapse
601
- ? [`${collapseKey} ${collapseLabel("expand")}`, detailFooterBase].filter(Boolean).join(" \u00b7 ")
653
+ ? [horizontalNavigationHint, `${collapseKey} ${collapseLabel("expand")}`, detailFooterBase]
654
+ .filter(Boolean)
655
+ .join(" \u00b7 ")
602
656
  : detailFooterBase;
603
657
  const bottomPrimary = titledBottomSegment(chromeTheme, primaryWidth, primaryFooterText, focus === "primary");
604
658
  const bottomDetail = titledBottomSegment(chromeTheme, detailWidth, detailFooterText, focus === "detail");
@@ -623,7 +677,8 @@ export function paneOverlay(options) {
623
677
  const legendIndex = row - legendStart - 1;
624
678
  primaryCell = legendIndex < legendLines.length ? padRight(legendLines[legendIndex], primaryWidth) : "";
625
679
  }
626
- const detailCell = detailVisible[row] ?? "";
680
+ primaryCell = paintScrollbarThumb(primaryCell, primaryWidth, row, primaryHeight, primaryRows.length, primaryState.scrollOffset, primaryScrollbar.visible && !collapsed);
681
+ const detailCell = paintScrollbarThumb(detailVisible[row] ?? "", detailWidth, row, detailHeight, detailRows.length, detailState.scrollOffset, detailScrollbar.visible);
627
682
  if (primaryWidth === 0) {
628
683
  const border = chromeTheme.fg("dim", "│");
629
684
  bodyLines.push(border + padRight(clipStyled(detailCell, detailWidth), detailWidth) + border);
@@ -669,6 +724,11 @@ export function paneOverlay(options) {
669
724
  requestRender();
670
725
  return;
671
726
  }
727
+ if (collapsed && collapse?.horizontalPrimaryNavigation && matchesAny(data, ["left", "right"])) {
728
+ movePrimary(matchesAny(data, ["right"]) ? 1 : -1, bodyHeight, 0);
729
+ requestRender();
730
+ return;
731
+ }
672
732
  if (matchesAny(data, ["left", "right", "tab"])) {
673
733
  toggleFocus();
674
734
  ensureFocusDetailWhenCollapsed();
@@ -686,8 +746,7 @@ export function paneOverlay(options) {
686
746
  movePrimary(delta, bodyHeight, legendLineCount);
687
747
  }
688
748
  else {
689
- markDetailManual();
690
- detailState.scrollOffset = moveScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, delta);
749
+ moveDetail(delta, detailRows.length, bodyHeight);
691
750
  }
692
751
  },
693
752
  home: () => {
@@ -695,8 +754,7 @@ export function paneOverlay(options) {
695
754
  homePrimary(bodyHeight, legendLineCount);
696
755
  }
697
756
  else {
698
- markDetailManual();
699
- detailState.scrollOffset = homeScrollOffset();
757
+ setDetailOffset(homeScrollOffset(), detailRows.length, bodyHeight);
700
758
  }
701
759
  },
702
760
  end: () => {
@@ -704,8 +762,7 @@ export function paneOverlay(options) {
704
762
  endPrimary(bodyHeight, legendLineCount);
705
763
  }
706
764
  else {
707
- markDetailManual();
708
- detailState.scrollOffset = endScrollOffset(detailRows.length, Math.max(1, bodyHeight));
765
+ setDetailOffset(endScrollOffset(detailRows.length, Math.max(1, bodyHeight)), detailRows.length, bodyHeight);
709
766
  }
710
767
  },
711
768
  });
@@ -718,8 +775,7 @@ export function paneOverlay(options) {
718
775
  halfPagePrimary(-1, bodyHeight, legendLineCount);
719
776
  }
720
777
  else {
721
- markDetailManual();
722
- detailState.scrollOffset = pageScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, -1, pageSizeHalf(bodyHeight));
778
+ setDetailOffset(pageScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, -1, pageSizeHalf(bodyHeight)), detailRows.length, bodyHeight);
723
779
  }
724
780
  requestRender();
725
781
  return;
@@ -729,8 +785,7 @@ export function paneOverlay(options) {
729
785
  halfPagePrimary(1, bodyHeight, legendLineCount);
730
786
  }
731
787
  else {
732
- markDetailManual();
733
- detailState.scrollOffset = pageScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, 1, pageSizeHalf(bodyHeight));
788
+ setDetailOffset(pageScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, 1, pageSizeHalf(bodyHeight)), detailRows.length, bodyHeight);
734
789
  }
735
790
  requestRender();
736
791
  return;
@@ -14,6 +14,39 @@ export function createTuiModeCapture() {
14
14
  },
15
15
  };
16
16
  }
17
+ function getLayoutRootTui(tui) {
18
+ if ((typeof tui !== "object" && typeof tui !== "function") || tui === null)
19
+ return undefined;
20
+ if (!("layoutRoot" in tui) || typeof tui.setLayoutRoot !== "function")
21
+ return undefined;
22
+ return tui;
23
+ }
24
+ // Pi 0.84 exposes setLayoutRoot() but no getter. Its TypeScript-private
25
+ // layoutRoot remains a runtime property; keep that compatibility bridge here.
26
+ function getLayoutRoot(tui) {
27
+ return tui.layoutRoot;
28
+ }
29
+ function createOverlayInputProxy(component) {
30
+ const target = component;
31
+ const proxy = {
32
+ render: () => [],
33
+ invalidate: () => target.invalidate?.(),
34
+ handleInput: (data) => target.handleInput?.(data),
35
+ get wantsKeyRelease() {
36
+ return target.wantsKeyRelease;
37
+ },
38
+ dispose: () => target.dispose?.(),
39
+ };
40
+ if (((typeof target === "object" && target !== null) || typeof target === "function") && "focused" in target) {
41
+ Object.defineProperty(proxy, "focused", {
42
+ get: () => target.focused,
43
+ set: (focused) => {
44
+ target.focused = focused;
45
+ },
46
+ });
47
+ }
48
+ return proxy;
49
+ }
17
50
  export function createUiClient(fullscreen, ctx, capture = createTuiModeCapture()) {
18
51
  return {
19
52
  async fullscreen(factory) {
@@ -22,20 +55,52 @@ export function createUiClient(fullscreen, ctx, capture = createTuiModeCapture()
22
55
  throw new Error("pi-extension-utils: ui.fullscreen requires an interactive UI (ctx.ui.custom is unavailable)");
23
56
  }
24
57
  const lease = fullscreen.acquire();
58
+ let restoreLayoutRoot;
59
+ let closed = false;
60
+ const close = () => {
61
+ closed = true;
62
+ restoreLayoutRoot?.();
63
+ };
25
64
  try {
26
65
  const wrapped = (tui, theme, keybindings, done) => {
27
66
  capture.noteTui(tui);
28
- return factory(tui, theme, keybindings, done);
67
+ const mount = (component) => {
68
+ const layoutTui = capture.isFullscreenTui() ? getLayoutRootTui(tui) : undefined;
69
+ if (!layoutTui)
70
+ return component;
71
+ if (closed)
72
+ return createOverlayInputProxy(component);
73
+ const priorRoot = getLayoutRoot(layoutTui);
74
+ let restored = false;
75
+ restoreLayoutRoot = () => {
76
+ if (restored)
77
+ return;
78
+ restored = true;
79
+ layoutTui.setLayoutRoot(priorRoot);
80
+ layoutTui.requestRender?.(true);
81
+ };
82
+ layoutTui.setLayoutRoot(component);
83
+ layoutTui.requestRender?.(true);
84
+ return createOverlayInputProxy(component);
85
+ };
86
+ const component = factory(tui, theme, keybindings, (result) => {
87
+ close();
88
+ done(result);
89
+ });
90
+ if (component && typeof component.then === "function") {
91
+ return Promise.resolve(component).then(mount);
92
+ }
93
+ return mount(component);
29
94
  };
30
95
  // In the fullscreen TUI a plain custom component mounts inside the
31
- // bottom dock (the editor slot), where the dock layout shrinks and
32
- // clips it while the transcript tail renders above it. Mount it as a
33
- // full-screen overlay instead so the component owns the whole screen,
34
- // matching the regular-TUI behavior it was designed for.
96
+ // bottom dock. Use the overlay path for focus/lifecycle, then let the
97
+ // wrapped factory replace a capable viewport TUI's layout root. The
98
+ // returned empty input proxy keeps the host overlay from rendering the
99
+ // same dashboard a second time.
35
100
  // Before any wrapped factory has rendered, the mode is unknown. Default
36
101
  // that cold-start case to an overlay: it is the only mount that remains
37
- // fullscreen and receives focus in the alt-screen TUI. Preserve the
38
- // legacy editor-slot mount only when regular mode was positively seen.
102
+ // safe in the alt-screen TUI. Preserve the legacy editor-slot mount only
103
+ // when regular mode was positively seen.
39
104
  const options = capture.isRegularTui()
40
105
  ? undefined
41
106
  : {
@@ -45,7 +110,12 @@ export function createUiClient(fullscreen, ctx, capture = createTuiModeCapture()
45
110
  return await ui.custom(wrapped, options);
46
111
  }
47
112
  finally {
48
- lease.release();
113
+ try {
114
+ close();
115
+ }
116
+ finally {
117
+ lease.release();
118
+ }
49
119
  }
50
120
  },
51
121
  };
package/docs/client.md CHANGED
@@ -44,9 +44,13 @@ await client.ui.fullscreen((tui, theme, keybindings, done) => new MyComponent(tu
44
44
 
45
45
  - acquires a fullscreen lease
46
46
  - blanks coordinated widgets
47
- - calls `ctx.ui.custom()`
47
+ - preserves the regular-TUI editor-slot mount
48
+ - temporarily replaces a capable fullscreen viewport TUI's layout root with the dashboard and forces redraws on install and restoration
49
+ - uses the overlay path as the safe cold-start/fallback mount without rendering the real dashboard twice
48
50
  - releases the lease in `finally`
49
51
 
52
+ Pi 0.84.x exposes the layout-root setter but no public getter, so exact restoration uses its runtime `layoutRoot` property through an isolated structural bridge. If a fullscreen TUI does not expose that setter/property shape, `ui.fullscreen()` safely falls back to the full-size overlay; that fallback cannot guarantee that terminal image placements from the transcript are cleared.
53
+
50
54
  Use `client.fullscreen.acquire()` only when you need manual lease control.
51
55
 
52
56
  ## Pane overlay
@@ -63,7 +63,7 @@ await client.ui.fullscreen(
63
63
  | `legendPlacement` | `footer` or `primary` |
64
64
  | `customActions` | Extra keys with labels and handlers |
65
65
  | `closeKeys` | Override close keys, e.g. omit `q` |
66
- | `collapse` | Optional primary/sidebar collapse key |
66
+ | `collapse` | Optional primary/sidebar collapse key; collapsed horizontal navigation can provide its own footer label |
67
67
  | `perSelectionScroll` | Keep separate detail scroll per selected key |
68
68
  | `stickyBottom` | Detail starts/follows at bottom until user scrolls |
69
69
  | `onRender` | Per-frame hook for external transient UI state |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extension-utils",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Shared Pi extension utilities for coordinated widgets, fullscreen leases, and logging.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",