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.
@@ -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
+ }
@@ -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
  }
@@ -13,6 +13,7 @@ export interface FullscreenController {
13
13
  export interface TuiModeCapture {
14
14
  noteTui(tui: unknown): void;
15
15
  isFullscreenTui(): boolean;
16
+ isRegularTui(): boolean;
16
17
  }
17
18
  export declare function createTuiModeCapture(): TuiModeCapture;
18
19
  /**
@@ -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
- const options = capture.isFullscreenTui()
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 {
@@ -109,14 +109,47 @@ export function registerWidgetHost(pi) {
109
109
  }
110
110
  }
111
111
  function createHostComponent(tui, theme, records, hidden) {
112
- const components = hidden ? [] : records.map((record) => record.factory(tui, theme));
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
- return components.flatMap((component) => component.render(width));
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 components)
119
- component.invalidate();
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extension-utils",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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",