pi-minimal 0.3.1 → 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 +60 -0
- package/CONTEXT.md +83 -0
- package/README.md +6 -3
- package/dist/config.js +21 -9
- package/dist/edit-diff.js +74 -0
- package/dist/footer-contribution-events.js +5 -3
- package/dist/footer-contributions.js +15 -13
- package/dist/git-state.js +31 -11
- package/dist/index.js +23 -206
- package/dist/reclaim.js +0 -3
- package/dist/session.js +155 -0
- package/dist/tools.js +224 -281
- package/dist/tracker.js +2 -2
- package/dist/working-status.js +2 -1
- package/doc/archive/README.md +3 -0
- package/doc/footer-contributions.md +7 -7
- package/package.json +4 -1
- package/release-notes.md +45 -19
- package/src/config.ts +20 -9
- package/src/edit-diff.ts +73 -0
- package/src/footer-contribution-events.ts +7 -4
- package/src/footer-contributions.ts +15 -13
- package/src/git-state.ts +29 -10
- package/src/index.ts +25 -199
- package/src/reclaim.ts +0 -4
- package/src/session.ts +159 -0
- package/src/tools.ts +279 -312
- package/src/tracker.ts +11 -5
- package/src/working-status.ts +2 -1
- package/test/config.test.ts +41 -20
- package/test/footer-contribution-events.test.ts +15 -0
- package/test/git-state.test.ts +35 -0
- package/test/index.test.ts +45 -3
- package/test/tools.test.ts +98 -0
- package/test/working-status.test.ts +53 -0
- /package/doc/{plans/2026-09-22-pi-minimal-package.md → archive/2026-09-22-plan-pi-minimal-package.md} +0 -0
- /package/doc/{specs/2026-09-22-pi-minimal-package.md → archive/2026-09-22-spec-pi-minimal-package.md} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,66 @@ 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
|
+
|
|
59
|
+
## [0.4.0] - 2026-10-27
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
- `editDiff` setting in `pi-minimal.json`: controls whether an `edit` row shows the
|
|
64
|
+
change itself. `"expanded"` (default) shows the unified diff in the `│`-ruled
|
|
65
|
+
block when the row is expanded with `ctrl+o`, where the bare "Successfully
|
|
66
|
+
replaced" text used to be. `"always"` shows it inline as soon as the edit lands,
|
|
67
|
+
capped at 40 lines with `… N more lines (ctrl+o)` for the rest. `"never"` keeps
|
|
68
|
+
the stats-only row. Lines use the theme's `toolDiffAdded` / `toolDiffRemoved` /
|
|
69
|
+
`toolDiffContext` colours and keep pi's line-number gutter; when one line
|
|
70
|
+
replaces one line, only the changed tokens are inverse-highlighted, as in pi's
|
|
71
|
+
own edit renderer. Failed edits and results without a diff are unchanged.
|
|
72
|
+
|
|
13
73
|
## [0.3.1] - 2026-10-25
|
|
14
74
|
|
|
15
75
|
### 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.
|
|
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
|
|
@@ -49,10 +49,13 @@ The package loads JSON in this order:
|
|
|
49
49
|
|
|
50
50
|
Project values override global values. A missing file, omitted key, or invalid value does not override the lower-precedence value.
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
Supported keys:
|
|
53
|
+
|
|
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`.
|
|
53
56
|
|
|
54
57
|
```json
|
|
55
|
-
{ "hideGauntletWidgets": true }
|
|
58
|
+
{ "hideGauntletWidgets": true, "editDiff": "always" }
|
|
56
59
|
```
|
|
57
60
|
|
|
58
61
|
## Optional footer activity API
|
package/dist/config.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
export const EDIT_DIFF_MODES = ["always", "never", "expanded"];
|
|
4
5
|
const DEFAULTS = {
|
|
5
|
-
|
|
6
|
+
// Hiding another extension's widgets is opt-in.
|
|
7
|
+
hideGauntletWidgets: false,
|
|
8
|
+
editDiff: "expanded",
|
|
6
9
|
};
|
|
7
10
|
export function loadPiMinimalConfig(options) {
|
|
8
11
|
const config = { ...DEFAULTS };
|
|
@@ -32,14 +35,23 @@ function applyLayer(config, path) {
|
|
|
32
35
|
console.error(`pi-minimal: expected an object in ${path}`);
|
|
33
36
|
return;
|
|
34
37
|
}
|
|
38
|
+
// Each key is validated on its own so one bad value does not discard the rest of the layer.
|
|
35
39
|
const record = parsed;
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
if (Object.hasOwn(record, "hideGauntletWidgets")) {
|
|
41
|
+
const value = record.hideGauntletWidgets;
|
|
42
|
+
if (typeof value === "boolean")
|
|
43
|
+
config.hideGauntletWidgets = value;
|
|
44
|
+
else
|
|
45
|
+
console.error(`pi-minimal: hideGauntletWidgets must be a boolean in ${path}`);
|
|
46
|
+
}
|
|
47
|
+
if (Object.hasOwn(record, "editDiff")) {
|
|
48
|
+
const value = record.editDiff;
|
|
49
|
+
if (isEditDiffMode(value))
|
|
50
|
+
config.editDiff = value;
|
|
51
|
+
else
|
|
52
|
+
console.error(`pi-minimal: editDiff must be one of ${EDIT_DIFF_MODES.map((mode) => `"${mode}"`).join(", ")} in ${path}`);
|
|
43
53
|
}
|
|
44
|
-
|
|
54
|
+
}
|
|
55
|
+
function isEditDiffMode(value) {
|
|
56
|
+
return typeof value === "string" && EDIT_DIFF_MODES.includes(value);
|
|
45
57
|
}
|
|
@@ -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
|
|
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 === "
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
89
|
+
if (ownerToken)
|
|
89
90
|
return { ok: false, code: "occupied" };
|
|
90
91
|
const token = Symbol(input.owner);
|
|
91
|
-
|
|
92
|
+
ownerToken = token;
|
|
92
93
|
return { ok: true, registration: {
|
|
93
94
|
publish(input) {
|
|
94
|
-
if (
|
|
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 (
|
|
107
|
+
if (ownerToken !== token)
|
|
107
108
|
return;
|
|
108
|
-
|
|
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
|
-
|
|
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:
|
|
123
|
+
readPlan: plan.read,
|
|
122
124
|
register: activity.register,
|
|
123
|
-
registerPlan:
|
|
125
|
+
registerPlan: plan.register,
|
|
124
126
|
subscribe(observer) {
|
|
125
|
-
const offActivity = activity.subscribe(observer),
|
|
126
|
-
return () => { offActivity();
|
|
127
|
+
const offActivity = activity.subscribe(observer), offPlan = plan.subscribe(observer);
|
|
128
|
+
return () => { offActivity(); offPlan(); };
|
|
127
129
|
},
|
|
128
|
-
reset(sessionId) { activity.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:
|
|
35
|
-
if (branchRun.code
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
}
|