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 +42 -1
- package/README.md +25 -1
- package/dist/chrome.js +15 -0
- package/dist/code-box.js +69 -0
- package/dist/cwd.js +5 -3
- 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 +54 -10
- package/dist/index.js +35 -4
- package/dist/tools.js +9 -6
- package/dist/turn-rule.js +5 -3
- package/doc/footer-contributions.md +132 -0
- package/package.json +1 -1
- package/release-notes.md +20 -19
- package/src/chrome.ts +18 -0
- package/src/code-box.ts +86 -0
- package/src/cwd.ts +7 -3
- 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 +50 -10
- package/src/index.ts +33 -4
- package/src/tools.ts +11 -6
- package/src/turn-rule.ts +7 -3
- package/test/code-box.test.ts +55 -0
- 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/test/theme.test.ts +2 -2
- package/themes/minimal-dark.json +1 -1
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.
|
|
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
|
-
- **
|
|
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
|
+
}
|
package/dist/code-box.js
ADDED
|
@@ -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
|
|
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 -
|
|
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(
|
|
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
|
|
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
|
-
|
|
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:
|
|
58
|
+
dispose: stopMount,
|
|
25
59
|
invalidate() { },
|
|
26
60
|
render(width) {
|
|
27
|
-
const inner = Math.max(8, width -
|
|
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(
|
|
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
|
-
|
|
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
|
}
|