pi-minimal 0.2.1 → 0.2.3

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,45 @@ Releases are cut automatically: when the `version` in `package.json` changes on
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.2.3] - 2026-10-24
14
+
15
+ ### Added
16
+ - Versioned, session-scoped footer activity contribution API. Companion extensions
17
+ can publish bounded activity rows after existing hints and tracker summaries
18
+ without replacing the footer. No visible change without a contributor.
19
+ - Icon-first activity rows with pi-minimal's spinner/check/failure colors,
20
+ plain/static modes, overflow handling, optional elapsed time/token metrics,
21
+ and optional task links with independent task-state badges.
22
+ - Snapshot validation, immutable metadata, exclusive slot ownership, stale-update
23
+ protection, and integration documentation.
24
+ - Independent plan-summary contribution slot with violet slanted progress segments
25
+ and exact accepted/total counts, rendered below the input. One blank row
26
+ separates nonempty footer groups; activity rows stay compact. The summary is
27
+ display-only and does not add task storage or infer acceptance from agent runs.
28
+ - Release checks for matching manifest/lockfile versions and a dated changelog entry.
29
+
30
+ ### Fixed
31
+ - Dispose activity subscriptions and animation timers across footer replacement
32
+ and shutdown; prevent delayed settle callbacks from reinstalling stale chrome.
33
+ - Synchronize the previously stale lockfile package version with the manifest.
34
+
35
+ ## [0.2.2] - 2026-10-22
36
+
37
+ ### Added
38
+ - Fenced code renders as a closed box (`╭─ lang ─╮` / `│ code │` / `╰───╯`)
39
+ instead of literal ``` lines, keeping syntax highlighting; long lines wrap
40
+ inside the box. pi-tui exposes no hook for this, so pi-minimal post-processes
41
+ the code token's rendered lines.
42
+
43
+ ### Changed
44
+ - Chrome margin (status line, footer, turn rule, tool rows) follows pi's
45
+ `outputPad` setting so it lines up with transcript text. Set
46
+ `editorPaddingX` to the same value for a flush left edge.
47
+ - Chrome modules tolerate a stale `chrome` module after `/reload`, which
48
+ previously could crash the renderer when the package was updated in place.
49
+ - `mdCodeBlockBorder` in `minimal-dark` is a faint grey again (it frames the
50
+ box now) instead of background-coloured.
51
+
13
52
  ## [0.2.1] - 2026-10-22
14
53
 
15
54
  ### Added
@@ -57,7 +96,9 @@ Releases are cut automatically: when the `version` in `package.json` changes on
57
96
  - Initial release: header/footer chrome, compact tool activity rows, working
58
97
  status line, Herdr sibling status, Gauntlet widget hiding.
59
98
 
60
- [Unreleased]: https://github.com/raviy06/pi-minimal/compare/v0.2.1...HEAD
99
+ [Unreleased]: https://github.com/raviy06/pi-minimal/compare/v0.2.3...HEAD
100
+ [0.2.3]: https://github.com/raviy06/pi-minimal/compare/v0.2.2...v0.2.3
101
+ [0.2.2]: https://github.com/raviy06/pi-minimal/compare/v0.2.1...v0.2.2
61
102
  [0.2.1]: https://github.com/raviy06/pi-minimal/compare/v0.2.0...v0.2.1
62
103
  [0.2.0]: https://github.com/raviy06/pi-minimal/compare/v0.1.0...v0.2.0
63
104
  [0.1.0]: https://github.com/raviy06/pi-minimal/releases/tag/v0.1.0
package/README.md CHANGED
@@ -35,7 +35,9 @@ Do not load this package beside the live `grok-chrome` extension. Disable or rem
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
- - **Theme** – ships `minimal-dark`: neutral tool backgrounds, visible user-message tint, dimmer thinking text, violet headings (yellow is reserved for warnings), and code-fence lines coloured like the background so fenced code reads as a padded block. Select it in `/settings` → Theme, or set `"theme": "minimal-dark"`.
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
+ - **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"`.
39
41
 
40
42
  ## Configuration
41
43
 
