pi-minimal 0.2.2 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,51 @@ Releases are cut automatically: when the `version` in `package.json` changes on
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.3.0] - 2026-10-25
14
+
15
+ ### Added
16
+ - `codemode` renders as a tool row: `✓ code 3 calls · plan_tracker ×2, bash … 54 lines · 1.2s`,
17
+ with one child row per nested call. Built-in tools inside a script read exactly like their
18
+ top-level rows (`bash` shows its command); other tools show up to three scalar arguments as
19
+ `key=value` instead of raw JSON. Children update live while the script runs. The script source
20
+ and output appear only on `ctrl+o`, in plain dim text, so syntax colours no longer compete with
21
+ status colours. A failed nested call or script error shows its first line under the row, like
22
+ `bash`. Previously codemode fell through to pi-core's default panel: a background slab with the
23
+ highlighted script, JSON arguments, an output preview and a `Took …` timer.
24
+
25
+ ### Changed
26
+ - `minimal-dark` contrast: `dim` (verbs, outcomes, tool output) L 48 % → 56 % and `thinkingText`
27
+ 52 % → 58 %, estimated ~3.1:1 → ~4.4:1 and ~3.7:1 → ~4.8:1 on the background. They were within
28
+ 4 % of each other and below WCAG's 4.5:1 text target. `faint` 34 % → 36 %.
29
+ - `minimal-dark` keeps syntax colours off the status hues: `syntaxFunction` was warning-yellow
30
+ (now lavender) and `syntaxNumber` was success-green (now teal), so code no longer looks like a
31
+ spinner or a check mark.
32
+ - `minimal-dark` draws no background behind pending/successful tool output (`toolPendingBg`,
33
+ `toolSuccessBg` → `bg`). Tools pi-minimal does not restyle yet (`subagent`, MCP tools) lose
34
+ their slab; failures keep `toolErrorBg`.
35
+
36
+ ## [0.2.3] - 2026-10-24
37
+
38
+ ### Added
39
+ - Versioned, session-scoped footer activity contribution API. Companion extensions
40
+ can publish bounded activity rows after existing hints and tracker summaries
41
+ without replacing the footer. No visible change without a contributor.
42
+ - Icon-first activity rows with pi-minimal's spinner/check/failure colors,
43
+ plain/static modes, overflow handling, optional elapsed time/token metrics,
44
+ and optional task links with independent task-state badges.
45
+ - Snapshot validation, immutable metadata, exclusive slot ownership, stale-update
46
+ protection, and integration documentation.
47
+ - Independent plan-summary contribution slot with violet slanted progress segments
48
+ and exact accepted/total counts, rendered below the input. One blank row
49
+ separates nonempty footer groups; activity rows stay compact. The summary is
50
+ display-only and does not add task storage or infer acceptance from agent runs.
51
+ - Release checks for matching manifest/lockfile versions and a dated changelog entry.
52
+
53
+ ### Fixed
54
+ - Dispose activity subscriptions and animation timers across footer replacement
55
+ and shutdown; prevent delayed settle callbacks from reinstalling stale chrome.
56
+ - Synchronize the previously stale lockfile package version with the manifest.
57
+
13
58
  ## [0.2.2] - 2026-10-22
14
59
 
15
60
  ### Added
@@ -74,7 +119,8 @@ Releases are cut automatically: when the `version` in `package.json` changes on
74
119
  - Initial release: header/footer chrome, compact tool activity rows, working
75
120
  status line, Herdr sibling status, Gauntlet widget hiding.
76
121
 
77
- [Unreleased]: https://github.com/raviy06/pi-minimal/compare/v0.2.2...HEAD
122
+ [Unreleased]: https://github.com/raviy06/pi-minimal/compare/v0.2.3...HEAD
123
+ [0.2.3]: https://github.com/raviy06/pi-minimal/compare/v0.2.2...v0.2.3
78
124
  [0.2.2]: https://github.com/raviy06/pi-minimal/compare/v0.2.1...v0.2.2
79
125
  [0.2.1]: https://github.com/raviy06/pi-minimal/compare/v0.2.0...v0.2.1
80
126
  [0.2.0]: https://github.com/raviy06/pi-minimal/compare/v0.1.0...v0.2.0
