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/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();
|
package/dist/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Container, truncateToWidth } from "@earendil-works/pi-tui";
|
|
1
|
+
import { Container, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { rowIsRunning } from "./activity.js";
|
|
3
3
|
import * as chrome from "./chrome.js";
|
|
4
|
+
import { callSummary, codemodeOutcome, nestedCalls, nestedItem, scriptOutput } from "./codemode-rows.js";
|
|
4
5
|
import { createLiveWatch } from "./live-watch.js";
|
|
5
6
|
import { bashBody, formatDuration, formatOutcome, layoutRow, resultText, rowGlyph, toolItem, } from "./tool-rows.js";
|
|
6
7
|
/** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
|
|
@@ -157,6 +158,141 @@ export function compactRenderers(kind) {
|
|
|
157
158
|
},
|
|
158
159
|
};
|
|
159
160
|
}
|
|
161
|
+
/** Max nested calls shown under a collapsed codemode row; the rest are counted. */
|
|
162
|
+
const NESTED_PREVIEW = 8;
|
|
163
|
+
/** Width of the nested tool-name column. */
|
|
164
|
+
const NESTED_NAME_MAX = 14;
|
|
165
|
+
function nestedGlyph(call, frame) {
|
|
166
|
+
if (call.status === "running")
|
|
167
|
+
return { glyph: rowGlyph("running", frame), color: "warning" };
|
|
168
|
+
if (call.status === "error")
|
|
169
|
+
return { glyph: "\u2717", color: "error" };
|
|
170
|
+
if (call.status === "cancelled")
|
|
171
|
+
return { glyph: "\u2298", color: "dim" };
|
|
172
|
+
return { glyph: "\u2713", color: "success" };
|
|
173
|
+
}
|
|
174
|
+
/** Lines of a `\u2502`-ruled block, dim and truncated, for script source or output on expand. */
|
|
175
|
+
function ruledLines(text, width, theme, indent, label) {
|
|
176
|
+
const inner = Math.max(0, width - indent.length - 2);
|
|
177
|
+
const rule = theme.fg("borderMuted", "\u2502");
|
|
178
|
+
let lines = text.split("\n");
|
|
179
|
+
if (lines.length > EXPANDED_MAX_LINES)
|
|
180
|
+
lines = [...lines.slice(0, EXPANDED_MAX_LINES), `\u2026 ${lines.length - EXPANDED_MAX_LINES} more lines`];
|
|
181
|
+
const out = label ? [`${indent}${rule} ${theme.fg("dim", truncateToWidth(label, inner))}`] : [];
|
|
182
|
+
return out.concat(lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* codemode as a tool row: `\u2713 code 3 calls \u00b7 plan_tracker \u00d72, bash \u2026 54 lines \u00b7 1.2s`, then one
|
|
186
|
+
* child row per nested call. The script and its output appear only when expanded, undecorated,
|
|
187
|
+
* so syntax colours never compete with status colours in the transcript.
|
|
188
|
+
*/
|
|
189
|
+
export function codemodeRenderers() {
|
|
190
|
+
return {
|
|
191
|
+
renderShell: "self",
|
|
192
|
+
renderCall(args, theme, rawContext) {
|
|
193
|
+
const context = rawContext;
|
|
194
|
+
const code = typeof args?.code === "string" ? (args.code) : "";
|
|
195
|
+
context.state.code = code;
|
|
196
|
+
const id = context.toolCallId || "codemode";
|
|
197
|
+
const running = rowIsRunning(context);
|
|
198
|
+
context.state.running = running;
|
|
199
|
+
if (running) {
|
|
200
|
+
context.state.startedAt ??= Date.now();
|
|
201
|
+
live.watch(id, context.invalidate);
|
|
202
|
+
}
|
|
203
|
+
else
|
|
204
|
+
live.unwatch(id);
|
|
205
|
+
return {
|
|
206
|
+
render(width) {
|
|
207
|
+
const state = context.state;
|
|
208
|
+
const status = state.outcome?.errorLine !== undefined || context.isError ? "error" : rowIsRunning(context) ? "running" : "ok";
|
|
209
|
+
const frame = status === "running" && live.has(id) ? live.frame : 0;
|
|
210
|
+
const calls = state.calls ?? [];
|
|
211
|
+
const right = status === "running" ? (state.startedAt ? formatDuration(Date.now() - state.startedAt) : "") : (state.outcome?.text ?? "");
|
|
212
|
+
const inner = Math.max(8, width - rowIndent());
|
|
213
|
+
const row = layoutRow(inner, "code", callSummary(calls), right);
|
|
214
|
+
const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim";
|
|
215
|
+
const lines = [
|
|
216
|
+
" ".repeat(rowIndent()) +
|
|
217
|
+
theme.fg(statusColor(status), rowGlyph(status, frame)) +
|
|
218
|
+
" " +
|
|
219
|
+
theme.fg("dim", row.verb) +
|
|
220
|
+
" " +
|
|
221
|
+
theme.fg(status === "error" ? "text" : "muted", row.item) +
|
|
222
|
+
" ".repeat(row.gap) +
|
|
223
|
+
theme.fg(rightColor, row.outcome),
|
|
224
|
+
];
|
|
225
|
+
const expanded = Boolean(context.expanded);
|
|
226
|
+
const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW);
|
|
227
|
+
const childIndent = " ".repeat(rowIndent() + 4);
|
|
228
|
+
if (shown.length < calls.length) {
|
|
229
|
+
lines.push(`${childIndent}${theme.fg("dim", `\u2026 ${calls.length - shown.length} earlier calls`)}`);
|
|
230
|
+
}
|
|
231
|
+
const nameWidth = Math.min(NESTED_NAME_MAX, Math.max(0, ...shown.map((call) => (call.name ?? "tool").length)));
|
|
232
|
+
for (const call of shown) {
|
|
233
|
+
const { glyph, color } = nestedGlyph(call, frame);
|
|
234
|
+
const name = truncateToWidth(call.name ?? "tool", nameWidth, "\u2026").padEnd(nameWidth);
|
|
235
|
+
const dur = call.status === "error" ? `failed \u00b7 ${formatDuration(call.durationMs)}` : formatDuration(call.durationMs);
|
|
236
|
+
const room = Math.max(8, width - childIndent.length);
|
|
237
|
+
// Reuse the row grid with the name as the "verb" column, then fix the glyph slot.
|
|
238
|
+
const prefixWidth = 2 + nameWidth + 1;
|
|
239
|
+
const itemRoom = Math.max(0, room - prefixWidth - (dur ? visibleWidth(dur) + 2 : 0));
|
|
240
|
+
const item = truncateToWidth(nestedItem(call), itemRoom, "\u2026");
|
|
241
|
+
const gap = dur ? Math.max(2, room - prefixWidth - visibleWidth(item) - visibleWidth(dur)) : 0;
|
|
242
|
+
lines.push(childIndent +
|
|
243
|
+
theme.fg(color, glyph) +
|
|
244
|
+
" " +
|
|
245
|
+
theme.fg("dim", name) +
|
|
246
|
+
" " +
|
|
247
|
+
theme.fg(call.status === "error" ? "text" : "muted", item) +
|
|
248
|
+
" ".repeat(gap) +
|
|
249
|
+
theme.fg(call.status === "error" ? "error" : "dim", dur));
|
|
250
|
+
}
|
|
251
|
+
return lines;
|
|
252
|
+
},
|
|
253
|
+
invalidate() { },
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
renderResult(result, options, theme, rawContext) {
|
|
257
|
+
const context = rawContext;
|
|
258
|
+
const typed = result;
|
|
259
|
+
context.state.calls = nestedCalls(typed);
|
|
260
|
+
if (!context.isPartial) {
|
|
261
|
+
context.state.done = true;
|
|
262
|
+
context.state.running = false;
|
|
263
|
+
const outcome = codemodeOutcome(typed, Boolean(context.isError), context.durationMs);
|
|
264
|
+
context.state.outcome = { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined };
|
|
265
|
+
context.state.output = scriptOutput(typed);
|
|
266
|
+
live.unwatch(context.toolCallId || "codemode");
|
|
267
|
+
}
|
|
268
|
+
// No invalidate here: the host runs renderCall and renderResult in one updateDisplay() and
|
|
269
|
+
// paints afterwards, so the call row's lazy render() already sees these calls. Calling
|
|
270
|
+
// invalidate() would re-enter updateDisplay() and recurse.
|
|
271
|
+
const container = new Container();
|
|
272
|
+
const expanded = Boolean(options.expanded);
|
|
273
|
+
const errorLine = context.state.outcome?.errorLine;
|
|
274
|
+
if (errorLine && !expanded)
|
|
275
|
+
container.addChild(errorLineBlock(errorLine, theme));
|
|
276
|
+
if (expanded) {
|
|
277
|
+
const indent = " ".repeat(rowIndent() + 4);
|
|
278
|
+
const code = context.state.code ?? "";
|
|
279
|
+
const output = context.state.output ?? "";
|
|
280
|
+
container.addChild({
|
|
281
|
+
render(width) {
|
|
282
|
+
const lines = [];
|
|
283
|
+
if (code)
|
|
284
|
+
lines.push(...ruledLines(code.trimEnd(), width, theme, indent, `// script \u00b7 ${code.trimEnd().split("\n").length} lines`));
|
|
285
|
+
if (output)
|
|
286
|
+
lines.push(...ruledLines(output, width, theme, indent, "// output"));
|
|
287
|
+
return lines;
|
|
288
|
+
},
|
|
289
|
+
invalidate() { },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return container;
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
160
296
|
/**
|
|
161
297
|
* Attach the compact renderers to the built-in tools without re-registering them. Registered at
|
|
162
298
|
* extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
|
|
@@ -165,6 +301,8 @@ export function installToolRenderers(pi) {
|
|
|
165
301
|
if (typeof pi.registerToolRenderer !== "function")
|
|
166
302
|
return;
|
|
167
303
|
pi.registerToolRenderer((toolName, next) => {
|
|
304
|
+
if (toolName === "codemode")
|
|
305
|
+
return codemodeRenderers();
|
|
168
306
|
if (!isToolKind(toolName))
|
|
169
307
|
return next();
|
|
170
308
|
return compactRenderers(toolName);
|
|
@@ -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.
|
package/package.json
CHANGED
package/release-notes.md
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
### Added
|
|
2
|
-
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
the
|
|
2
|
+
- `codemode` renders as a tool row: `✓ code 3 calls · plan_tracker ×2, bash … 54 lines · 1.2s`,
|
|
3
|
+
with one child row per nested call. Built-in tools inside a script read exactly like their
|
|
4
|
+
top-level rows (`bash` shows its command); other tools show up to three scalar arguments as
|
|
5
|
+
`key=value` instead of raw JSON. Children update live while the script runs. The script source
|
|
6
|
+
and output appear only on `ctrl+o`, in plain dim text, so syntax colours no longer compete with
|
|
7
|
+
status colours. A failed nested call or script error shows its first line under the row, like
|
|
8
|
+
`bash`. Previously codemode fell through to pi-core's default panel: a background slab with the
|
|
9
|
+
highlighted script, JSON arguments, an output preview and a `Took …` timer.
|
|
6
10
|
|
|
7
11
|
### Changed
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
- `minimal-dark` contrast: `dim` (verbs, outcomes, tool output) L 48 % → 56 % and `thinkingText`
|
|
13
|
+
52 % → 58 %, estimated ~3.1:1 → ~4.4:1 and ~3.7:1 → ~4.8:1 on the background. They were within
|
|
14
|
+
4 % of each other and below WCAG's 4.5:1 text target. `faint` 34 % → 36 %.
|
|
15
|
+
- `minimal-dark` keeps syntax colours off the status hues: `syntaxFunction` was warning-yellow
|
|
16
|
+
(now lavender) and `syntaxNumber` was success-green (now teal), so code no longer looks like a
|
|
17
|
+
spinner or a check mark.
|
|
18
|
+
- `minimal-dark` draws no background behind pending/successful tool output (`toolPendingBg`,
|
|
19
|
+
`toolSuccessBg` → `bg`). Tools pi-minimal does not restyle yet (`subagent`, MCP tools) lose
|
|
20
|
+
their slab; failures keep `toolErrorBg`.
|
|
15
21
|
|
|
16
22
|
## What's Changed
|
|
17
|
-
*
|
|
18
|
-
* Add Dependabot configuration file by @raviy06 in https://github.com/raviy06/pi-minimal/pull/9
|
|
19
|
-
* Create SECURITY.md for security policy by @raviy06 in https://github.com/raviy06/pi-minimal/pull/10
|
|
20
|
-
* Pin npm 11 for trusted publishing by @raviy06 in https://github.com/raviy06/pi-minimal/pull/8
|
|
21
|
-
* Drop registry-url so npm uses OIDC instead of a placeholder token by @raviy06 in https://github.com/raviy06/pi-minimal/pull/11
|
|
22
|
-
* Add repository metadata required for npm provenance by @raviy06 in https://github.com/raviy06/pi-minimal/pull/12
|
|
23
|
-
* Frame fenced code, align chrome with outputPad (0.2.2) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/13
|
|
23
|
+
* feat: render codemode as a tool row; raise minimal-dark contrast (0.3.0) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/15
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
**Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.2.
|
|
26
|
+
**Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.2.3...v0.3.0
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
**Install / upgrade**
|
|
30
30
|
```bash
|
|
31
|
-
pi install npm:pi-minimal@0.
|
|
31
|
+
pi install npm:pi-minimal@0.3.0 # or: pi update --extensions
|
|
32
32
|
```
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { firstLine, formatDuration, resultText, toolItem, type ToolKind, type ToolResultLike } from "./tool-rows.js"
|
|
2
|
+
|
|
3
|
+
/** Mirrors pi-core's `CodemodeNestedCall`; every field is optional so older hosts degrade quietly. */
|
|
4
|
+
export type NestedCall = {
|
|
5
|
+
id?: string
|
|
6
|
+
name?: string
|
|
7
|
+
/** Compact JSON of the arguments, truncated by the host at 200 chars (may not parse). */
|
|
8
|
+
args?: string
|
|
9
|
+
status?: "running" | "ok" | "error" | "cancelled"
|
|
10
|
+
durationMs?: number
|
|
11
|
+
error?: string
|
|
12
|
+
cost?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const KINDS: ReadonlySet<string> = new Set<ToolKind>(["bash", "read", "edit", "write", "grep", "find", "ls"])
|
|
16
|
+
const SCRIPT_HEADER = /^Script (?:completed|failed)\nWall time [\d.]+ seconds\nOutput:\n?/
|
|
17
|
+
/** Argument keys toolItem() reads, in the order it prefers them. */
|
|
18
|
+
const ITEM_KEYS = ["command", "path", "file_path", "target_directory", "pattern"] as const
|
|
19
|
+
|
|
20
|
+
export function nestedCalls(result: ToolResultLike | undefined): NestedCall[] {
|
|
21
|
+
const calls = (result?.details as { calls?: unknown } | undefined)?.calls
|
|
22
|
+
if (!Array.isArray(calls)) return []
|
|
23
|
+
return calls.filter((call): call is NestedCall => Boolean(call) && typeof call === "object")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Script output without pi-core's "Script completed / Wall time / Output:" header. */
|
|
27
|
+
export function scriptOutput(result: ToolResultLike | undefined): string {
|
|
28
|
+
return resultText(result).replace(SCRIPT_HEADER, "").trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse the host's argument preview. Complete JSON parses directly; a preview cut at 200 chars
|
|
33
|
+
* still yields the first string value of each known key, which is all a one-line row needs.
|
|
34
|
+
*/
|
|
35
|
+
export function parseArgs(args: string | undefined): Record<string, unknown> {
|
|
36
|
+
if (!args) return {}
|
|
37
|
+
try {
|
|
38
|
+
const value = JSON.parse(args) as unknown
|
|
39
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
|
40
|
+
} catch {
|
|
41
|
+
const out: Record<string, unknown> = {}
|
|
42
|
+
const pair = /"([A-Za-z_$][\w$]*)":(?:"((?:[^"\\]|\\.)*)"?|(-?\d+(?:\.\d+)?|true|false|null))/g
|
|
43
|
+
for (const m of args.matchAll(pair)) {
|
|
44
|
+
if (m[1] in out) continue
|
|
45
|
+
if (m[2] !== undefined) out[m[1]] = unescape(m[2])
|
|
46
|
+
else if (m[3] !== undefined) out[m[1]] = JSON.parse(m[3])
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function unescape(raw: string): string {
|
|
53
|
+
// A cut can land inside an escape; drop a dangling backslash so JSON.parse accepts the rest.
|
|
54
|
+
const safe = raw.replace(/\\(u[0-9a-fA-F]{0,3})?$/, "")
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(`"${safe}"`) as string
|
|
57
|
+
} catch {
|
|
58
|
+
return safe
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scalar(value: unknown): string | undefined {
|
|
63
|
+
if (typeof value === "string") return value.split("\n")[0]?.trim()
|
|
64
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The item column of a nested call. Built-in tools read exactly like their top-level rows
|
|
70
|
+
* (`bash` shows its command, `read` a short path). Other tools show up to three scalar
|
|
71
|
+
* arguments as `key=value`, strings unquoted, instead of raw JSON.
|
|
72
|
+
*/
|
|
73
|
+
export function nestedItem(call: NestedCall): string {
|
|
74
|
+
const name = call.name ?? "tool"
|
|
75
|
+
const args = parseArgs(call.args)
|
|
76
|
+
if (KINDS.has(name)) {
|
|
77
|
+
const picked: Record<string, unknown> = {}
|
|
78
|
+
for (const key of ITEM_KEYS) if (key in args) picked[key] = args[key]
|
|
79
|
+
return toolItem(name as ToolKind, picked)
|
|
80
|
+
}
|
|
81
|
+
const parts: string[] = []
|
|
82
|
+
for (const [key, value] of Object.entries(args)) {
|
|
83
|
+
const shown = scalar(value)
|
|
84
|
+
if (shown === undefined || shown === "") continue
|
|
85
|
+
parts.push(`${key}=${shown}`)
|
|
86
|
+
if (parts.length === 3) break
|
|
87
|
+
}
|
|
88
|
+
return parts.join(" ")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `3 calls · plan_tracker ×2, bash` — names in first-call order with repeat counts. */
|
|
92
|
+
export function callSummary(calls: readonly NestedCall[]): string {
|
|
93
|
+
if (calls.length === 0) return "script"
|
|
94
|
+
const counts = new Map<string, number>()
|
|
95
|
+
for (const call of calls) counts.set(call.name ?? "tool", (counts.get(call.name ?? "tool") ?? 0) + 1)
|
|
96
|
+
const names = [...counts].map(([name, n]) => (n > 1 ? `${name} ×${n}` : name)).join(", ")
|
|
97
|
+
return `${calls.length} call${calls.length === 1 ? "" : "s"} · ${names}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type CodemodeOutcome = { text: string; failed: boolean; errorLine?: string }
|
|
101
|
+
|
|
102
|
+
/** Right-hand outcome of the parent row, and the line shown under it when something failed. */
|
|
103
|
+
export function codemodeOutcome(
|
|
104
|
+
result: ToolResultLike | undefined,
|
|
105
|
+
isError: boolean,
|
|
106
|
+
durationMs: number | undefined,
|
|
107
|
+
): CodemodeOutcome {
|
|
108
|
+
const calls = nestedCalls(result)
|
|
109
|
+
const output = scriptOutput(result)
|
|
110
|
+
const failedCalls = calls.filter((call) => call.status === "error")
|
|
111
|
+
const dur = formatDuration(durationMs)
|
|
112
|
+
const join = (...parts: string[]) => parts.filter(Boolean).join(" · ")
|
|
113
|
+
if (isError || failedCalls.length > 0) {
|
|
114
|
+
const label = failedCalls.length > 0 ? `${failedCalls.length} failed` : "failed"
|
|
115
|
+
const scriptError = /Script error:\s*(.*)/.exec(output)?.[1]
|
|
116
|
+
const errorLine = firstLine(failedCalls[0]?.error ?? "") ?? scriptError ?? firstLine(output)
|
|
117
|
+
return { text: join(label, dur), failed: true, errorLine }
|
|
118
|
+
}
|
|
119
|
+
const lines = output ? output.split("\n").filter((line) => line.length > 0).length : 0
|
|
120
|
+
return { text: join(lines === 0 ? "ok" : `${lines} line${lines === 1 ? "" : "s"}`, dur), failed: false }
|
|
121
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
|
|
2
|
+
import type { ActivityMetrics, ActivityTask } from "./footer-contributions.js"
|
|
3
|
+
|
|
4
|
+
export type ActivityTone = "warning" | "success" | "error" | "muted" | "accent"
|
|
5
|
+
export type ActivityTheme = { fg: (color: ActivityTone, text: string) => string }
|
|
6
|
+
|
|
7
|
+
export function taskPrefix(task: ActivityTask | undefined, plain: boolean, theme: ActivityTheme): string {
|
|
8
|
+
if (!task) return ""
|
|
9
|
+
if (plain) return `[${task.status}] ${task.id} / `
|
|
10
|
+
const badges: Partial<Record<ActivityTask["status"], [string, ActivityTone]>> = {
|
|
11
|
+
blocked: ["!", "warning"], in_review: ["◇", "accent"], done: ["✓", "success"], cancelled: ["⊘", "muted"],
|
|
12
|
+
}
|
|
13
|
+
const badge = badges[task.status]
|
|
14
|
+
return `${badge ? theme.fg(badge[1], badge[0]) + " " : ""}${task.id} · `
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function elapsed(ms: number | undefined, missing: string): string {
|
|
18
|
+
if (ms === undefined) return missing
|
|
19
|
+
const seconds = Math.floor(ms / 1000)
|
|
20
|
+
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${String(Math.floor(seconds / 60) % 60).padStart(2, "0")}m`
|
|
21
|
+
if (seconds >= 60) return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`
|
|
22
|
+
return `${seconds}s`
|
|
23
|
+
}
|
|
24
|
+
function tokens(value: number | undefined, missing: string): string {
|
|
25
|
+
if (value === undefined) return missing
|
|
26
|
+
if (value >= 999950) return `${(value / 1000000).toFixed(1)}m`
|
|
27
|
+
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`
|
|
28
|
+
return String(value)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Metrics never displace activity text: first drop tokens, then elapsed time. */
|
|
32
|
+
export function alignActivityMetrics(left: string, metrics: ActivityMetrics | undefined, width: number, plain: boolean, theme: ActivityTheme): string {
|
|
33
|
+
if (metrics) {
|
|
34
|
+
const missing = plain ? "-" : "—"
|
|
35
|
+
const time = elapsed(metrics.elapsedMs, missing)
|
|
36
|
+
const full = `${time}${plain ? " / " : " · "}${tokens(metrics.tokens, missing)} tok`
|
|
37
|
+
for (const right of [full, time]) {
|
|
38
|
+
const gap = width - visibleWidth(left) - visibleWidth(right)
|
|
39
|
+
if (gap >= 2) return left + " ".repeat(gap) + theme.fg("muted", right)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return truncateToWidth(left, width)
|
|
43
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui"
|
|
2
|
+
import { cleanActivityText, type ActivitySnapshot, type ActivityState } from "./footer-contributions.js"
|
|
3
|
+
import { rowGlyph } from "./tool-rows.js"
|
|
4
|
+
import { alignActivityMetrics, taskPrefix, type ActivityTone, type ActivityTheme } from "./footer-activity-details.js"
|
|
5
|
+
const MAX_ROWS = 4
|
|
6
|
+
const indicators: Record<ActivityState, [string, ActivityTone]> = {
|
|
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: ActivitySnapshot | undefined): boolean {
|
|
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: ActivitySnapshot | undefined, width: number, theme: ActivityTheme, frame: 0 | 1 = 0): string[] {
|
|
14
|
+
if (!snapshot || width <= 0) return []
|
|
15
|
+
const parents = new Map(snapshot.rows.map(row => [row.key, row.parentKey]))
|
|
16
|
+
const lines = snapshot.rows.slice(0, MAX_ROWS).map(row => {
|
|
17
|
+
const [fallback, tone] = indicators[row.state]
|
|
18
|
+
const glyph = row.state === "running" ? rowGlyph("running", snapshot.motion === "spinner" ? frame : 0)
|
|
19
|
+
: row.state === "completed" ? rowGlyph("ok", 0) : row.state === "failed" ? rowGlyph("error", 0) : fallback
|
|
20
|
+
const marker = snapshot.format === "plain" ? `[${row.state}]` : glyph
|
|
21
|
+
let depth = 0, parent = row.parentKey
|
|
22
|
+
while (parent && depth < 2) { depth++; parent = parents.get(parent) }
|
|
23
|
+
const reason = row.state === "stopping" && snapshot.format === "icons" ? "Stopping: " : ""
|
|
24
|
+
const task = taskPrefix(row.task, snapshot.format === "plain", theme)
|
|
25
|
+
const text = `${" ".repeat(depth)}${theme.fg(tone, marker)} ${cleanActivityText(row.agent)} ${task}${reason}${cleanActivityText(row.text)}`.trimEnd()
|
|
26
|
+
return alignActivityMetrics(text, row.metrics, width, snapshot.format === "plain", theme)
|
|
27
|
+
})
|
|
28
|
+
const omitted = Math.min(Number.MAX_SAFE_INTEGER, snapshot.omitted + Math.max(0, snapshot.rows.length - MAX_ROWS))
|
|
29
|
+
if (omitted > 0) lines.push(truncateToWidth(theme.fg("muted", `+ ${omitted} more`), width))
|
|
30
|
+
return lines
|
|
31
|
+
}
|