@pi-unipi/unipi 2.13.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -1
  3. package/package.json +23 -23
  4. package/packages/ask-user/package.json +3 -3
  5. package/packages/autocomplete/package.json +1 -1
  6. package/packages/autocomplete/src/constants.ts +2 -0
  7. package/packages/background-tasks/package.json +3 -3
  8. package/packages/btw/package.json +3 -3
  9. package/packages/compactor/package.json +4 -4
  10. package/packages/core/constants.ts +1 -0
  11. package/packages/core/package.json +1 -1
  12. package/packages/footer/package.json +4 -4
  13. package/packages/image/README.md +8 -1
  14. package/packages/image/package.json +2 -2
  15. package/packages/image/src/index.ts +37 -5
  16. package/packages/image/src/models.ts +24 -1
  17. package/packages/info-screen/index.ts +49 -10
  18. package/packages/info-screen/package.json +3 -3
  19. package/packages/info-screen/tui/info-overlay.ts +54 -22
  20. package/packages/input-shortcuts/package.json +3 -3
  21. package/packages/kanboard/package.json +3 -3
  22. package/packages/mcp/package.json +3 -3
  23. package/packages/memory/package.json +3 -3
  24. package/packages/milestone/package.json +3 -3
  25. package/packages/notify/package.json +3 -3
  26. package/packages/ralph/package.json +3 -3
  27. package/packages/subagents/package.json +3 -3
  28. package/packages/trajectory/package.json +1 -1
  29. package/packages/unipi/bundled.js +4371 -1553
  30. package/packages/updater/package.json +3 -3
  31. package/packages/utility/README.md +20 -0
  32. package/packages/utility/package.json +3 -3
  33. package/packages/utility/src/commands.ts +54 -0
  34. package/packages/utility/src/index.ts +15 -0
  35. package/packages/utility/src/skill-discovery.ts +178 -0
  36. package/packages/web-api/package.json +2 -2
  37. package/packages/workflow/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.14.1] — 2026-08-28
10
+
11
+ ### Added
12
+
13
+ - `utility`: **skill startup discovery toggle** (`/unipi:skills-settings`, default on) — controls whether Unipi's built-in skills are cataloged in the agent's system prompt at startup. When off (`unipi.skills.discovery: false` in pi settings.json), Unipi's bundled skills are removed from the `<available_skills>` section every turn while user-owned skills (global, project, settings-mounted, third-party packages) stay cataloged; `/skill:name` invocation keeps working since pi expands those commands by reading SKILL.md directly from disk. Filtering is anchor-based (agentskills.io tags) and applied consistently per turn, preserving provider prefix cache.
14
+ - `autocomplete`: registered `/unipi:skills-settings` in COMMAND_REGISTRY + COMMAND_DESCRIPTIONS so the command autosuggests.
15
+
16
+ ### Fixed
17
+
18
+ - `info-screen`: **boot splash no longer eats keystrokes or strands the session** — the auto-close boot dashboard was a capturing overlay during its 2s window: typing during boot was swallowed (worse, its vim-style keys interpreted command text as tab switches), the first-keypress-cancels-auto-close rule left it on screen forever as an unclosable input-eating zombie, and prompts appeared to never send (the agent actually responded behind the overlay). The splash is now non-capturing (typing always reaches the editor) and dismisses itself via a stack-safe `handle.hide()` that only fires while it is the topmost visible overlay entry — if the user opens any dialog during the splash window (e.g. `/unipi:skills-settings`), dismissal defers until it closes, so the overlay can no longer be orphaned with a pending interaction (previously the covered overlay vanished while its `ctx.ui.select` promise hung forever). Interactive (`bootMode: "on"`) dashboards are unchanged; splash footer now reads "auto-dismissing…" instead of keyboard hints that could never reach it.
19
+
20
+ ## [2.14.0] — 2026-08-29
21
+
22
+ ### Changed
23
+
24
+ - `image`: **`image_recognize` is now hidden while the session model has vision** — a model that accepts image input reads images natively through pi's own read tool, so the separate recognition round-trip only duplicated the ability and burned system-prompt context. The extension drops the tool from the active set on `session_start` and `model_select` when the session model declares image input (`input` includes `"image"`), and restores it automatically when a text-only model takes over mid-session. Models that do not declare input modalities are treated as non-vision and keep the tool, matching the package's existing convention. The info screen shows "Hidden (model has vision)" for the Recognize stat while suppressed.
25
+
9
26
  ## [2.13.0] — 2026-08-28