package/README.md CHANGED
@@ -30,14 +30,14 @@ Do not load this package beside the live `grok-chrome` extension. Disable or rem
30
30
 
31
31
  ## What it changes
32
32
 
33
- - **Tool rows** – every built-in tool call (`bash`, `read`, `edit`, `write`, `grep`, `find`, `ls`) renders as one line: status glyph (`◐` running, `✓` ok, `✗` failed), verb, item, and a right-aligned outcome (`34 lines · 1.0s`, `exit 1`, `+3 −1`, `4 matches`). Failed rows show the first line of output underneath; `ctrl+o` expands full output in a `│`-ruled block.
33
+ - **Tool rows** – every built-in tool call (`bash`, `read`, `edit`, `write`, `grep`, `find`, `ls`) renders as one line: status glyph (`◐` running, `✓` ok, `✗` failed), verb, item, and a right-aligned outcome (`34 lines · 1.0s`, `exit 1`, `+3 −1`, `4 matches`). Failed rows show the first line of output underneath; `ctrl+o` expands full output in a `│`-ruled block. `codemode` renders the same way, as a `code` row with one child row per nested tool call; `ctrl+o` shows the script and its output.
34
34
  - **Turn rule** – after each agent run a dim `── 15:51 · 12s · 4 tools ──` separator is appended (stored as a custom entry; never sent to the model).
35
35
  - **Thinking gutter** – thinking paragraphs are prefixed with `┊` so they read as secondary.
36
36
  - **Status line** – one line above the editor: model · thinking level · context meter (amber at 70 %, red at 90 %) · cwd · `⎇ branch ±dirty` · Herdr siblings.
37
37
  - **Footer** – key hints appear only when the agent is idle and the editor is empty; `/compact` is suggested under context pressure.
38
38
  - **Code blocks** – fenced code renders as a closed box with the language as its title, keeping syntax highlighting, instead of literal ``` lines; long lines wrap inside the box.
39
39
  - **Alignment** – chrome follows pi's `outputPad` setting. Pi's editor uses the separate `editorPaddingX` setting (default `0`); set `"editorPaddingX": 1` for a flush left edge with the transcript.
40
- - **Theme** – ships `minimal-dark`: neutral tool backgrounds, visible user-message tint, dimmer thinking text, violet headings (yellow is reserved for warnings), and faint code-block frames. Select it in `/settings` → Theme, or set `"theme": "minimal-dark"`.
40
+ - **Theme** – ships `minimal-dark`: no background behind tool rows (failures excepted), a grey ladder whose readable tiers stay near 4.5:1, syntax colours kept off the status hues, visible user-message tint, dimmer thinking text, violet headings (yellow is reserved for warnings), and faint code-block frames. Select it in `/settings` → Theme, or set `"theme": "minimal-dark"`.
41
41
 
42
42
  ## Configuration
43
43
 
@@ -55,8 +55,30 @@ The only supported key is `hideGauntletWidgets`. It defaults to `true`.
55
55
  { "hideGauntletWidgets": true }
56
56
  ```
57
57
 
58
+ ## Optional footer activity API
59
+
60
+ Companion extensions can publish session-scoped activity rows through the versioned
61
+ in-process footer contribution API. Pi-minimal keeps ownership of the footer and
62
+ renders activity **after** existing hints and tracker summaries. An independent
63
+ `summary` slot supplies a violet segmented plan bar below the input. Nonempty
64
+ groups are separated by one blank row; agent rows remain compact. The API adds no
65
+ visible content without a contributor and changes no other extension's settings.
66
+
67
+ Rows use pi-minimal's yellow running spinner, green completion check, and red
68
+ failure glyph. Plain-text and static modes are supported. See
69
+ [Footer activity contributions](doc/footer-contributions.md) for the protocol,
70
+ validation limits, lifecycle, and integration example.
71
+
72
+ This is a display integration seam, not a subagent runtime or task tracker.
73
+
58
74
  ## Releasing
59
75
 
76
+ Every new extension PR must include a version bump before it is opened, so merging
77
+ it can trigger publication and users can update. Keep `package.json`, both root
78
+ version fields in `package-lock.json`, the package test expectation, and the dated
79
+ changelog entry synchronized. Before merging concurrent PRs, verify that the
80
+ selected version is still newer than `main` and has not already been published.
81
+
60
82
  Releases are automatic. To ship a version:
