@pi-unipi/unipi 3.0.0-alpha.41 → 3.0.0-alpha.42

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 (39) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +23 -23
  3. package/packages/app-bridge/package.json +2 -2
  4. package/packages/app-bridge/src/bridge.ts +9 -2
  5. package/packages/app-bridge/src/snapshot.ts +26 -0
  6. package/packages/app-bridge/src/wire.ts +5 -2
  7. package/packages/ask-user/package.json +2 -2
  8. package/packages/autocomplete/package.json +1 -1
  9. package/packages/background-tasks/package.json +2 -2
  10. package/packages/btw/package.json +2 -2
  11. package/packages/compactor/package.json +3 -3
  12. package/packages/core/index.ts +1 -0
  13. package/packages/core/package.json +1 -1
  14. package/packages/core/src/skill-conditions.ts +68 -0
  15. package/packages/core/src/work/tray.ts +70 -9
  16. package/packages/dream/package.json +2 -2
  17. package/packages/footer/package.json +3 -3
  18. package/packages/footer/src/index.ts +1 -1
  19. package/packages/fusion/package.json +4 -4
  20. package/packages/info-screen/package.json +2 -2
  21. package/packages/input-shortcuts/package.json +2 -2
  22. package/packages/kanboard/package.json +7 -7
  23. package/packages/long-horizon/package.json +2 -2
  24. package/packages/mcp/package.json +2 -2
  25. package/packages/memory/package.json +3 -3
  26. package/packages/notify/package.json +2 -2
  27. package/packages/skill-registry/index.ts +16 -4
  28. package/packages/skill-registry/package.json +2 -2
  29. package/packages/subagents/package.json +3 -3
  30. package/packages/subagents/src/index.ts +1 -1
  31. package/packages/unipi/bundled.js +743 -509
  32. package/packages/updater/package.json +2 -2
  33. package/packages/utility/package.json +2 -2
  34. package/packages/utility/skills/browser/SKILL.md +73 -0
  35. package/packages/utility/src/browser/state.ts +218 -0
  36. package/packages/utility/src/index.ts +24 -0
  37. package/packages/watchdog/package.json +3 -3
  38. package/packages/web-api/package.json +2 -2
  39. package/packages/workflow/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.0.0-alpha.42] — 2026-10-16
10
+
11
+ ### Added
12
+ - `skill:browser`: the agent uses the PC's browser, the one you see in the UniPi app, with `agent-browser`. While the browser is on, UniPi points agent-browser at it (`AGENT_BROWSER_CDP`), so `agent-browser open <url>` just works, with no start or connect step. The browser is CloakBrowser, a stealthy Chromium shown only in the app; the agent's clicks and typing look human, and you can take over. Needs UniPi host 0.5.0. (UNI-295, UNI-303)
13
+ - The agent sees `skill:browser` and a one-line note only while the PC's browser is on. Turning it off hides both from the next prompt, and `/skill:browser` explains how to turn it on. Any skill can now be shown only when a condition holds (`registerSkillCondition`). (UNI-300)
14
+ - The work tray has a hint again: while it has items, one dim line under the input reads, for example, "Background tasks 1 running · Subagents 2 · ↓ open". (UNI-291)
15
+ - App: "Start from here" on one of your messages puts that message, with its images, back in the app's input, like `/tree` in the terminal. (UNI-290)
16
+
9
17
  ## [3.0.0-alpha.41] — 2026-10-13
10
18
 
11
19
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -80,28 +80,28 @@
80
80
  "typebox": "^1.1.38"
81
81
  },