10
27
 
11
28
  ### Added
package/README.md CHANGED
@@ -110,7 +110,7 @@ Coexists triggers enhance behavior when packages are installed together. Workflo
110
110
  | `spawn_helper` / `get_helper_result` | subagents | Parallel agents |
111
111
  | `memory_store` / `memory_search` / `memory_delete` | memory | Memory CRUD |
112
112
  | `web_search` / `multi_web_content_read` / `web_llm_summarize` | web-api | Web research |
113
- | `image_generate` / `image_recognize` | image | Image generation and vision |
113
+ | `image_generate` / `image_recognize` | image | Image generation and vision (recognize auto-hides while the session model has vision) |
114
114
  | `notify_user` | notify | Push notifications |
115
115
  | `ask_user` | ask-user | User input |
116
116
  | `compact` / `session_recall` / `sandbox` | compactor | Context management |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "2.13.0",
3
+ "version": "2.14.1",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -82,28 +82,28 @@
82
82
  "typebox": "^1.1.38"
83
83
  },
84
84
  "dependencies": {
85
- "@pi-unipi/ask-user": "2.12.0",
86
- "@pi-unipi/background-tasks": "2.13.0",
87
- "@pi-unipi/btw": "2.12.0",
88
- "@pi-unipi/command-enchantment": "2.12.0",
89
- "@pi-unipi/compactor": "2.12.0",
90
- "@pi-unipi/core": "2.12.0",
91
- "@pi-unipi/footer": "2.13.0",
92
- "@pi-unipi/image": "2.12.0",
93
- "@pi-unipi/info-screen": "2.12.0",
94
- "@pi-unipi/input-shortcuts": "2.12.0",
95
- "@pi-unipi/kanboard": "2.12.0",
96
- "@pi-unipi/mcp": "2.12.0",
97
- "@pi-unipi/memory": "2.12.0",
98
- "@pi-unipi/milestone": "2.12.0",
99
- "@pi-unipi/notify": "2.12.0",
100
- "@pi-unipi/ralph": "2.12.0",
101
- "@pi-unipi/subagents": "2.12.0",
102
- "@pi-unipi/trajectory": "2.12.0",
103
- "@pi-unipi/updater": "2.12.0",
104
- "@pi-unipi/utility": "2.12.0",
105
- "@pi-unipi/web-api": "2.12.0",
106
- "@pi-unipi/workflow": "2.12.0"
85
+ "@pi-unipi/ask-user": "2.14.1",
86
+ "@pi-unipi/background-tasks": "2.14.1",
87
+ "@pi-unipi/btw": "2.14.1",
88
+ "@pi-unipi/command-enchantment": "2.14.1",
89
+ "@pi-unipi/compactor": "2.14.1",
90
+ "@pi-unipi/core": "2.14.1",
91
+ "@pi-unipi/footer": "2.14.1",
92
+ "@pi-unipi/image": "2.14.1",
93
+ "@pi-unipi/info-screen": "2.14.1",
94
+ "@pi-unipi/input-shortcuts": "2.14.1",
95
+ "@pi-unipi/kanboard": "2.14.1",
96
+ "@pi-unipi/mcp": "2.14.1",
97
+ "@pi-unipi/memory": "2.14.1",
98
+ "@pi-unipi/milestone": "2.14.1",
99
+ "@pi-unipi/notify": "2.14.1",
100
+ "@pi-unipi/ralph": "2.14.1",
101
+ "@pi-unipi/subagents": "2.14.1",
102
+ "@pi-unipi/trajectory": "2.14.1",
103
+ "@pi-unipi/updater": "2.14.1",
104
+ "@pi-unipi/utility": "2.14.1",
105
+ "@pi-unipi/web-api": "2.14.1",
106
+ "@pi-unipi/workflow": "2.14.1"
107
107
  },