61
83
 
62
84
  1. Add a `## [x.y.z] - YYYY-MM-DD` section to `CHANGELOG.md`.
@@ -0,0 +1,109 @@
1
+ import { firstLine, formatDuration, resultText, toolItem } from "./tool-rows.js";
2
+ const KINDS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
3
+ const SCRIPT_HEADER = /^Script (?:completed|failed)\nWall time [\d.]+ seconds\nOutput:\n?/;
4
+ /** Argument keys toolItem() reads, in the order it prefers them. */
5
+ const ITEM_KEYS = ["command", "path", "file_path", "target_directory", "pattern"];
6
+ export function nestedCalls(result) {
7
+ const calls = result?.details?.calls;
8
+ if (!Array.isArray(calls))
9
+ return [];
10
+ return calls.filter((call) => Boolean(call) && typeof call === "object");
11
+ }
12
+ /** Script output without pi-core's "Script completed / Wall time / Output:" header. */
13
+ export function scriptOutput(result) {
14
+ return resultText(result).replace(SCRIPT_HEADER, "").trim();
15
+ }
16
+ /**
17
+ * Parse the host's argument preview. Complete JSON parses directly; a preview cut at 200 chars
18
+ * still yields the first string value of each known key, which is all a one-line row needs.
19
+ */
20
+ export function parseArgs(args) {
21
+ if (!args)
22
+ return {};
23
+ try {
24
+ const value = JSON.parse(args);
25
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
26
+ }
27
+ catch {
28
+ const out = {};
29
+ const pair = /"([A-Za-z_$][\w$]*)":(?:"((?:[^"\\]|\\.)*)"?|(-?\d+(?:\.\d+)?|true|false|null))/g;
30
+ for (const m of args.matchAll(pair)) {
31
+ if (m[1] in out)
32
+ continue;
33
+ if (m[2] !== undefined)
34
+ out[m[1]] = unescape(m[2]);
35
+ else if (m[3] !== undefined)
36
+ out[m[1]] = JSON.parse(m[3]);
37
+ }
38
+ return out;
39
+ }
40
+ }
41
+ function unescape(raw) {
42
+ // A cut can land inside an escape; drop a dangling backslash so JSON.parse accepts the rest.
43
+ const safe = raw.replace(/\\(u[0-9a-fA-F]{0,3})?$/, "");
44
+ try {
45
+ return JSON.parse(`"${safe}"`);
46
+ }
47
+ catch {
48
+ return safe;
49
+ }
50
+ }
51
+ function scalar(value) {
52
+ if (typeof value === "string")
53
+ return value.split("\n")[0]?.trim();
54
+ if (typeof value === "number" || typeof value === "boolean")
55
+ return String(value);
56
+ return undefined;
57
+ }
58
+ /**
59
+ * The item column of a nested call. Built-in tools read exactly like their top-level rows
60
+ * (`bash` shows its command, `read` a short path). Other tools show up to three scalar
61
+ * arguments as `key=value`, strings unquoted, instead of raw JSON.
62
+ */
63
+ export function nestedItem(call) {
64
+ const name = call.name ?? "tool";
65
+ const args = parseArgs(call.args);
66
+ if (KINDS.has(name)) {
67
+ const picked = {};
68
+ for (const key of ITEM_KEYS)
69
+ if (key in args)
70
+ picked[key] = args[key];
71
+ return toolItem(name, picked);
72
+ }
73
+ const parts = [];
74
+ for (const [key, value] of Object.entries(args)) {
75
+ const shown = scalar(value);
76
+ if (shown === undefined || shown === "")
77
+ continue;
78
+ parts.push(`${key}=${shown}`);
79
+ if (parts.length === 3)
80
+ break;
81
+ }
82
+ return parts.join(" ");
83
+ }
84
+ /** `3 calls · plan_tracker ×2, bash` — names in first-call order with repeat counts. */
85
+ export function callSummary(calls) {
86
+ if (calls.length === 0)
87
+ return "script";
88
+ const counts = new Map();
89
+ for (const call of calls)
90
+ counts.set(call.name ?? "tool", (counts.get(call.name ?? "tool") ?? 0) + 1);
91
+ const names = [...counts].map(([name, n]) => (n > 1 ? `${name} ×${n}` : name)).join(", ");
92
+ return `${calls.length} call${calls.length === 1 ? "" : "s"} · ${names}`;
93
+ }
94
+ /** Right-hand outcome of the parent row, and the line shown under it when something failed. */
95
+ export function codemodeOutcome(result, isError, durationMs) {
96
+ const calls = nestedCalls(result);
97
+ const output = scriptOutput(result);
98
+ const failedCalls = calls.filter((call) => call.status === "error");
99
+ const dur = formatDuration(durationMs);
100
+ const join = (...parts) => parts.filter(Boolean).join(" · ");
101
+ if (isError || failedCalls.length > 0) {
102
+ const label = failedCalls.length > 0 ? `${failedCalls.length} failed` : "failed";
103
+ const scriptError = /Script error:\s*(.*)/.exec(output)?.[1];
104
+ const errorLine = firstLine(failedCalls[0]?.error ?? "") ?? scriptError ?? firstLine(output);
105
+ return { text: join(label, dur), failed: true, errorLine };
106
+ }
107
+ const lines = output ? output.split("\n").filter((line) => line.length > 0).length : 0;
108
+ return { text: join(lines === 0 ? "ok" : `${lines} line${lines === 1 ? "" : "s"}`, dur), failed: false };
109
+ }
@@ -0,0 +1,45 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ export function taskPrefix(task, plain, theme) {
3
+ if (!task)
4
+ return "";
5
+ if (plain)
6
+ return `[${task.status}] ${task.id} / `;
7
+ const badges = {
8
+ blocked: ["!", "warning"], in_review: ["◇", "accent"], done: ["✓", "success"], cancelled: ["⊘", "muted"],
9
+ };
10
+ const badge = badges[task.status];
11
+ return `${badge ? theme.fg(badge[1], badge[0]) + " " : ""}${task.id} · `;
12
+ }
13
+ function elapsed(ms, missing) {
14
+ if (ms === undefined)
15
+ return missing;
16
+ const seconds = Math.floor(ms / 1000);
17
+ if (seconds >= 3600)
18
+ return `${Math.floor(seconds / 3600)}h ${String(Math.floor(seconds / 60) % 60).padStart(2, "0")}m`;
19
+ if (seconds >= 60)
20
+ return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
21
+ return `${seconds}s`;
22
+ }
23
+ function tokens(value, missing) {
24
+ if (value === undefined)
25
+ return missing;
26
+ if (value >= 999950)
27
+ return `${(value / 1000000).toFixed(1)}m`;
28
+ if (value >= 1000)
29
+ return `${(value / 1000).toFixed(1)}k`;
30
+ return String(value);
31
+ }
32
+ /** Metrics never displace activity text: first drop tokens, then elapsed time. */
33
+ export function alignActivityMetrics(left, metrics, width, plain, theme) {
34
+ if (metrics) {
35
+ const missing = plain ? "-" : "—";
36
+ const time = elapsed(metrics.elapsedMs, missing);
37
+ const full = `${time}${plain ? " / " : " · "}${tokens(metrics.tokens, missing)} tok`;
38
+ for (const right of [full, time]) {
39
+ const gap = width - visibleWidth(left) - visibleWidth(right);
40
+ if (gap >= 2)
41
+ return left + " ".repeat(gap) + theme.fg("muted", right);
42
+ }
43
+ }
44
+ return truncateToWidth(left, width);
45
+ }
@@ -0,0 +1,36 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { cleanActivityText } from "./footer-contributions.js";
3
+ import { rowGlyph } from "./tool-rows.js";
4
+ import { alignActivityMetrics, taskPrefix } from "./footer-activity-details.js";
5
+ const MAX_ROWS = 4;
6
+ const indicators = {
7
+ queued: ["○", "muted"], running: ["◐", "warning"], waiting: ["!", "warning"], paused: ["Ⅱ", "muted"],
8
+ stopping: ["!", "warning"], completed: ["✓", "success"], failed: ["✗", "error"], stopped: ["⊘", "muted"], unknown: ["?", "muted"],
9
+ };
10
+ export function activityNeedsAnimation(snapshot) {
11
+ return snapshot?.motion === "spinner" && snapshot.format === "icons" && snapshot.rows.slice(0, MAX_ROWS).some(row => row.state === "running");
12
+ }
13
+ export function renderFooterActivity(snapshot, width, theme, frame = 0) {
14
+ if (!snapshot || width <= 0)
15
+ return [];
16
+ const parents = new Map(snapshot.rows.map(row => [row.key, row.parentKey]));
17
+ const lines = snapshot.rows.slice(0, MAX_ROWS).map(row => {
18
+ const [fallback, tone] = indicators[row.state];
19
+ const glyph = row.state === "running" ? rowGlyph("running", snapshot.motion === "spinner" ? frame : 0)
20
+ : row.state === "completed" ? rowGlyph("ok", 0) : row.state === "failed" ? rowGlyph("error", 0) : fallback;
21
+ const marker = snapshot.format === "plain" ? `[${row.state}]` : glyph;
22
+ let depth = 0, parent = row.parentKey;
23
+ while (parent && depth < 2) {
24
+ depth++;
25
+ parent = parents.get(parent);
26
+ }
27
+ const reason = row.state === "stopping" && snapshot.format === "icons" ? "Stopping: " : "";
28
+ const task = taskPrefix(row.task, snapshot.format === "plain", theme);
29
+ const text = `${" ".repeat(depth)}${theme.fg(tone, marker)} ${cleanActivityText(row.agent)} ${task}${reason}${cleanActivityText(row.text)}`.trimEnd();
30
+ return alignActivityMetrics(text, row.metrics, width, snapshot.format === "plain", theme);
31
+ });
32
+ const omitted = Math.min(Number.MAX_SAFE_INTEGER, snapshot.omitted + Math.max(0, snapshot.rows.length - MAX_ROWS));
33
+ if (omitted > 0)
34
+ lines.push(truncateToWidth(theme.fg("muted", `+ ${omitted} more`), width));
35
+ return lines;
36
+ }
@@ -0,0 +1,35 @@
1
+ export const FOOTER_EVENTS = {
2
+ register: "pi-minimal:footer:v1:register",
3
+ ready: "pi-minimal:footer:v1:ready",
4
+ unavailable: "pi-minimal:footer:v1:unavailable",
5
+ };
6
+ /** Bind once per session, not once per footer mount. Trusted in-process protocol. */
7
+ export function bindFooterContributionEvents(events, registry, sessionId, mode) {
8
+ registry.reset(mode === "tui" ? sessionId : null);
9
+ const unsubscribe = events.on(FOOTER_EVENTS.register, (value) => {
10
+ if (typeof value !== "object" || value === null || Array.isArray(value))
11
+ return;
12
+ const request = value;
13
+ // A different host may have answered first. Never overwrite its result.
14
+ if (request.result !== undefined)
15
+ return;
16
+ if (request.version !== 1 || (request.slot !== "activity" && request.slot !== "summary"))
17
+ request.result = { ok: false, code: "invalid" };
18
+ else if (mode !== "tui")
19
+ request.result = { ok: false, code: "not-tui" };
20
+ else
21
+ request.result = request.slot === "summary" ? registry.registerPlan(request) : registry.register(request);
22
+ });
23
+ if (mode === "tui")
24
+ events.emit(FOOTER_EVENTS.ready, { version: 1, sessionId, slots: ["activity", "summary"] });
25
+ let disposed = false;
26
+ return () => {
27
+ if (disposed)
28
+ return;
29
+ disposed = true;
30
+ unsubscribe();
31
+ registry.reset(null);
32
+ if (mode === "tui")
33
+ events.emit(FOOTER_EVENTS.unavailable, { version: 1, sessionId });
34
+ };
35
+ }
@@ -0,0 +1,130 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import { normalizePlanSnapshot } from "./footer-plan.js";
3
+ export const ACTIVITY_STATES = ["queued", "running", "waiting", "paused", "stopping", "completed", "failed", "stopped", "unknown"];
4
+ export const TASK_STATUSES = ["todo", "in_progress", "blocked", "in_review", "done", "cancelled"];
5
+ function record(value) {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+ function identity(value) {
9
+ return typeof value === "string" && /^[a-zA-Z0-9._:@+-]{1,128}$/.test(value);
10
+ }
11
+ export function cleanActivityText(value) {
12
+ return stripVTControlCharacters(value)
13
+ // Also remove bare C0/C1 and bidi controls; no terminal commands from labels.
14
+ // eslint-disable-next-line no-control-regex
15
+ .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, " ")
16
+ .replace(/\s+/gu, " ").trim();
17
+ }
18
+ function validMetrics(value) {
19
+ return record(value) && [value.elapsedMs, value.tokens].every(n => n === undefined || (Number.isSafeInteger(n) && n >= 0));
20
+ }
21
+ function validTask(value) {
22
+ return record(value) && identity(value.planId) && identity(value.id) && TASK_STATUSES.includes(value.status);
23
+ }
24
+ function normalizeSnapshot(input) {
25
+ if (!record(input) || !Number.isSafeInteger(input.revision) || input.revision < 0 ||
26
+ !Number.isSafeInteger(input.omitted) || input.omitted < 0 ||
27
+ !["spinner", "static"].includes(input.motion) || !["icons", "plain"].includes(input.format) ||
28
+ !Array.isArray(input.rows) || input.rows.length > 64)
29
+ return;
30
+ const rows = [];
31
+ const parents = new Map();
32
+ for (const item of input.rows) {
33
+ if (!record(item) || !identity(item.key) || parents.has(item.key) ||
34
+ typeof item.agent !== "string" || item.agent.length > 96 || !cleanActivityText(item.agent) ||
35
+ typeof item.text !== "string" || item.text.length > 256 ||
36
+ !ACTIVITY_STATES.includes(item.state) ||
37
+ (item.parentKey !== undefined && !identity(item.parentKey)) ||
38
+ (item.metrics !== undefined && !validMetrics(item.metrics)) ||
39
+ (item.task !== undefined && !validTask(item.task)))
40
+ return;
41
+ parents.set(item.key, item.parentKey);
42
+ rows.push(Object.freeze({ key: item.key, agent: cleanActivityText(item.agent), state: item.state,
43
+ text: cleanActivityText(item.text), ...(item.parentKey === undefined ? {} : { parentKey: item.parentKey }),
44
+ ...(item.metrics === undefined ? {} : { metrics: Object.freeze({ elapsedMs: item.metrics.elapsedMs, tokens: item.metrics.tokens }) }),
45
+ ...(item.task === undefined ? {} : { task: Object.freeze({ planId: item.task.planId, id: item.task.id, status: item.task.status }) }),
46
+ }));
47
+ }
48
+ for (const row of rows) {
49
+ const seen = new Set([row.key]);
50
+ let parent = row.parentKey;
51
+ while (parent !== undefined) {
52
+ if (!parents.has(parent) || seen.has(parent))
53
+ return;
54
+ seen.add(parent);
55
+ parent = parents.get(parent);
56
+ }
57
+ }
58
+ return Object.freeze({ revision: input.revision, rows: Object.freeze(rows), omitted: input.omitted,
59
+ motion: input.motion, format: input.format });
60
+ }
61
+ function createFooterSlot(normalize, initialSessionId) {
62
+ let sessionId = initialSessionId;
63
+ let owner;
64
+ let snapshot;
65
+ const observers = new Set();
66
+ const notify = () => {
67
+ for (const observer of [...observers]) {
68
+ try {
69
+ observer();
70
+ }
71
+ catch { /* A disposed UI must not invalidate a successful publish. */ }
72
+ }
73
+ };
74
+ return {
75
+ read: () => snapshot,
76
+ subscribe(observer) { observers.add(observer); return () => { observers.delete(observer); }; },
77
+ reset(nextSessionId) {
78
+ sessionId = nextSessionId;
79
+ owner = undefined;
80
+ snapshot = undefined;
81
+ notify();
82
+ },
83
+ register(input) {
84
+ if (!record(input) || !identity(input.owner) || typeof input.sessionId !== "string" || !input.sessionId)
85
+ return { ok: false, code: "invalid" };
86
+ if (sessionId === null || input.sessionId !== sessionId)
87
+ return { ok: false, code: "wrong-session" };
88
+ if (owner)
89
+ return { ok: false, code: "occupied" };
90
+ const token = Symbol(input.owner);
91
+ owner = token;
92
+ return { ok: true, registration: {
93
+ publish(input) {
94
+ if (owner !== token)
95
+ return { ok: false, code: "disposed" };
96
+ const next = normalize(input);
97
+ if (!next)
98
+ return { ok: false, code: "invalid" };
99
+ if (snapshot && next.revision <= snapshot.revision)
100
+ return { ok: false, code: "stale" };
101
+ snapshot = next;
102
+ notify();
103
+ return { ok: true };
104
+ },
105
+ dispose() {
106
+ if (owner !== token)
107
+ return;
108
+ owner = undefined;
109
+ snapshot = undefined;
110
+ notify();
111
+ },
112
+ } };
113
+ },
114
+ };
115
+ }
116
+ export function createFooterContributions(initialSessionId = null) {
117
+ const activity = createFooterSlot(normalizeSnapshot, initialSessionId);
118
+ const summary = createFooterSlot(normalizePlanSnapshot, initialSessionId);
119
+ return {
120
+ read: activity.read,
121
+ readPlan: summary.read,
122
+ register: activity.register,
123
+ registerPlan: summary.register,
124
+ subscribe(observer) {
125
+ const offActivity = activity.subscribe(observer), offSummary = summary.subscribe(observer);
126
+ return () => { offActivity(); offSummary(); };
127
+ },
128
+ reset(sessionId) { activity.reset(sessionId); summary.reset(sessionId); },
129
+ };
130
+ }
@@ -0,0 +1,41 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ function record(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+ const count = (value) => Number.isSafeInteger(value) && value >= 0;
6
+ export function normalizePlanSnapshot(value) {
7
+ if (!record(value) || !count(value.revision) || (value.format !== "icons" && value.format !== "plain"))
8
+ return;
9
+ if (value.plan === undefined)
10
+ return Object.freeze({ revision: value.revision, format: value.format });
11
+ const plan = value.plan;
12
+ if (!record(plan) || typeof plan.id !== "string" || !/^[a-zA-Z0-9._:@+-]{1,128}$/.test(plan.id) ||
13
+ !count(plan.accepted) || !count(plan.total) || !count(plan.inReview) || !count(plan.blocked) ||
14
+ plan.accepted > plan.total || plan.inReview > plan.total - plan.accepted || plan.blocked > plan.total - plan.accepted)
15
+ return;
16
+ return Object.freeze({ revision: value.revision, format: value.format,
17
+ plan: Object.freeze({ id: plan.id, accepted: plan.accepted, total: plan.total, inReview: plan.inReview, blocked: plan.blocked }) });
18
+ }
19
+ /** Display-only accepted-task counts, never inferred from agent execution. */
20
+ export function renderFooterPlan(snapshot, width, theme) {
21
+ if (!snapshot?.plan || width <= 0)
22
+ return [];
23
+ const { id, accepted, total, inReview, blocked } = snapshot.plan;
24
+ const plain = snapshot.format === "plain", separator = plain ? " / " : " · ";
25
+ const label = theme.fg("accent", `Plan ${id}`);
26
+ if (!total)
27
+ return [truncateToWidth(`${label}${separator}No tasks yet`, width)];
28
+ const slots = total <= 12 ? total : 10;
29
+ const filled = total <= 12 ? accepted : Math.floor(accepted / total * slots);
30
+ const bar = theme.fg("accent", (plain ? "#" : "▰").repeat(filled)) + theme.fg("muted", (plain ? "." : "▱").repeat(slots - filled));
31
+ const counts = `${accepted}/${total}`;
32
+ const attention = [inReview ? `${plain ? "" : "◇ "}${inReview} review` : "", blocked ? `! ${blocked} blocked` : ""].filter(Boolean);
33
+ const suffix = attention.length ? separator + attention.join(separator) : "";
34
+ for (const text of [`${label}${separator}${bar} ${counts}${suffix}`, `${label}${separator}${counts}${suffix}`, `${label}${separator}${counts}`]) {
35
+ if (visibleWidth(text) <= width)
36
+ return [text];
37
+ }
38
+ // Exact counts outrank a long plan label on extremely narrow terminals.
39
+ const room = width - visibleWidth(counts) - 1;
40
+ return [room > 0 ? `${truncateToWidth(label, room)} ${counts}` : truncateToWidth(counts, width)];
41
+ }
package/dist/footer.js CHANGED
@@ -2,6 +2,9 @@ import { keyHint } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import * as chrome from "./chrome.js";
4
4
  import { formatTrackerLines } from "./tracker.js";
5
+ import { activityNeedsAnimation, renderFooterActivity } from "./footer-activity.js";
6
+ import { createLiveWatch } from "./live-watch.js";
7
+ import { renderFooterPlan } from "./footer-plan.js";
5
8
  /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
6
9
  const margin = () => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1));