@@ -53,8 +55,30 @@ The only supported key is `hideGauntletWidgets`. It defaults to `true`.
53
55
  { "hideGauntletWidgets": true }
54
56
  ```
55
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
+
56
74
  ## Releasing
57
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
+
58
82
  Releases are automatic. To ship a version:
59
83
 
60
84
  1. Add a `## [x.y.z] - YYYY-MM-DD` section to `CHANGELOG.md`.
package/dist/chrome.js CHANGED
@@ -1 +1,16 @@
1
+ /**
2
+ * Horizontal margin for pi-minimal's chrome (status line, footer, turn rule, tool rows).
3
+ * Follows pi's `outputPad` setting so chrome lines up with transcript text. Pi's editor uses
4
+ * the separate `editorPaddingX` setting (default 0); set it to the same value for a flush
5
+ * left edge.
6
+ */
7
+ const KEY = Symbol.for("pi-minimal.chrome-margin");
8
+ const store = globalThis;
9
+ /** Kept for modules linked against an older copy of this file across a hot reload. */
1
10
  export const CHROME_MARGIN = 1;
11
+ export function chromeMargin() {
12
+ return store[KEY] ?? CHROME_MARGIN;
13
+ }
14
+ export function setChromeMargin(value) {
15
+ store[KEY] = value === 0 ? 0 : 1;
16
+ }
@@ -0,0 +1,69 @@
1
+ import { Markdown, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+ const PATCHED = Symbol.for("pi-minimal.code-box");
3
+ /** Current framing implementation. Lives on globalThis so a /reload swaps the behaviour of the
4
+ * wrapper that an earlier load already installed on pi-tui's shared Markdown prototype. */
5
+ const FRAME_SLOT = Symbol.for("pi-minimal.code-box.frame");
6
+ const slots = globalThis;
7
+ export function frameCodeLines(lines, lang, width, theme) {
8
+ if (lines.length < 2)
9
+ return lines;
10
+ // Trailing spacer ("" after the closing fence) is preserved as-is.
11
+ let end = lines.length;
12
+ while (end > 0 && lines[end - 1] === "")
13
+ end -= 1;
14
+ if (end < 2)
15
+ return lines;
16
+ const trailer = lines.slice(end);
17
+ const body = lines.slice(1, end - 1);
18
+ const indent = theme.codeBlockIndent ?? " ";
19
+ const outer = Math.max(6, width);
20
+ // "│ " + content + " │"
21
+ const innerWidth = outer - 4;
22
+ const label = lang ? ` ${lang} ` : "";
23
+ const topRule = "─".repeat(Math.max(1, outer - 3 - visibleWidth(label)));
24
+ const top = theme.codeBlockBorder(`╭─${label}${topRule}╮`);
25
+ const bottom = theme.codeBlockBorder(`╰${"─".repeat(outer - 2)}╯`);
26
+ const edge = theme.codeBlockBorder("│");
27
+ const framed = [];
28
+ for (const line of body) {
29
+ const content = line.startsWith(indent) ? line.slice(indent.length) : line;
30
+ for (const piece of wrapTextWithAnsi(content, innerWidth)) {
31
+ const pad = Math.max(0, innerWidth - visibleWidth(piece));
32
+ framed.push(`${edge} ${piece}${" ".repeat(pad)} ${edge}`);
33
+ }
34
+ }
35
+ if (framed.length === 0)
36
+ framed.push(`${edge} ${" ".repeat(innerWidth)} ${edge}`);
37
+ return [top, ...framed, bottom, ...trailer];
38
+ }
39
+ /**
40
+ * Patch pi-tui's Markdown renderer. The prototype wrapper is installed once per process; every
41
+ * call (including after /reload) points it at this module's `frameCodeLines`.
42
+ * Returns false when the renderer is not patchable.
43
+ */
44
+ export function installCodeBox() {
45
+ slots[FRAME_SLOT] = frameCodeLines;
46
+ const proto = Markdown.prototype;
47
+ if (proto[PATCHED])
48
+ return true;
49
+ const original = proto.renderToken;
50
+ if (typeof original !== "function")
51
+ return false;
52
+ const originalRender = original;
53
+ proto.renderToken = function (token, width, next, style) {
54
+ const lines = originalRender.call(this, token, width, next, style);
55
+ if (token?.type !== "code" || !this.theme?.codeBlockBorder)
56
+ return lines;
57
+ const frame = slots[FRAME_SLOT];
58
+ if (!frame)
59
+ return lines;
60
+ try {
61
+ return frame(lines, token.lang, width, this.theme);
62
+ }
63
+ catch {
64
+ return lines;
65
+ }
66
+ };
67
+ proto[PATCHED] = true;
68
+ return true;
69
+ }
package/dist/cwd.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import path from "node:path";
3
- import { CHROME_MARGIN } from "./chrome.js";
3
+ import * as chrome from "./chrome.js";
4
4
  import { formatGitState } from "./git-state.js";
5
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
6
+ const margin = () => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1));
5
7
  export function formatCwd(cwd) {
6
8
  const home = process.env.HOME || process.env.USERPROFILE || "";
7
9
  if (!home)
@@ -95,7 +97,7 @@ export function installCwd(ctx, sources) {
95
97
  dispose: unsub,
96
98
  invalidate() { },
97
99
  render(width) {
98
- const inner = Math.max(8, width - CHROME_MARGIN * 2);
100
+ const inner = Math.max(8, width - margin() * 2);
99
101
  const id = ctx.model?.id || "no-model";
100
102
  const icon = theme.fg("accent", modelIcon(id));
101
103
  const name = theme.fg("text", shortModel(id));
@@ -136,7 +138,7 @@ export function installCwd(ctx, sources) {
136
138
  .join(theme.fg("dim", " · ")));
137
139
  }
138
140
  const row = splitRow(left, right, inner);
139
- return [`${" ".repeat(CHROME_MARGIN)}${row}`];
141
+ return [`${" ".repeat(margin())}${row}`];
140
142
  },
141
143
  };
