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/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
- installFooter(ctx, {
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
- setTimeout(() => pinChrome(ctx, "session_settle"), 0);
176
- setTimeout(() => pinChrome(ctx, "session_settle"), 50);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-minimal",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
package/release-notes.md CHANGED
@@ -1,32 +1,32 @@
1
1
  ### Added
2
- - Fenced code renders as a closed box (`╭─ lang ─╮` / `│ code │` / `╰───╯`)
3
- instead of literal ``` lines, keeping syntax highlighting; long lines wrap
4
- inside the box. pi-tui exposes no hook for this, so pi-minimal post-processes
5
- the code token's rendered lines.
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
- - Chrome margin (status line, footer, turn rule, tool rows) follows pi's
9
- `outputPad` setting so it lines up with transcript text. Set
10
- `editorPaddingX` to the same value for a flush left edge.
11
- - Chrome modules tolerate a stale `chrome` module after `/reload`, which
12
- previously could crash the renderer when the package was updated in place.
13
- - `mdCodeBlockBorder` in `minimal-dark` is a faint grey again (it frames the
14
- box now) instead of background-coloured.
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
- * Fix trusted publishing: upgrade npm, make Publish re-runnable by @raviy06 in https://github.com/raviy06/pi-minimal/pull/7
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.1...v0.2.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.2.2 # or: pi update --extensions
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
+ }