pi-crew 0.9.50 → 0.9.51
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 +115 -0
- package/dist/build-meta.json +42 -33
- package/dist/index.mjs +149 -54
- package/dist/index.mjs.map +4 -4
- package/docs/decisions/2026-07-26-c6-mascot-visibility-not-wired.md +84 -0
- package/package.json +1 -1
- package/skills/distill-software/SKILL.md +24 -1
- package/src/ui/live-run-sidebar.ts +5 -13
- package/src/ui/loaders.ts +6 -176
- package/src/ui/mascot.ts +14 -1
- package/src/ui/render-scheduler.ts +60 -6
- package/src/ui/run-dashboard.ts +12 -21
- package/src/ui/shared-overlay-scheduler.ts +96 -0
- package/src/ui/widget/index.ts +45 -11
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# C6 — mascot visibility gate: NOT WIRED (closed)
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-07-26
|
|
4
|
+
**Status:** CLOSED — gate kept (tested), wiring intentionally NOT done
|
|
5
|
+
**Supersedes / closes:** finding C6 in `reports/ui-animation-audit-2026-07-24.md`
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
UI-animation-audit finding **C6** (LOW): `src/ui/mascot.ts` `tick()` calls
|
|
10
|
+
`requestRender()` unconditionally, even when the mascot overlay is obscured by
|
|
11
|
+
another overlay on top. The audit suggested adding a visibility flag
|
|
12
|
+
(`setVisible()`), gated by a note: *"needs visibility flag infrastructure;
|
|
13
|
+
bounded by 7s auto-close so low priority."*
|
|
14
|
+
|
|
15
|
+
The `setVisible()` gate was implemented + unit-tested (`mascot.ts:440`,
|
|
16
|
+
`test/unit/mascot.test.ts` "AnimatedMascot does not requestRender when
|
|
17
|
+
invisible (C6)"). The remaining question was whether to **wire** it — i.e.,
|
|
18
|
+
have a caller invoke `setVisible(false)` when the mascot is obscured.
|
|
19
|
+
|
|
20
|
+
## Investigation
|
|
21
|
+
|
|
22
|
+
Read the pi source (`earendil-works/pi` @ `cee5ff75`) to determine whether an
|
|
23
|
+
overlay component can know it is obscured:
|
|
24
|
+
|
|
25
|
+
- **`ctx.ui.custom()` exposes `onHandle?: (handle: OverlayHandle) => void`**
|
|
26
|
+
(`packages/coding-agent/.../interactive-mode.ts:2448`). The `OverlayHandle`
|
|
27
|
+
offers `isFocused()` — a usable *proxy* for "not obscured" (a capturing
|
|
28
|
+
overlay loses focus when another opens on top). So wiring IS technically
|
|
29
|
+
feasible (~5 lines: `onHandle` → `mascot.setOverlayHandle(h)`; `tick()` sets
|
|
30
|
+
`this.visible = !h || h.isFocused()`).
|
|
31
|
+
- **BUT pi composites ALL non-hidden overlays** (`packages/tui/src/tui.ts:1044`
|
|
32
|
+
filters `visibleEntries`, then `component.render()` at `:1054` for each).
|
|
33
|
+
`isOverlayVisible` checks only the `hidden` flag + a terminal-dimension
|
|
34
|
+
callback — **it does not account for obscuring.** So when the mascot is
|
|
35
|
+
obscured, the host STILL calls `mascot.render()` (the output is computed then
|
|
36
|
+
painted over by the overlay above).
|
|
37
|
+
|
|
38
|
+
## Decision
|
|
39
|
+
|
|
40
|
+
**Do NOT wire C6.** Keep the tested `setVisible()` gate (harmless, ready if pi
|
|
41
|
+
ever exposes a true visibility signal), but do not add the `onHandle`/`isFocused`
|
|
42
|
+
wiring.
|
|
43
|
+
|
|
44
|
+
## Rationale
|
|
45
|
+
|
|
46
|
+
C6 does not save the work it appears to target:
|
|
47
|
+
|
|
48
|
+
| Aspect | Saved by C6 wiring? |
|
|
49
|
+
|---|---|
|
|
50
|
+
| Mascot's `render()` (the actual paint work — ~31-col image) | **No** — host calls it during compositing regardless (`visibleEntries` includes the non-hidden mascot) |
|
|
51
|
+
| Mascot's animation state advancement in `tick()` | **No** — the gate is on `requestRender` only; the animation computation still runs |
|
|
52
|
+
| Mascot's `requestRender()` pokes | Yes — but these are **cheap + render-coalesced + redundant** whenever the obscuring overlay is itself active (dashboard/settings/mailbox all drive their own repaints) |
|
|
53
|
+
|
|
54
|
+
The only scenario where C6 saves a *real* repaint is: mascot obscured by a
|
|
55
|
+
**static, non-render-requesting** overlay. That is rare, bounded to the
|
|
56
|
+
mascot's **7 s auto-close** window, and even there the `render()` work still
|
|
57
|
+
runs. Net measurable benefit: near zero.
|
|
58
|
+
|
|
59
|
+
Contrast with the sibling findings that DID land: **C1** (30→10 fps) cut
|
|
60
|
+
repaint pressure 3× on *every* mascot frame and fixed Windows flicker; **C3**
|
|
61
|
+
(shared scheduler) cut 9→3 subscriptions/timers and per-event `schedule()` CPU
|
|
62
|
+
in active runs. C6 is an order of magnitude below either.
|
|
63
|
+
|
|
64
|
+
## Consequences
|
|
65
|
+
|
|
66
|
+
- The `setVisible()` gate stays in `mascot.ts` (tested, documented here as
|
|
67
|
+
intentionally unwired). Future callers can use it if a true visibility signal
|
|
68
|
+
becomes available.
|
|
69
|
+
- The **correct** fix for "render the mascot when obscured" is a **pi-core
|
|
70
|
+
compositing optimization**: skip `render()` for overlays fully occluded by a
|
|
71
|
+
higher opaque overlay. That belongs upstream in `packages/tui/src/tui.ts`
|
|
72
|
+
(the `visibleEntries` compositing loop), not in pi-crew. Track as a pi
|
|
73
|
+
upstream suggestion, not a pi-crew task.
|
|
74
|
+
- `isFocused()` is an imperfect proxy anyway (a *non-capturing* overlay on top
|
|
75
|
+
would not steal focus, so the mascot would stay "focused" while painted over
|
|
76
|
+
— a false-negative). Wiring it would trade a rare wasted repaint for a
|
|
77
|
+
semantic mismatch; not worth it.
|
|
78
|
+
|
|
79
|
+
## Verification
|
|
80
|
+
|
|
81
|
+
- `setVisible()` gate: `test/unit/mascot.test.ts` → 7/7 pass (incl. the C6
|
|
82
|
+
"does not requestRender when invisible" test).
|
|
83
|
+
- No production caller of `setVisible()` exists (grep confirms); the gate is
|
|
84
|
+
inert but correct.
|
package/package.json
CHANGED
|
@@ -9,6 +9,9 @@ triggers:
|
|
|
9
9
|
- "how does this codebase do things"
|
|
10
10
|
- "distill software expertise"
|
|
11
11
|
- "distill [repo] conventions"
|
|
12
|
+
- "distill [source] to [target]"
|
|
13
|
+
- "port [source] features to [target]"
|
|
14
|
+
- "bring [source] capabilities to [target]"
|
|
12
15
|
---
|
|
13
16
|
|
|
14
17
|
# distill-software
|
|
@@ -120,6 +123,11 @@ Ask (defaults provided; never block value):
|
|
|
120
123
|
4. **Cost tier** (quote before Phase 1): quick (3 streams) / standard (6+extra) / deep (full archive). Codebase sweep scales with repo size.
|
|
121
124
|
5. **Ethics** (engineer flavor, living non-public colleague): consent gate — require subject-provided material + consent flag (inherited M-F3).
|
|
122
125
|
6. **Decomposition for large targets** (Core Principle #6 — decide HERE): if the repo is large (>200 files OR >5 subsystems/packages), decompose into sub-targets and distill each, then merge. Never ôm đồm (take it all at once). Decompose by **package/subsystem** (each gets its own coverage-manifest + 3-empty-rounds gate); then sweep the **cross-cutting** conventions (the ones spanning packages). Record the decomposition tree in `DISTILLATION-PROCESS-CHECKLIST.md`. Recursive if a sub-package is still large.
|
|
126
|
+
7. **🔴 Transfer vs Audit mode** (learned from real runs: audit-only distillation produced 0 visible features for the target). Default = **Transfer** (user says "distill X to Y" → they want Y to GAIN capabilities from X). Two modes:
|
|
127
|
+
- **Transfer mode** ⭐ (default for "distill X to/about/into Y"): enumerate source **CAPABILITIES** (features, not files) → compare against target **CAPABILITY DEPTH** (not surface) → apply the gaps. Deliverable = visible/behavioral new features in target. "The user can SEE or EXPERIENCE the difference."
|
|
128
|
+
- **Audit mode**: enumerate source conventions → compare against target surface → apply pattern refactors/lint rules. Deliverable = cleaner code, invisible refactors.
|
|
129
|
+
- **Detection**: "distill X to/about/into Y" / "port X features" / "bring X to Y" → **Transfer**. "audit X conventions" / "how does X do Y" / "what can Y learn from X's conventions" → **Audit**. **When in doubt → Transfer** (audit is a subset — transfer includes convention adoption as a side effect).
|
|
130
|
+
- **Decomposition follows mode**: Transfer mode decomposes by **CAPABILITY BUCKET** (terminal/PTY, plugin system, markdown rendering, routing) NOT by directory (`components/`, `lib/`). Audit mode decomposes by package/subsystem (existing Principle #6).
|
|
123
131
|
|
|
124
132
|
## Phase 0.5 — ANALYZE TARGET
|
|
125
133
|
|
|
@@ -208,7 +216,8 @@ Mỗi verified model đi qua 3-chiều filter against the TARGET (KHÔNG phải
|
|
|
208
216
|
❌ KHÔNG → NECESSITY: target CẦN không?
|
|
209
217
|
❌ KHÔNG CẦN → SKIP (complexity vô ích).
|
|
210
218
|
✅ CẦN → ADOPT (thêm mới).
|
|
211
|
-
✅ CÓ →
|
|
219
|
+
✅ CÓ → **🔴 CAPABILITY DEPTH check**: "target has a similar SURFACE" ≠ "target has the same CAPABILITY". Surface match (target has an extension-point system) can mask a capability gap (target lacks route-level extensibility — the distinctive feature). Before SKIP on PRESENCE=yes, verify the target has the FULL capability at the FEATURE level, not just a similarly-named file/module. If target has a SUBSET → the missing subset is an ADOPT candidate (transfer the gap), NOT a SKIP.
|
|
220
|
+
Then QUALITY COMPARISON:
|
|
212
221
|
Source TỐT HƠN → IMPROVE (thay bằng cách source).
|
|
213
222
|
Source BẰNG/TỆ → SKIP.
|
|
214
223
|
COMPLEMENTARY → MERGE.
|
|
@@ -294,6 +303,12 @@ Mỗi TO-APPLY item → HOW to apply, then APPLY. Plan channels: AGENTS.md updat
|
|
|
294
303
|
(e) Mỗi destructive action cần confirmation riêng.
|
|
295
304
|
(f) Atomic writes (temp sibling + rename) — không ghi dở.
|
|
296
305
|
- **APPLY-LOG.md**: every edit recorded (file:line + before→after + verification). ≥3 items required before "done". This is THE deliverable proof — NOT a SKILL.md.
|
|
306
|
+
- **🔴 Post-APPLY integration checks (learned from real runs: artifact-level gates were ALL-GREEN while code had 12 runtime bugs; multiple review rounds were needed to find them):**
|
|
307
|
+
(g) **WIRED** — is the applied code actually CALLED by existing code? `grep -rn '<exported-symbol>' target-src/` — if 0 hits outside the defining file, it's DEAD CODE. Wire it into a consumer or remove it. (Observed: a routing-merge function was exported but never called by the app shell; a component prop was added but no consumer passed it.)
|
|
308
|
+
(h) **VISIBLE DELTA** — can the user SEE or EXPERIENCE the difference? If the apply is a pure refactor (same behavior, different code structure), it is NOT a capability transfer — flag it. Transfer mode (Phase 0 #7) REQUIRES visible deltas; invisible refactors are Audit-mode artifacts.
|
|
309
|
+
(i) **RUNTIME** — does the feature WORK in the real system? Don't just run unit tests — run the BUILD (`tsc --noEmit`, `vite build`, `cargo build`) AND, if the feature spans runtime boundaries (server + client, gateway + web), test END-TO-END (start the server, hit the endpoint, verify the response). Unit tests passing ≠ feature working. (Observed: a component passed unit tests but crashed in a real browser — missing vendor API flags and browser-only globals that the test environment mocked away.)
|
|
310
|
+
(j) **API VERIFIED** — before USING any target API (component prop, function signature, config option), READ ITS SOURCE to confirm the API matches your usage. Do NOT assume props exist. (Observed: code used a component prop that doesn't exist in its interface; the TypeScript object-literal excess-property check was silently bypassed.) This is the APPLY-phase analog of Phase 1's "excavate, don't recall".
|
|
311
|
+
(k) **RESOURCE LIFECYCLE** — if the applied code spawns processes, opens connections, registers listeners, or creates timers: verify CLEANUP on ALL exit paths (close, dispose, kill, unsubscribe). A missing disconnect handler that kills child processes = orphan leak. (Observed: a server endpoint spawned shell commands but never killed them when the client disconnected — process leak.)
|
|
297
312
|
|
|
298
313
|
## Phase 4 — VERIFY TARGET IMPROVED (Fidelity + Darwin ratchet, inherited F2' + software edge)
|
|
299
314
|
|
|
@@ -343,6 +358,9 @@ Refuse to ship if ANY fails; iterate Phase 2→4 until green:
|
|
|
343
358
|
- [ ] **Coverage manifest complete** (inherited exhaustive-sweep): every content-bearing part UNCOVERED→COVERED or `[UNREADABLE — reason]`; no dangling UNCOVERED rows at ship time (the codebase analog of persona's no-dangling-checklist rule).
|
|
344
359
|
- [ ] **DISTILLATION-PROCESS-CHECKLIST.md present + every phase ✅** (no ⬜/⏳ dangling) — proves no phase skipped
|
|
345
360
|
- [ ] **3-empty-rounds gate fired** for every sweep/synthesis phase (≥3 consecutive zero-new rounds recorded in the round log) — proves deep-dive wasn't cut short at 1-2 passes
|
|
361
|
+
- [ ] **🔴 Runtime verification**: full regression `npx vitest run` (or project test runner) passes with 0 failures — NOT just the new test file, the FULL suite. `npx tsc --noEmit` (or project typechecker) introduces 0 NEW errors. `vite build` / `cargo build` / `npm run build` succeeds.
|
|
362
|
+
- [ ] **🔴 Applied code is WIRED** — every new export/function/component is imported + called by ≥1 existing file (grep proof in APPLY-LOG). Dead code (exported but never imported) = failed apply.
|
|
363
|
+
- [ ] **🔴 VISIBLE DELTA** (Transfer mode only) — the user can see or experience the difference. Invisible refactors require Audit-mode justification.
|
|
346
364
|
|
|
347
365
|
> **Tiered effort (#8)**: for a trivial distillation (single small file, <5 conventions) you may skip the costliest sub-step (the independent dual-agent fidelity re-score → self-score with the single-agent caveat) — but never skip the structural, V5-grep, coverage, or ungrep-ratio gates.
|
|
348
366
|
|
|
@@ -358,6 +376,11 @@ Refuse to ship if ANY fails; iterate Phase 2→4 until green:
|
|
|
358
376
|
| 7 | Recency erasure (engineer reversed a stance) | Skill presents current-only view; hides evolution | preserve as temporal contradiction; "近期观点" prevails, old mentioned |
|
|
359
377
|
| 8 | Orphaned operational scripts | Scripts exist but agent never invokes them → dead code | wire INTO Agentic Protocol Step 2 (F13) |
|
|
360
378
|
| 9 | Single-source monoculture (domain skill) | One-source bias; no cross-verification | topic skills cite ≥3 independent sources |
|
|
379
|
+
| 10 | **Audit-only when user wanted Transfer** — "distill X to Y" produces invisible refactors (routing table, lint config) not visible features | User sees nothing new; 0 visible delta = failed transfer | Phase 0 #7 Transfer-vs-Audit routing (default Transfer); VISIBLE DELTA check in Phase 3 + ship-gate |
|
|
380
|
+
| 11 | **Surface-match SKIP masks capability gap** — target has an extension-point module → SKIP, but target lacks route-level extensibility (the distinctive capability) | Misses the highest-value transfers; filter is correct on paper, wrong in DEPTH | Phase 2.5 CAPABILITY DEPTH check: PRESENCE=yes → verify FULL capability at FEATURE level, not just surface |
|
|
381
|
+
| 12 | **Applied code is dead (never wired)** — a routing function is exported but never called; a component prop is added but no consumer passes it | APPLY-LOG says "applied" but target behavior unchanged; user sees nothing | Phase 3 (g) WIRED check (grep for usage); ship-gate WIRED assertion |
|
|
382
|
+
| 13 | **Unit tests pass but feature doesn't work** — tests mock the environment (jsdom lacks canvas/matchMedia/ResizeObserver) but the real browser needs them; a server endpoint has 0 integration test | Green CI, broken runtime; multiple review rounds needed to find bugs | Phase 3 (i) RUNTIME check (build + end-to-end); ship-gate runtime verification |
|
|
383
|
+
| 14 | **Missing resource cleanup** — spawned child processes, open WebSockets, registered listeners never cleaned up on disconnect/unmount | Orphaned processes accumulate; memory/connection leaks in production | Phase 3 (k) RESOURCE LIFECYCLE check (verify cleanup on ALL exit paths) |
|
|
361
384
|
|
|
362
385
|
## Self-containment
|
|
363
386
|
This engine embeds its methodology inline (inherited from distill-persona). The methodology is self-contained (copy dir → runs); `language`+`distilled_against` make the extraction's staleness auditable.
|
|
@@ -8,8 +8,8 @@ import type { TeamTaskState } from "../state/types.ts";
|
|
|
8
8
|
import { aggregateUsage, formatUsage } from "../state/usage.ts";
|
|
9
9
|
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
10
10
|
import { pad, truncate } from "../utils/visual.ts";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import type { OverlaySchedulerHandle } from "./shared-overlay-scheduler.ts";
|
|
12
|
+
import { registerOverlayScheduler } from "./shared-overlay-scheduler.ts";
|
|
13
13
|
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
|
|
14
14
|
import { spinnerBucket, spinnerFrame } from "./spinner.ts";
|
|
15
15
|
import { colorizeStatusGlyphs, iconForStatus } from "./status-colors.ts";
|
|
@@ -53,7 +53,7 @@ export class LiveRunSidebar {
|
|
|
53
53
|
private readonly theme: CrewTheme;
|
|
54
54
|
private readonly config: CrewUiConfig;
|
|
55
55
|
private readonly unsubscribeTheme: () => void;
|
|
56
|
-
private readonly
|
|
56
|
+
private readonly schedulerHandle: OverlaySchedulerHandle;
|
|
57
57
|
private readonly snapshotCache?: RunSnapshotCache;
|
|
58
58
|
private cachedLines: string[] = [];
|
|
59
59
|
private cachedWidth = 0;
|
|
@@ -82,15 +82,7 @@ export class LiveRunSidebar {
|
|
|
82
82
|
// subscribing independently a single event triggered up to 9 callbacks
|
|
83
83
|
// and ~150 invalidates/sec under load. The scheduler collapses bursts
|
|
84
84
|
// into one debounced invalidate.
|
|
85
|
-
this.
|
|
86
|
-
runEventBusAsRenderScheduler(["run:state", "worker:lifecycle", "ui:invalidate"]),
|
|
87
|
-
() => this.invalidate(),
|
|
88
|
-
{
|
|
89
|
-
debounceMs: 75,
|
|
90
|
-
fallbackMs: 750,
|
|
91
|
-
events: ["run:state", "worker:lifecycle", "ui:invalidate"],
|
|
92
|
-
},
|
|
93
|
-
);
|
|
85
|
+
this.schedulerHandle = registerOverlayScheduler(() => this.invalidate());
|
|
94
86
|
}
|
|
95
87
|
|
|
96
88
|
private buildSignature(
|
|
@@ -150,7 +142,7 @@ export class LiveRunSidebar {
|
|
|
150
142
|
this.autoCloseTimeout = undefined;
|
|
151
143
|
}
|
|
152
144
|
this.unsubscribeTheme();
|
|
153
|
-
this.
|
|
145
|
+
this.schedulerHandle.dispose();
|
|
154
146
|
}
|
|
155
147
|
|
|
156
148
|
render(width: number): string[] {
|
package/src/ui/loaders.ts
CHANGED
|
@@ -1,176 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
message: string;
|
|
8
|
-
cancellable?: boolean;
|
|
9
|
-
frames?: string[];
|
|
10
|
-
intervalMs?: number;
|
|
11
|
-
minWidth?: number;
|
|
12
|
-
onAbort?: () => void;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const DEFAULT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
16
|
-
|
|
17
|
-
export class CrewBorderedLoader {
|
|
18
|
-
private readonly abortController = new AbortController();
|
|
19
|
-
private readonly frameOptions: string[];
|
|
20
|
-
private readonly intervalMs: number;
|
|
21
|
-
private readonly minWidth: number;
|
|
22
|
-
private readonly onAbort?: () => void;
|
|
23
|
-
private theme: CrewTheme;
|
|
24
|
-
private message: string;
|
|
25
|
-
private lineCache = "";
|
|
26
|
-
private width = 0;
|
|
27
|
-
private startedAt = Date.now();
|
|
28
|
-
|
|
29
|
-
constructor(_ui: unknown, themeLike: unknown, options: BorderedLoaderOptions) {
|
|
30
|
-
const theme = asCrewTheme(themeLike);
|
|
31
|
-
this.theme = theme;
|
|
32
|
-
this.message = options.message;
|
|
33
|
-
this.minWidth = Math.max(12, options.minWidth ?? 24);
|
|
34
|
-
this.onAbort = options.onAbort;
|
|
35
|
-
this.frameOptions = options.frames ?? DEFAULT_FRAMES;
|
|
36
|
-
this.intervalMs = Math.max(40, options.intervalMs ?? 120);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
private spinnerFrame(): string {
|
|
40
|
-
if (this.frameOptions.length === 0) return "•";
|
|
41
|
-
const elapsed = Date.now() - this.startedAt;
|
|
42
|
-
const index = Math.floor(elapsed / this.intervalMs) % this.frameOptions.length;
|
|
43
|
-
return this.frameOptions[Math.max(0, index)];
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
setMessage(message: string): void {
|
|
47
|
-
this.message = message;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
get signal(): AbortSignal {
|
|
51
|
-
return this.abortController.signal;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
handleInput(data: string): void {
|
|
55
|
-
if (!this.onAbort || this.abortController.signal.aborted) return;
|
|
56
|
-
if (data === "c" || data === "q" || data === "\u001b" || data === "\u0003") {
|
|
57
|
-
this.abortController.abort();
|
|
58
|
-
this.onAbort();
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
render(width: number): string[] {
|
|
63
|
-
if (width === this.width && this.lineCache) {
|
|
64
|
-
return this.lineCache.split("\n");
|
|
65
|
-
}
|
|
66
|
-
const innerWidth = Math.max(this.minWidth - 4, 1);
|
|
67
|
-
const contentWidth = Math.max(1, Math.min(width - 4, innerWidth));
|
|
68
|
-
const frame = this.spinnerFrame();
|
|
69
|
-
const loaderLine = ` ${frame} ${truncate(this.message, Math.max(1, contentWidth - 4))} `;
|
|
70
|
-
const body = ` ${truncate(loaderLine, contentWidth - 2)} `;
|
|
71
|
-
const inner = ` ${pad(body, contentWidth - 1)} `;
|
|
72
|
-
const padWidth = Math.max(0, width - (contentWidth + 4));
|
|
73
|
-
const leftRightPad = " ".repeat(Math.floor(padWidth / 2));
|
|
74
|
-
const widthAwareInner = contentWidth + padWidth;
|
|
75
|
-
const border = new DynamicCrewBorder(this.theme).render(widthAwareInner + 2)[0];
|
|
76
|
-
const top = `${leftRightPad}${this.theme.fg("border", "┌")}${border}${this.theme.fg("border", "┐")}`;
|
|
77
|
-
const line = `${leftRightPad}${this.theme.fg("border", "│")} ${truncate(inner, widthAwareInner)} ${this.theme.fg("border", "│")}`;
|
|
78
|
-
const hint = `${leftRightPad}${this.theme.fg("border", "│")}${" ".repeat(widthAwareInner + 2)}${this.theme.fg("border", "│")}`;
|
|
79
|
-
const bottom = `${leftRightPad}${this.theme.fg("border", "└")}${border}${this.theme.fg("border", "┘")}`;
|
|
80
|
-
const lineWithHint = optionsHint(this.theme, this.message, widthAwareInner);
|
|
81
|
-
this.width = width;
|
|
82
|
-
const lines = [top, line, `${leftRightPad}│ ${pad(lineWithHint, widthAwareInner)} │`, hint, bottom];
|
|
83
|
-
this.lineCache = lines.join("\n");
|
|
84
|
-
return lines;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
invalidate(): void {
|
|
88
|
-
this.lineCache = "";
|
|
89
|
-
this.width = 0;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
dispose(): void {
|
|
93
|
-
this.abortController.abort();
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface CountdownTimerOptions {
|
|
98
|
-
timeoutMs: number;
|
|
99
|
-
onTick: (seconds: number) => void;
|
|
100
|
-
onExpire: () => void;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export class CountdownTimer {
|
|
104
|
-
private readonly onExpire: () => void;
|
|
105
|
-
private readonly onTick: (seconds: number) => void;
|
|
106
|
-
private readonly startedAt: number;
|
|
107
|
-
private readonly timeoutMs: number;
|
|
108
|
-
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
109
|
-
private expired = false;
|
|
110
|
-
private lastEmittedSeconds = -1;
|
|
111
|
-
|
|
112
|
-
constructor(options: CountdownTimerOptions) {
|
|
113
|
-
this.timeoutMs = Math.max(0, options.timeoutMs);
|
|
114
|
-
this.onTick = options.onTick;
|
|
115
|
-
this.onExpire = options.onExpire;
|
|
116
|
-
this.startedAt = Date.now();
|
|
117
|
-
this.lastEmittedSeconds = this.secondsLeft();
|
|
118
|
-
this.onTick(this.lastEmittedSeconds);
|
|
119
|
-
if (this.timeoutMs === 0) {
|
|
120
|
-
this.emitExpire();
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
this.scheduleNextTick();
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Schedule the next tick via recursive setTimeout. Each tick re-emits the
|
|
128
|
-
* current `secondsLeft()` only if it differs from the last emitted value
|
|
129
|
-
* (lastEmittedSeconds guard). This makes the countdown correct even under
|
|
130
|
-
* event-loop pressure: if the previous tick fired 1.2s late, the next
|
|
131
|
-
* tick still emits the right value for the current second rather than
|
|
132
|
-
* skipping it (the pre-fix `setInterval` could SKIP a second value when
|
|
133
|
-
* the loop was busy, producing [3,2,0] instead of [3,2,1,0] in tests).
|
|
134
|
-
*/
|
|
135
|
-
private scheduleNextTick(): void {
|
|
136
|
-
this.timer = setTimeout(() => {
|
|
137
|
-
const seconds = this.secondsLeft();
|
|
138
|
-
if (seconds !== this.lastEmittedSeconds) {
|
|
139
|
-
this.lastEmittedSeconds = seconds;
|
|
140
|
-
this.onTick(seconds);
|
|
141
|
-
}
|
|
142
|
-
if (seconds <= 0) {
|
|
143
|
-
this.emitExpire();
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
this.scheduleNextTick();
|
|
147
|
-
}, 1000);
|
|
148
|
-
// Defense-in-depth: never let the countdown timer keep the event loop
|
|
149
|
-
// alive. If dispose() is missed (e.g. UI unmount race), the timer must
|
|
150
|
-
// not block process exit.
|
|
151
|
-
if (typeof this.timer.unref === "function") this.timer.unref();
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
private emitExpire(): void {
|
|
155
|
-
if (this.expired) return;
|
|
156
|
-
this.expired = true;
|
|
157
|
-
this.dispose();
|
|
158
|
-
this.onExpire();
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
private secondsLeft(): number {
|
|
162
|
-
const remainingMs = this.startedAt + this.timeoutMs - Date.now();
|
|
163
|
-
return Math.max(0, Math.ceil(remainingMs / 1000));
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
dispose(): void {
|
|
167
|
-
if (this.timer === undefined) return;
|
|
168
|
-
clearTimeout(this.timer);
|
|
169
|
-
this.timer = undefined;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function optionsHint(theme: CrewTheme, message: string, width: number): string {
|
|
174
|
-
if (!message) return "";
|
|
175
|
-
return truncate(theme.fg("muted", message), width);
|
|
176
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* CrewBorderedLoader and CountdownTimer were removed (UI-animation-audit C9).
|
|
3
|
+
* They were dead production code — referenced only by test/unit/loaders.test.ts
|
|
4
|
+
* (now deleted) and never instantiated in src/ or re-exported from the public
|
|
5
|
+
* index. If a new loader component is needed, add it here.
|
|
6
|
+
*/
|
package/src/ui/mascot.ts
CHANGED
|
@@ -99,6 +99,7 @@ export class AnimatedMascot {
|
|
|
99
99
|
private currentArminGrid: string[][];
|
|
100
100
|
private effectState: EffectState = {};
|
|
101
101
|
private effectDone = false;
|
|
102
|
+
private visible = true;
|
|
102
103
|
private frame = 0;
|
|
103
104
|
private effectPhase = 0;
|
|
104
105
|
private gridVersion = 0;
|
|
@@ -199,7 +200,10 @@ export class AnimatedMascot {
|
|
|
199
200
|
this.gridVersion++;
|
|
200
201
|
}
|
|
201
202
|
this.invalidate();
|
|
202
|
-
|
|
203
|
+
// Only request a re-render when the mascot is visible (not obscured by
|
|
204
|
+
// another overlay such as the dashboard or live sidebar). This avoids
|
|
205
|
+
// pointless repaints on every animation frame while hidden.
|
|
206
|
+
if (this.visible) this.requestRender?.();
|
|
203
207
|
}
|
|
204
208
|
|
|
205
209
|
private tickArminEffect(): boolean {
|
|
@@ -428,6 +432,15 @@ export class AnimatedMascot {
|
|
|
428
432
|
}
|
|
429
433
|
}
|
|
430
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Set whether the mascot is currently visible (not obscured by another
|
|
437
|
+
* overlay). When invisible, tick() skips requestRender so the animation
|
|
438
|
+
* does not trigger needless repaints while hidden.
|
|
439
|
+
*/
|
|
440
|
+
setVisible(visible: boolean): void {
|
|
441
|
+
this.visible = visible;
|
|
442
|
+
}
|
|
443
|
+
|
|
431
444
|
dispose(): void {
|
|
432
445
|
this.doneGuard.called = true;
|
|
433
446
|
if (this.interval) clearInterval(this.interval);
|
|
@@ -21,6 +21,13 @@ export interface RenderSchedulerOptions {
|
|
|
21
21
|
* trigger a single cache invalidate instead of N. Set to 0 to disable.
|
|
22
22
|
*/
|
|
23
23
|
invalidateCoalesceMs?: number;
|
|
24
|
+
/**
|
|
25
|
+
* R1: maximum number of consecutive catch-up renders the fallback loop
|
|
26
|
+
* emits while idle (no real external event) before it stops re-arming.
|
|
27
|
+
* Prevents a hung run from rendering forever. A real event resets the
|
|
28
|
+
* count and re-arms the loop. Defaults to 8.
|
|
29
|
+
*/
|
|
30
|
+
maxIdleFallbackRenders?: number;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
const DEFAULT_EVENTS = [
|
|
@@ -34,6 +41,9 @@ const DEFAULT_EVENTS = [
|
|
|
34
41
|
"crew.mailbox.message",
|
|
35
42
|
];
|
|
36
43
|
|
|
44
|
+
/** R4: max exponent for flush re-entrancy backoff (delay = debounceMs * 2^exp). */
|
|
45
|
+
const CAP_BACKOFF_MAX_EXP = 5;
|
|
46
|
+
|
|
37
47
|
/**
|
|
38
48
|
* Coordinates UI renders with debounce + fallback polling.
|
|
39
49
|
*
|
|
@@ -50,6 +60,7 @@ export class RenderScheduler {
|
|
|
50
60
|
private readonly fallbackProvider: () => number;
|
|
51
61
|
private readonly fallbackMs: number;
|
|
52
62
|
private readonly invalidateCoalesceMs: number;
|
|
63
|
+
private readonly maxIdleFallbackRenders: number;
|
|
53
64
|
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
54
65
|
private fallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
55
66
|
private invalidateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -59,6 +70,10 @@ export class RenderScheduler {
|
|
|
59
70
|
private lastEventAt = 0;
|
|
60
71
|
private rendering = false;
|
|
61
72
|
private pendingRender = false;
|
|
73
|
+
/** R1: consecutive idle fallback renders since the last real event. */
|
|
74
|
+
private idleFallbackRenders = 0;
|
|
75
|
+
/** R4: consecutive re-entrancy cap-hits — drives exponential backoff. */
|
|
76
|
+
private consecutiveCapHits = 0;
|
|
62
77
|
private readonly unsubs: Array<() => void> = [];
|
|
63
78
|
|
|
64
79
|
constructor(events: RenderSchedulerEventBus | undefined, render: () => void, options: RenderSchedulerOptions = {}) {
|
|
@@ -69,6 +84,7 @@ export class RenderScheduler {
|
|
|
69
84
|
this.fallbackProvider = typeof fallback === "function" ? fallback : () => fallback;
|
|
70
85
|
this.fallbackMs = typeof fallback === "number" ? fallback : 750;
|
|
71
86
|
this.invalidateCoalesceMs = options.invalidateCoalesceMs ?? 50;
|
|
87
|
+
this.maxIdleFallbackRenders = options.maxIdleFallbackRenders ?? 8;
|
|
72
88
|
for (const event of options.events ?? DEFAULT_EVENTS) this.subscribe(events, event);
|
|
73
89
|
this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.currentFallbackMs());
|
|
74
90
|
this.fallbackTimer.unref();
|
|
@@ -100,13 +116,25 @@ export class RenderScheduler {
|
|
|
100
116
|
if (this.disposed) return;
|
|
101
117
|
const fallbackMs = this.currentFallbackMs();
|
|
102
118
|
if (Date.now() - this.lastEventAt < fallbackMs) {
|
|
103
|
-
|
|
119
|
+
// A real external event arrived recently — not idle. Keep polling.
|
|
120
|
+
this.idleFallbackRenders = 0;
|
|
104
121
|
this.fallbackTimer = setTimeout(() => this.fallbackLoop(), fallbackMs);
|
|
105
122
|
this.fallbackTimer.unref();
|
|
106
123
|
return;
|
|
107
124
|
}
|
|
108
|
-
|
|
109
|
-
|
|
125
|
+
// R1: truly idle (no real event for `fallbackMs`). `lastEventAt` is only
|
|
126
|
+
// advanced by real external events (schedule()), so this idle check stays
|
|
127
|
+
// honest — unlike the old code which called schedule() here and thereby
|
|
128
|
+
// reset lastEventAt, self-perpetuating a ~fallbackMs render loop forever.
|
|
129
|
+
if (this.idleFallbackRenders >= this.maxIdleFallbackRenders) {
|
|
130
|
+
// Sustained idleness — stop re-arming so a hung run does not render
|
|
131
|
+
// forever. A subsequent real event (schedule()) re-arms the loop.
|
|
132
|
+
this.fallbackTimer = undefined;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Emit one catch-up render WITHOUT advancing lastEventAt (no real event).
|
|
136
|
+
this.armDebouncedRender();
|
|
137
|
+
this.idleFallbackRenders += 1;
|
|
110
138
|
this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.currentFallbackMs());
|
|
111
139
|
this.fallbackTimer.unref();
|
|
112
140
|
}
|
|
@@ -114,12 +142,30 @@ export class RenderScheduler {
|
|
|
114
142
|
schedule(payload?: unknown): void {
|
|
115
143
|
if (this.disposed) return;
|
|
116
144
|
this.lastEventAt = Date.now();
|
|
145
|
+
this.idleFallbackRenders = 0;
|
|
146
|
+
this.consecutiveCapHits = 0;
|
|
117
147
|
this.invalidate(payload);
|
|
148
|
+
this.armDebouncedRender();
|
|
149
|
+
// R1: if the fallback loop stopped itself after a sustained idle period,
|
|
150
|
+
// a real event means activity resumed — re-arm the safety-net loop.
|
|
151
|
+
if (!this.fallbackTimer) {
|
|
152
|
+
this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.currentFallbackMs());
|
|
153
|
+
this.fallbackTimer.unref();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Arm (or re-arm) the debounce timer that triggers a single flush. Shared by
|
|
159
|
+
* real-event schedules, the fallback path, and the re-entrancy cap drain.
|
|
160
|
+
* `delay` lets the cap apply exponential backoff (R4) without touching
|
|
161
|
+
* `lastEventAt` or `onInvalidate` (those belong to real external events).
|
|
162
|
+
*/
|
|
163
|
+
private armDebouncedRender(delay: number = this.debounceMs): void {
|
|
118
164
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
119
165
|
this.debounceTimer = setTimeout(() => {
|
|
120
166
|
this.debounceTimer = undefined;
|
|
121
167
|
this.flush();
|
|
122
|
-
},
|
|
168
|
+
}, delay);
|
|
123
169
|
this.debounceTimer.unref();
|
|
124
170
|
}
|
|
125
171
|
|
|
@@ -196,9 +242,17 @@ export class RenderScheduler {
|
|
|
196
242
|
logInternalError("render-scheduler.render", error);
|
|
197
243
|
} finally {
|
|
198
244
|
this.rendering = false;
|
|
199
|
-
//
|
|
245
|
+
// R4: if we hit the re-entrancy cap with more work pending, drain it —
|
|
246
|
+
// but with exponential backoff on consecutive cap-hits so a pathological
|
|
247
|
+
// render() cannot sustain a tight debounceMs render loop. We re-arm via
|
|
248
|
+
// armDebouncedRender (not schedule()) so lastEventAt / onInvalidate are
|
|
249
|
+
// not perturbed by a pure re-entrancy drain.
|
|
200
250
|
if (iterations >= 5 && this.pendingRender && !this.disposed) {
|
|
201
|
-
this.
|
|
251
|
+
this.consecutiveCapHits += 1;
|
|
252
|
+
const exp = Math.min(this.consecutiveCapHits - 1, CAP_BACKOFF_MAX_EXP);
|
|
253
|
+
this.armDebouncedRender(this.debounceMs * 2 ** exp);
|
|
254
|
+
} else {
|
|
255
|
+
this.consecutiveCapHits = 0;
|
|
202
256
|
}
|
|
203
257
|
}
|
|
204
258
|
}
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -20,8 +20,8 @@ import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts";
|
|
|
20
20
|
import { DynamicCrewBorder } from "./dynamic-border.ts";
|
|
21
21
|
import { dashboardActionForKey } from "./keybinding-map.ts";
|
|
22
22
|
import { HelpOverlay } from "./overlays/help-overlay.ts";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
23
|
+
import type { OverlaySchedulerHandle } from "./shared-overlay-scheduler.ts";
|
|
24
|
+
import { registerOverlayScheduler } from "./shared-overlay-scheduler.ts";
|
|
25
25
|
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
|
|
26
26
|
import { spinnerBucket, spinnerFrame } from "./spinner.ts";
|
|
27
27
|
import { applyStatusColor, colorizeStatusGlyphs, iconForStatus, type RunStatus } from "./status-colors.ts";
|
|
@@ -423,7 +423,7 @@ export class RunDashboard implements DashboardComponent {
|
|
|
423
423
|
private cachedSignatureAt = 0;
|
|
424
424
|
private cachedSignature = "";
|
|
425
425
|
private readonly unsubscribeTheme: () => void;
|
|
426
|
-
private readonly
|
|
426
|
+
private readonly schedulerHandle: OverlaySchedulerHandle | undefined;
|
|
427
427
|
|
|
428
428
|
constructor(
|
|
429
429
|
runs: TeamRunManifest[],
|
|
@@ -466,21 +466,12 @@ export class RunDashboard implements DashboardComponent {
|
|
|
466
466
|
const renderTick = (): void => {
|
|
467
467
|
this.invalidateAndRender();
|
|
468
468
|
};
|
|
469
|
-
this.
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
events: ["run:state", "worker:lifecycle", "ui:invalidate"],
|
|
476
|
-
onInvalidate: () => {
|
|
477
|
-
// Drop any cached signature — data underneath may have
|
|
478
|
-
// changed so the next buildSignature() needs to recompute.
|
|
479
|
-
this.cachedSignature = "";
|
|
480
|
-
this.cachedSignatureAt = 0;
|
|
481
|
-
},
|
|
482
|
-
},
|
|
483
|
-
);
|
|
469
|
+
this.schedulerHandle = registerOverlayScheduler(renderTick, () => {
|
|
470
|
+
// Drop any cached signature — data underneath may have
|
|
471
|
+
// changed so the next buildSignature() needs to recompute.
|
|
472
|
+
this.cachedSignature = "";
|
|
473
|
+
this.cachedSignatureAt = 0;
|
|
474
|
+
});
|
|
484
475
|
// FIND-07: route theme changes through the scheduler so they coalesce
|
|
485
476
|
// with run:state / worker:lifecycle / ui:invalidate bursts instead of
|
|
486
477
|
// each firing their own immediate invalidate+requestRender.
|
|
@@ -498,8 +489,8 @@ export class RunDashboard implements DashboardComponent {
|
|
|
498
489
|
* (defensive — currently always created in the constructor).
|
|
499
490
|
*/
|
|
500
491
|
private scheduleRender(): void {
|
|
501
|
-
if (this.
|
|
502
|
-
this.
|
|
492
|
+
if (this.schedulerHandle) {
|
|
493
|
+
this.schedulerHandle.schedule();
|
|
503
494
|
return;
|
|
504
495
|
}
|
|
505
496
|
this.invalidateAndRender();
|
|
@@ -619,7 +610,7 @@ export class RunDashboard implements DashboardComponent {
|
|
|
619
610
|
|
|
620
611
|
dispose(): void {
|
|
621
612
|
this.unsubscribeTheme();
|
|
622
|
-
this.
|
|
613
|
+
this.schedulerHandle?.dispose();
|
|
623
614
|
}
|
|
624
615
|
|
|
625
616
|
private selectedRunId(): string | undefined {
|