pi-minimal 0.4.0 → 0.5.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 CHANGED
@@ -10,6 +10,52 @@ Releases are cut automatically: when the `version` in `package.json` changes on
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.5.0] - 2026-10-27
14
+
15
+ Session lifecycle, one tool-row lifecycle, and a domain glossary. `hideGauntletWidgets`
16
+ is now opt-in and the 200 ms chrome steal interval is gone.
17
+
18
+ ### Added
19
+
20
+ - `write` rows show the written content (first 400 lines) when expanded with `ctrl+o`.
21
+ - With `editDiff: "expanded"` (the default), diffs of up to 8 lines are shown inline;
22
+ hiding them saved nothing and cost a keypress. Longer diffs still wait for `ctrl+o`.
23
+ - `CONTEXT.md`: a glossary of the domain (chrome, reclaim, slots, owner token, the snapshot
24
+ kinds, plan vs the legacy pi-gauntlet projection, activity rows, hints, turn rule, working
25
+ status). The 0.1 spec and plan moved to `doc/archive/`.
26
+ - The plan slot can be addressed as `slot: "plan"`; `summary` stays accepted as its legacy
27
+ name and the ready event now lists `["activity", "plan", "summary"]`.
28
+
29
+ ### Changed
30
+
31
+ - `hideGauntletWidgets` now defaults to `false`. Hiding another extension's widgets is
32
+ opt-in; set it to `true` to keep the previous behaviour.
33
+ - Chrome is re-asserted only on events (session start and its two settle ticks, turn
34
+ end, tool end). The 200 ms interval that cleared pi-gauntlet's widgets five times a
35
+ second is gone; it never reinstalled the header or footer anyway.
36
+ - All session-scoped state (config, cwd, the footer contribution registry, the git and
37
+ Herdr pollers, the chrome) now lives on one `Session` object created at session start
38
+ and disposed at shutdown or when the next session starts. Late async results from a
39
+ disposed session are dropped instead of drawing into the new one.
40
+ - The compact, generic and codemode tool rows are now three small strategies over one
41
+ row lifecycle (`createRowRenderers`): running state, live spinner, status colours,
42
+ the error line and expansion are implemented once. Edit-diff colouring moved to
43
+ `src/edit-diff.ts`. No visible change beyond the two items above.
44
+ - Internal renames for clarity: `TrackerSnapshot` → `GauntletProjection`,
45
+ `readSnapshot` → `readGauntletProjection`, the tracker's `TaskStatus` →
46
+ `GauntletTaskStatus`, and the slot's authority symbol is `ownerToken`. No wire change.
47
+ - `doc/footer-contributions.md` now states precisely when the legacy projection is replaced
48
+ (only while a plan line renders), how control characters are sanitised (VT stripped,
49
+ C0/C1/bidi replaced with spaces), and that `inReview`/`blocked` are bounded individually.
50
+
51
+ ### Fixed
52
+
53
+ - The working message is cleared on session shutdown; it used to drop the context
54
+ before clearing and leave the last status on screen.
55
+ - Git state reads the working directory once per refresh, so branch and dirty count
56
+ always describe the same tree, and one throwing change listener no longer blocks
57
+ the others.
58
+
13
59
  ## [0.4.0] - 2026-10-27
14
60
 
15
61
  ### Added
