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