pi-minimal 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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";
@@ -11,11 +13,19 @@ import { installWorkingStatus } from "./working-status.js";
11
13
  import { installTurnRule } from "./turn-rule.js";
12
14
  import { installTranscript } from "./transcript.js";
13
15
  import { createGitState } from "./git-state.js";
16
+ import { installCodeBox } from "./code-box.js";
17
+ import { setChromeMargin } from "./chrome.js";
18
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
14
19
  export default function piMinimalExtension(pi) {
15
20
  const stopWorkingStatus = installWorkingStatus(pi);
16
21
  const stopTurnRule = installTurnRule(pi);
17
22
  installToolRenderers(pi);
18
23
  installTranscript(pi);
24
+ installCodeBox();
25
+ const contributions = createFooterContributions();
26
+ let stopFooter = () => { };
27
+ let stopContributionEvents = () => { };
28
+ let sessionActive = false;
19
29
  let fastTimer;
20
30
  let slowTimer;
21
31
  let stealTimer;
@@ -124,11 +134,12 @@ export default function piMinimalExtension(pi) {
124
134
  return Math.max(0, Math.min(100, Math.round((usage.tokens / window) * 100)));
125
135
  };
126
136
  const installChromeFooter = (ctx) => {
127
- installFooter(ctx, {
137
+ stopFooter();
138
+ stopFooter = installFooter(ctx, {
128
139
  busy: () => busy,
129
140
  editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
130
141
  contextPct: () => contextPct(ctx),
131
- });
142
+ }, contributions);
132
143
  };
133
144
  const installStatusLine = (ctx) => {
134
145
  installCwd(ctx, {
@@ -145,6 +156,11 @@ export default function piMinimalExtension(pi) {
145
156
  installStatusLine(ctx);
146
157
  };
147
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);
148
164
  if (ctx.mode !== "tui")
149
165
  return;
150
166
  const config = loadPiMinimalConfig({
@@ -154,6 +170,13 @@ export default function piMinimalExtension(pi) {
154
170
  });
155
171
  hideWidgets = config.hideGauntletWidgets;
156
172
  currentCwd = ctx.cwd;
173
+ try {
174
+ const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
175
+ setChromeMargin(settings.getOutputPad());
176
+ }
177
+ catch {
178
+ // Keep the default margin when settings are unavailable.
179
+ }
157
180
  installHeader(ctx);
158
181
  installChromeFooter(ctx);
159
182
  installStatusLine(ctx);
@@ -161,8 +184,13 @@ export default function piMinimalExtension(pi) {
161
184
  pinChrome(ctx, "session_start");
162
185
  ctx.ui.setHiddenThinkingLabel("◆ Thought");
163
186
  ctx.ui.setWorkingIndicator();
164
- setTimeout(() => pinChrome(ctx, "session_settle"), 0);
165
- 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);
166
194
  if (!stealTimer) {
167
195
  stealTimer = setInterval(() => hideForeignChrome(ctx, "interval"), 200);
168
196
  }
@@ -186,6 +214,9 @@ export default function piMinimalExtension(pi) {
186
214
  void gitState.refresh();
187
215
  });
188
216
  pi.on("session_shutdown", async () => {
217
+ sessionActive = false;
218
+ stopFooter();
219
+ stopContributionEvents();
189
220
  busy = false;
190
221
  stopTimers();
191
222
  stopWorkingStatus();
package/dist/tools.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { Container, truncateToWidth } from "@earendil-works/pi-tui";
2
2
  import { rowIsRunning } from "./activity.js";
3
+ import * as chrome from "./chrome.js";
3
4
  import { createLiveWatch } from "./live-watch.js";
4
5
  import { bashBody, formatDuration, formatOutcome, layoutRow, resultText, rowGlyph, toolItem, } from "./tool-rows.js";
6
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
7
+ const margin = () => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1));
5
8
  const live = createLiveWatch(400);
6
- /** Left indent of every tool row, in cells. */
7
- const ROW_INDENT = 2;
9
+ /** Left indent of every tool row: chrome margin plus one cell so rows sit inside message text. */
10
+ const rowIndent = () => margin() + 1;
8
11
  /** Max lines of output shown when a row is expanded. */
9
12
  const EXPANDED_MAX_LINES = 400;
10
13
  export function stopLiveWatch() {
@@ -44,7 +47,7 @@ function outputBlock(kind, result, theme) {
44
47
  const lines = expandedLines(kind, result);
45
48
  if (lines.length === 0)
46
49
  return new Container();
47
- const indent = " ".repeat(ROW_INDENT + 2);
50
+ const indent = " ".repeat(rowIndent() + 2);
48
51
  const rule = theme.fg("borderMuted", "│");
49
52
  return {
50
53
  render(width) {
@@ -57,7 +60,7 @@ function outputBlock(kind, result, theme) {
57
60
  function errorLineBlock(line, theme) {
58
61
  if (!line)
59
62
  return undefined;
60
- const indent = " ".repeat(ROW_INDENT + 2);
63
+ const indent = " ".repeat(rowIndent() + 2);
61
64
  const rule = theme.fg("error", "│");
62
65
  return {
63
66
  render(width) {
@@ -98,7 +101,7 @@ function activityRow(kind, args, theme, context) {
98
101
  right = right ? `${stats} · ${right}` : stats;
99
102
  }
100
103
  }
101
- const inner = Math.max(8, width - ROW_INDENT);
104
+ const inner = Math.max(8, width - rowIndent());
102
105
  const row = layoutRow(inner, kind, item, right);
103
106
  let coloredRight;
104
107
  if (status === "running")
@@ -112,7 +115,7 @@ function activityRow(kind, args, theme, context) {
112
115
  }
113
116
  else
114
117
  coloredRight = theme.fg("dim", row.outcome);
115
- const line = " ".repeat(ROW_INDENT) +
118
+ const line = " ".repeat(rowIndent()) +
116
119
  theme.fg(statusColor(status), glyph) +
117
120
  " " +
118
121
  theme.fg("dim", row.verb) +
package/dist/turn-rule.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { CHROME_MARGIN } from "./chrome.js";
2
+ import * as chrome from "./chrome.js";
3
3
  import { formatElapsed } from "./working-status.js";
4
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
5
+ const margin = () => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1));
4
6
  export const TURN_ENTRY_TYPE = "pi-minimal.turn";
5
7
  export function formatClock(epochMs) {
6
8
  const d = new Date(epochMs);
@@ -70,10 +72,10 @@ export function installTurnRule(pi) {
70
72
  const color = entry.data.outcome === "completed" ? "borderMuted" : entry.data.outcome === "aborted" ? "warning" : "error";
71
73
  return {
72
74
  render(width) {
73
- const inner = Math.max(0, width - CHROME_MARGIN * 2);
75
+ const inner = Math.max(0, width - margin() * 2);
74
76
  const rule = ruleLine(inner, label);
75
77
  const colored = rule.replace(label, theme.fg("dim", label));
76
- return ["", `${" ".repeat(CHROME_MARGIN)}${theme.fg(color, colored)}`];
78
+ return ["", `${" ".repeat(margin())}${theme.fg(color, colored)}`];
77
79
  },
78
80
  invalidate() { },
79
81
  };
@@ -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.1",
3
+ "version": "0.2.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
package/release-notes.md CHANGED
@@ -1,30 +1,31 @@
1
1
  ### Added
2
- - Automated releases: when `version` in `package.json` changes on `main`, the
3
- Publish workflow verifies the build, tags `vX.Y.Z`, creates a GitHub Release
4
- (CHANGELOG section + generated notes + package tarball), records a deployment
5
- to the `npm` environment, and publishes to npm via OIDC trusted publishing.
6
- - `CHANGELOG.md` and a "Releasing" section in the README.
7
- - The Publish workflow upgrades npm to >= 11.5.1 (required for OIDC trusted
8
- publishing) and is re-runnable: an existing tag whose version is missing
9
- from npm is published without re-tagging.
10
- - `repository`, `homepage`, and `bugs` fields in `package.json`, required for
11
- npm provenance verification.
2
+ - Versioned, session-scoped footer activity contribution API. Companion extensions
3
+ can publish bounded activity rows after existing hints and tracker summaries
4
+ without replacing the footer. No visible change without a contributor.
5
+ - Icon-first activity rows with pi-minimal's spinner/check/failure colors,
6
+ plain/static modes, overflow handling, optional elapsed time/token metrics,
7
+ and optional task links with independent task-state badges.
8
+ - Snapshot validation, immutable metadata, exclusive slot ownership, stale-update
9
+ protection, and integration documentation.
10
+ - Independent plan-summary contribution slot with violet slanted progress segments
11
+ and exact accepted/total counts, rendered below the input. One blank row
12
+ separates nonempty footer groups; activity rows stay compact. The summary is
13
+ display-only and does not add task storage or infer acceptance from agent runs.
14
+ - Release checks for matching manifest/lockfile versions and a dated changelog entry.
12
15
 
13
- ### Changed
14
- - The release-triggered publish workflow is now a manual, token-based fallback
15
- (`release.yml`) for re-publishing an existing tag.
16
+ ### Fixed
17
+ - Dispose activity subscriptions and animation timers across footer replacement
18
+ and shutdown; prevent delayed settle callbacks from reinstalling stale chrome.
19
+ - Synchronize the previously stale lockfile package version with the manifest.
16
20
 
17
21
  ## What's Changed
18
- * Document trusted publishing setup by @raviy06 in https://github.com/raviy06/pi-minimal/pull/3
19
- * Use npm for installation by @raviy06 in https://github.com/raviy06/pi-minimal/pull/4
20
- * Design pass: tool rows, turn rule, status line, minimal-dark theme by @raviy06 in https://github.com/raviy06/pi-minimal/pull/5
21
- * Automate releases on version change (0.2.1) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/6
22
+ * feat: add cooperative footer activity contributions by @raviy06 in https://github.com/raviy06/pi-minimal/pull/14
22
23
 
23
24
 
24
- **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.1.0...v0.2.1
25
+ **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.2.2...v0.2.3
25
26
 
26
27
  ---
27
28
  **Install / upgrade**
28
29
  ```bash
29
- pi install npm:pi-minimal@0.2.1 # or: pi update --extensions
30
+ pi install npm:pi-minimal@0.2.3 # or: pi update --extensions
30
31
  ```
package/src/chrome.ts CHANGED
@@ -1 +1,19 @@
1
+ /**
2
+ * Horizontal margin for pi-minimal's chrome (status line, footer, turn rule, tool rows).
3
+ * Follows pi's `outputPad` setting so chrome lines up with transcript text. Pi's editor uses
4
+ * the separate `editorPaddingX` setting (default 0); set it to the same value for a flush
5
+ * left edge.
6
+ */
7
+ const KEY = Symbol.for("pi-minimal.chrome-margin")
8
+ const store = globalThis as unknown as Record<symbol, number | undefined>
9
+
10
+ /** Kept for modules linked against an older copy of this file across a hot reload. */
1
11
  export const CHROME_MARGIN = 1
12
+
13
+ export function chromeMargin(): number {
14
+ return store[KEY] ?? CHROME_MARGIN
15
+ }
16
+
17
+ export function setChromeMargin(value: number): void {
18
+ store[KEY] = value === 0 ? 0 : 1
19
+ }
@@ -0,0 +1,86 @@
1
+ import { Markdown, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"
2
+
3
+ /**
4
+ * pi-tui renders fenced code as literal ``` lines. Pi exposes no hook for the code token, so
5
+ * pi-minimal post-processes the rendered lines of every `code` token into a closed box:
6
+ *
7
+ * ╭─ json ──────────────╮
8
+ * │ { "a": 1 } │
9
+ * ╰─────────────────────╯
10
+ *
11
+ * Code lines longer than the box are wrapped inside it so the right edge stays intact.
12
+ */
13
+
14
+ type CodeToken = { type?: string; lang?: string; text?: string }
15
+ type MarkdownThemeLike = {
16
+ codeBlockBorder: (text: string) => string
17
+ codeBlockIndent?: string
18
+ }
19
+
20
+ const PATCHED = Symbol.for("pi-minimal.code-box")
21
+ /** Current framing implementation. Lives on globalThis so a /reload swaps the behaviour of the
22
+ * wrapper that an earlier load already installed on pi-tui's shared Markdown prototype. */
23
+ const FRAME_SLOT = Symbol.for("pi-minimal.code-box.frame")
24
+
25
+ type Framer = (lines: string[], lang: string | undefined, width: number, theme: MarkdownThemeLike) => string[]
26
+ const slots = globalThis as unknown as Record<symbol, Framer | undefined>
27
+
28
+ export function frameCodeLines(lines: string[], lang: string | undefined, width: number, theme: MarkdownThemeLike): string[] {
29
+ if (lines.length < 2) return lines
30
+ // Trailing spacer ("" after the closing fence) is preserved as-is.
31
+ let end = lines.length
32
+ while (end > 0 && lines[end - 1] === "") end -= 1
33
+ if (end < 2) return lines
34
+ const trailer = lines.slice(end)
35
+ const body = lines.slice(1, end - 1)
36
+ const indent = theme.codeBlockIndent ?? " "
37
+ const outer = Math.max(6, width)
38
+ // "│ " + content + " │"
39
+ const innerWidth = outer - 4
40
+
41
+ const label = lang ? ` ${lang} ` : ""
42
+ const topRule = "─".repeat(Math.max(1, outer - 3 - visibleWidth(label)))
43
+ const top = theme.codeBlockBorder(`╭─${label}${topRule}╮`)
44
+ const bottom = theme.codeBlockBorder(`╰${"─".repeat(outer - 2)}╯`)
45
+ const edge = theme.codeBlockBorder("│")
46
+
47
+ const framed: string[] = []
48
+ for (const line of body) {
49
+ const content = line.startsWith(indent) ? line.slice(indent.length) : line
50
+ for (const piece of wrapTextWithAnsi(content, innerWidth)) {
51
+ const pad = Math.max(0, innerWidth - visibleWidth(piece))
52
+ framed.push(`${edge} ${piece}${" ".repeat(pad)} ${edge}`)
53
+ }
54
+ }
55
+ if (framed.length === 0) framed.push(`${edge} ${" ".repeat(innerWidth)} ${edge}`)
56
+ return [top, ...framed, bottom, ...trailer]
57
+ }
58
+
59
+ type RenderToken = (this: { theme: MarkdownThemeLike }, token: CodeToken, width: number, next?: string, style?: unknown) => string[]
60
+
61
+ /**
62
+ * Patch pi-tui's Markdown renderer. The prototype wrapper is installed once per process; every
63
+ * call (including after /reload) points it at this module's `frameCodeLines`.
64
+ * Returns false when the renderer is not patchable.
65
+ */
66
+ export function installCodeBox(): boolean {
67
+ slots[FRAME_SLOT] = frameCodeLines
68
+ const proto = Markdown.prototype as unknown as Record<string | symbol, unknown>
69
+ if (proto[PATCHED]) return true
70
+ const original = proto.renderToken
71
+ if (typeof original !== "function") return false
72
+ const originalRender = original as RenderToken
73
+ proto.renderToken = function (this: { theme: MarkdownThemeLike }, token: CodeToken, width: number, next?: string, style?: unknown) {
74
+ const lines = originalRender.call(this, token, width, next, style)
75
+ if (token?.type !== "code" || !this.theme?.codeBlockBorder) return lines
76
+ const frame = slots[FRAME_SLOT]
77
+ if (!frame) return lines
78
+ try {
79
+ return frame(lines, token.lang, width, this.theme)
80
+ } catch {
81
+ return lines
82
+ }
83
+ }
84
+ proto[PATCHED] = true
85
+ return true
86
+ }
package/src/cwd.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
3
3
  import path from "node:path"
4
- import { CHROME_MARGIN } from "./chrome.js"
4
+ import * as chrome from "./chrome.js"
5
5
  import { formatGitState, type GitState } from "./git-state.js"
6
6
  import type { Sibling } from "./herdr.js"
7
7
 
8
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
9
+ const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
10
+
11
+
8
12
  export function formatCwd(cwd: string): string {
9
13
  const home = process.env.HOME || process.env.USERPROFILE || ""
10
14
  if (!home) return cwd
@@ -103,7 +107,7 @@ export function installCwd(ctx: ExtensionContext, sources: StatusLineSources): v
103
107
  dispose: unsub,
104
108
  invalidate() {},
105
109
  render(width: number): string[] {
106
- const inner = Math.max(8, width - CHROME_MARGIN * 2)
110
+ const inner = Math.max(8, width - margin() * 2)
107
111
  const id = ctx.model?.id || "no-model"
108
112
  const icon = theme.fg("accent", modelIcon(id))
109
113
  const name = theme.fg("text", shortModel(id))
@@ -147,7 +151,7 @@ export function installCwd(ctx: ExtensionContext, sources: StatusLineSources): v
147
151
  )
148
152
  }
149
153
  const row = splitRow(left, right, inner)
150
- return [`${" ".repeat(CHROME_MARGIN)}${row}`]
154
+ return [`${" ".repeat(margin())}${row}`]
151
155
  },
152
156
  }
153
157
  },
@@ -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
+ }
@@ -0,0 +1,38 @@
1
+ import type { FooterContributions, RegistrationResult } from "./footer-contributions.js"
2
+
3
+ export const FOOTER_EVENTS = {
4
+ register: "pi-minimal:footer:v1:register",
5
+ ready: "pi-minimal:footer:v1:ready",
6
+ unavailable: "pi-minimal:footer:v1:unavailable",
7
+ } as const
8
+ export type RegisterRequest = {
9
+ version: 1; sessionId: string; owner: string; slot: "activity" | "summary"
10
+ result?: RegistrationResult | { ok: false; code: "not-tui" }
11
+ }
12
+ type Events = {
13
+ on(name: string, handler: (value: unknown) => void): () => void
14
+ emit(name: string, value: unknown): void
15
+ }
16
+
17
+ /** Bind once per session, not once per footer mount. Trusted in-process protocol. */
18
+ export function bindFooterContributionEvents(events: Events, registry: FooterContributions, sessionId: string, mode: string): () => void {
19
+ registry.reset(mode === "tui" ? sessionId : null)
20
+ const unsubscribe = events.on(FOOTER_EVENTS.register, (value) => {
21
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return
22
+ const request = value as Partial<RegisterRequest>
23
+ // A different host may have answered first. Never overwrite its result.
24
+ if (request.result !== undefined) return
25
+ if (request.version !== 1 || (request.slot !== "activity" && request.slot !== "summary")) request.result = { ok: false, code: "invalid" }
26
+ else if (mode !== "tui") request.result = { ok: false, code: "not-tui" }
27
+ else request.result = request.slot === "summary" ? registry.registerPlan(request) : registry.register(request)
28
+ })
29
+ if (mode === "tui") events.emit(FOOTER_EVENTS.ready, { version: 1, sessionId, slots: ["activity", "summary"] })
30
+ let disposed = false
31
+ return () => {
32
+ if (disposed) return
33
+ disposed = true
34
+ unsubscribe()
35
+ registry.reset(null)
36
+ if (mode === "tui") events.emit(FOOTER_EVENTS.unavailable, { version: 1, sessionId })
37
+ }
38
+ }