package/CONTEXT.md ADDED
@@ -0,0 +1,83 @@
1
+ # pi-minimal domain glossary
2
+
3
+ The vocabulary used in code and docs, one entry per concept, with the file that defines it.
4
+ When a word below has a narrower meaning than in everyday use, the narrower meaning wins.
5
+
6
+ ## Surfaces pi-minimal owns
7
+
8
+ - **Chrome** — the three host slots pi-minimal draws into: the header (`src/header.ts`, intentionally
9
+ empty), the footer (`src/footer.ts`) and the status line (`src/cwd.ts`, a widget above the editor;
10
+ it is *not* pi's `setStatus`). pi has no owner concept for these, so chrome is re-asserted on
11
+ events; see *reclaim*.
12
+ - **Reclaim** — re-installing chrome after an event that may have let another extension draw over
13
+ it (`src/reclaim.ts`). A *reclaim reason* (`session_start`, `session_settle`, `turn_end`,
14
+ `tool_execution_end`) maps to a policy: whether to reset the editor and whether to reinstall chrome.
15
+ - **Status line** — `model · thinking · context meter · cwd · ⎇ branch ±dirty · Herdr siblings`
16
+ (`src/cwd.ts`). *Siblings* are other Herdr panes in the same workspace (`src/herdr.ts`).
17
+ - **Hints** — the keybinding labels in the footer (`send · thinking · expand tools`, plus
18
+ `/compact` from 70 % context), shown only when idle with an empty editor (`src/footer.ts`).
19
+ Not task hints and not the change hints other extensions emit.
20
+ - **Tool row** — one line per tool call: `glyph verb item … outcome` (`src/tools.ts`,
21
+ `src/tool-rows.ts`). *Compact* rows are the built-in tools, *generic* rows are tools with no
22
+ renderer of their own (most MCP tools), the *codemode* row has one child row per nested call.
23
+ A row's **outcome** is its right-aligned summary (`34 lines · 1.0s`, `+3 −1`, `exit 1`).
24
+ - **Turn rule** — the dim `── 15:51 · 12s · 4 tools ──` separator appended after each agent run
25
+ (`src/turn-rule.ts`). Stored as a `pi-minimal.turn` custom session entry, never sent to the model.
26
+ Its data is a *TurnSummary*, unrelated to the plan slot.
27
+ - **Working status** — the transient `Thinking · 3s` / `Running ls · 2 tools · 5s` message while
28
+ an agent runs (`src/working-status.ts`), driven by agent/tool/message events and cleared when
29
+ the agent settles. Its state object is a *WorkingSnapshot*.
30
+ - **Thinking gutter** — the `┊` column in front of assistant thinking text (`src/transcript.ts`).
31
+ - **Code box** — the faint frame around fenced code blocks (`src/code-box.ts`, a pi-tui
32
+ `Markdown.prototype.renderToken` patch).
33
+
34
+ ## Footer contributions (the extension point)
35
+
36
+ - **Contribution** — data another extension publishes for pi-minimal to render in the footer,
37
+ through the in-process `pi-minimal:footer:v1:*` events (`src/footer-contribution-events.ts`).
38
+ pi-minimal keeps ownership of the footer; contributors never draw.
39
+ - **Slot** — one of two independent places a contribution can go: `activity` (agent rows) and
40
+ `plan` (one plan bar). `summary` is the legacy wire name of the plan slot and stays accepted.
41
+ One owner per slot per session (`src/footer-contributions.ts`).
42
+ - **Owner** vs **owner token** — `owner` is the registrant's display identity (a validated
43
+ string). The *owner token* is the `Symbol` handed back on registration; it alone authorises
44
+ `publish`/`dispose`. A slot is *occupied* while a token is live.
45
+ - **Snapshot** — a complete replacement value published to a slot. Two kinds:
46
+ **ActivitySnapshot** (`revision`, `rows`, `omitted`, `motion`, `format`) and **PlanSnapshot**
47
+ (`revision`, `format`, optional `plan`). Publishing never patches; it replaces.
48
+ - **Revision** — a per-slot monotonic counter on each snapshot. A publish whose revision is not
49
+ greater than the current one is *stale* and ignored.
50
+ - **Activity row** — one keyed line in the activity slot: `agent`, `state`, `text`, optional
51
+ `parentKey` (two levels of nesting), `metrics` (`elapsedMs`, `tokens`), `task`
52
+ (`planId`, `id`, `status`). Rendered in producer order, at most four plus `+ N more`
53
+ (`src/footer-activity.ts`, `src/footer-activity-details.ts`).
54
+ - **Activity state** — `queued | running | waiting | paused | stopping | completed | failed |
55
+ stopped | unknown`. Display-only; pi-minimal infers nothing about execution from it.
56
+ - **Plan** — the plan slot's payload: `id`, `accepted`, `total`, `inReview`, `blocked`. Counts
57
+ are supplied by the contributor, never inferred from agent runs (`src/footer-plan.ts`).
58
+ - **Task status (contribution)** — `todo | in_progress | blocked | in_review | done | cancelled`
59
+ on an activity row's `task`. Distinct from the gauntlet task status below.
60
+
61
+ ## Legacy pi-gauntlet projection
62
+
63
+ - **Gauntlet projection** — what pi-gauntlet last reported on the session branch, read from its
64
+ `phase_tracker` / `plan_tracker` tool-result `details` (`src/tracker.ts`,
65
+ `readGauntletProjection`). A projection of another extension's history, not a contribution:
66
+ unversioned and only as stable as those tools' result shapes. A rendered plan line from the
67
+ plan slot replaces it in the footer.
68
+ - **Phase** — `brainstorm → plan → implement → verify → ship`, each
69
+ `pending | in_progress | complete | skipped`.
70
+ - **Gauntlet task status** — `pending | in_progress | complete | failed` (`GauntletTaskStatus`).
71
+ Not interchangeable with the contribution task status.
72
+ - **hideGauntletWidgets** — opt-in setting to clear pi-gauntlet's own widgets on each reclaim,
73
+ since the footer already shows that data (`src/config.ts`).
74
+
75
+ ## Process vs session
76
+
77
+ - **Load-time hooks** — installed once per extension instance: tool renderers, the markdown
78
+ transformer, the code-box patch, working status and the turn rule (`src/index.ts`).
79
+ - **Session** — everything scoped to one pi session: config, cwd, the contribution registry,
80
+ git and Herdr pollers, settle timers and chrome (`src/session.ts`, when present). Created on
81
+ `session_start`, disposed on `session_shutdown` or when the next session starts.
82
+ - **Chrome margin** — pi's `outputPad`, mirrored on `globalThis` so module copies reloaded by
83
+ `/reload` agree (`src/chrome.ts`).
package/README.md CHANGED
@@ -11,7 +11,7 @@ No other Pi extension is required. Pi supplies the peer packages used by this ex
11
11
 
