@pi-unipi/info-screen 2.12.0 → 2.14.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/index.ts CHANGED
@@ -113,10 +113,19 @@ export default function (pi: ExtensionAPI) {
113
113
  */
114
114
  function showOverlay(ctx: ExtensionContext, autoCloseMs?: number): void {
115
115
  let overlay: InfoOverlay;
116
+ // Splash mode (auto-close): the overlay must NEVER take keyboard focus —
117
+ // it lives exactly during the window where the user starts typing their
118
+ // first prompt. A capturing overlay here eats those keystrokes (worse: its
119
+ // vim-style keys "work", so command text vanishes into tab switches), and
120
+ // its first-keypress-cancels-auto-close rule strands it on screen forever
121
+ // as an unclosable, input-eating zombie. Non-capturing + stack-safe
122
+ // self-dismiss (see startBootTimer) makes it pure eye-candy.
123
+ const splashMode = autoCloseMs !== undefined && autoCloseMs > 0;
116
124
  ctx.ui.custom<void>(
117
125
  (tui, theme, _keybindings, done) => {
118
126
  overlay = new InfoOverlay();
119
127
  overlay.setTheme(theme);
128
+ overlay.interactive = !splashMode;
120
129
  overlayVisible = true;
121
130
  overlay.onClose = () => {
122
131
  overlayVisible = false;
@@ -127,14 +136,37 @@ export default function (pi: ExtensionAPI) {
127
136
  const component = {
128
137
  render: (w: number) => overlay.render(w),
129
138
  invalidate: () => overlay.invalidate(),
139
+ // In splash mode the overlay is non-capturing and never receives
140
+ // input; keep handleInput wired for interactive ("on") mode.
130
141
  handleInput: (data: string) => {
131
142
  overlay.handleInput(data);
132
143
  tui.requestRender();
133
144
  },
134
145
  };
135
- // Boot dashboard dismisses itself; any keypress cancels the timer.
146
+ // Splash self-dismiss may only run while this overlay is the topmost
147
+ // VISIBLE entry of the TUI overlay stack. If the user opens anything
148
+ // during the splash window (a /unipi:… settings dialog, the updater's
149
+ // update prompt, …), dismissal must wait — removing a covered entry
150
+ // breaks the covering overlay (pi retargets focus and orphans its
151
+ // pending interaction, e.g. a hung ctx.ui.select promise). Re-check
152
+ // on every timer tick; once the stack clears we dismiss as usual.
153
+ const isTopmostVisible = (): boolean => {
154
+ try {
155
+ const stack = (tui as unknown as { overlayStack?: Array<{ component?: unknown; hidden?: boolean }> }).overlayStack;
156
+ if (!stack || stack.length === 0) return true;
157
+ for (let i = stack.length - 1; i >= 0; i--) {
158
+ const entry = stack[i];
159
+ if (entry?.hidden) continue;
160
+ return entry?.component === component;
161
+ }
162
+ return true;
163
+ } catch {
164
+ return true; // Stack unreadable — assume topmost (legacy behavior).
165
+ }
166
+ };
167
+ // Boot dashboard dismisses itself (splash mode).
136
168
  if (autoCloseMs && autoCloseMs > 0) {
137
- overlay.startBootTimer(autoCloseMs);
169
+ overlay.startBootTimer(autoCloseMs, isTopmostVisible);
138
170
  }
139
171
  return component;
140
172
  },
@@ -145,17 +177,24 @@ export default function (pi: ExtensionAPI) {
145
177
  minWidth: 60,
146
178
  anchor: "center" as const,
147
179
  margin: 2,
180
+ nonCapturing: splashMode,
148
181
  },
149
- // `done()` (the extension UI's close callback) pops the *topmost* overlay
150
- // in the TUI stack, not this one specifically. When another overlay (e.g.
151
- // the updater's "Update Available" prompt) is stacked on top, the boot
152
- // auto-close timer must not fire `done()` that would pop the covering
153
- // overlay and strand this dashboard with a spent one-shot close the user
154
- // can no longer dismiss. `isTopmostOverlay` lets the boot timer defer
155
- // until we are the focused (topmost) entry; the user can still press
156
- // q/Esc to close once the covering overlay is gone.
182
+ // In splash mode the timer uses `selfHide` (handle.hide() splices this
183
+ // entry out by identity) guarded by the topmost-visible check above
184
+ // never `done()`, which pops whatever is TOPMOST. `isTopmostOverlay`
185
+ // remains as the focused-done() fallback for interactive ("on") mode,
186
+ // where the user drives the dashboard directly: it is topmost while
187
+ // being driven, so the q/Esc done() path is correct there.
157
188
  onHandle: (handle) => {
158
189
  overlay.isTopmostOverlay = () => handle.isFocused();
190
+ overlay.selfHide = () => {
191
+ overlayVisible = false;
192
+ if (typeof handle.hide === "function") {
193
+ handle.hide();
194
+ } else if (typeof (handle as { setHidden?: (v: boolean) => void }).setHidden === "function") {
195
+ (handle as { setHidden: (v: boolean) => void }).setHidden(true);
196
+ }
197
+ };
159
198
  },
160
199
  }
161
200
  );
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "2.12.0",
4
- "description": "Dashboard and module registry for Unipi configurable info overlay with tabbed groups",
3
+ "version": "2.14.1",
4
+ "description": "Dashboard and module registry for Unipi \u2014 configurable info overlay with tabbed groups",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.12.0"
36
+ "@pi-unipi/core": "2.14.1"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -69,6 +69,21 @@ export class InfoOverlay implements Component {
69
69
  * `startBootTimer` for why.
70
70
  */
71
71
  isTopmostOverlay?: () => boolean;
72
+ /**
73
+ * Stack-safe self-removal via the overlay handle (`handle.hide()` splices
74
+ * this entry out of the TUI stack by identity, unlike `done()` which pops
75
+ * whatever is TOPMOST). Set from `onHandle` in index.ts. When available,
76
+ * the boot auto-close timer prefers this over `done()` so a splash that
77
+ * lingers while another overlay opens can never dismiss that overlay
78
+ * instead of itself.
79
+ */
80
+ selfHide?: () => void;
81
+ /**
82
+ * False when running as a non-capturing boot splash (auto-close): the
83
+ * overlay never receives keyboard input, so interactive hints like
84
+ * "q/Esc close" would be misleading and are replaced accordingly.
85
+ */
86
+ interactive = true;
72
87
 
73
88
  private overlay = new OverlayTheme();
74
89
 
@@ -232,31 +247,46 @@ export class InfoOverlay implements Component {
232
247
  }
233
248
 
234
249
  /**
235
- * Auto-close the overlay after `ms`, unless the user interacts first.
250
+ * Auto-close the overlay after `ms`.
236
251
  *
237
- * Used when the overlay is shown on boot: the dashboard is informational, so
238
- * it should get out of the way on its own rather than requiring a keypress.
252
+ * Used when the overlay is shown as a boot splash: the dashboard is
253
+ * informational, so it should get out of the way on its own rather than
254
+ * requiring a keypress.
239
255
  *
240
- * The close callback (`onClose` → `done`) pops the *topmost* overlay in the
241
- * TUI stack, not this one specifically. When another overlay (e.g. the
242
- * updater's "Update Available" prompt) is stacked on top, firing `done()`
243
- * here would remove the covering overlay and strand this dashboard with a
244
- * spent one-shot close the user can no longer trigger — leaving the starting
245
- * screen stuck. So we defer the auto-close until we are actually the
246
- * focused/topmost overlay; the user can still press q/Esc to dismiss it once
247
- * the covering overlay is gone.
256
+ * Dismissal uses `selfHide` (handle.hide() removes THIS entry from the
257
+ * TUI overlay stack by identity) and only fires while `isTopmostVisible`
258
+ * confirms nothing is stacked above: dismissing a covered overlay breaks
259
+ * the covering one (pi retargets focus and orphans its pending
260
+ * interaction, e.g. a ctx.ui.select promise that never resolves while its
261
+ * overlay vanishes). If covered, the timer re-arms and retries.
262
+ *
263
+ * If `selfHide` is unavailable (older host), falls back to the guarded
264
+ * `onClose` (`done()`) path, which requires focus (topmost) for the same
265
+ * reason.
248
266
  */
249
- startBootTimer(ms: number): void {
267
+ startBootTimer(ms: number, isTopmostVisible?: () => boolean): void {
250
268
  this.cancelBootTimer();
251
269
  if (!Number.isFinite(ms) || ms <= 0) return;
252
270
  const arm = (): void => {
253
271
  this.bootTimer = setTimeout(() => {
254
272
  this.bootTimer = null;
255
273
  if (this._destroyed) return;
274
+ if (this.selfHide) {
275
+ if (isTopmostVisible && !isTopmostVisible()) {
276
+ // Something is stacked on top of us — dismissing now would break
277
+ // it (orphaned select promise, focus retarget). Retry shortly;
278
+ // once the stack clears we dismiss as usual.
279
+ arm();
280
+ return;
281
+ }
282
+ this.selfHide();
283
+ this.destroy();
284
+ return;
285
+ }
256
286
  if (this.isTopmostOverlay && !this.isTopmostOverlay()) {
257
- // Something is stacked on top of us — closing now would pop it
258
- // instead of this dashboard. Retry shortly; once the covering
259
- // overlay closes we'll be topmost and can auto-close safely.
287
+ // Fallback (no selfHide available): closing now would pop the
288
+ // covering overlay instead of this dashboard. Retry shortly; once
289
+ // we are topmost the close is safe.
260
290
  arm();
261
291
  return;
262
292
  }
@@ -346,7 +376,7 @@ export class InfoOverlay implements Component {
346
376
  lines.push(this.overlay.frameLine(this.overlay.fg("dim", "Modules will register groups on startup."), innerWidth));
347
377
  for (let i = 0; i < 4; i++) lines.push(this.overlay.frameLine("", innerWidth));
348
378
  lines.push(this.overlay.ruleLine(innerWidth));
349
- lines.push(this.overlay.frameLine(this.overlay.fg("dim", "q/Esc close · r refresh"), innerWidth));
379
+ lines.push(this.overlay.frameLine(this.overlay.fg("dim", this.interactive ? "q/Esc close · r refresh" : "auto-dismissing…"), innerWidth));
350
380
  lines.push(this.overlay.borderLine(innerWidth, "bottom"));
351
381
  return lines;
352
382
  }
@@ -529,12 +559,14 @@ export class InfoOverlay implements Component {
529
559
  const lastUp = infoRegistry.getLastUpdated(group?.id ?? "");
530
560
  const age = lastUp > 0 ? humanizeAge(Date.now() - lastUp) : "loading…";
531
561
 
532
- const hints = [
533
- `${this.overlay.fg("accent", "←/→")} tabs`,
534
- `${this.overlay.fg("success", "↑/↓")} scroll`,
535
- `${this.overlay.fg("warning", "r")} refresh`,
536
- `${this.overlay.fg("error", "q/Esc")} close`,
537
- ];
562
+ const hints = this.interactive
563
+ ? [
564
+ `${this.overlay.fg("accent", "←/→")} tabs`,
565
+ `${this.overlay.fg("success", "↑/↓")} scroll`,
566
+ `${this.overlay.fg("warning", "r")} refresh`,
567
+ `${this.overlay.fg("error", "q/Esc")} close`,
568
+ ]
569
+ : [`${this.overlay.fg("dim", "auto-dismissing…")}`];
538
570
 
539
571
  const hintStr = hints.join(` ${this.overlay.fg("borderMuted", "•")} `);
540
572