108
108
  "devDependencies": {
109
109
  "@earendil-works/pi-agent-core": "^0.84.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "2.12.0",
4
- "description": "Structured user input tool for Pi coding agent single-select, multi-select, freeform",
3
+ "version": "2.14.1",
4
+ "description": "Structured user input tool for Pi coding agent \u2014 single-select, multi-select, freeform",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "license": "MIT",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@pi-unipi/core": "2.12.0"
43
+ "@pi-unipi/core": "2.14.1"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/command-enchantment",
3
- "version": "2.12.0",
3
+ "version": "2.14.1",
4
4
  "description": "Enhanced TUI autocomplete for /unipi:* commands — colored, sorted, and grouped by package",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -131,6 +131,7 @@ export const COMMAND_REGISTRY: Record<string, string> = {
131
131
  "unipi:badge-toggle": "utility",
132
132
  "unipi:badge-settings": "utility",
133
133
  "unipi:util-settings": "utility",
134
+ "unipi:skills-settings": "utility",
134
135
 
135
136
  // ask-user (1 command)
136
137
  "unipi:ask-user-settings": "ask-user",
@@ -270,6 +271,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
270
271
  "unipi:badge-toggle": "Configure badge settings (autoGen, badgeEnabled, agentTool)",
271
272
  "unipi:badge-settings": "Configure badge settings via TUI overlay",
272
273
  "unipi:util-settings": "Unified settings — badge + diff rendering config",
274
+ "unipi:skills-settings": "Toggle skill startup discovery (skills catalog in system prompt)",
273
275
  "unipi:kanboard": "Start the kanboard visualization server",
274
276
  "unipi:kanboard-doctor": "Diagnose and fix kanboard parser issues",
275
277
  "unipi:trajectory": "Open/reuse trajectory; add stop/off or toggle to close it",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi-unipi/background-tasks",
3
- "version": "2.13.0",
4
- "description": "Background tasks for UniPi durable shell jobs, delegated agents, attested Pi runs, and fixed-purpose Fusion workflows",
3
+ "version": "2.14.1",
4
+ "description": "Background tasks for UniPi \u2014 durable shell jobs, delegated agents, attested Pi runs, and fixed-purpose Fusion workflows",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "types": "src/index.ts",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@earendil-works/pi-ai": "^0.84.0",
13
- "@pi-unipi/core": "2.12.0",
13
+ "@pi-unipi/core": "2.14.1",
14
14
  "turndown": "^7.2.4"
15
15
  },
16
16
  "devDependencies": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi-unipi/btw",
3
- "version": "2.12.0",
4
- "description": "A pi extension for parallel side conversations with /unipi:btw part of the Unipi suite",
3
+ "version": "2.14.1",
4
+ "description": "A pi extension for parallel side conversations with /unipi:btw \u2014 part of the Unipi suite",
5
5
  "type": "module",
6
6
  "main": "extensions/btw.ts",
7
7
  "license": "MIT",
@@ -37,7 +37,7 @@
37
37
  "@earendil-works/pi-tui": "^0.84.0"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "2.12.0"
40
+ "@pi-unipi/core": "2.14.1"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi-unipi/compactor",
3
- "version": "2.12.0",
4
- "description": "Context engine for Pi zero-LLM compaction, session continuity, sandbox execution, and tool display optimization",
3
+ "version": "2.14.1",
4
+ "description": "Context engine for Pi \u2014 zero-LLM compaction, session continuity, sandbox execution, and tool display optimization",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "license": "MIT",
@@ -34,8 +34,8 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.12.0",
38
- "@pi-unipi/info-screen": "2.12.0",
37
+ "@pi-unipi/core": "2.14.1",
38
+ "@pi-unipi/info-screen": "2.14.1",
39
39
  "@earendil-works/pi-agent-core": "^0.84.0"
40
40
  },