12
12
  Optional integrations:
13
13
 
14
- - `pi-gauntlet`: supplies the phase and task data shown in the footer. Its widgets are hidden by default.
14
+ - `pi-gauntlet`: supplies the phase and task data shown in the footer. Set `hideGauntletWidgets: true` to also hide its own widgets, since the footer already shows that data.
15
15
  - Herdr: sibling status appears only when Pi is running inside Herdr.
16
16
 
17
17
  ## Installation
@@ -51,8 +51,8 @@ Project values override global values. A missing file, omitted key, or invalid v
51
51
 
52
52
  Supported keys:
53
53
 
54
- - `hideGauntletWidgets` (boolean, default `true`).
55
- - `editDiff` (`"always"` | `"never"` | `"expanded"`, default `"expanded"`) – when an `edit` row shows its diff. `"expanded"` puts it behind `ctrl+o`; `"always"` shows it inline as the edit lands (first 40 lines, the rest behind `ctrl+o`); `"never"` keeps the `+3 −1` stats only.
54
+ - `hideGauntletWidgets` (boolean, default `false`) – when `true`, pi-minimal clears pi-gauntlet's `phase_tracker` and `plan_tracker` widgets each time it re-asserts its chrome (session start, turn end, tool end).
55
+ - `editDiff` (`"always"` | `"never"` | `"expanded"`, default `"expanded"`) – when an `edit` row shows its diff. `"expanded"` shows diffs of up to 8 lines inline and puts longer ones behind `ctrl+o`; `"always"` shows every diff inline as the edit lands (first 40 lines, the rest behind `ctrl+o`); `"never"` keeps the `+3 −1` stats only. `write` rows show the written content behind `ctrl+o`.
56
56
 
