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 +1 -1
- package/dist/src/pane/chrome.d.ts +8 -2
- package/dist/src/pane/chrome.js +9 -7
- package/dist/src/pane/mouse.d.ts +7 -8
- package/dist/src/pane/mouse.js +7 -8
- package/dist/src/pane/overlay.d.ts +6 -0
- package/dist/src/pane/overlay.js +96 -41
- package/dist/src/ui/client.js +78 -8
- package/docs/client.md +5 -1
- package/docs/pane-overlay.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Minimal theme shape for chrome helpers: only `fg(color, text)` is required
|
|
3
|
-
*
|
|
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. */
|
package/dist/src/pane/chrome.js
CHANGED
|
@@ -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
|
|
111
|
-
?
|
|
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) {
|
package/dist/src/pane/mouse.d.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
/**
|
|
13
|
+
/** 0-based terminal column. */
|
|
15
14
|
x: number;
|
|
16
|
-
/**
|
|
15
|
+
/** 0-based terminal row. */
|
|
17
16
|
y: number;
|
|
18
17
|
/** Button press (not motion, not release). */
|
|
19
18
|
press: boolean;
|
package/dist/src/pane/mouse.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
package/dist/src/pane/overlay.js
CHANGED
|
@@ -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
|
|
334
|
-
|
|
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)
|
|
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
|
|
370
|
-
*
|
|
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
|
-
|
|
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 (
|
|
402
|
-
const lines = direction * (event.shift ? 5 : 1);
|
|
465
|
+
if (!inDetailColumn || (!inBodyRow && !collapsed)) {
|
|
403
466
|
focus = "primary";
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/src/ui/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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
|
-
//
|
|
38
|
-
//
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
package/docs/pane-overlay.md
CHANGED
|
@@ -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 |
|