41
41
  "peerDependencies": {
@@ -161,6 +161,7 @@ export const UTILITY_COMMANDS = {
161
161
  BADGE_SETTINGS: "badge-settings",
162
162
  UTIL_SETTINGS: "util-settings",
163
163
  PREFIX_CACHE: "prefix-cache",
164
+ SKILLS_SETTINGS: "skills-settings",
164
165
  } as const;
165
166
 
166
167
  /** Utility tool names */
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "2.12.0",
3
+ "version": "2.14.1",
4
4
  "description": "Shared utilities, event types, and constants for Unipi extension suite",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "2.13.0",
4
- "description": "Persistent status bar for Unipi subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
3
+ "version": "2.14.1",
4
+ "description": "Persistent status bar for Unipi \u2014 subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "license": "MIT",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.12.0",
36
- "@pi-unipi/background-tasks": "2.13.0"
35
+ "@pi-unipi/core": "2.14.1",
36
+ "@pi-unipi/background-tasks": "2.14.1"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -7,7 +7,7 @@ Image generation and image recognition tools for the agent.
7
7
  | Tool | Description |
8
8
  |------|-------------|
9
9
  | `image_generate` | Generate an image from a text prompt. Returned inline and saved to disk. |
10
- | `image_recognize` | Analyze an image with a vision model. Accepts a file path, `data:` URL, or base64. |
10
+ | `image_recognize` | Analyze an image with a vision model. Accepts a file path, `data:` URL, or base64. Automatically hidden while the session model itself has vision. |
11
11
 
12
12
  ## Commands
13
13
 
@@ -63,6 +63,13 @@ works. Supported: PNG, JPEG, GIF, WebP. Remote URLs are not fetched.
63
63
  Prefer file paths — inlining base64 into the conversation is far more
64
64
  expensive in tokens.
65
65
 
66
+ **Vision models don't get this tool.** When the session's current model already
67
+ accepts image input, `image_recognize` is dropped from the active tool set —
68
+ the model reads images natively through pi's own tools, so a separate
69
+ recognition round-trip through another model would only duplicate that ability
70
+ and burn context. Switch to a text-only model (via `/model`) and the tool comes
71
+ back automatically.
72
+
66
73
  ## Configuration
67
74
 
68
75
  `~/.unipi/config/image/config.json`:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/image",
3
- "version": "2.12.0",
3
+ "version": "2.14.1",
4
4
  "description": "Image generation and image recognition tools for the Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.12.0"
37
+ "@pi-unipi/core": "2.14.1"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-ai": "^0.84.0",
@@ -20,7 +20,13 @@ import {
20
20
 
21
21
  import { registerImageCommands } from "./commands.js";
22
22
  import { registerImageTools } from "./tools.js";
23
- import { listImageGenModels, listVisionModels, type ChatModelRegistry } from "./models.js";
23
+ import {
24
+ applyRecognizeGating,
25
+ isVisionModel,
26
+ listImageGenModels,
27
+ listVisionModels,
28
+ type ChatModelRegistry,
29
+ } from "./models.js";
24
30
  import { registerRegistryImageProviders } from "./register-providers.js";
25
31
  import { loadConfig } from "./settings.js";
26
32
 
@@ -37,10 +43,27 @@ function getInfoRegistry() {
37
43
  ).__unipi_info_registry;
38
44
  }
39
45
 
46
+ /**
47
+ * Hide image_recognize when the session model can natively see images, and
48
+ * restore it when a text-only model takes over. Returns whether the tool is
49
+ * provided after gating.
50
+ */
51
+ function applyVisionGating(pi: ExtensionAPI, model: unknown): boolean {
52
+ const active = pi.getActiveTools();
53
+ const next = applyRecognizeGating(active, model, IMAGE_TOOLS.RECOGNIZE);
54
+ if (next !== active) pi.setActiveTools(next);
55
+ return next.includes(IMAGE_TOOLS.RECOGNIZE);
56
+ }
57
+
40
58
  export default function (pi: ExtensionAPI) {
41
59
  registerImageTools(pi);
42
60
  registerImageCommands(pi);
43
61
 
62
+ pi.on("model_select", (event) => {
63
+ if (!loadConfig().recognize.enabled) return;
64
+ applyVisionGating(pi, event.model);
65
+ });
66
+
44
67
  pi.on("session_start", async (_event, ctx) => {
45
68
  const config = loadConfig();
46
69
 
@@ -51,9 +74,16 @@ export default function (pi: ExtensionAPI) {
51
74
  (ctx as unknown as { modelRegistry?: ChatModelRegistry }).modelRegistry,
52
75
  ).catch(() => undefined);
53
76
 
77
+ // A vision-capable session model reads images itself, so image_recognize
78
+ // would only duplicate that ability. Drop it from the active tool set;
79
+ // the model_select handler above restores it when a text-only model is
80
+ // chosen later in the same session.
81
+ const recognizeProvided =
82
+ config.recognize.enabled && applyVisionGating(pi, ctx.model);
83
+
54
84
  const tools: string[] = [];
55
85
  if (config.generate.enabled) tools.push(IMAGE_TOOLS.GENERATE);
56
- if (config.recognize.enabled) tools.push(IMAGE_TOOLS.RECOGNIZE);
86
+ if (recognizeProvided) tools.push(IMAGE_TOOLS.RECOGNIZE);
57
87
 
58
88
  emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
59
89
  name: MODULES.IMAGE,
@@ -92,9 +122,11 @@ export default function (pi: ExtensionAPI) {
92
122
  .modelRegistry;
93
123
  const vision = chatRegistry ? listVisionModels(chatRegistry) : [];
94
124
 
95
- const recognize = current.recognize.enabled
96
- ? current.recognize.model || "Session model"
97
- : "Disabled";
125
+ const recognize = !current.recognize.enabled
126
+ ? "Disabled"
127
+ : isVisionModel(ctx.model)
128
+ ? "Hidden (model has vision)"
129
+ : current.recognize.model || "Session model";
98
130
 
99
131
  return {
100
132
  generate: { value: generate },
@@ -383,7 +383,7 @@ export function listVisionModels(registry: ChatModelRegistry): VisionModel[] {
383
383
  return models.filter(isVisionModel);
384
384
  }
385
385
 
386
- function isVisionModel(model: unknown): model is VisionModel {
386
+ export function isVisionModel(model: unknown): model is VisionModel {
387
387
  if (model === null || typeof model !== "object") return false;
388
388
  const candidate = model as Partial<VisionModel>;
389
389
  if (typeof candidate.id !== "string" || typeof candidate.provider !== "string") {
@@ -392,6 +392,29 @@ function isVisionModel(model: unknown): model is VisionModel {
392
392
  return Array.isArray(candidate.input) && candidate.input.includes("image");
393
393
  }
394
394
 
395
+ /**
396
+ * Active tool names after hiding `recognizeTool` for a vision-capable model.
397
+ *
398
+ * A model that accepts image input can read images natively (pi's own read
399
+ * tool hands it the pixels), so a separate image_recognize round-trip through
400
+ * another model only duplicates the ability and burns system-prompt context.
401
+ * Text-only models get the tool back. Models that do not declare their input
402
+ * modalities are treated as non-vision, matching `isVisionModel`.
403
+ */
404
+ export function applyRecognizeGating(
405
+ active: string[],
406
+ model: unknown,
407
+ recognizeTool: string,
408
+ ): string[] {
409
+ const vision = isVisionModel(model);
410
+ const present = active.includes(recognizeTool);
411
+ // Already correct: hidden for a vision model, or provided for a text-only one.
412
+ if (vision !== present) return active;
413
+ return vision
414
+ ? active.filter((name) => name !== recognizeTool)
415
+ : [...active, recognizeTool];
416
+ }
417
+
395
418
  /**
396
419
  * Resolve a vision-model reference, restricted to image-capable models.
397
420
  *
@@ -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
  );
@@ -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