57
57
  ```json
58
58
  { "hideGauntletWidgets": true, "editDiff": "always" }
package/dist/config.js CHANGED
@@ -3,7 +3,8 @@ import { join } from "node:path";
3
3
  import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
4
4
  export const EDIT_DIFF_MODES = ["always", "never", "expanded"];
5
5
  const DEFAULTS = {
6
- hideGauntletWidgets: true,
6
+ // Hiding another extension's widgets is opt-in.
7
+ hideGauntletWidgets: false,
7
8
  editDiff: "expanded",
8
9
  };
9
10
  export function loadPiMinimalConfig(options) {
@@ -0,0 +1,74 @@
1
+ import { diffWords } from "diff";
2
+ /** The edit tool's `details.diff`, when present and non-empty. */
3
+ export function editDiffText(result) {
4
+ const details = result?.details;
5
+ return typeof details?.diff === "string" && details.diff.trim() ? details.diff : undefined;
6
+ }
7
+ /** pi's edit diff lines: "+123 content", "-123 content", " 123 context"; anything else (e.g. "...") is a separator. */
8
+ function parseDiffLine(line) {
9
+ const match = /^([+\- ])(\s*\d*) (.*)$/.exec(line);
10
+ if (!match)
11
+ return undefined;
12
+ return { prefix: match[1], lineNum: match[2], content: match[3].replace(/\t/g, " ") };
13
+ }
14
+ /** Word-level diff of a 1:1 replacement; changed tokens are inverted, leading indentation is left alone, as in pi. */
15
+ function highlightPair(removed, added, theme) {
16
+ let out = { removed: "", added: "" };
17
+ let firstRemoved = true;
18
+ let firstAdded = true;
19
+ for (const part of diffWords(removed, added)) {
20
+ if (part.removed || part.added) {
21
+ const key = part.removed ? "removed" : "added";
22
+ let value = part.value;
23
+ if (part.removed ? firstRemoved : firstAdded) {
24
+ const ws = /^(\s*)/.exec(value)?.[1] ?? "";
25
+ value = value.slice(ws.length);
26
+ out = { ...out, [key]: out[key] + ws };
27
+ if (part.removed)
28
+ firstRemoved = false;
29
+ else
30
+ firstAdded = false;
31
+ }
32
+ if (value)
33
+ out = { ...out, [key]: out[key] + theme.inverse(value) };
34
+ }
35
+ else {
36
+ out = { removed: out.removed + part.value, added: out.added + part.value };
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+ /** Colours pi's edit diff like pi does: theme diff colours per line, inverse on changed tokens of single-line replacements. */
42
+ export function colorDiffLines(lines, theme) {
43
+ const out = [];
44
+ for (let i = 0; i < lines.length;) {
45
+ const parsed = parseDiffLine(lines[i]);
46
+ if (!parsed) {
47
+ out.push(theme.fg("toolDiffContext", lines[i]));
48
+ i++;
49
+ continue;
50
+ }
51
+ if (parsed.prefix === " ") {
52
+ out.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${parsed.content}`));
53
+ i++;
54
+ continue;
55
+ }
56
+ const removed = [];
57
+ const added = [];
58
+ for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "-"; p = parseDiffLine(lines[++i] ?? ""))
59
+ removed.push(p);
60
+ for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "+"; p = parseDiffLine(lines[++i] ?? ""))
61
+ added.push(p);
62
+ if (removed.length === 1 && added.length === 1) {
63
+ const pair = highlightPair(removed[0].content, added[0].content, theme);
64
+ out.push(theme.fg("toolDiffRemoved", `-${removed[0].lineNum} ${pair.removed}`));
65
+ out.push(theme.fg("toolDiffAdded", `+${added[0].lineNum} ${pair.added}`));
66
+ continue;
67
+ }
68
+ for (const line of removed)
69
+ out.push(theme.fg("toolDiffRemoved", `-${line.lineNum} ${line.content}`));
70
+ for (const line of added)
71
+ out.push(theme.fg("toolDiffAdded", `+${line.lineNum} ${line.content}`));
72
+ }
73
+ return out;
74
+ }
@@ -3,6 +3,8 @@ export const FOOTER_EVENTS = {
3
3
  ready: "pi-minimal:footer:v1:ready",
4
4
  unavailable: "pi-minimal:footer:v1:unavailable",
5
5
  };
