pi-extension-utils 0.7.7 → 0.7.9

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.
@@ -223,8 +223,19 @@ export function paneOverlay(options) {
223
223
  primaryState.cursor = selectableIndex;
224
224
  primaryState.scrollOffset = selectableIndex;
225
225
  };
226
+ let previousPrimaryKeys = [];
226
227
  const computeSelectionFromRows = (primaryRows, bodyHeight) => {
227
228
  applyInitialSelection(primaryRows);
229
+ // The cursor indexes the previous snapshot. Resolve its stable identity
230
+ // before interpreting that index against refreshed or reordered rows.
231
+ if (options.primary.mode === "cursor" && options.primary.selectionKey) {
232
+ const keys = primaryRows.map((row, index) => isSeparatorRow(row) ? undefined : selectionKeyFor(row, index));
233
+ const previousKey = previousPrimaryKeys[primaryState.cursor];
234
+ const nextIndex = previousKey === undefined ? -1 : keys.indexOf(previousKey);
235
+ if (nextIndex >= 0)
236
+ primaryState.cursor = nextIndex;
237
+ previousPrimaryKeys = keys;
238
+ }
228
239
  const primaryMode = options.primary.mode ?? "scroll";
229
240
  let selectedIndex = 0;
230
241
  if (primaryMode === "cursor") {
@@ -256,17 +256,33 @@ function padToWidth(line, width) {
256
256
  }
257
257
  const SYSTEM_REMINDER_MIN_BODY_WIDTH = 76;
258
258
  const SYSTEM_REMINDER_BODY_WIDTH_RATIO = 0.9;
259
+ /**
260
+ * Bordered transcript box for one reminder message.
261
+ *
262
+ * Content and details are fixed for the component's lifetime, so the laid-out lines depend only on
263
+ * width and theme colors. Lines are cached for the last width because the TUI renders every
264
+ * transcript component on each frame and large reminders are expensive to re-wrap. `invalidate()`
265
+ * drops the cache so theme changes take effect on the next render.
266
+ */
259
267
  class SystemReminderComponent {
260
268
  content;
261
269
  details;
262
270
  theme;
271
+ cache;
263
272
  constructor(content, details, theme) {
264
273
  this.content = content;
265
274
  this.details = details;
266
275
  this.theme = theme;
267
276
  }
268
- invalidate() { }
277
+ invalidate() {
278
+ this.cache = undefined;
279
+ }
269
280
  render(width) {
281
+ if (this.cache?.width !== width)
282
+ this.cache = { width, lines: this.layout(width) };
283
+ return this.cache.lines;
284
+ }
285
+ layout(width) {
270
286
  if (width < 3)
271
287
  return [truncateToWidth("System Reminder", width)];
272
288
  const availableBodyWidth = width - 2;
@@ -70,11 +70,14 @@ export function connectWidgetCoordinator(pi, opts) {
70
70
  mount.disposed = false;
71
71
  mount.tui?.requestRender?.();
72
72
  }
73
+ function fallbackKey(record) {
74
+ return `pi-extension-utils-fallback:${JSON.stringify([clientId, record.placement, record.key])}`;
75
+ }
73
76
  function clearFallback(record) {
74
- opts.ctx.ui.setWidget(record.key, undefined, { placement: record.placement });
77
+ opts.ctx.ui.setWidget(fallbackKey(record), undefined, { placement: record.placement });
75
78
  }
76
79
  function restoreFallback(record) {
77
- opts.ctx.ui.setWidget(record.key, record.fallbackFactory, { placement: record.placement });
80
+ opts.ctx.ui.setWidget(fallbackKey(record), record.fallbackFactory, { placement: record.placement });
78
81
  }
79
82
  function attach() {
80
83
  if (disposed || coordinated)
@@ -114,7 +114,7 @@ function createHostComponent(tui, theme, records, hidden) {
114
114
  // whole agent: render failures drop that widget until its client
115
115
  // re-registers. Records are replaced wholesale on every re-registration
116
116
  // and remount, so a dropped component cannot linger past the handshake.
117
- const healthy = [];
117
+ let healthy = [];
118
118
  if (!hidden) {
119
119
  for (const record of records) {
120
120
  try {
@@ -128,19 +128,26 @@ function createHostComponent(tui, theme, records, hidden) {
128
128
  return {
129
129
  render(width) {
130
130
  const lines = [];
131
+ const failed = new Set();
131
132
  for (const component of [...healthy]) {
133
+ if (failed.has(component))
134
+ continue;
132
135
  try {
133
136
  lines.push(...component.render(width));
134
137
  }
135
138
  catch (error) {
136
- const failedIndex = healthy.indexOf(component);
137
- if (failedIndex >= 0)
138
- healthy.splice(failedIndex, 1);
139
+ failed.add(component);
140
+ healthy = healthy.filter((child) => child !== component);
141
+ disposeChild(component);
139
142
  console.warn(`pi-extension-utils: widget render threw, dropping it until re-registration`, error instanceof Error ? error.message : error);
140
143
  }
141
144
  }
142
145
  return lines;
143
146
  },
147
+ dispose() {
148
+ for (const component of new Set(healthy.splice(0)))
149
+ disposeChild(component);
150
+ },
144
151
  invalidate() {
145
152
  for (const component of healthy) {
146
153
  try {
@@ -153,6 +160,14 @@ function createHostComponent(tui, theme, records, hidden) {
153
160
  },
154
161
  };
155
162
  }
163
+ function disposeChild(component) {
164
+ try {
165
+ component.dispose?.();
166
+ }
167
+ catch (error) {
168
+ console.warn("pi-extension-utils: widget dispose threw", error instanceof Error ? error.message : error);
169
+ }
170
+ }
156
171
  function hostKey(placement) {
157
172
  return `${HOST_WIDGET_PREFIX}-${placement}`;
158
173
  }
package/docs/widgets.md CHANGED
@@ -48,9 +48,15 @@ When any client holds a fullscreen lease, coordinated widgets are hidden and res
48
48
  await client.ui.fullscreen((tui, theme, keybindings, done) => new MyComponent(tui, theme, done));
49
49
  ```
50
50
 
51
+ ## Component ownership
52
+
53
+ A widget factory transfers ownership of its returned component to the mount. Pi disposes the old mount before calling a replacement factory. Return a fresh component on each mount; do not reuse a disposed component or share one across placements or independent mounts.
54
+
55
+ The host remounts a placement when its registrations or fullscreen visibility change. It disposes each owned child once on replacement, removal, hiding, or Pi UI teardown. A child whose render throws is detached and disposed immediately; a throwing disposer does not prevent sibling cleanup. If a factory throws before returning a component, it must clean up any resources it already created.
56
+
51
57
  ## Fallback
52
58
 
53
- If the host is not ready yet, each logical widget registers one stable proxy through the extension's own `ctx.ui.setWidget`. Later `widgets.set` calls replace the proxy's delegated component and request a redraw without re-registering the raw widget, preserving cross-extension insertion order on Pi versions that delete before setting. Set and fullscreen-acquire activity also retry the hello handshake without a polling timer. When the host announces readiness, the client clears the fallback widget and re-registers through the coordinator.
59
+ If the host is not ready yet, each logical widget registers one stable proxy through the extension's own `ctx.ui.setWidget`. Its internal raw key includes the client ID, placement, and logical key, so separate clients and placements can use the same logical key. Later `widgets.set` calls replace the proxy's delegated component and request a redraw without re-registering the raw widget, preserving cross-extension insertion order on Pi versions that delete before setting. Set and fullscreen-acquire activity also retry the hello handshake without a polling timer. When the host announces readiness, the client clears the fallback widget and re-registers through the coordinator.
54
60
 
55
61
  ## Example
56
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extension-utils",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
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",
@@ -43,10 +43,10 @@
43
43
  "@earendil-works/pi-tui": "*"
44
44
  },
45
45
  "devDependencies": {
46
- "@earendil-works/pi-agent-core": "^0.75.4",
47
- "@earendil-works/pi-ai": "^0.75.4",
48
- "@earendil-works/pi-coding-agent": "^0.75.4",
49
- "@earendil-works/pi-tui": "^0.79.3",
46
+ "@earendil-works/pi-agent-core": "0.87.1",
47
+ "@earendil-works/pi-ai": "0.87.1",
48
+ "@earendil-works/pi-coding-agent": "0.87.1",
49
+ "@earendil-works/pi-tui": "0.87.1",
50
50
  "@types/node": "^24.0.0",
51
51
  "typescript": "^6.0.3"
52
52
  },