142
144
  }, { placement: "aboveEditor" });
@@ -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
@@ -1,7 +1,12 @@
1
1
  import { keyHint } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
- import { CHROME_MARGIN } from "./chrome.js";
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";
8
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
9
+ const margin = () => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1));
5
10
  /**
6
11
  * Key hints are shown only when nothing else needs the line: idle agent, empty editor. Otherwise
7
12
  * the footer collapses to zero lines (tracker lines excepted).
@@ -17,14 +22,43 @@ export function footerHints(sources, hints) {
17
22
  out.push("/compact");
18
23
  return out;
19
24
  }
20
- export function installFooter(ctx, sources) {
25
+ export function installFooter(ctx, sources, contributions) {
26
+ let stopMount = () => { };
27
+ let stopped = false;
21
28
  ctx.ui.setFooter((tui, theme, footerData) => {
22
- 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();
23
57
  return {
24
- dispose: unsub,
58
+ dispose: stopMount,
25
59
  invalidate() { },
26
60
  render(width) {
27
- const inner = Math.max(8, width - CHROME_MARGIN * 2);
61
+ const inner = Math.max(8, width - margin() * 2);
28
62
  const out = [];
29
63
  const hints = footerHints(sources, [
30
64
  keyHint("tui.input.submit", "send"),
@@ -33,13 +67,23 @@ export function installFooter(ctx, sources) {
33
67
  ].filter(Boolean));
34
68
  if (hints.length > 0) {
35
69
  const line = theme.fg("dim", hints.join(" · "));
36
- out.push(`${" ".repeat(CHROME_MARGIN)}${truncateToWidth(line, inner, "")}`);
37
- }
38
- for (const extra of formatTrackerLines(ctx, theme)) {
39
- out.push(`${" ".repeat(CHROME_MARGIN)}${truncateToWidth(extra, inner, "")}`);
70
+ out.push(`${" ".repeat(margin())}${truncateToWidth(line, inner, "")}`);
40
71
  }
41
- 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])];
42
85
  },
43
86
  };
44
87
  });
88
+ return () => { stopped = true; stopMount(); };
45
89
  }