6
+ /** Slot names on the wire. `summary` is the legacy name of the plan slot and stays accepted. */
7
+ export const FOOTER_SLOTS = ["activity", "plan", "summary"];
6
8
  /** Bind once per session, not once per footer mount. Trusted in-process protocol. */
7
9
  export function bindFooterContributionEvents(events, registry, sessionId, mode) {
8
10
  registry.reset(mode === "tui" ? sessionId : null);
@@ -13,15 +15,15 @@ export function bindFooterContributionEvents(events, registry, sessionId, mode)
13
15
  // A different host may have answered first. Never overwrite its result.
14
16
  if (request.result !== undefined)
15
17
  return;
16
- if (request.version !== 1 || (request.slot !== "activity" && request.slot !== "summary"))
18
+ if (request.version !== 1 || !FOOTER_SLOTS.includes(request.slot))
17
19
  request.result = { ok: false, code: "invalid" };
18
20
  else if (mode !== "tui")
19
21
  request.result = { ok: false, code: "not-tui" };
20
22
  else
21
- request.result = request.slot === "summary" ? registry.registerPlan(request) : registry.register(request);
23
+ request.result = request.slot === "activity" ? registry.register(request) : registry.registerPlan(request);
22
24
  });
23
25
  if (mode === "tui")
24
- events.emit(FOOTER_EVENTS.ready, { version: 1, sessionId, slots: ["activity", "summary"] });
26
+ events.emit(FOOTER_EVENTS.ready, { version: 1, sessionId, slots: [...FOOTER_SLOTS] });
25
27
  let disposed = false;