7
10
  /**
@@ -19,11 +22,40 @@ export function footerHints(sources, hints) {
19
22
  out.push("/compact");
20
23
  return out;
21
24
  }
22
- export function installFooter(ctx, sources) {
25
+ export function installFooter(ctx, sources, contributions) {
26
+ let stopMount = () => { };
27
+ let stopped = false;
23
28
  ctx.ui.setFooter((tui, theme, footerData) => {
24
- const unsub = footerData.onBranchChange(() => tui.requestRender());
29
+ stopMount();
30
+ if (stopped)
31
+ return { render: () => [], invalidate() { } };
32
+ const live = createLiveWatch(400);
33
+ let disposed = false;
34
+ let activityHasSpace = true;
35
+ const redraw = () => { if (!disposed)
36
+ tui.requestRender(); };
37
+ const syncAnimation = () => {
38
+ if (disposed)
39
+ return;
40
+ if (activityHasSpace && activityNeedsAnimation(contributions?.read()))
41
+ live.watch("footer", redraw);
42
+ else
43
+ live.unwatch("footer");
44
+ };
45
+ const update = () => { syncAnimation(); redraw(); };
46
+ const unsub = footerData.onBranchChange(redraw);
47
+ const unsubscribeActivity = contributions?.subscribe(update);
48
+ stopMount = () => {
49
+ if (disposed)
50
+ return;
51
+ disposed = true;
52
+ unsub();
53
+ unsubscribeActivity?.();
54
+ live.stop();
55
+ };
56
+ update();
25
57
  return {
26
- dispose: unsub,
58
+ dispose: stopMount,
27
59
  invalidate() { },
28
60
  render(width) {
29
61
  const inner = Math.max(8, width - margin() * 2);
@@ -37,11 +69,21 @@ export function installFooter(ctx, sources) {
37
69
  const line = theme.fg("dim", hints.join(" · "));
38
70
  out.push(`${" ".repeat(margin())}${truncateToWidth(line, inner, "")}`);
39
71
  }
40
- for (const extra of formatTrackerLines(ctx, theme)) {
41
- out.push(`${" ".repeat(margin())}${truncateToWidth(extra, inner, "")}`);
42
- }
43
- return out;
72
+ const activityWidth = Math.max(0, width - margin() * 2);
73
+ const plan = renderFooterPlan(contributions?.readPlan(), activityWidth, theme);
74
+ const summary = (plan.length ? plan : formatTrackerLines(ctx, theme))
75
+ .map(line => `${" ".repeat(margin())}${truncateToWidth(line, plan.length ? activityWidth : inner, "")}`);
76
+ activityHasSpace = activityWidth > 0;
77
+ syncAnimation();
78
+ const activity = renderFooterActivity(contributions?.read(), activityWidth, theme, live.frame)
79
+ .map(line => `${" ".repeat(margin())}${line}`);
80
+ // Preserve legacy-only layout; contributions opt into the spaced layout.
81
+ if (!plan.length && !activity.length)
82
+ return [...out, ...summary];
83
+ const groups = [out, summary, activity].filter(group => group.length > 0);
84
+ return ["", ...groups.flatMap((group, index) => index === 0 ? group : ["", ...group])];
44
85
  },
45
86
  };
46
87
  });
88
+ return () => { stopped = true; stopMount(); };
47
89
  }