82
82
  "dependencies": {
83
- "@pi-unipi/app-bridge": "3.0.0-alpha.41",
84
- "@pi-unipi/ask-user": "3.0.0-alpha.41",
85
- "@pi-unipi/background-tasks": "3.0.0-alpha.41",
86
- "@pi-unipi/btw": "3.0.0-alpha.41",
87
- "@pi-unipi/command-enchantment": "3.0.0-alpha.41",
88
- "@pi-unipi/compactor": "3.0.0-alpha.41",
89
- "@pi-unipi/core": "3.0.0-alpha.41",
90
- "@pi-unipi/dream": "3.0.0-alpha.41",
91
- "@pi-unipi/footer": "3.0.0-alpha.41",
92
- "@pi-unipi/fusion": "3.0.0-alpha.41",
93
- "@pi-unipi/info-screen": "3.0.0-alpha.41",
94
- "@pi-unipi/input-shortcuts": "3.0.0-alpha.41",
95
- "@pi-unipi/kanboard": "3.0.0-alpha.41",
96
- "@pi-unipi/mcp": "3.0.0-alpha.41",
97
- "@pi-unipi/memory": "3.0.0-alpha.41",
98
- "@pi-unipi/notify": "3.0.0-alpha.41",
99
- "@pi-unipi/skill-registry": "3.0.0-alpha.41",
100
- "@pi-unipi/subagents": "3.0.0-alpha.41",
101
- "@pi-unipi/updater": "3.0.0-alpha.41",
102
- "@pi-unipi/utility": "3.0.0-alpha.41",
103
- "@pi-unipi/web-api": "3.0.0-alpha.41",
104
- "@pi-unipi/workflow": "3.0.0-alpha.41",
83
+ "@pi-unipi/app-bridge": "3.0.0-alpha.42",
84
+ "@pi-unipi/ask-user": "3.0.0-alpha.42",
85
+ "@pi-unipi/background-tasks": "3.0.0-alpha.42",
86
+ "@pi-unipi/btw": "3.0.0-alpha.42",
87
+ "@pi-unipi/command-enchantment": "3.0.0-alpha.42",
88
+ "@pi-unipi/compactor": "3.0.0-alpha.42",
89
+ "@pi-unipi/core": "3.0.0-alpha.42",
90
+ "@pi-unipi/dream": "3.0.0-alpha.42",
91
+ "@pi-unipi/footer": "3.0.0-alpha.42",
92
+ "@pi-unipi/fusion": "3.0.0-alpha.42",
93
+ "@pi-unipi/info-screen": "3.0.0-alpha.42",
94
+ "@pi-unipi/input-shortcuts": "3.0.0-alpha.42",
95
+ "@pi-unipi/kanboard": "3.0.0-alpha.42",
96
+ "@pi-unipi/mcp": "3.0.0-alpha.42",
97
+ "@pi-unipi/memory": "3.0.0-alpha.42",
98
+ "@pi-unipi/notify": "3.0.0-alpha.42",
99
+ "@pi-unipi/skill-registry": "3.0.0-alpha.42",
100
+ "@pi-unipi/subagents": "3.0.0-alpha.42",
101
+ "@pi-unipi/updater": "3.0.0-alpha.42",
102
+ "@pi-unipi/utility": "3.0.0-alpha.42",
103
+ "@pi-unipi/web-api": "3.0.0-alpha.42",
104
+ "@pi-unipi/workflow": "3.0.0-alpha.42",
105
105
  "@resvg/resvg-wasm": "^2.6.2",
106
106
  "beautiful-mermaid": "^1.1.3"
107
107
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/app-bridge",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "Live bridge between a pi TUI session and the UniPi phone app — streaming chat, prompts, steering and dialogs from the phone",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@pi-unipi/core": "3.0.0-alpha.41"
34
+ "@pi-unipi/core": "3.0.0-alpha.42"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -14,7 +14,7 @@ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync
14
14
  import { homedir } from "node:os";
15
15
  import { join, relative, resolve as resolvePath } from "node:path";
16
16
  import { DialogHub, wrapUi } from "./dialogs.js";
17
- import { ENTRIES_BUDGET, HELLO_ENTRIES_BUDGET, LINE_BUDGET, fitLine, historyPage, jsonBytes, phoneSafe, snapshotEntries, clipText, editedAway, wantedEntry } from "./snapshot.js";
17
+ import { ENTRIES_BUDGET, HELLO_ENTRIES_BUDGET, LINE_BUDGET, fitLine, historyPage, jsonBytes, phoneSafe, snapshotEntries, clipText, editedAway, wantedEntry, treeGoRestore, type TreeGoRestore } from "./snapshot.js";
18
18
  import { searchSession } from "./search.js";
19
19
  import {
20
20
  BRIDGE_PROTOCOL,
@@ -1510,11 +1510,18 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1510
1510
  case "tree_go": {
1511
1511
  if (!(await ensureIdle(msg.force))) return write(sock, { t: "error", message: "pi is busy. Stop the current run first.", code: "busy", ref: msg.ref });
1512
1512
  try {
1513
+ let restore: TreeGoRestore | undefined;
1513
1514
  await runCommandOp(async (cctx) => {
1515
+ // Read before navigating: the target is a user message → pi moves
1516
+ // the leaf to just before it, and (like /tree) its text goes back
1517
+ // to the composer (UNI-290).
1518
+ const target = cctx.sessionManager.getEntry?.(msg.id);
1514
1519
  const result = await cctx.navigateTree(msg.id, { summarize: msg.summarize });
1515
1520
  if (result.cancelled) throw new Error("Cancelled");
1521
+ const leaf = cctx.sessionManager.getLeafId?.() ?? null;
1522
+ restore = treeGoRestore(target, (result as { editorText?: unknown }).editorText, leaf);
1516
1523
  });
1517
- ack();
1524
+ write(sock, { t: "ack", ref: msg.ref, ...(restore ?? {}) });
1518
1525
  } catch (error) {
1519
1526
  fail(error instanceof Error ? error.message : String(error));
1520
1527
  }
@@ -273,3 +273,29 @@ export function historyPage(branch: readonly unknown[], before: string, budget =
273
273
  }
274
274
  return { entries: out.reverse(), more };
275
275
  }
276
+
277
+ /** UNI-290: what goes back into the app's composer after `tree_go` to a user
278
+ * message — its text (pi's own `editorText` when the runtime passes it on,
279
+ * else read off the entry, joined the same way pi does) and its images as
280
+ * the usual phone-safe refs (inline thumbnail or a `mediaRef`). */
281
+ export type TreeGoRestore = { restore: { text: string; images?: Array<{ mime: string; data?: string; mediaRef?: string; bytes?: number }> } };
282
+
283
+ /** The ack extras for a `tree_go` that landed: only when `target` is a user
284
+ * message and pi really moved the leaf off it (to its parent) — a no-op
285
+ * navigation (already there) restores nothing. */
286
+ export function treeGoRestore(target: unknown, editorText: unknown, leafAfter: string | null): TreeGoRestore | undefined {
287
+ const e = target as { id?: string; type?: string; message?: { role?: string; content?: unknown } } | null | undefined;
288
+ if (!e || e.type !== "message" || e.message?.role !== "user") return undefined;
289
+ if (leafAfter !== null && leafAfter === e.id) return undefined;
290
+ const content = e.message.content;
291
+ const parts = Array.isArray(content) ? (content as Array<{ type?: string; text?: unknown }>) : [];
292
+ const fromEntry = typeof content === "string" ? content : parts.filter((p) => p?.type === "text" && typeof p.text === "string").map((p) => p.text as string).join("");
293
+ const text = typeof editorText === "string" ? editorText : fromEntry;
294
+ const images = parts
295
+ .filter((p) => p?.type === "image")
296
+ .map((p) => phoneSafe(p) as { mime?: string; data?: string; mediaRef?: string; bytes?: number } | null)
297
+ .filter((p): p is { mime?: string; data?: string; mediaRef?: string; bytes?: number } => !!p && (typeof p.data === "string" || typeof p.mediaRef === "string"))
298
+ .map((p) => ({ mime: p.mime ?? "image/*", ...(p.data ? { data: p.data } : {}), ...(p.mediaRef ? { mediaRef: p.mediaRef } : {}), ...(typeof p.bytes === "number" ? { bytes: p.bytes } : {}) }));
299
+ if (!text && !images.length) return undefined;
300
+ return { restore: { text: clipText(text, TEXT_CLIP), ...(images.length ? { images } : {}) } };
301
+ }
@@ -275,8 +275,11 @@ export type OutMsg =
275
275
  | { t: "btw_list"; pages: BtwListPage[]; complete?: true; ref?: string }
276
276
  /** `code: "busy"`: new/resume/fork/tree_go while pi is running — the phone may retry with `force:true`. */
277
277
  | { t: "error"; message: string; ref?: string; code?: "busy" }
278
- /** `as: "command"`: the prompt ran an extension command (no chat message follows). */
279
- | { t: "ack"; ref?: string; as?: "command" }
278
+ /** `as: "command"`: the prompt ran an extension command (no chat message follows).
279
+ * `restore` (UNI-290, `tree_go` only): the target was a user message, so pi
280
+ * moved the leaf to just before it — its text (and images, phone-safe refs)
281
+ * go back into the phone's composer, like /tree puts it in the TUI editor. */
282
+ | { t: "ack"; ref?: string; as?: "command"; restore?: { text: string; images?: Array<{ mime: string; data?: string; mediaRef?: string; bytes?: number }> } }
280
283
  /** The session is being replaced (new/resume/fork/reload): reconnect to get the new session's hello. */
281
284
  | { t: "reconnect"; reason: string }
282
285
  /** A page of older entries (oldest first) ending right before `before`. */
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "Structured user input tool for Pi coding agent — single-select, multi-select, freeform",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -42,7 +42,7 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@pi-unipi/core": "3.0.0-alpha.41"
45
+ "@pi-unipi/core": "3.0.0-alpha.42"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/command-enchantment",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/background-tasks",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "Background tasks for UniPi — durable shell jobs and delegated background agents with a footer dock",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@earendil-works/pi-ai": "^0.87.1",
13
- "@pi-unipi/core": "3.0.0-alpha.41",
13
+ "@pi-unipi/core": "3.0.0-alpha.42",
14
14
  "turndown": "^7.2.4"
15
15
  },
16
16
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/btw",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "A pi extension for parallel side conversations with /unipi:btw — part of the Unipi suite",
5
5
  "type": "module",
6
6
  "main": "extensions/btw.ts",
@@ -37,7 +37,7 @@
37
37
  "@earendil-works/pi-tui": "^0.87.1"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "3.0.0-alpha.41"
40
+ "@pi-unipi/core": "3.0.0-alpha.42"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/compactor",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "Compaction for Pi — lossless zero-LLM or model summaries, rebuilt from full history, with session recall",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -33,8 +33,8 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "3.0.0-alpha.41",
37
- "@pi-unipi/info-screen": "3.0.0-alpha.41",
36
+ "@pi-unipi/core": "3.0.0-alpha.42",
37
+ "@pi-unipi/info-screen": "3.0.0-alpha.42",
38
38
  "@earendil-works/pi-agent-core": "^0.87.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -54,3 +54,4 @@ export * from "./src/hints/store.js";
54
54
  export { CRAB_14_LINES_256, CRAB_14_LINES_TRUECOLOR, CRAB_22_LINES_256, CRAB_22_LINES_TRUECOLOR } from "./src/hints/crab-data.js";
55
55
  export * from "./harness-messages.js";
56
56
  export * from "./remote-dialogs.js";
57
+ export * from "./src/skill-conditions.js";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "Shared utilities, event types, and constants for Unipi extension suite",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @pi-unipi/core — skill visibility conditions (UNI-300)
3
+ *
4
+ * A generic, tiny gate any module can use to hide one of its bundled
5
+ * skills from the model when some runtime fact says it can't be used
6
+ * right now (e.g. the PC's browser is off). Skill-registry (and anything
7
+ * else shaping `options.skills`) calls `skillConditionsPass` per skill
8
+ * name; a module that owns the fact calls `registerSkillCondition` once
9
+ * at load time with a cheap, synchronous predicate.
10
+ *
11
+ * Deliberately process-global like the rest of core's small registries
12
+ * (command-runner, settings): modules load once per process, and this
13
+ * needs no session/ctx threading. A condition must never throw or block;
14
+ * callers treat a throwing condition as "false" (hidden) defensively but
15
+ * conditions should fail open themselves when unsure.
16
+ */
17
+
18
+ export type SkillCondition = () => boolean;
19
+
20
+ const conditions = new Map<string, SkillCondition>();
21
+
22
+ /** Registers (or replaces) the visibility condition for a skill name. */
23
+ export function registerSkillCondition(skillName: string, condition: SkillCondition): void {
24
+ conditions.set(skillName, condition);
25
+ }
26
+
27
+ export function unregisterSkillCondition(skillName: string): void {
28
+ conditions.delete(skillName);
29
+ }
30
+
31
+ /** True when the skill has no registered condition, or its condition returns true. A throwing
32
+ * condition counts as false (hidden) — safer than crashing the turn to show a broken skill. */
33
+ export function skillConditionPasses(skillName: string): boolean {
34
+ const condition = conditions.get(skillName);
35
+ if (!condition) return true;
36
+ try {
37
+ return condition();
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /** Reason a skill is currently hidden (for a clear `/skill:name` refusal), or undefined when
44
+ * it isn't gated by a condition, or its condition is registered but no reason was given. */
45
+ export type SkillConditionReason = () => string | undefined;
46
+
47
+ const reasons = new Map<string, SkillConditionReason>();
48
+
49
+ /** Optional: a human reason shown when `/skill:name` is blocked because the condition is false. */
50
+ export function registerSkillConditionReason(skillName: string, reason: SkillConditionReason): void {
51
+ reasons.set(skillName, reason);
52
+ }
53
+
54
+ export function skillConditionReason(skillName: string): string | undefined {
55
+ const fn = reasons.get(skillName);
56
+ if (!fn) return undefined;
57
+ try {
58
+ return fn();
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ /** Test hook. */
65
+ export function resetSkillConditionsForTests(): void {
66
+ conditions.clear();
67
+ reasons.clear();
68
+ }
@@ -6,7 +6,10 @@
6
6
  * ── ▌Background tasks (2 · 1 running)▐ Subagents (3) ───── ←→ tabs · esc close
7
7
  * <the active tab's pane: the bg task list/logs, or the subagent list/transcript>
8
8
  *
9
- * Nothing is drawn while the tray is closed (no strip, no spinners).
9
+ * While the tray is closed and has items, ONE dim hint line sits under the
10
+ * editor (UNI-291) — `Background tasks 1 running · Subagents 2 · ↓ open` —
11
+ * so ↓ is discoverable; nothing at all when the tray is empty or open (no
12
+ * strip chrome, no per-item spinners).
10
13
  *
11
14
  * Owners register a tab (`registerWorkTrayTab`) and supply the pane; this
12
15
  * module owns the tab strip, ←/→ switching, the ↓ key and the open/close
@@ -63,11 +66,9 @@ export interface WorkTrayTab {
63
66
 
64
67
  export type TrayTheme = Pick<Theme, "fg" | "bold">;
65
68
 
66
- /** A zero-line widget: only there to learn the TUI (for the ↓ focus check).
67
- * The tray draws NOTHING while closed — no strip, no spinners (the user
68
- * found the always-on "◆ Background tasks … · ↓ open" + per-agent spinner
69
- * lines messy); the footer's single "Working… · ↓ open" line is the only
70
- * hint that wake-capable work is pending. */
69
+ /** The below-editor widget: learns the TUI (for the ↓ focus check) and draws
70
+ * the one-line hint while the tray is closed and has items (UNI-291). The
71
+ * old "◆ …" strip + per-agent spinner lines stay gone (5b7f2e1: messy). */
71
72
  const STRIP_KEY = "work-tray-strip";
72
73
  export const TRAY_HINT = "←→ tabs · esc close";
73
74
 
@@ -261,6 +262,47 @@ export class WorkTray implements Component {
261
262
  }
262
263
  }
263
264
 
265
+ // ── hint line (below the editor while the tray is closed) ─────────────────────
266
+
267
+ export const TRAY_OPEN_HINT = "↓ open";
268
+
269
+ /** One tab's part of the hint: `Label N`, `Label N running` (all running) or
270
+ * `Label N (k running)`; level 1 uses the short label, level 2 also the
271
+ * compact `Short N/k` (k running of N). */
272
+ function hintPart(tab: WorkTrayTab, c: WorkTrayCounts, level: number): string {
273
+ const label = level > 0 ? (tab.shortLabel ?? tab.label) : tab.label;
274
+ const n = String(c.total);
275
+ if (c.running <= 0) return `${label} ${n}`;
276
+ if (level >= 2) return `${label} ${n}/${String(c.running)}`;
277
+ return c.running >= c.total ? `${label} ${n} running` : `${label} ${n} (${String(c.running)} running)`;
278
+ }
279
+
280
+ /** The plain hint text (no colour) for `width` columns, or undefined when no
281
+ * visible tab has items. Full labels, then short labels, then compact
282
+ * counts; past that the counts are cut so "↓ open" always stays visible. */
283
+ export function trayHintText(list: readonly WorkTrayTab[], width: number): string | undefined {
284
+ const items = list.map((tab) => ({ tab, c: safeCounts(tab) })).filter(({ c }) => c.total > 0);
285
+ if (items.length === 0 || width <= 0) return undefined;
286
+ const suffix = ` · ${TRAY_OPEN_HINT}`;
287
+ let line = "";
288
+ for (const level of [0, 1, 2]) {
289
+ line = items.map(({ tab, c }) => hintPart(tab, c, level)).join(" · ");
290
+ if (visibleWidth(line) + visibleWidth(suffix) <= width) return `${line}${suffix}`;
291
+ }
292
+ const room = width - visibleWidth(suffix);
293
+ if (room < 4) return truncateToWidth(TRAY_OPEN_HINT, width, "");
294
+ return `${truncateToWidth(line, room, "…")}${suffix}`;
295
+ }
296
+
297
+ /** The below-editor hint: [] when the tray is open or empty, else one dim
298
+ * line one column short of the terminal (exactly-full-width lines desync
299
+ * wrapping terminals — footer issue #31), indented like the editor text. */
300
+ export function renderTrayHint(theme: TrayTheme, list: readonly WorkTrayTab[], width: number, open = false): string[] {
301
+ if (open || width <= 2) return [];
302
+ const text = trayHintText(list, width - 2);
303
+ return text === undefined ? [] : [` ${theme.fg("dim", text)}`];
304
+ }
305
+
264
306
  // ── registration + lifecycle ────────────────────────────────────────────────
265
307
 
266
308
  /** True while the tray is open. */
@@ -301,10 +343,29 @@ function ensureWorkTray(pi: ExtensionAPI): void {
301
343
  unsubInput?.();
302
344
  unsubInput = undefined;
303
345
  if (!ctx.hasUI) return;
304
- // Zero-line widget: only captures the TUI ref for the ↓ focus check.
305
- ctx.ui.setWidget(STRIP_KEY, (tui) => {
346
+ // The hint line under the editor (UNI-291), also the TUI ref for the
347
+ // ↓ focus check. Renders nothing with no items, so idle sessions show
348
+ // no line; work changes re-render it (the TUI diffs, so an unchanged
349
+ // line costs nothing and never moves focus).
350
+ ctx.ui.setWidget(STRIP_KEY, (tui, theme) => {
306
351
  tuiRef = tui;
307
- return { render: () => [], invalidate() {} };
352
+ let unsub: () => void = () => {};
353
+ try {
354
+ unsub = subscribeWorkChanges(() => tui.requestRender());
355
+ } catch {
356
+ /* the line is still right on the next render */
357
+ }
358
+ return {
359
+ render: (width: number) => {
360
+ try {
361
+ return renderTrayHint(theme, sortedTabs(), width, openTray !== undefined);
362
+ } catch {
363
+ return [];
364
+ }
365
+ },
366
+ invalidate() {},
367
+ dispose: () => unsub(),
368
+ };
308
369
  }, { placement: "belowEditor" });
309
370
  unsubInput = ctx.ui.onTerminalInput(onTerminalInput);
310
371
  } catch {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/dream",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "A pi extension that dreams: reviews past session struggles in the background, turns repeated false paths into memory lessons, and crafts skills from errors — part of the Unipi suite",
5
5
  "type": "module",
6
6
  "main": "extensions/dream.ts",
@@ -39,7 +39,7 @@
39
39
  "@earendil-works/pi-tui": "^0.87.1"
40
40
  },
41
41
  "dependencies": {
42
- "@pi-unipi/core": "3.0.0-alpha.41"
42
+ "@pi-unipi/core": "3.0.0-alpha.42"
43
43
  },
44
44
  "pi": {
45
45
  "extensions": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "3.0.0-alpha.41",
36
- "@pi-unipi/background-tasks": "3.0.0-alpha.41"
35
+ "@pi-unipi/core": "3.0.0-alpha.42",
36
+ "@pi-unipi/background-tasks": "3.0.0-alpha.42"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -269,7 +269,7 @@ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterSta
269
269
  // bg: npm test · 12s") — the pane must not read as finished. It is
270
270
  // a status, not part of the bg dots, so it ignores processLine/
271
271
  // strip-hiding settings. It is the only bg/subagent line outside
272
- // the ↓ work tray (the tray draws nothing while closed).
272
+ // the ↓ work tray (closed, the tray draws only its one "↓ open" hint).
273
273
  const ctx = state.piContext as { isIdle?: () => boolean } | undefined;
274
274
  const now = Date.now();
275
275
  const frame = Math.floor(now / 80);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/fusion",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -32,9 +32,9 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "3.0.0-alpha.41",
36
- "@pi-unipi/subagents": "3.0.0-alpha.41",
37
- "@pi-unipi/utility": "3.0.0-alpha.41"
35
+ "@pi-unipi/core": "3.0.0-alpha.42",
36
+ "@pi-unipi/subagents": "3.0.0-alpha.42",
37
+ "@pi-unipi/utility": "3.0.0-alpha.42"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-ai": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "Unicrab startup splash and the /unipi:info dashboard — this session, context, usage, compaction savings",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "3.0.0-alpha.41"
36
+ "@pi-unipi/core": "3.0.0-alpha.42"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/input-shortcuts",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "Keyboard shortcuts for stash/restore, undo/redo, clipboard, and thinking toggle — chord-based overlay system",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "3.0.0-alpha.41"
36
+ "@pi-unipi/core": "3.0.0-alpha.42"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "Kanboard v3 — deferred-work board: CLI/daemon bridge, task runner and skill",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@pi-unipi/core": "3.0.0-alpha.41"
34
+ "@pi-unipi/core": "3.0.0-alpha.42"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -48,10 +48,10 @@
48
48
  "themes": []
49
49
  },
50
50
  "optionalDependencies": {
51
- "@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.41",
52
- "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.41",
53
- "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.41",
54
- "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.41",
55
- "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.41"
51
+ "@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.42",
52
+ "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.42",
53
+ "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.42",
54
+ "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.42",
55
+ "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.42"
56
56
  }
57
57
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/long-horizon",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "Long-horizon execution for Pi — mode-gated /goal /ralph /swarm /graph orchestration with a TypeSafe prompt judge",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -37,7 +37,7 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "3.0.0-alpha.41",
40
+ "@pi-unipi/core": "3.0.0-alpha.42",
41
41
  "@resvg/resvg-wasm": "^2.6.2",
42
42
  "beautiful-mermaid": "^1.1.3"
43
43
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/mcp",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "MCP server management extension for Pi coding agent — browse, add, configure, and use MCP servers",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -30,7 +30,7 @@
30
30
  "test": "npx tsx --test tests/**/*.test.ts"
31
31
  },
32
32
  "dependencies": {
33
- "@pi-unipi/core": "3.0.0-alpha.41"
33
+ "@pi-unipi/core": "3.0.0-alpha.42"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.42",
4
4
  "description": "Persistent cross-session memory on MemPalace (auto-installed): daemon-routed writes, shared palace with other agents, for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -44,8 +44,8 @@
44
44
  "README.md"
45
45
  ],
46
46
  "dependencies": {
47
- "@pi-unipi/core": "3.0.0-alpha.41",
48
- "@pi-unipi/info-screen": "3.0.0-alpha.41",
47
+ "@pi-unipi/core": "3.0.0-alpha.42",
48
+ "@pi-unipi/info-screen": "3.0.0-alpha.42",
49
49
  "js-yaml": "^4.1.0"
50
50
  },
51
51
  "peerDependencies": {