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 +46 -0
- package/CONTEXT.md +83 -0
- package/README.md +3 -3
- package/dist/config.js +2 -1
- 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 +24 -208
- package/dist/reclaim.js +0 -3
- package/dist/session.js +155 -0
- package/dist/tools.js +213 -374
- 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 +1 -1
- package/release-notes.md +46 -12
- package/src/config.ts +2 -1
- 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 +26 -201
- package/src/reclaim.ts +0 -4
- package/src/session.ts +159 -0
- package/src/tools.ts +261 -389
- package/src/tracker.ts +11 -5
- package/src/working-status.ts +2 -1
- package/test/config.test.ts +2 -2
- 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 +36 -3
- 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/dist/tracker.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"];
|
|
2
|
-
function
|
|
2
|
+
export function readGauntletProjection(ctx) {
|
|
3
3
|
let phases;
|
|
4
4
|
let tasks;
|
|
5
5
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
@@ -21,7 +21,7 @@ function phaseActive(phases) {
|
|
|
21
21
|
return PHASES.some((p) => phases[p]?.status && phases[p].status !== "pending");
|
|
22
22
|
}
|
|
23
23
|
export function formatTrackerLines(ctx, theme) {
|
|
24
|
-
const { phases, tasks } =
|
|
24
|
+
const { phases, tasks } = readGauntletProjection(ctx);
|
|
25
25
|
const lines = [];
|
|
26
26
|
if (phases && phaseActive(phases)) {
|
|
27
27
|
const parts = PHASES.map((p) => {
|
package/dist/working-status.js
CHANGED
|
@@ -184,8 +184,9 @@ export function installWorkingStatus(pi) {
|
|
|
184
184
|
stop();
|
|
185
185
|
});
|
|
186
186
|
pi.on("session_shutdown", () => {
|
|
187
|
-
ctx
|
|
187
|
+
// Clear the message while ctx is still known; dropping ctx first left it on screen.
|
|
188
188
|
stop();
|
|
189
|
+
ctx = undefined;
|
|
189
190
|
});
|
|
190
191
|
return stop;
|
|
191
192
|
}
|
|
@@ -4,14 +4,14 @@ Pi-minimal remains the single footer owner. A trusted companion extension can pu
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
-
Two independent slots are supported: `activity` and `
|
|
7
|
+
Two independent slots are supported: `activity` and `plan`, with one owner per slot. (`summary` is the legacy wire name of the plan slot and remains accepted.) A rendered plan line replaces the legacy pi-gauntlet projection, avoiding duplicate plan bars; a plan snapshot that omits `plan`, or clearing the slot, lets the legacy projection show again. This is display composition, not a task tracker, subagent launcher, or PR/workspace display.
|
|
8
8
|
|
|
9
9
|
## Events
|
|
10
10
|
|
|
11
11
|
| Event | Direction | Payload |
|
|
12
12
|
|---|---|---|
|
|
13
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"] }` |
|
|
14
|
+
| `pi-minimal:footer:v1:ready` | host → consumers | `{ version: 1, sessionId, slots: ["activity", "plan", "summary"] }` |
|
|
15
15
|
| `pi-minimal:footer:v1:unavailable` | host → consumers | `{ version: 1, sessionId }` |
|
|
16
16
|
|
|
17
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.
|
|
@@ -51,7 +51,7 @@ type RegisterRequest = {
|
|
|
51
51
|
version: 1;
|
|
52
52
|
sessionId: string;
|
|
53
53
|
owner: string;
|
|
54
|
-
slot: "activity" | "summary";
|
|
54
|
+
slot: "activity" | "plan" | "summary"; // "summary" is the legacy alias of "plan"
|
|
55
55
|
result?:
|
|
56
56
|
| { ok: true; registration: {
|
|
57
57
|
publish(snapshot: unknown): { ok: true } | { ok: false; code: string };
|
|
@@ -63,7 +63,7 @@ type RegisterRequest = {
|
|
|
63
63
|
|
|
64
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
65
|
|
|
66
|
-
## Plan
|
|
66
|
+
## Plan snapshot (`plan` slot; legacy name `summary`)
|
|
67
67
|
|
|
68
68
|
```ts
|
|
69
69
|
{
|
|
@@ -74,8 +74,8 @@ No result means the host is absent/not ready. Registration errors include `inval
|
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
The plan ID follows row-key bounds. Counts/revision are nonnegative safe integers;
|
|
77
|
-
accepted cannot exceed total;
|
|
78
|
-
|
|
77
|
+
accepted cannot exceed total; `inReview` and `blocked` are each bounded by
|
|
78
|
+
`total - accepted`, but their sum is not checked (a task may be both). Counts are supplied by the tracker, never inferred from agent
|
|
79
79
|
runs. Omit `plan` on a newer revision to clear the contribution without releasing
|
|
80
80
|
ownership. Snapshot objects are cloned/frozen, and revision/disposal rules match
|
|
81
81
|
the activity slot. Each slot has independent ownership and revision tracking.
|
|
@@ -109,7 +109,7 @@ Metrics are right-aligned only if the full activity text still fits with at leas
|
|
|
109
109
|
|
|
110
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
111
|
|
|
112
|
-
Owner uses the same character/length rules as row keys. Display text has ANSI/OSC
|
|
112
|
+
Owner uses the same character/length rules as row keys. Display text has ANSI/OSC sequences stripped; remaining C0/C1 and bidi control characters are replaced with spaces, then whitespace is collapsed and trimmed. 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
113
|
|
|
114
114
|
## Presentation and responsibility boundaries
|
|
115
115
|
|
package/package.json
CHANGED
package/release-notes.md
CHANGED
|
@@ -1,23 +1,57 @@
|
|
|
1
|
+
Session lifecycle, one tool-row lifecycle, and a domain glossary. `hideGauntletWidgets`
|
|
2
|
+
is now opt-in and the 200 ms chrome steal interval is gone.
|
|
3
|
+
|
|
1
4
|
### Added
|
|
2
5
|
|
|
3
|
-
- `
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
- `write` rows show the written content (first 400 lines) when expanded with `ctrl+o`.
|
|
7
|
+
- With `editDiff: "expanded"` (the default), diffs of up to 8 lines are shown inline;
|
|
8
|
+
hiding them saved nothing and cost a keypress. Longer diffs still wait for `ctrl+o`.
|
|
9
|
+
- `CONTEXT.md`: a glossary of the domain (chrome, reclaim, slots, owner token, the snapshot
|
|
10
|
+
kinds, plan vs the legacy pi-gauntlet projection, activity rows, hints, turn rule, working
|
|
11
|
+
status). The 0.1 spec and plan moved to `doc/archive/`.
|
|
12
|
+
- The plan slot can be addressed as `slot: "plan"`; `summary` stays accepted as its legacy
|
|
13
|
+
name and the ready event now lists `["activity", "plan", "summary"]`.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- `hideGauntletWidgets` now defaults to `false`. Hiding another extension's widgets is
|
|
18
|
+
opt-in; set it to `true` to keep the previous behaviour.
|
|
19
|
+
- Chrome is re-asserted only on events (session start and its two settle ticks, turn
|
|
20
|
+
end, tool end). The 200 ms interval that cleared pi-gauntlet's widgets five times a
|
|
21
|
+
second is gone; it never reinstalled the header or footer anyway.
|
|
22
|
+
- All session-scoped state (config, cwd, the footer contribution registry, the git and
|
|
23
|
+
Herdr pollers, the chrome) now lives on one `Session` object created at session start
|
|
24
|
+
and disposed at shutdown or when the next session starts. Late async results from a
|
|
25
|
+
disposed session are dropped instead of drawing into the new one.
|
|
26
|
+
- The compact, generic and codemode tool rows are now three small strategies over one
|
|
27
|
+
row lifecycle (`createRowRenderers`): running state, live spinner, status colours,
|
|
28
|
+
the error line and expansion are implemented once. Edit-diff colouring moved to
|
|
29
|
+
`src/edit-diff.ts`. No visible change beyond the two items above.
|
|
30
|
+
- Internal renames for clarity: `TrackerSnapshot` → `GauntletProjection`,
|
|
31
|
+
`readSnapshot` → `readGauntletProjection`, the tracker's `TaskStatus` →
|
|
32
|
+
`GauntletTaskStatus`, and the slot's authority symbol is `ownerToken`. No wire change.
|
|
33
|
+
- `doc/footer-contributions.md` now states precisely when the legacy projection is replaced
|
|
34
|
+
(only while a plan line renders), how control characters are sanitised (VT stripped,
|
|
35
|
+
C0/C1/bidi replaced with spaces), and that `inReview`/`blocked` are bounded individually.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- The working message is cleared on session shutdown; it used to drop the context
|
|
40
|
+
before clearing and leave the last status on screen.
|
|
41
|
+
- Git state reads the working directory once per refresh, so branch and dirty count
|
|
42
|
+
always describe the same tree, and one throwing change listener no longer blocks
|
|
43
|
+
the others.
|
|
12
44
|
|
|
13
45
|
## What's Changed
|
|
14
|
-
*
|
|
46
|
+
* refactor: per-session lifecycle, no chrome steal interval, hideGauntletWidgets opt-in by @raviy06 in https://github.com/raviy06/pi-minimal/pull/19
|
|
47
|
+
* refactor(tools): one row lifecycle + strategies; write content on expand; small diffs inline by @raviy06 in https://github.com/raviy06/pi-minimal/pull/20
|
|
48
|
+
* docs: CONTEXT.md glossary, clearer internal names, 'plan' slot alias (0.5.0) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/21
|
|
15
49
|
|
|
16
50
|
|
|
17
|
-
**Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.
|
|
51
|
+
**Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.4.0...v0.5.0
|
|
18
52
|
|
|
19
53
|
---
|
|
20
54
|
**Install / upgrade**
|
|
21
55
|
```bash
|
|
22
|
-
pi install npm:pi-minimal@0.
|
|
56
|
+
pi install npm:pi-minimal@0.5.0 # or: pi update --extensions
|
|
23
57
|
```
|
package/src/config.ts
CHANGED
package/src/edit-diff.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { diffWords } from "diff"
|
|
3
|
+
import type { ToolResultLike } from "./tool-rows.js"
|
|
4
|
+
|
|
5
|
+
/** The edit tool's `details.diff`, when present and non-empty. */
|
|
6
|
+
export function editDiffText(result: ToolResultLike | undefined): string | undefined {
|
|
7
|
+
const details = result?.details as { diff?: unknown } | undefined
|
|
8
|
+
return typeof details?.diff === "string" && details.diff.trim() ? details.diff : undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type DiffLine = { prefix: "+" | "-" | " "; lineNum: string; content: string }
|
|
12
|
+
|
|
13
|
+
/** pi's edit diff lines: "+123 content", "-123 content", " 123 context"; anything else (e.g. "...") is a separator. */
|
|
14
|
+
function parseDiffLine(line: string): DiffLine | undefined {
|
|
15
|
+
const match = /^([+\- ])(\s*\d*) (.*)$/.exec(line)
|
|
16
|
+
if (!match) return undefined
|
|
17
|
+
return { prefix: match[1] as DiffLine["prefix"], lineNum: match[2]!, content: match[3]!.replace(/\t/g, " ") }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Word-level diff of a 1:1 replacement; changed tokens are inverted, leading indentation is left alone, as in pi. */
|
|
21
|
+
function highlightPair(removed: string, added: string, theme: Theme): { removed: string; added: string } {
|
|
22
|
+
let out = { removed: "", added: "" }
|
|
23
|
+
let firstRemoved = true
|
|
24
|
+
let firstAdded = true
|
|
25
|
+
for (const part of diffWords(removed, added)) {
|
|
26
|
+
if (part.removed || part.added) {
|
|
27
|
+
const key = part.removed ? "removed" : "added"
|
|
28
|
+
let value = part.value
|
|
29
|
+
if (part.removed ? firstRemoved : firstAdded) {
|
|
30
|
+
const ws = /^(\s*)/.exec(value)?.[1] ?? ""
|
|
31
|
+
value = value.slice(ws.length)
|
|
32
|
+
out = { ...out, [key]: out[key] + ws }
|
|
33
|
+
if (part.removed) firstRemoved = false
|
|
34
|
+
else firstAdded = false
|
|
35
|
+
}
|
|
36
|
+
if (value) out = { ...out, [key]: out[key] + theme.inverse(value) }
|
|
37
|
+
} else {
|
|
38
|
+
out = { removed: out.removed + part.value, added: out.added + part.value }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Colours pi's edit diff like pi does: theme diff colours per line, inverse on changed tokens of single-line replacements. */
|
|
45
|
+
export function colorDiffLines(lines: string[], theme: Theme): string[] {
|
|
46
|
+
const out: string[] = []
|
|
47
|
+
for (let i = 0; i < lines.length; ) {
|
|
48
|
+
const parsed = parseDiffLine(lines[i]!)
|
|
49
|
+
if (!parsed) {
|
|
50
|
+
out.push(theme.fg("toolDiffContext", lines[i]!))
|
|
51
|
+
i++
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
if (parsed.prefix === " ") {
|
|
55
|
+
out.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${parsed.content}`))
|
|
56
|
+
i++
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
const removed: DiffLine[] = []
|
|
60
|
+
const added: DiffLine[] = []
|
|
61
|
+
for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "-"; p = parseDiffLine(lines[++i] ?? "")) removed.push(p)
|
|
62
|
+
for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "+"; p = parseDiffLine(lines[++i] ?? "")) added.push(p)
|
|
63
|
+
if (removed.length === 1 && added.length === 1) {
|
|
64
|
+
const pair = highlightPair(removed[0]!.content, added[0]!.content, theme)
|
|
65
|
+
out.push(theme.fg("toolDiffRemoved", `-${removed[0]!.lineNum} ${pair.removed}`))
|
|
66
|
+
out.push(theme.fg("toolDiffAdded", `+${added[0]!.lineNum} ${pair.added}`))
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
for (const line of removed) out.push(theme.fg("toolDiffRemoved", `-${line.lineNum} ${line.content}`))
|
|
70
|
+
for (const line of added) out.push(theme.fg("toolDiffAdded", `+${line.lineNum} ${line.content}`))
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
@@ -5,8 +5,11 @@ export const FOOTER_EVENTS = {
|
|
|
5
5
|
ready: "pi-minimal:footer:v1:ready",
|
|
6
6
|
unavailable: "pi-minimal:footer:v1:unavailable",
|
|
7
7
|
} as const
|
|
8
|
+
/** Slot names on the wire. `summary` is the legacy name of the plan slot and stays accepted. */
|
|
9
|
+
export const FOOTER_SLOTS = ["activity", "plan", "summary"] as const
|
|
10
|
+
export type FooterSlot = typeof FOOTER_SLOTS[number]
|
|
8
11
|
export type RegisterRequest = {
|
|
9
|
-
version: 1; sessionId: string; owner: string; slot:
|
|
12
|
+
version: 1; sessionId: string; owner: string; slot: FooterSlot
|
|
10
13
|
result?: RegistrationResult | { ok: false; code: "not-tui" }
|
|
11
14
|
}
|
|
12
15
|
type Events = {
|
|
@@ -22,11 +25,11 @@ export function bindFooterContributionEvents(events: Events, registry: FooterCon
|
|
|
22
25
|
const request = value as Partial<RegisterRequest>
|
|
23
26
|
// A different host may have answered first. Never overwrite its result.
|
|
24
27
|
if (request.result !== undefined) return
|
|
25
|
-
if (request.version !== 1 || (request.slot
|
|
28
|
+
if (request.version !== 1 || !FOOTER_SLOTS.includes(request.slot as FooterSlot)) request.result = { ok: false, code: "invalid" }
|
|
26
29
|
else if (mode !== "tui") request.result = { ok: false, code: "not-tui" }
|
|
27
|
-
else request.result = request.slot === "
|
|
30
|
+
else request.result = request.slot === "activity" ? registry.register(request) : registry.registerPlan(request)
|
|
28
31
|
})
|
|
29
|
-
if (mode === "tui") events.emit(FOOTER_EVENTS.ready, { version: 1, sessionId, slots: [
|
|
32
|
+
if (mode === "tui") events.emit(FOOTER_EVENTS.ready, { version: 1, sessionId, slots: [...FOOTER_SLOTS] })
|
|
30
33
|
let disposed = false
|
|
31
34
|
return () => {
|
|
32
35
|
if (disposed) return
|
|
@@ -77,7 +77,8 @@ function normalizeSnapshot(input: unknown): ActivitySnapshot | undefined {
|
|
|
77
77
|
|
|
78
78
|
function createFooterSlot<T extends { revision: number }>(normalize: (input: unknown) => T | undefined, initialSessionId: string | null) {
|
|
79
79
|
let sessionId = initialSessionId
|
|
80
|
-
|
|
80
|
+
// The registrant's `owner` string is a display identity; this symbol is the actual authority to publish.
|
|
81
|
+
let ownerToken: symbol | undefined
|
|
81
82
|
let snapshot: T | undefined
|
|
82
83
|
const observers = new Set<() => void>()
|
|
83
84
|
const notify = () => {
|
|
@@ -89,17 +90,17 @@ function createFooterSlot<T extends { revision: number }>(normalize: (input: unk
|
|
|
89
90
|
read: () => snapshot,
|
|
90
91
|
subscribe(observer: () => void) { observers.add(observer); return () => { observers.delete(observer) } },
|
|
91
92
|
reset(nextSessionId: string | null) {
|
|
92
|
-
sessionId = nextSessionId;
|
|
93
|
+
sessionId = nextSessionId; ownerToken = undefined; snapshot = undefined; notify()
|
|
93
94
|
},
|
|
94
95
|
register(input: unknown): RegistrationResult {
|
|
95
96
|
if (!record(input) || !identity(input.owner) || typeof input.sessionId !== "string" || !input.sessionId) return { ok: false, code: "invalid" }
|
|
96
97
|
if (sessionId === null || input.sessionId !== sessionId) return { ok: false, code: "wrong-session" }
|
|
97
|
-
if (
|
|
98
|
+
if (ownerToken) return { ok: false, code: "occupied" }
|
|
98
99
|
const token = Symbol(input.owner)
|
|
99
|
-
|
|
100
|
+
ownerToken = token
|
|
100
101
|
return { ok: true, registration: {
|
|
101
102
|
publish(input: unknown) {
|
|
102
|
-
if (
|
|
103
|
+
if (ownerToken !== token) return { ok: false, code: "disposed" }
|
|
103
104
|
const next = normalize(input)
|
|
104
105
|
if (!next) return { ok: false, code: "invalid" }
|
|
105
106
|
if (snapshot && next.revision <= snapshot.revision) return { ok: false, code: "stale" }
|
|
@@ -107,8 +108,8 @@ function createFooterSlot<T extends { revision: number }>(normalize: (input: unk
|
|
|
107
108
|
return { ok: true }
|
|
108
109
|
},
|
|
109
110
|
dispose() {
|
|
110
|
-
if (
|
|
111
|
-
|
|
111
|
+
if (ownerToken !== token) return
|
|
112
|
+
ownerToken = undefined; snapshot = undefined; notify()
|
|
112
113
|
},
|
|
113
114
|
} }
|
|
114
115
|
},
|
|
@@ -116,17 +117,18 @@ function createFooterSlot<T extends { revision: number }>(normalize: (input: unk
|
|
|
116
117
|
}
|
|
117
118
|
export function createFooterContributions(initialSessionId: string | null = null) {
|
|
118
119
|
const activity = createFooterSlot(normalizeSnapshot, initialSessionId)
|
|
119
|
-
|
|
120
|
+
// The plan slot; "summary" is its legacy wire name.
|
|
121
|
+
const plan = createFooterSlot(normalizePlanSnapshot, initialSessionId)
|
|
120
122
|
return {
|
|
121
123
|
read: activity.read,
|
|
122
|
-
readPlan:
|
|
124
|
+
readPlan: plan.read,
|
|
123
125
|
register: activity.register,
|
|
124
|
-
registerPlan:
|
|
126
|
+
registerPlan: plan.register,
|
|
125
127
|
subscribe(observer: () => void) {
|
|
126
|
-
const offActivity = activity.subscribe(observer),
|
|
127
|
-
return () => { offActivity();
|
|
128
|
+
const offActivity = activity.subscribe(observer), offPlan = plan.subscribe(observer)
|
|
129
|
+
return () => { offActivity(); offPlan() }
|
|
128
130
|
},
|
|
129
|
-
reset(sessionId: string | null) { activity.reset(sessionId);
|
|
131
|
+
reset(sessionId: string | null) { activity.reset(sessionId); plan.reset(sessionId) },
|
|
130
132
|
}
|
|
131
133
|
}
|
|
132
134
|
export type FooterContributions = ReturnType<typeof createFooterContributions>
|
package/src/git-state.ts
CHANGED
|
@@ -30,30 +30,45 @@ export function createGitState(pi: ExtensionAPI, cwd: () => string, minIntervalM
|
|
|
30
30
|
let state: GitState | undefined
|
|
31
31
|
let lastRun = 0
|
|
32
32
|
let inFlight: Promise<void> | undefined
|
|
33
|
+
let disposed = false
|
|
33
34
|
const listeners = new Set<() => void>()
|
|
34
35
|
|
|
36
|
+
const notify = () => {
|
|
37
|
+
for (const listener of listeners) {
|
|
38
|
+
try {
|
|
39
|
+
listener()
|
|
40
|
+
} catch {
|
|
41
|
+
// One listener's failure must not starve the others.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
const refresh = (force = false): Promise<void> => {
|
|
47
|
+
if (disposed) return Promise.resolve()
|
|
36
48
|
const now = Date.now()
|
|
37
49
|
if (inFlight) return inFlight
|
|
38
50
|
if (!force && now - lastRun < minIntervalMs) return Promise.resolve()
|
|
39
51
|
lastRun = now
|
|
52
|
+
// Read the directory once so both commands describe the same tree even if cwd changes mid-refresh.
|
|
53
|
+
const dir = cwd()
|
|
40
54
|
inFlight = (async () => {
|
|
55
|
+
let next: GitState | undefined
|
|
41
56
|
try {
|
|
42
|
-
const branchRun = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd:
|
|
43
|
-
if (branchRun.code
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
const branchRun = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 2000 })
|
|
58
|
+
if (branchRun.code === 0) {
|
|
59
|
+
const branch = parseBranch(branchRun.stdout)
|
|
60
|
+
const statusRun = await pi.exec("git", ["status", "--porcelain"], { cwd: dir, timeout: 3000 })
|
|
61
|
+
next = { branch, dirty: statusRun.code === 0 ? parsePorcelain(statusRun.stdout) : undefined }
|
|
46
62
|
}
|
|
47
|
-
const branch = parseBranch(branchRun.stdout)
|
|
48
|
-
const statusRun = await pi.exec("git", ["status", "--porcelain"], { cwd: cwd(), timeout: 3000 })
|
|
49
|
-
const dirty = statusRun.code === 0 ? parsePorcelain(statusRun.stdout) : undefined
|
|
50
|
-
state = { branch, dirty }
|
|
51
63
|
} catch {
|
|
52
|
-
|
|
64
|
+
next = undefined
|
|
53
65
|
} finally {
|
|
54
66
|
inFlight = undefined
|
|
55
|
-
for (const listener of listeners) listener()
|
|
56
67
|
}
|
|
68
|
+
// A result that lands after dispose belongs to a session that no longer exists.
|
|
69
|
+
if (disposed) return
|
|
70
|
+
state = next
|
|
71
|
+
notify()
|
|
57
72
|
})()
|
|
58
73
|
return inFlight
|
|
59
74
|
}
|
|
@@ -65,6 +80,10 @@ export function createGitState(pi: ExtensionAPI, cwd: () => string, minIntervalM
|
|
|
65
80
|
listeners.add(listener)
|
|
66
81
|
return () => listeners.delete(listener)
|
|
67
82
|
},
|
|
83
|
+
dispose() {
|
|
84
|
+
disposed = true
|
|
85
|
+
listeners.clear()
|
|
86
|
+
},
|
|
68
87
|
}
|
|
69
88
|
}
|
|
70
89
|
|