pi-extension-utils 0.6.0 → 0.7.1
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/dist/src/pane/mouse.d.ts +28 -0
- package/dist/src/pane/mouse.js +35 -0
- package/dist/src/pane/overlay.js +139 -0
- package/dist/src/ui/client.d.ts +1 -0
- package/dist/src/ui/client.js +11 -4
- package/dist/src/widgets/host.js +37 -4
- package/package.json +1 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SGR (1006) mouse sequence parsing for pane overlays.
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
export interface SgrMouseEvent {
|
|
12
|
+
/** 0 = left, 1 = middle, 2 = right, 64 = wheel up, 65 = wheel down. */
|
|
13
|
+
button: number;
|
|
14
|
+
/** 1-based terminal column. */
|
|
15
|
+
x: number;
|
|
16
|
+
/** 1-based terminal row. */
|
|
17
|
+
y: number;
|
|
18
|
+
/** Button press (not motion, not release). */
|
|
19
|
+
press: boolean;
|
|
20
|
+
release: boolean;
|
|
21
|
+
/** Motion with a button held (drag). */
|
|
22
|
+
motion: boolean;
|
|
23
|
+
shift: boolean;
|
|
24
|
+
alt: boolean;
|
|
25
|
+
ctrl: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** Parse a full SGR mouse sequence. Returns undefined for any other input. */
|
|
28
|
+
export declare function parseSgrMouseEvent(data: string): SgrMouseEvent | undefined;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SGR (1006) mouse sequence parsing for pane overlays.
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
|
12
|
+
/** Parse a full SGR mouse sequence. Returns undefined for any other input. */
|
|
13
|
+
export function parseSgrMouseEvent(data) {
|
|
14
|
+
const match = SGR_MOUSE_RE.exec(data);
|
|
15
|
+
if (!match)
|
|
16
|
+
return undefined;
|
|
17
|
+
const cb = Number(match[1]);
|
|
18
|
+
const x = Number(match[2]);
|
|
19
|
+
const y = Number(match[3]);
|
|
20
|
+
const isRelease = match[4] === "m";
|
|
21
|
+
const motion = (cb & 32) !== 0 && !isRelease;
|
|
22
|
+
const wheel = (cb & 64) !== 0;
|
|
23
|
+
const buttonBits = cb & 3;
|
|
24
|
+
return {
|
|
25
|
+
button: wheel ? 64 + buttonBits : buttonBits,
|
|
26
|
+
x,
|
|
27
|
+
y,
|
|
28
|
+
press: !isRelease && !motion,
|
|
29
|
+
release: isRelease,
|
|
30
|
+
motion,
|
|
31
|
+
shift: (cb & 4) !== 0,
|
|
32
|
+
alt: (cb & 8) !== 0,
|
|
33
|
+
ctrl: (cb & 16) !== 0,
|
|
34
|
+
};
|
|
35
|
+
}
|
package/dist/src/pane/overlay.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dispatchNavKeys } from "./key-dispatch.js";
|
|
2
|
+
import { parseSgrMouseEvent } from "./mouse.js";
|
|
2
3
|
import { computeFixedSidebarLayout, computeSplitPaneLayout, endScrollOffset, ensureCursorVisible, homeScrollOffset, moveScrollOffset, pageScrollOffset, resizeSplitPane, } from "./state.js";
|
|
3
4
|
import { boxRow, clipStyled, flatRule, formatScrollInfo, padRight, renderKeyRow, titledBottomSegment, titledTopSegment, } from "./chrome.js";
|
|
4
5
|
import { matchesKey } from "@earendil-works/pi-tui";
|
|
@@ -94,6 +95,9 @@ export function paneOverlay(options) {
|
|
|
94
95
|
return typeof label === "function" ? label(collapsed) : (label ?? fallback);
|
|
95
96
|
};
|
|
96
97
|
let leftFraction = split.initialFraction ?? 0.5;
|
|
98
|
+
let dividerDragActive = false;
|
|
99
|
+
let lastPrimaryClick;
|
|
100
|
+
let componentRef;
|
|
97
101
|
let lastRenderWidth = 80;
|
|
98
102
|
let lastSelectedKey;
|
|
99
103
|
let initialSelectionApplied = false;
|
|
@@ -361,6 +365,135 @@ export function paneOverlay(options) {
|
|
|
361
365
|
const keyWidth = Math.min(11, Math.max(3, Math.floor(totalWidth / 4)));
|
|
362
366
|
return entries.map((entry) => chromeTheme.fg("dim", renderKeyRow(entry.key, entry.label, totalWidth, keyWidth)));
|
|
363
367
|
};
|
|
368
|
+
/**
|
|
369
|
+
* Mouse support (fullscreen TUI only, where a focused overlay receives raw
|
|
370
|
+
* SGR sequences). Wheel scrolls the pane under the pointer; click selects
|
|
371
|
+
* rows and focuses panes; double-click activates (synthesizes Enter);
|
|
372
|
+
* dragging the divider resizes the split. All bounds are in overlay-local
|
|
373
|
+
* coordinates: the overlay mounts top-left at 100% width, so terminal
|
|
374
|
+
* columns/rows map 1:1 to rendered lines.
|
|
375
|
+
*/
|
|
376
|
+
const handleMouseEvent = (event) => {
|
|
377
|
+
if (finished)
|
|
378
|
+
return;
|
|
379
|
+
const totalWidth = lastRenderWidth;
|
|
380
|
+
if (totalWidth <= 0)
|
|
381
|
+
return;
|
|
382
|
+
const bodyHeight = computeBodyHeight(tui, options.height);
|
|
383
|
+
const { primaryWidth, detailWidth } = currentWidths();
|
|
384
|
+
const { selectedIndex, selectedRow, selectedKey } = computeSelectionFromRows(getPrimaryRows(makeContext(0, undefined, "", primaryWidth, detailWidth)), bodyHeight);
|
|
385
|
+
const ctx = makeContext(selectedIndex, selectedRow, selectedKey, primaryWidth, detailWidth);
|
|
386
|
+
const detailRows = options.detail.rows(ctx) ?? [];
|
|
387
|
+
const legendLineCount = legendPlacement === "primary"
|
|
388
|
+
? renderLegendLines(legendEntries(ctx), totalWidth).length
|
|
389
|
+
: 0;
|
|
390
|
+
const maxRow = 1 + bodyHeight + (legendPlacement === "footer"
|
|
391
|
+
? renderLegendLines(legendEntries(ctx), totalWidth).length
|
|
392
|
+
: 0);
|
|
393
|
+
const inPrimaryColumn = primaryWidth > 0 && event.x >= 1 && event.x <= primaryWidth;
|
|
394
|
+
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).
|
|
397
|
+
if (event.button === 64 || event.button === 65) {
|
|
398
|
+
if (event.y > maxRow)
|
|
399
|
+
return;
|
|
400
|
+
const direction = event.button === 64 ? -1 : 1;
|
|
401
|
+
if (inPrimaryColumn || (primaryWidth > 0 && event.x <= primaryWidth + 1)) {
|
|
402
|
+
const lines = direction * (event.shift ? 5 : 3);
|
|
403
|
+
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
|
+
});
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
const lines = direction * (event.shift ? 5 : 1);
|
|
423
|
+
focus = "detail";
|
|
424
|
+
markDetailManual();
|
|
425
|
+
detailState.scrollOffset = moveScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, lines);
|
|
426
|
+
}
|
|
427
|
+
requestRender();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
// Divider drag: press on the divider starts it, motion resizes, release ends.
|
|
431
|
+
if (onDivider && event.press && event.button === 0) {
|
|
432
|
+
dividerDragActive = true;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (event.motion && dividerDragActive) {
|
|
436
|
+
const usable = Math.max(1, totalWidth - 3);
|
|
437
|
+
const minFraction = Math.max(split.minFraction ?? 0, (split.minPrimaryWidth ?? 8) / usable);
|
|
438
|
+
const maxFraction = Math.min(split.maxFraction ?? 1, (usable - (split.minDetailWidth ?? 8)) / usable, split.maxPrimaryWidth !== undefined ? split.maxPrimaryWidth / usable : 1);
|
|
439
|
+
leftFraction = Math.max(Math.min(minFraction, maxFraction), Math.min(maxFraction, (event.x - 1) / usable));
|
|
440
|
+
requestRender();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (event.release && event.button === 0 && dividerDragActive) {
|
|
444
|
+
dividerDragActive = false;
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (!event.press || event.button !== 0 || event.y > maxRow)
|
|
448
|
+
return;
|
|
449
|
+
// Title/border rows: click focuses the pane under the pointer.
|
|
450
|
+
if (event.y === 0 || event.y === bodyHeight + 1) {
|
|
451
|
+
if (primaryWidth > 0 && event.x <= primaryWidth + 1)
|
|
452
|
+
focus = "primary";
|
|
453
|
+
else
|
|
454
|
+
focus = "detail";
|
|
455
|
+
ensureFocusDetailWhenCollapsed();
|
|
456
|
+
requestRender();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
// Body rows.
|
|
460
|
+
if (event.y >= 1 && event.y <= bodyHeight) {
|
|
461
|
+
if (onDivider)
|
|
462
|
+
return;
|
|
463
|
+
if (inPrimaryColumn && !collapsed) {
|
|
464
|
+
focus = "primary";
|
|
465
|
+
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
466
|
+
const rowIndex = event.y - 1;
|
|
467
|
+
const absoluteIndex = primaryState.scrollOffset + rowIndex;
|
|
468
|
+
applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
|
|
469
|
+
if (absoluteIndex >= rows.length)
|
|
470
|
+
return;
|
|
471
|
+
state.cursor = nearestSelectableIndex(rows, absoluteIndex);
|
|
472
|
+
const next = ensureCursorVisible({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
|
|
473
|
+
state.scrollOffset = next.scroll;
|
|
474
|
+
});
|
|
475
|
+
const now = Date.now();
|
|
476
|
+
const isDoubleClick = lastPrimaryClick
|
|
477
|
+
&& lastPrimaryClick.row === event.y
|
|
478
|
+
&& lastPrimaryClick.col === event.x
|
|
479
|
+
&& now - lastPrimaryClick.at < 450;
|
|
480
|
+
lastPrimaryClick = { row: event.y, col: event.x, at: now };
|
|
481
|
+
requestRender();
|
|
482
|
+
if (isDoubleClick)
|
|
483
|
+
componentRef?.handleInput("\r");
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
requestRender();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (inDetailColumn) {
|
|
490
|
+
focus = "detail";
|
|
491
|
+
ensureFocusDetailWhenCollapsed();
|
|
492
|
+
requestRender();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
};
|
|
364
497
|
const component = {
|
|
365
498
|
dispose() {
|
|
366
499
|
// no-op is acceptable
|
|
@@ -508,6 +641,11 @@ export function paneOverlay(options) {
|
|
|
508
641
|
handleInput(data) {
|
|
509
642
|
if (finished)
|
|
510
643
|
return;
|
|
644
|
+
const mouseEvent = parseSgrMouseEvent(data);
|
|
645
|
+
if (mouseEvent) {
|
|
646
|
+
handleMouseEvent(mouseEvent);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
511
649
|
const bodyHeight = computeBodyHeight(tui, options.height);
|
|
512
650
|
if (matchesAny(data, closeKeys)) {
|
|
513
651
|
finish(options.closeResult);
|
|
@@ -615,6 +753,7 @@ export function paneOverlay(options) {
|
|
|
615
753
|
}
|
|
616
754
|
},
|
|
617
755
|
};
|
|
756
|
+
componentRef = component;
|
|
618
757
|
return component;
|
|
619
758
|
};
|
|
620
759
|
}
|
package/dist/src/ui/client.d.ts
CHANGED
package/dist/src/ui/client.js
CHANGED
|
@@ -9,6 +9,9 @@ export function createTuiModeCapture() {
|
|
|
9
9
|
isFullscreenTui() {
|
|
10
10
|
return mode === "fullscreen";
|
|
11
11
|
},
|
|
12
|
+
isRegularTui() {
|
|
13
|
+
return mode === "regular";
|
|
14
|
+
},
|
|
12
15
|
};
|
|
13
16
|
}
|
|
14
17
|
export function createUiClient(fullscreen, ctx, capture = createTuiModeCapture()) {
|
|
@@ -29,12 +32,16 @@ export function createUiClient(fullscreen, ctx, capture = createTuiModeCapture()
|
|
|
29
32
|
// clips it while the transcript tail renders above it. Mount it as a
|
|
30
33
|
// full-screen overlay instead so the component owns the whole screen,
|
|
31
34
|
// matching the regular-TUI behavior it was designed for.
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
// Before any wrapped factory has rendered, the mode is unknown. Default
|
|
36
|
+
// 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.
|
|
39
|
+
const options = capture.isRegularTui()
|
|
40
|
+
? undefined
|
|
41
|
+
: {
|
|
34
42
|
overlay: true,
|
|
35
43
|
overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" },
|
|
36
|
-
}
|
|
37
|
-
: undefined;
|
|
44
|
+
};
|
|
38
45
|
return await ui.custom(wrapped, options);
|
|
39
46
|
}
|
|
40
47
|
finally {
|
package/dist/src/widgets/host.js
CHANGED
|
@@ -109,14 +109,47 @@ export function registerWidgetHost(pi) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
function createHostComponent(tui, theme, records, hidden) {
|
|
112
|
-
|
|
112
|
+
// A widget whose factory or render throws (e.g. an extension holding a
|
|
113
|
+
// ctx that went stale during session reload) must never take down the
|
|
114
|
+
// whole agent: render failures drop that widget until its client
|
|
115
|
+
// re-registers. Records are replaced wholesale on every re-registration
|
|
116
|
+
// and remount, so a dropped component cannot linger past the handshake.
|
|
117
|
+
const healthy = [];
|
|
118
|
+
if (!hidden) {
|
|
119
|
+
for (const record of records) {
|
|
120
|
+
try {
|
|
121
|
+
healthy.push(record.factory(tui, theme));
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
console.warn(`pi-extension-utils: widget factory for ${record.clientId}/${record.key} threw, dropping it until re-registration`, error instanceof Error ? error.message : error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
113
128
|
return {
|
|
114
129
|
render(width) {
|
|
115
|
-
|
|
130
|
+
const lines = [];
|
|
131
|
+
for (const component of [...healthy]) {
|
|
132
|
+
try {
|
|
133
|
+
lines.push(...component.render(width));
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
const failedIndex = healthy.indexOf(component);
|
|
137
|
+
if (failedIndex >= 0)
|
|
138
|
+
healthy.splice(failedIndex, 1);
|
|
139
|
+
console.warn(`pi-extension-utils: widget render threw, dropping it until re-registration`, error instanceof Error ? error.message : error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return lines;
|
|
116
143
|
},
|
|
117
144
|
invalidate() {
|
|
118
|
-
for (const component of
|
|
119
|
-
|
|
145
|
+
for (const component of healthy) {
|
|
146
|
+
try {
|
|
147
|
+
component.invalidate();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// invalidate is best-effort; ignore failures
|
|
151
|
+
}
|
|
152
|
+
}
|
|
120
153
|
},
|
|
121
154
|
};
|
|
122
155
|
}
|