26
28
  return () => {
27
29
  if (disposed)
@@ -60,7 +60,8 @@ function normalizeSnapshot(input) {
60
60
  }
61
61
  function createFooterSlot(normalize, initialSessionId) {
62
62
  let sessionId = initialSessionId;
63
- let owner;
63
+ // The registrant's `owner` string is a display identity; this symbol is the actual authority to publish.
64
+ let ownerToken;
64
65
  let snapshot;
65
66
  const observers = new Set();
66
67
  const notify = () => {
@@ -76,7 +77,7 @@ function createFooterSlot(normalize, initialSessionId) {
76
77
  subscribe(observer) { observers.add(observer); return () => { observers.delete(observer); }; },
77
78
  reset(nextSessionId) {
78
79
  sessionId = nextSessionId;
79
- owner = undefined;
80
+ ownerToken = undefined;
80
81
  snapshot = undefined;
81
82
  notify();
82
83
  },
@@ -85,13 +86,13 @@ function createFooterSlot(normalize, initialSessionId) {
85
86
  return { ok: false, code: "invalid" };
86
87
  if (sessionId === null || input.sessionId !== sessionId)
87
88
  return { ok: false, code: "wrong-session" };
88
- if (owner)
89
+ if (ownerToken)
89
90
  return { ok: false, code: "occupied" };
90
91
  const token = Symbol(input.owner);
91
- owner = token;
92
+ ownerToken = token;
92
93
  return { ok: true, registration: {
93
94
  publish(input) {
94
- if (owner !== token)
95
+ if (ownerToken !== token)
95
96
  return { ok: false, code: "disposed" };
96
97
  const next = normalize(input);
97
98
  if (!next)
@@ -103,9 +104,9 @@ function createFooterSlot(normalize, initialSessionId) {
103
104
  return { ok: true };
104
105
  },
105
106
  dispose() {
106
- if (owner !== token)
107
+ if (ownerToken !== token)
107
108
  return;
108
- owner = undefined;
109
+ ownerToken = undefined;
109
110
  snapshot = undefined;
110
111
  notify();
111
112
  },
@@ -115,16 +116,17 @@ function createFooterSlot(normalize, initialSessionId) {
115
116
  }
116
117
  export function createFooterContributions(initialSessionId = null) {
117
118
  const activity = createFooterSlot(normalizeSnapshot, initialSessionId);
118
- const summary = createFooterSlot(normalizePlanSnapshot, initialSessionId);
119
+ // The plan slot; "summary" is its legacy wire name.
120
+ const plan = createFooterSlot(normalizePlanSnapshot, initialSessionId);
119
121
  return {
120
122
  read: activity.read,
121
- readPlan: summary.read,
123
+ readPlan: plan.read,
122
124
  register: activity.register,
123
- registerPlan: summary.register,
125
+ registerPlan: plan.register,
124
126
  subscribe(observer) {
125
- const offActivity = activity.subscribe(observer), offSummary = summary.subscribe(observer);
126
- return () => { offActivity(); offSummary(); };
127
+ const offActivity = activity.subscribe(observer), offPlan = plan.subscribe(observer);
128
+ return () => { offActivity(); offPlan(); };
127
129
  },
128
- reset(sessionId) { activity.reset(sessionId); summary.reset(sessionId); },
130
+ reset(sessionId) { activity.reset(sessionId); plan.reset(sessionId); },
129
131
  };
130
132
  }
package/dist/git-state.js CHANGED
@@ -21,34 +21,50 @@ export function createGitState(pi, cwd, minIntervalMs = 5000) {
21
21
  let state;
22
22
  let lastRun = 0;
23
23
  let inFlight;
24
+ let disposed = false;
24
25
  const listeners = new Set();
26
+ const notify = () => {
27
+ for (const listener of listeners) {
28
+ try {
29
+ listener();
30
+ }
31
+ catch {
32
+ // One listener's failure must not starve the others.
33
+ }
34
+ }
35
+ };
25
36
  const refresh = (force = false) => {
37
+ if (disposed)
38
+ return Promise.resolve();
26
39
  const now = Date.now();
27
40
  if (inFlight)
28
41
  return inFlight;
29
42
  if (!force && now - lastRun < minIntervalMs)
30
43
  return Promise.resolve();
31
44
  lastRun = now;
45
+ // Read the directory once so both commands describe the same tree even if cwd changes mid-refresh.
46
+ const dir = cwd();
32
47
  inFlight = (async () => {
48
+ let next;
33
49
  try {
34
- const branchRun = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: cwd(), timeout: 2000 });
35
- if (branchRun.code !== 0) {
36
- state = undefined;
37
- return;
50
+ const branchRun = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 2000 });
51
+ if (branchRun.code === 0) {
52
+ const branch = parseBranch(branchRun.stdout);
53
+ const statusRun = await pi.exec("git", ["status", "--porcelain"], { cwd: dir, timeout: 3000 });
54
+ next = { branch, dirty: statusRun.code === 0 ? parsePorcelain(statusRun.stdout) : undefined };
38
55
  }
39
- const branch = parseBranch(branchRun.stdout);
40
- const statusRun = await pi.exec("git", ["status", "--porcelain"], { cwd: cwd(), timeout: 3000 });
41
- const dirty = statusRun.code === 0 ? parsePorcelain(statusRun.stdout) : undefined;
42
- state = { branch, dirty };
43
56
  }
44
57
  catch {
45
- state = undefined;
58
+ next = undefined;
46
59
  }
47
60
  finally {
48
61
  inFlight = undefined;
49
- for (const listener of listeners)
50
- listener();
51
62
  }
63
+ // A result that lands after dispose belongs to a session that no longer exists.
64
+ if (disposed)
65
+ return;
66
+ state = next;
67
+ notify();
52
68
  })();
53
69
  return inFlight;
54
70
  };
@@ -59,5 +75,9 @@ export function createGitState(pi, cwd, minIntervalMs = 5000) {
59
75
  listeners.add(listener);
60
76
  return () => listeners.delete(listener);
61
77
  },
78
+ dispose() {
79
+ disposed = true;
80
+ listeners.clear();
81
+ },
62
82
  };
63
83
  }