pi-minimal 0.2.2 → 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 +24 -1
- package/README.md +22 -0
- package/dist/footer-activity-details.js +45 -0
- package/dist/footer-activity.js +36 -0
- package/dist/footer-contribution-events.js +35 -0
- package/dist/footer-contributions.js +130 -0
- package/dist/footer-plan.js +41 -0
- package/dist/footer.js +49 -7
- package/dist/index.js +24 -4
- package/doc/footer-contributions.md +132 -0
- package/package.json +1 -1
- package/release-notes.md +20 -21
- package/src/footer-activity-details.ts +43 -0
- package/src/footer-activity.ts +31 -0
- package/src/footer-contribution-events.ts +38 -0
- package/src/footer-contributions.ts +132 -0
- package/src/footer-plan.ts +44 -0
- package/src/footer.ts +43 -7
- package/src/index.ts +23 -4
- package/test/footer-activity.test.ts +93 -0
- package/test/footer-composition.test.ts +129 -0
- package/test/footer-contribution-events.test.ts +67 -0
- package/test/footer-contributions.test.ts +105 -0
- package/test/footer-plan.test.ts +53 -0
- package/test/index.test.ts +35 -2
- package/test/package.test.ts +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,28 @@ 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
|
+
|
|
13
35
|
## [0.2.2] - 2026-10-22
|
|
14
36
|
|
|
15
37
|
### Added
|
|
@@ -74,7 +96,8 @@ Releases are cut automatically: when the `version` in `package.json` changes on
|
|
|
74
96
|
- Initial release: header/footer chrome, compact tool activity rows, working
|
|
75
97
|
status line, Herdr sibling status, Gauntlet widget hiding.
|
|
76
98
|
|
|
77
|
-
[Unreleased]: https://github.com/raviy06/pi-minimal/compare/v0.2.
|
|
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
|
|
78
101
|
[0.2.2]: https://github.com/raviy06/pi-minimal/compare/v0.2.1...v0.2.2
|
|
79
102
|
[0.2.1]: https://github.com/raviy06/pi-minimal/compare/v0.2.0...v0.2.1
|
|
80
103
|
[0.2.0]: https://github.com/raviy06/pi-minimal/compare/v0.1.0...v0.2.0
|
package/README.md
CHANGED
|
@@ -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,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
|
-
|
|
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:
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
}
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,8 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { loadPiMinimalConfig } from "./config.js";
|
|
3
3
|
import { installHeader } from "./header.js";
|
|
4
4
|
import { installFooter } from "./footer.js";
|
|
5
|
+
import { createFooterContributions } from "./footer-contributions.js";
|
|
6
|
+
import { bindFooterContributionEvents } from "./footer-contribution-events.js";
|
|
5
7
|
import { installCwd } from "./cwd.js";
|
|
6
8
|
import { listSiblings, parseAgentList, parseTabList } from "./herdr.js";
|
|
7
9
|
import { hideGauntletWidgets } from "./tracker.js";
|
|
@@ -20,6 +22,10 @@ export default function piMinimalExtension(pi) {
|
|
|
20
22
|
installToolRenderers(pi);
|
|
21
23
|
installTranscript(pi);
|
|
22
24
|
installCodeBox();
|
|
25
|
+
const contributions = createFooterContributions();
|
|
26
|
+
let stopFooter = () => { };
|
|
27
|
+
let stopContributionEvents = () => { };
|
|
28
|
+
let sessionActive = false;
|
|
23
29
|
let fastTimer;
|
|
24
30
|
let slowTimer;
|
|
25
31
|
let stealTimer;
|
|
@@ -128,11 +134,12 @@ export default function piMinimalExtension(pi) {
|
|
|
128
134
|
return Math.max(0, Math.min(100, Math.round((usage.tokens / window) * 100)));
|
|
129
135
|
};
|
|
130
136
|
const installChromeFooter = (ctx) => {
|
|
131
|
-
|
|
137
|
+
stopFooter();
|
|
138
|
+
stopFooter = installFooter(ctx, {
|
|
132
139
|
busy: () => busy,
|
|
133
140
|
editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
|
|
134
141
|
contextPct: () => contextPct(ctx),
|
|
135
|
-
});
|
|
142
|
+
}, contributions);
|
|
136
143
|
};
|
|
137
144
|
const installStatusLine = (ctx) => {
|
|
138
145
|
installCwd(ctx, {
|
|
@@ -149,6 +156,11 @@ export default function piMinimalExtension(pi) {
|
|
|
149
156
|
installStatusLine(ctx);
|
|
150
157
|
};
|
|
151
158
|
pi.on("session_start", async (_event, ctx) => {
|
|
159
|
+
stopFooter();
|
|
160
|
+
stopContributionEvents();
|
|
161
|
+
sessionActive = true;
|
|
162
|
+
generation += 1;
|
|
163
|
+
stopContributionEvents = bindFooterContributionEvents(pi.events, contributions, ctx.sessionManager.getSessionId(), ctx.mode);
|
|
152
164
|
if (ctx.mode !== "tui")
|
|
153
165
|
return;
|
|
154
166
|
const config = loadPiMinimalConfig({
|
|
@@ -172,8 +184,13 @@ export default function piMinimalExtension(pi) {
|
|
|
172
184
|
pinChrome(ctx, "session_start");
|
|
173
185
|
ctx.ui.setHiddenThinkingLabel("◆ Thought");
|
|
174
186
|
ctx.ui.setWorkingIndicator();
|
|
175
|
-
|
|
176
|
-
|
|
187
|
+
const sessionGeneration = generation;
|
|
188
|
+
const settle = () => {
|
|
189
|
+
if (sessionActive && generation === sessionGeneration)
|
|
190
|
+
pinChrome(ctx, "session_settle");
|
|
191
|
+
};
|
|
192
|
+
setTimeout(settle, 0);
|
|
193
|
+
setTimeout(settle, 50);
|
|
177
194
|
if (!stealTimer) {
|
|
178
195
|
stealTimer = setInterval(() => hideForeignChrome(ctx, "interval"), 200);
|
|
179
196
|
}
|
|
@@ -197,6 +214,9 @@ export default function piMinimalExtension(pi) {
|
|
|
197
214
|
void gitState.refresh();
|
|
198
215
|
});
|
|
199
216
|
pi.on("session_shutdown", async () => {
|
|
217
|
+
sessionActive = false;
|
|
218
|
+
stopFooter();
|
|
219
|
+
stopContributionEvents();
|
|
200
220
|
busy = false;
|
|
201
221
|
stopTimers();
|
|
202
222
|
stopWorkingStatus();
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Footer activity contributions (v1)
|
|
2
|
+
|
|
3
|
+
Pi-minimal remains the single footer owner. A trusted companion extension can publish a bounded activity snapshot without replacing the footer or fighting other widgets. With no contributor, existing hints and legacy phase/task summaries are unchanged.
|
|
4
|
+
|
|
5
|
+
Order: **input → existing hints → plan summary → activity rows last**. When either contribution is visible, a blank row separates the footer from input and separates each nonempty group. Agent rows stay compact, and empty groups leave no extra gaps. Legacy-only output is unchanged.
|
|
6
|
+
|
|
7
|
+
Two independent slots are supported: `activity` and `summary`, with one owner per slot. An explicit plan summary replaces the legacy tracker projection while present, avoiding duplicate plan bars; clearing it restores the legacy summary. This is display composition, not a task tracker, subagent launcher, or PR/workspace display.
|
|
8
|
+
|
|
9
|
+
## Events
|
|
10
|
+
|
|
11
|
+
| Event | Direction | Payload |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `pi-minimal:footer:v1:register` | consumer → host | Mutable registration request; host assigns `result` synchronously |
|
|
14
|
+
| `pi-minimal:footer:v1:ready` | host → consumers | `{ version: 1, sessionId, slots: ["activity", "summary"] }` |
|
|
15
|
+
| `pi-minimal:footer:v1:unavailable` | host → consumers | `{ version: 1, sessionId }` |
|
|
16
|
+
|
|
17
|
+
Use `ctx.sessionManager.getSessionId()`, not a file path. Events are in-process only, not a cross-process API. One consumer owns each slot at a time; a tracker and activity extension can register independently. Names/keys are display identity, not permission or process-control tokens.
|
|
18
|
+
|
|
19
|
+
Register during `session_start`; also listen for `ready` to tolerate either extension load order. Clear the local handle on `unavailable`. Do not attempt to reinstall the footer if no host responds.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const request = {
|
|
23
|
+
version: 1,
|
|
24
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
25
|
+
owner: "my-activity-extension",
|
|
26
|
+
slot: "activity",
|
|
27
|
+
result: undefined as RegisterRequest["result"],
|
|
28
|
+
};
|
|
29
|
+
pi.events.emit("pi-minimal:footer:v1:register", request);
|
|
30
|
+
if (request.result?.ok) {
|
|
31
|
+
const registration = request.result.registration;
|
|
32
|
+
const result = registration.publish({
|
|
33
|
+
revision: 0,
|
|
34
|
+
motion: "spinner",
|
|
35
|
+
format: "icons",
|
|
36
|
+
omitted: 0,
|
|
37
|
+
rows: [
|
|
38
|
+
{ key: "child-1", agent: "worker", state: "running", text: "Last: executing tests" },
|
|
39
|
+
{ key: "child-2", agent: "reviewer", state: "completed", text: "Review output available" },
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
// Inspect result.ok; publication is not a task-completion acknowledgement.
|
|
43
|
+
// On consumer shutdown: registration.dispose().
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This snippet assumes the following structural types, copied into the consumer or maintained as a type-only development contract. No runtime import from an independently installed npm package is necessary. `src/footer-contribution-events.ts` and `src/footer-contributions.ts` are the host's canonical definitions.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
type RegisterRequest = {
|
|
51
|
+
version: 1;
|
|
52
|
+
sessionId: string;
|
|
53
|
+
owner: string;
|
|
54
|
+
slot: "activity" | "summary";
|
|
55
|
+
result?:
|
|
56
|
+
| { ok: true; registration: {
|
|
57
|
+
publish(snapshot: unknown): { ok: true } | { ok: false; code: string };
|
|
58
|
+
dispose(): void;
|
|
59
|
+
} }
|
|
60
|
+
| { ok: false; code: string };
|
|
61
|
+
};
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
No result means the host is absent/not ready. Registration errors include `invalid`, `wrong-session`, `occupied`, and `not-tui`. Publish errors include `invalid`, `stale`, and `disposed`.
|
|
65
|
+
|
|
66
|
+
## Plan summary snapshot (`summary` slot)
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
{
|
|
70
|
+
revision: 0,
|
|
71
|
+
format: "icons", // or "plain"
|
|
72
|
+
plan: { id: "P12", accepted: 3, total: 7, inReview: 1, blocked: 1 }
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The plan ID follows row-key bounds. Counts/revision are nonnegative safe integers;
|
|
77
|
+
accepted cannot exceed total; review and blocked counts cannot individually exceed
|
|
78
|
+
remaining tasks. Counts are supplied by the tracker, never inferred from agent
|
|
79
|
+
runs. Omit `plan` on a newer revision to clear the contribution without releasing
|
|
80
|
+
ownership. Snapshot objects are cloned/frozen, and revision/disposal rules match
|
|
81
|
+
the activity slot. Each slot has independent ownership and revision tracking.
|
|
82
|
+
|
|
83
|
+
The label and filled segments use the semantic `accent` color (violet in
|
|
84
|
+
minimal-dark); empty segments are muted. Up to 12 tasks use one slanted segment
|
|
85
|
+
per task (`▰▰▰▱▱▱▱ 3/7`). Larger plans use ten proportional segments and retain the
|
|
86
|
+
exact count; these represent aggregate progress, not individual task states.
|
|
87
|
+
Plain mode uses `###....`. The strip drops before counts/attention on narrow
|
|
88
|
+
terminals. Zero total displays `No tasks yet`. No animation or task persistence
|
|
89
|
+
is introduced by this display slot.
|
|
90
|
+
|
|
91
|
+
## Activity snapshot schema (`activity` slot)
|
|
92
|
+
|
|
93
|
+
- `revision`: nonnegative safe integer, strictly increasing for each successful publish on one registration. Start at zero again after a new registration.
|
|
94
|
+
- `motion`: `spinner` or `static`.
|
|
95
|
+
- `format`: `icons` or `plain` (explicit textual state labels).
|
|
96
|
+
- `omitted`: nonnegative safe integer; extra underlying records not supplied in this snapshot.
|
|
97
|
+
- `rows`: at most 64 records; an empty array clears visible activity without releasing ownership.
|
|
98
|
+
|
|
99
|
+
Each row:
|
|
100
|
+
- `key`: unique 1–128 character identity, ASCII letters/numbers plus `. _ : @ + -`.
|
|
101
|
+
- `agent`: nonempty label after sanitization, at most 96 input characters.
|
|
102
|
+
- `state`: `queued`, `running`, `waiting`, `paused`, `stopping`, `completed`, `failed`, `stopped`, or `unknown`.
|
|
103
|
+
- `text`: at most 256 input characters; keep it short, safe, and evidence-grounded.
|
|
104
|
+
- Optional `parentKey`: references a key in the same snapshot. Missing references, self-parenting, and cycles are rejected.
|
|
105
|
+
- Optional `metrics`: `{ elapsedMs?: number, tokens?: number }`. Both values must be nonnegative safe integers. An absent object preserves the original compact row; an empty object displays unknown values (`—`, or `-` in plain mode), never invented zeros.
|
|
106
|
+
- Optional `task`: `{ planId: string, id: string, status: "todo" | "in_progress" | "blocked" | "in_review" | "done" | "cancelled" }`. Both IDs use the same bounds as row keys. The link is display context, not permission to read/write a plan.
|
|
107
|
+
|
|
108
|
+
Metrics are right-aligned only if the full activity text still fits with at least two columns of separation. Drop tokens first, then time, before truncating activity. Elapsed time is supplied by the producer: compute from the run start while active, freeze at the run's end, and do not advance completed/unknown runs by inference. `tokens` means this child's own cumulative reported input + output tokens, excluding separately summed descendants. It is not context-window occupancy; omit unavailable data rather than guessing. The host does not calculate elapsed time from wall-clock time.
|
|
109
|
+
|
|
110
|
+
Task IDs appear beside activity. In-progress tasks need no extra badge; blocked/review/accepted/cancelled tasks get a distinct badge, and plain mode retains textual status. A completed agent with an `in_review` task shows a green run check plus an accent review diamond. This optional data does not create a tracker or automatically accept a task.
|
|
111
|
+
|
|
112
|
+
Owner uses the same character/length rules as row keys. Display text has ANSI/OSC, C0/C1, and bidi controls removed, with whitespace collapsed. The host clones and freezes validated snapshots. Invalid/stale publishes preserve the previous valid snapshot. Consumers must handle publish failure and send an explicit unknown/error observation when appropriate; an absent update does not prove a run ended.
|
|
113
|
+
|
|
114
|
+
## Presentation and responsibility boundaries
|
|
115
|
+
|
|
116
|
+
The host renders the first four supplied rows and one overflow line, without reordering them. Consumer owns stable ordering, priority, retention, dismissal and source reconciliation. Nesting is indented at most two levels. Terminal-width helpers enforce visible-column limits.
|
|
117
|
+
|
|
118
|
+
Running uses pi-minimal's yellow `◐`/`◓` frames every 400ms. Completed uses green `✓`; failed uses red `✗`. Attention/stopping is yellow `!`; stopping includes an explicit prefix. Other shapes remain distinct in monochrome. Plain mode retains state words and does not animate. `static` also disables animation. Consumers should choose static/plain according to user accessibility preferences; this terminal contract does not pretend to detect browser reduced-motion settings.
|
|
119
|
+
|
|
120
|
+
Only visible running rows animate; animation performs no provider queries, model calls, or filesystem reads. Host semantic theme colors are resolved each render. A completed run means only that run completed; it must not be presented as accepted task work without separate evidence.
|
|
121
|
+
|
|
122
|
+
The host does not validate execution truth. It validates display shape and lifecycle ownership. It does not publish or hide pi-subagents widgets, mutate any other extension's settings, store run history, or expose stop/steer actions.
|
|
123
|
+
|
|
124
|
+
## Lifecycle and rollback
|
|
125
|
+
|
|
126
|
+
Footer component reinstalls preserve the session registry, while disposing old redraw/branch listeners and animation timers. Session shutdown/replacement invalidates registration handles even if the next session reuses the same ID. A stale disposer cannot clear a newer owner. Dispose is idempotent.
|
|
127
|
+
|
|
128
|
+
Consumers dispose their handle and event subscriptions on shutdown, and register again in the new runtime. Use strictly increasing revisions to prevent late responses overwriting fresh data; request/session correlation remains the consumer's responsibility.
|
|
129
|
+
|
|
130
|
+
Disable the consumer to remove activity rows. No process is stopped and no task state is changed. The host contribution API is inert without a consumer. Test a new consumer in an isolated pi profile before changing live configuration.
|
|
131
|
+
|
|
132
|
+
This is a trusted extension contract, not a sandbox against malicious code already loaded into the process.
|