pi-extension-utils 0.5.2 → 0.7.0

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.
@@ -1,11 +1,12 @@
1
1
  import { createRemindersClient } from "../reminders/client.js";
2
- import { createUiClient } from "../ui/client.js";
2
+ import { createTuiModeCapture, createUiClient } from "../ui/client.js";
3
3
  import { connectWidgetCoordinator } from "../widgets/client.js";
4
4
  let nextClientId = 1;
5
5
  export function connect(pi, opts) {
6
6
  const clientId = opts.clientId ?? `client-${process.pid}-${Date.now()}-${nextClientId++}`;
7
7
  let disposed = false;
8
- const coordinator = connectWidgetCoordinator(pi, { ctx: opts.ctx, clientId });
8
+ const tuiCapture = createTuiModeCapture();
9
+ const coordinator = connectWidgetCoordinator(pi, { ctx: opts.ctx, clientId, tui: tuiCapture });
9
10
  return {
10
11
  clientId,
11
12
  get mode() {
@@ -13,7 +14,7 @@ export function connect(pi, opts) {
13
14
  },
14
15
  widgets: coordinator.widgets,
15
16
  fullscreen: coordinator.fullscreen,
16
- ui: createUiClient(coordinator.fullscreen, opts.ctx),
17
+ ui: createUiClient(coordinator.fullscreen, opts.ctx, tuiCapture),
17
18
  reminders: createRemindersClient(pi, () => disposed),
18
19
  dispose() {
19
20
  if (disposed)
@@ -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,133 @@ 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 lines = (event.button === 64 ? -1 : 1) * (event.shift ? 5 : 3);
401
+ if (inPrimaryColumn || (primaryWidth > 0 && event.x <= primaryWidth + 1)) {
402
+ focus = "primary";
403
+ applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
404
+ if ((options.primary.mode ?? "scroll") === "cursor") {
405
+ const indexes = selectableIndexes(rows);
406
+ if (indexes.length === 0)
407
+ return;
408
+ const currentIndex = nearestSelectableIndex(rows, state.cursor);
409
+ const currentOrdinal = Math.max(0, indexes.indexOf(currentIndex));
410
+ const nextOrdinal = Math.max(0, Math.min(indexes.length - 1, currentOrdinal + lines));
411
+ state.cursor = indexes[nextOrdinal] ?? currentIndex;
412
+ const next = ensureCursorVisible({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
413
+ state.scrollOffset = next.scroll;
414
+ }
415
+ else {
416
+ state.scrollOffset = moveScrollOffset({ offset: state.scrollOffset, contentLength: rows.length, viewportHeight }, lines);
417
+ }
418
+ });
419
+ }
420
+ else {
421
+ focus = "detail";
422
+ markDetailManual();
423
+ detailState.scrollOffset = moveScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, lines);
424
+ }
425
+ requestRender();
426
+ return;
427
+ }
428
+ // Divider drag: press on the divider starts it, motion resizes, release ends.
429
+ if (onDivider && event.press && event.button === 0) {
430
+ dividerDragActive = true;
431
+ return;
432
+ }
433
+ if (event.motion && dividerDragActive) {
434
+ const usable = Math.max(1, totalWidth - 3);
435
+ const minFraction = Math.max(split.minFraction ?? 0, (split.minPrimaryWidth ?? 8) / usable);
436
+ const maxFraction = Math.min(split.maxFraction ?? 1, (usable - (split.minDetailWidth ?? 8)) / usable, split.maxPrimaryWidth !== undefined ? split.maxPrimaryWidth / usable : 1);
437
+ leftFraction = Math.max(Math.min(minFraction, maxFraction), Math.min(maxFraction, (event.x - 1) / usable));
438
+ requestRender();
439
+ return;
440
+ }
441
+ if (event.release && event.button === 0 && dividerDragActive) {
442
+ dividerDragActive = false;
443
+ return;
444
+ }
445
+ if (!event.press || event.button !== 0 || event.y > maxRow)
446
+ return;
447
+ // Title/border rows: click focuses the pane under the pointer.
448
+ if (event.y === 0 || event.y === bodyHeight + 1) {
449
+ if (primaryWidth > 0 && event.x <= primaryWidth + 1)
450
+ focus = "primary";
451
+ else
452
+ focus = "detail";
453
+ ensureFocusDetailWhenCollapsed();
454
+ requestRender();
455
+ return;
456
+ }
457
+ // Body rows.
458
+ if (event.y >= 1 && event.y <= bodyHeight) {
459
+ if (onDivider)
460
+ return;
461
+ if (inPrimaryColumn && !collapsed) {
462
+ focus = "primary";
463
+ if ((options.primary.mode ?? "scroll") === "cursor") {
464
+ const rowIndex = event.y - 1;
465
+ const absoluteIndex = primaryState.scrollOffset + rowIndex;
466
+ applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
467
+ if (absoluteIndex >= rows.length)
468
+ return;
469
+ state.cursor = nearestSelectableIndex(rows, absoluteIndex);
470
+ const next = ensureCursorVisible({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
471
+ state.scrollOffset = next.scroll;
472
+ });
473
+ const now = Date.now();
474
+ const isDoubleClick = lastPrimaryClick
475
+ && lastPrimaryClick.row === event.y
476
+ && lastPrimaryClick.col === event.x
477
+ && now - lastPrimaryClick.at < 450;
478
+ lastPrimaryClick = { row: event.y, col: event.x, at: now };
479
+ requestRender();
480
+ if (isDoubleClick)
481
+ componentRef?.handleInput("\r");
482
+ return;
483
+ }
484
+ requestRender();
485
+ return;
486
+ }
487
+ if (inDetailColumn) {
488
+ focus = "detail";
489
+ ensureFocusDetailWhenCollapsed();
490
+ requestRender();
491
+ return;
492
+ }
493
+ }
494
+ };
364
495
  const component = {
365
496
  dispose() {
366
497
  // no-op is acceptable
@@ -508,6 +639,11 @@ export function paneOverlay(options) {
508
639
  handleInput(data) {
509
640
  if (finished)
510
641
  return;
642
+ const mouseEvent = parseSgrMouseEvent(data);
643
+ if (mouseEvent) {
644
+ handleMouseEvent(mouseEvent);
645
+ return;
646
+ }
511
647
  const bodyHeight = computeBodyHeight(tui, options.height);
512
648
  if (matchesAny(data, closeKeys)) {
513
649
  finish(options.closeResult);
@@ -615,6 +751,7 @@ export function paneOverlay(options) {
615
751
  }
616
752
  },
617
753
  };
754
+ componentRef = component;
618
755
  return component;
619
756
  };
620
757
  }
@@ -4,6 +4,17 @@ export interface FullscreenLeaseLike {
4
4
  export interface FullscreenController {
5
5
  acquire(): FullscreenLeaseLike;
6
6
  }
7
+ /**
8
+ * Lazily-captured TUI reference used to detect the fullscreen TUI mode.
9
+ * Populated whenever pi invokes any wrapped UI factory (widget, footer,
10
+ * editor, custom component). Structural on purpose: the helper must not
11
+ * depend on pi-tui types directly.
12
+ */
13
+ export interface TuiModeCapture {
14
+ noteTui(tui: unknown): void;
15
+ isFullscreenTui(): boolean;
16
+ }
17
+ export declare function createTuiModeCapture(): TuiModeCapture;
7
18
  /**
8
19
  * Factory signature accepted by `ctx.ui.custom`. Typed structurally so the
9
20
  * helper does not depend on pi-tui types directly.
@@ -19,4 +30,4 @@ export interface UiClient {
19
30
  }
20
31
  export declare function createUiClient(fullscreen: FullscreenController, ctx: {
21
32
  ui: unknown;
22
- }): UiClient;
33
+ }, capture?: TuiModeCapture): UiClient;
@@ -1,4 +1,17 @@
1
- export function createUiClient(fullscreen, ctx) {
1
+ export function createTuiModeCapture() {
2
+ let mode;
3
+ return {
4
+ noteTui(tui) {
5
+ const value = tui?.mode;
6
+ if (typeof value === "string")
7
+ mode = value;
8
+ },
9
+ isFullscreenTui() {
10
+ return mode === "fullscreen";
11
+ },
12
+ };
13
+ }
14
+ export function createUiClient(fullscreen, ctx, capture = createTuiModeCapture()) {
2
15
  return {
3
16
  async fullscreen(factory) {
4
17
  const ui = ctx.ui;
@@ -7,7 +20,22 @@ export function createUiClient(fullscreen, ctx) {
7
20
  }
8
21
  const lease = fullscreen.acquire();
9
22
  try {
10
- return await ui.custom(factory);
23
+ const wrapped = (tui, theme, keybindings, done) => {
24
+ capture.noteTui(tui);
25
+ return factory(tui, theme, keybindings, done);
26
+ };
27
+ // In the fullscreen TUI a plain custom component mounts inside the
28
+ // bottom dock (the editor slot), where the dock layout shrinks and
29
+ // clips it while the transcript tail renders above it. Mount it as a
30
+ // full-screen overlay instead so the component owns the whole screen,
31
+ // matching the regular-TUI behavior it was designed for.
32
+ const options = capture.isFullscreenTui()
33
+ ? {
34
+ overlay: true,
35
+ overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" },
36
+ }
37
+ : undefined;
38
+ return await ui.custom(wrapped, options);
11
39
  }
12
40
  finally {
13
41
  lease.release();
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type TuiModeCapture } from "../ui/client.ts";
2
3
  import { type WidgetFactory, type WidgetPlacement } from "./protocol.ts";
3
4
  export interface WidgetSetOptions {
4
5
  order?: number;
@@ -22,6 +23,8 @@ export interface WidgetCoordinatorClient {
22
23
  interface WidgetCoordinatorClientOptions {
23
24
  ctx: ExtensionContext;
24
25
  clientId: string;
26
+ /** Optional shared TUI-mode capture fed by every widget factory invocation. */
27
+ tui?: TuiModeCapture;
25
28
  }
26
29
  export declare function connectWidgetCoordinator(pi: ExtensionAPI, opts: WidgetCoordinatorClientOptions): WidgetCoordinatorClient;
27
30
  export {};
@@ -1,5 +1,11 @@
1
1
  import { basePayload, EVENTS, PROTOCOL_VERSION } from "./protocol.js";
2
2
  let nextLeaseId = 1;
3
+ function withTuiCapture(factory, capture) {
4
+ return (tui, theme) => {
5
+ capture.noteTui(tui);
6
+ return factory(tui, theme);
7
+ };
8
+ }
3
9
  export function connectWidgetCoordinator(pi, opts) {
4
10
  const { clientId } = opts;
5
11
  const widgets = new Map();
@@ -78,13 +84,18 @@ export function connectWidgetCoordinator(pi, opts) {
78
84
  set(placement, key, factory, setOpts = {}) {
79
85
  if (disposed)
80
86
  return;
81
- const record = { placement, key, factory, order: setOpts.order ?? 0 };
87
+ const record = {
88
+ placement,
89
+ key,
90
+ factory: opts.tui ? withTuiCapture(factory, opts.tui) : factory,
91
+ order: setOpts.order ?? 0,
92
+ };
82
93
  widgets.set(widgetId(placement, key), record);
83
94
  if (coordinated) {
84
95
  emitRegister(record);
85
96
  }
86
97
  else if (leases.size === 0) {
87
- opts.ctx.ui.setWidget(key, factory, { placement });
98
+ opts.ctx.ui.setWidget(key, record.factory, { placement });
88
99
  }
89
100
  },
90
101
  remove(placement, key) {
@@ -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.5.2",
3
+ "version": "0.7.0",
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",