pi-crew 0.11.8 → 0.11.10
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 +94 -0
- package/dist/index.mjs +466 -466
- package/docs/usage.md +1 -1
- package/install.mjs +4 -1
- package/package.json +1 -1
- package/skills/real-test-pi-crew/SKILL.md +26 -0
- package/src/config/defaults.ts +8 -1
- package/src/extension/command-completions.ts +44 -22
- package/src/extension/registration/commands/shared.ts +4 -14
- package/src/extension/registration/render-loop.ts +51 -21
- package/src/extension/registration/viewers.ts +25 -1
- package/src/extension/team-tool/handle-settings.ts +4 -1
- package/src/ui/dashboard-panes/agents-pane.ts +3 -2
- package/src/ui/dashboard-panes/health-pane.ts +6 -1
- package/src/ui/dashboard-panes/mailbox-pane.ts +12 -7
- package/src/ui/dashboard-panes/plan-pane.ts +5 -2
- package/src/ui/dashboard-panes/progress-pane.ts +8 -2
- package/src/ui/live-conversation-overlay.ts +5 -3
- package/src/ui/live-duration.ts +5 -4
- package/src/ui/live-run-sidebar.ts +90 -20
- package/src/ui/overlays/mailbox-compose-overlay.ts +17 -1
- package/src/ui/run-action-dispatcher.ts +2 -1
- package/src/ui/run-dashboard.ts +62 -22
- package/src/ui/run-snapshot-cache.ts +32 -0
- package/src/ui/settings-overlay.ts +26 -2
- package/src/ui/tool-renderers/brief-mode.ts +2 -2
- package/src/ui/transcript-viewer.ts +4 -1
- package/src/ui/widget/index.ts +10 -2
- package/src/ui/widget/task-list.ts +12 -4
- package/src/ui/widget/widget-model.ts +5 -4
- package/src/ui/widget/widget-renderer.ts +164 -38
- package/src/utils/socket-path.ts +48 -8
|
@@ -12,9 +12,12 @@ import { formatCount, teamWorkflowLabel } from "./format-helpers.ts";
|
|
|
12
12
|
import { goalFlagSuffix } from "./goal-flag.ts";
|
|
13
13
|
import { DASHBOARD_KEYS } from "./keybinding-map.ts";
|
|
14
14
|
import { ACTIVE, canopyLine, formatHint, RAIL, type RailSlot, railLine, sectionLine, shortId, statusSlot } from "./rail.ts";
|
|
15
|
+
// L5: the EXTENDED cache interface — the base type in snapshot-types.ts does
|
|
16
|
+
// not declare the paint-path accessor (readForRender) or scheduleRefresh.
|
|
17
|
+
import type { RunSnapshotCache } from "./run-snapshot-cache.ts";
|
|
15
18
|
import type { OverlaySchedulerHandle } from "./shared-overlay-scheduler.ts";
|
|
16
19
|
import { registerOverlayScheduler } from "./shared-overlay-scheduler.ts";
|
|
17
|
-
import type {
|
|
20
|
+
import type { RunUiSnapshot } from "./snapshot-types.ts";
|
|
18
21
|
import { spinnerBucket, spinnerFrame } from "./spinner.ts";
|
|
19
22
|
import { colorizeStatusGlyphs, iconForStatus } from "./status-colors.ts";
|
|
20
23
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
@@ -79,6 +82,9 @@ export class LiveRunSidebar {
|
|
|
79
82
|
private cachedWidth = 0;
|
|
80
83
|
private cachedSignature = "";
|
|
81
84
|
private autoCloseTimeout?: NodeJS.Timeout;
|
|
85
|
+
/** L4 (real-test 2026-10-07): FIXED close deadline (epoch ms), captured
|
|
86
|
+
* ONCE on first eligibility — re-renders must never slide it. */
|
|
87
|
+
private autoCloseDeadline?: number;
|
|
82
88
|
private hasAutoClosed = false;
|
|
83
89
|
|
|
84
90
|
constructor(input: {
|
|
@@ -157,10 +163,7 @@ export class LiveRunSidebar {
|
|
|
157
163
|
// M-10 fix (code-review 2026-06-23): clear the auto-close timer so a
|
|
158
164
|
// disposed sidebar (not closed via the normal path) doesn't fire this.done()
|
|
159
165
|
// on a disposed component.
|
|
160
|
-
|
|
161
|
-
clearTimeout(this.autoCloseTimeout);
|
|
162
|
-
this.autoCloseTimeout = undefined;
|
|
163
|
-
}
|
|
166
|
+
this.clearAutoClose();
|
|
164
167
|
this.unsubscribeTheme();
|
|
165
168
|
this.schedulerHandle.dispose();
|
|
166
169
|
}
|
|
@@ -180,8 +183,15 @@ export class LiveRunSidebar {
|
|
|
180
183
|
let rawAgents: ReturnType<typeof readCrewAgents>;
|
|
181
184
|
let snapshot: RunUiSnapshot | undefined;
|
|
182
185
|
if (this.snapshotCache) {
|
|
186
|
+
// L5 (real-test 2026-10-07): the paint path reads the cache ONLY.
|
|
187
|
+
// refreshIfStale rebuilt synchronously between paints (multiple stats +
|
|
188
|
+
// manifest/tasks parse + sha256), stuttering frame pacing. readForRender
|
|
189
|
+
// returns the cached snapshot and schedules the COALESCED ASYNC refresh
|
|
190
|
+
// when the entry's TTL has lapsed; a missing entry schedules nothing, so
|
|
191
|
+
// kick one here or the pre-load frame below would never fill.
|
|
183
192
|
try {
|
|
184
|
-
snapshot = this.snapshotCache.
|
|
193
|
+
snapshot = this.snapshotCache.readForRender(this.runId);
|
|
194
|
+
if (!snapshot) this.snapshotCache.scheduleRefresh(this.runId);
|
|
185
195
|
} catch {
|
|
186
196
|
snapshot = undefined;
|
|
187
197
|
}
|
|
@@ -331,25 +341,29 @@ export class LiveRunSidebar {
|
|
|
331
341
|
}
|
|
332
342
|
// F-6: compute the auto-close countdown BEFORE the cap so the countdown
|
|
333
343
|
// renders inside the frame rather than below it.
|
|
334
|
-
// Auto-close logic: if run is terminal and no active agents, close after delay
|
|
344
|
+
// Auto-close logic: if run is terminal and no active agents, close after delay.
|
|
345
|
+
// L4 (real-test 2026-10-07): the deadline is captured ONCE on FIRST
|
|
346
|
+
// eligibility and never re-armed while eligibility persists. The old code
|
|
347
|
+
// did clearTimeout+setTimeout(full delay) on every fresh render, so any
|
|
348
|
+
// trailing event < autoCloseMs apart slid the deadline forever and the
|
|
349
|
+
// countdown froze while the sidebar stayed open indefinitely.
|
|
335
350
|
const isTerminal = ["completed", "failed", "cancelled", "blocked"].includes(run.status);
|
|
336
351
|
const hasActiveAgents = agents.some((a) => a.status === "running");
|
|
337
352
|
if (isTerminal && !hasActiveAgents && !this.hasAutoClosed) {
|
|
338
353
|
const autoCloseMs = this.config?.autoCloseDashboardMs ?? 3000;
|
|
339
354
|
if (autoCloseMs > 0) {
|
|
340
|
-
if (this.
|
|
341
|
-
|
|
342
|
-
this.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
this.
|
|
346
|
-
lines.push(this.bodyRow(this.theme.fg("dim", `auto-close in ${Math.round(autoCloseMs / 1000)}s…`), budget));
|
|
355
|
+
if (this.autoCloseDeadline === undefined) {
|
|
356
|
+
this.autoCloseDeadline = Date.now() + autoCloseMs;
|
|
357
|
+
this.armAutoClose(this.autoCloseDeadline);
|
|
358
|
+
}
|
|
359
|
+
const remainingMs = Math.max(0, this.autoCloseDeadline - Date.now());
|
|
360
|
+
lines.push(this.bodyRow(this.theme.fg("dim", `auto-close in ${Math.ceil(remainingMs / 1000)}s…`), budget));
|
|
347
361
|
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
this.
|
|
362
|
+
} else {
|
|
363
|
+
// Off the terminal window (run resumed, active agents (re)appeared) or
|
|
364
|
+
// already closed: cancel AND forget the deadline — the NEXT eligibility
|
|
365
|
+
// captures a fresh one. hasAutoClosed keeps done() from re-arming.
|
|
366
|
+
this.clearAutoClose();
|
|
353
367
|
}
|
|
354
368
|
this.cachedLines = renderLines(this.renderFrame(lines, budget, slot), w);
|
|
355
369
|
this.cachedSignature = signature;
|
|
@@ -363,13 +377,69 @@ export class LiveRunSidebar {
|
|
|
363
377
|
return railLine(RAIL.body, "border", content, this.theme, budget);
|
|
364
378
|
}
|
|
365
379
|
|
|
366
|
-
/**
|
|
380
|
+
/** L4: arm the close callback for a FIXED deadline. Re-renders never touch
|
|
381
|
+
* it, so the close lands at firstEligibility + autoCloseMs regardless of
|
|
382
|
+
* how many trailing events repaint the frame in between. */
|
|
383
|
+
private armAutoClose(deadline: number): void {
|
|
384
|
+
if (this.autoCloseTimeout) clearTimeout(this.autoCloseTimeout);
|
|
385
|
+
this.autoCloseTimeout = setTimeout(
|
|
386
|
+
() => {
|
|
387
|
+
this.hasAutoClosed = true;
|
|
388
|
+
this.done(undefined);
|
|
389
|
+
},
|
|
390
|
+
Math.max(0, deadline - Date.now()),
|
|
391
|
+
);
|
|
392
|
+
this.autoCloseTimeout?.unref();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** L4: cancel the fixed deadline — only a NEW eligibility re-arms it. */
|
|
396
|
+
private clearAutoClose(): void {
|
|
397
|
+
if (this.autoCloseTimeout) {
|
|
398
|
+
clearTimeout(this.autoCloseTimeout);
|
|
399
|
+
this.autoCloseTimeout = undefined;
|
|
400
|
+
}
|
|
401
|
+
this.autoCloseDeadline = undefined;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Append the `┗ <hint>` cap, lock the frame to ONE stable height (L6)
|
|
405
|
+
* and colorize the glyphs (F-1 / V-3). */
|
|
367
406
|
private renderFrame(lines: string[], budget: number, slot: RailSlot): string[] {
|
|
368
407
|
const hint = this.theme.fg("dim", formatHint(SIDEBAR_HINT));
|
|
369
408
|
const framed = [...lines, railLine(RAIL.close, slot, hint, this.theme, budget)];
|
|
409
|
+
this.lockHeight(framed, budget);
|
|
370
410
|
return framed.map((entry) => this.colorLine(entry));
|
|
371
411
|
}
|
|
372
412
|
|
|
413
|
+
/**
|
|
414
|
+
* L6 (real-test 2026-10-07): pad with blank rail rows (cap stays LAST) or
|
|
415
|
+
* slice (cap preserved) so EVERY frame — the 3-line pre-load frame and the
|
|
416
|
+
* 20+ line loaded frame — is exactly targetHeight() lines. The host
|
|
417
|
+
* positions overlays by the returned line count; a frame that flips
|
|
418
|
+
* between heights moves the anchor row and the differential renderer
|
|
419
|
+
* leaves a ghost footprint below the sidebar. Same contract as
|
|
420
|
+
* run-dashboard's targetHeight() clamp.
|
|
421
|
+
*/
|
|
422
|
+
private lockHeight(lines: string[], budget: number): void {
|
|
423
|
+
const target = this.targetHeight();
|
|
424
|
+
if (lines.length < target) {
|
|
425
|
+
const blankRow = railLine(RAIL.body, "border", "", this.theme, budget);
|
|
426
|
+
const bottom = lines.pop();
|
|
427
|
+
while (lines.length < target - 1) lines.push(blankRow);
|
|
428
|
+
if (bottom !== undefined) lines.push(bottom);
|
|
429
|
+
} else if (lines.length > target) {
|
|
430
|
+
const bottom = lines[lines.length - 1];
|
|
431
|
+
lines.length = target - 1;
|
|
432
|
+
lines.push(bottom);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** L6: stable overlay height — clamped like run-dashboard's targetHeight()
|
|
437
|
+
* but scaled for the sidebar's single column (12–30 instead of 12–36). */
|
|
438
|
+
private targetHeight(): number {
|
|
439
|
+
const rows = Number.isFinite(process.stdout?.rows) ? Number(process.stdout?.rows) : 30;
|
|
440
|
+
return Math.max(12, Math.min(30, rows - 2));
|
|
441
|
+
}
|
|
442
|
+
|
|
373
443
|
handleInput(data: string): void {
|
|
374
444
|
if (data === "q" || data === "\u001b") this.done(undefined);
|
|
375
445
|
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* from `formatHint` (discard LAST, keys through `keyToken`).
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import
|
|
26
|
+
import * as piTui from "@earendil-works/pi-tui";
|
|
27
27
|
import type { MailboxDirection } from "../../state/coordination/mailbox.ts";
|
|
28
28
|
import { pad, sanitizeLine, truncate } from "../../utils/visual.ts";
|
|
29
29
|
import { overlayActionForKey } from "../keybinding-map.ts";
|
|
@@ -32,6 +32,22 @@ import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
|
|
|
32
32
|
import { ConfirmOverlay } from "./confirm-overlay.ts";
|
|
33
33
|
import { renderComposePreview } from "./mailbox-compose-preview.ts";
|
|
34
34
|
|
|
35
|
+
/** Resolve pi-tui's zero-width IME cursor anchor defensively (L8, policy
|
|
36
|
+
* W4/G22) — same typeof-guard shape as hyperlink() at widget-renderer.ts:
|
|
37
|
+
* 118-128 and the sibling resolver in settings-overlay.ts. The peer range is
|
|
38
|
+
* `*`, so a host running a pi-tui build without the export must still render
|
|
39
|
+
* instead of failing the named import at link time. Exported for the
|
|
40
|
+
* fallback pin in test/unit/ui/settings-overlay-cursor-marker.test.ts. */
|
|
41
|
+
export function resolveCursorMarker(mod: unknown): string {
|
|
42
|
+
const candidate = (mod as { CURSOR_MARKER?: unknown }).CURSOR_MARKER;
|
|
43
|
+
return typeof candidate === "string" ? candidate : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** "" on hosts whose pi-tui predates CURSOR_MARKER — the active field then
|
|
47
|
+
* renders without the IME anchor (marker is zero-width, stripped before
|
|
48
|
+
* painting; pure enhancement, never a layout dependency). */
|
|
49
|
+
const CURSOR_MARKER = resolveCursorMarker(piTui);
|
|
50
|
+
|
|
35
51
|
export interface MailboxComposePayload {
|
|
36
52
|
from: string;
|
|
37
53
|
to: string;
|
|
@@ -19,6 +19,7 @@ import type { MailboxDirection, MailboxMessage } from "../state/coordination/mai
|
|
|
19
19
|
import { appendEventBuffered } from "../state/event-log/event-log.ts";
|
|
20
20
|
import { loadRunManifestById, saveRunTasks } from "../state/stores/state-store.ts";
|
|
21
21
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
22
|
+
import { formatCount } from "./format-helpers.ts";
|
|
22
23
|
|
|
23
24
|
export interface RunActionResult {
|
|
24
25
|
ok: boolean;
|
|
@@ -107,7 +108,7 @@ export async function dispatchMailboxAckAll(ctx: ExtensionContext, runId: string
|
|
|
107
108
|
}
|
|
108
109
|
return {
|
|
109
110
|
ok: true,
|
|
110
|
-
message: `Acknowledged ${count}
|
|
111
|
+
message: `Acknowledged ${formatCount(count, "message")}.`,
|
|
111
112
|
data: { count },
|
|
112
113
|
};
|
|
113
114
|
}
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { renderPlanPane } from "./dashboard-panes/plan-pane.ts";
|
|
|
28
28
|
import { renderProgressPane } from "./dashboard-panes/progress-pane.ts";
|
|
29
29
|
import { renderScheduleDetails, renderSchedulesPane } from "./dashboard-panes/schedules-pane.ts";
|
|
30
30
|
import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts";
|
|
31
|
-
import { formatCount, teamWorkflowLabel } from "./format-helpers.ts";
|
|
31
|
+
import { formatCount, formatDuration, formatTokens, teamWorkflowLabel } from "./format-helpers.ts";
|
|
32
32
|
import { goalFlagSuffix } from "./goal-flag.ts";
|
|
33
33
|
import { type DashboardKeyAction, dashboardActionForKey } from "./keybinding-map.ts";
|
|
34
34
|
import { HelpOverlay } from "./overlays/help-overlay.ts";
|
|
@@ -228,14 +228,14 @@ function runListWindow(scrollOffset: number, count: number): RunListWindow {
|
|
|
228
228
|
return { slots, hasTop, hasBottom };
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/** L7 (real-test 2026-10-07): agent age in the shared formatDuration dialect
|
|
232
|
+
* (`5m44s`), rendered bare — the retired `age=` prefix was wire format.
|
|
233
|
+
* Sub-second ages read `now` (quieter than `123ms` on a rail row). */
|
|
231
234
|
function formatAge(iso: string | undefined): string | undefined {
|
|
232
235
|
if (!iso) return undefined;
|
|
233
236
|
const ms = Math.max(0, Date.now() - new Date(iso).getTime());
|
|
234
237
|
if (!Number.isFinite(ms)) return undefined;
|
|
235
|
-
|
|
236
|
-
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
|
|
237
|
-
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
|
|
238
|
-
return `${Math.floor(ms / 3_600_000)}h`;
|
|
238
|
+
return ms < 1000 ? "now" : formatDuration(ms);
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): string[] {
|
|
@@ -271,17 +271,21 @@ function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?:
|
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
|
|
274
|
+
/**
|
|
275
|
+
* L7 (real-test 2026-10-07): TUI usage dialect — `↑1.0k ↓250 $0.012` —
|
|
276
|
+
* mirroring the compactUsage pattern in live-run-sidebar.ts (kept local
|
|
277
|
+
* because the sidebar's helper is module-private; do not edit that file).
|
|
278
|
+
* Replaces the retired `tok=…/in=…/out=…/cache=…/$…` wire format, which
|
|
279
|
+
* leaked log-line grammar onto rail rows and buried the numbers behind
|
|
280
|
+
* key=value prefixes. cacheRead/cacheWrite stay hidden (sidebar parity).
|
|
281
|
+
*/
|
|
282
|
+
function compactUsage(usage: UsageState | undefined): string | undefined {
|
|
275
283
|
if (!usage) return undefined;
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (usage.output) parts.push(`out=${usage.output}`);
|
|
282
|
-
if (usage.cacheRead) parts.push(`cache=${usage.cacheRead}`);
|
|
283
|
-
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
284
|
-
return parts.join("/");
|
|
284
|
+
const parts: string[] = [];
|
|
285
|
+
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
286
|
+
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
287
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(3)}`);
|
|
288
|
+
return parts.length ? parts.join(" ") : undefined;
|
|
285
289
|
}
|
|
286
290
|
|
|
287
291
|
function snapshotFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): RunUiSnapshot | undefined {
|
|
@@ -292,6 +296,38 @@ function snapshotFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): Ru
|
|
|
292
296
|
}
|
|
293
297
|
}
|
|
294
298
|
|
|
299
|
+
/**
|
|
300
|
+
* L5 (real-test 2026-10-07): paint-path snapshot read for the frame-local
|
|
301
|
+
* SnapshotResolver in `renderUnsafe`. Uses the cache's `readForRender`
|
|
302
|
+
* accessor when present — cached snapshot ONLY; a lapsed TTL schedules the
|
|
303
|
+
* coalesced async refresh off the paint path; a missing entry schedules one
|
|
304
|
+
* refresh so the pre-load rows fill on a later frame (sidebar precedent,
|
|
305
|
+
* live-run-sidebar.ts render()). Feature-detected because
|
|
306
|
+
* `RunDashboardOptions.snapshotCache` keeps the BASE interface
|
|
307
|
+
* (snapshot-types.ts): production wires the extended cache, while test mocks
|
|
308
|
+
* and legacy callers may implement only get/refreshIfStale — for those a
|
|
309
|
+
* plain get() keeps the render read-only. Keypress handlers and other
|
|
310
|
+
* read-your-writes sites keep `snapshotFor()` (sync refreshIfStale) — Tier 11a.
|
|
311
|
+
*/
|
|
312
|
+
function snapshotForRender(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): RunUiSnapshot | undefined {
|
|
313
|
+
const cache = snapshotCache as
|
|
314
|
+
| (RunSnapshotCache & {
|
|
315
|
+
readForRender?: (runId: string) => RunUiSnapshot | undefined;
|
|
316
|
+
scheduleRefresh?: (runId: string) => void;
|
|
317
|
+
})
|
|
318
|
+
| undefined;
|
|
319
|
+
if (typeof cache?.readForRender === "function") {
|
|
320
|
+
try {
|
|
321
|
+
const snapshot = cache.readForRender(run.runId);
|
|
322
|
+
if (!snapshot && typeof cache.scheduleRefresh === "function") cache.scheduleRefresh(run.runId);
|
|
323
|
+
return snapshot;
|
|
324
|
+
} catch {
|
|
325
|
+
return snapshotCache?.get(run.runId);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return snapshotCache?.get(run.runId);
|
|
329
|
+
}
|
|
330
|
+
|
|
295
331
|
/**
|
|
296
332
|
* PERF (2026-08-24, task 20) — per-frame snapshot resolver. The render path
|
|
297
333
|
* used to resolve each run's snapshot 6-8 times per frame (refreshRuns,
|
|
@@ -344,16 +380,16 @@ function usageForAgent(agent: CrewAgentRecord, task: TeamTaskState | undefined):
|
|
|
344
380
|
function agentPreviewLine(agent: CrewAgentRecord, task: TeamTaskState | undefined, options: RunDashboardOptions): string {
|
|
345
381
|
const stats = [
|
|
346
382
|
agent.progress?.activityState,
|
|
347
|
-
options.showModel !== false
|
|
383
|
+
options.showModel !== false ? modelForAgent(agent, task) : undefined,
|
|
348
384
|
options.showTokens !== false
|
|
349
|
-
? (
|
|
350
|
-
(agent.progress?.tokens !== undefined ?
|
|
385
|
+
? (compactUsage(usageForAgent(agent, task)) ??
|
|
386
|
+
(agent.progress?.tokens !== undefined ? `↑${formatTokens(agent.progress.tokens)}` : undefined))
|
|
351
387
|
: undefined,
|
|
352
388
|
options.showTools !== false && agent.progress?.currentTool ? `tool=${agent.progress.currentTool}` : undefined,
|
|
353
389
|
options.showTools !== false && agent.toolUses !== undefined ? formatCount(agent.toolUses, "tool") : undefined,
|
|
354
|
-
agent.progress?.turns !== undefined ?
|
|
390
|
+
agent.progress?.turns !== undefined ? formatCount(agent.progress.turns, "turn") : undefined,
|
|
355
391
|
agent.progress?.failedTool ? `failedTool=${agent.progress.failedTool}` : undefined,
|
|
356
|
-
agent.startedAt ?
|
|
392
|
+
agent.startedAt ? formatAge(agent.completedAt ?? agent.startedAt) : undefined,
|
|
357
393
|
].filter((part): part is string => Boolean(part));
|
|
358
394
|
const recent = agent.progress?.recentOutput?.at(-1);
|
|
359
395
|
const icon = iconForStatus(agent.status, {
|
|
@@ -394,7 +430,8 @@ function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashbo
|
|
|
394
430
|
cost: number;
|
|
395
431
|
},
|
|
396
432
|
);
|
|
397
|
-
const
|
|
433
|
+
const usage = compactUsage(totals);
|
|
434
|
+
const header = usage ? `Agents: ${usage}` : "Agents:";
|
|
398
435
|
return [
|
|
399
436
|
header,
|
|
400
437
|
...agents.slice(0, maxLines).map((agent) => agentPreviewLine(agent, taskForAgent(tasks, agent), options)),
|
|
@@ -834,9 +871,12 @@ export class RunDashboard implements DashboardComponent {
|
|
|
834
871
|
// PERF (2026-08-24): snapshot resolution stat'd 7-8 files per run 6-8
|
|
835
872
|
// times per frame. Resolve once per frame into a local map and thread
|
|
836
873
|
// it through every render-path consumer below.
|
|
874
|
+
// L5 (real-test 2026-10-07): the resolver reads the cache ONLY
|
|
875
|
+
// (snapshotForRender → readForRender) — the sync refreshIfStale rebuild
|
|
876
|
+
// moved off the paint path; refreshes are coalesced + async.
|
|
837
877
|
const frameSnapshots = new Map<string, RunUiSnapshot | undefined>();
|
|
838
878
|
const snapshotOnce: SnapshotResolver = (run) => {
|
|
839
|
-
if (!frameSnapshots.has(run.runId)) frameSnapshots.set(run.runId,
|
|
879
|
+
if (!frameSnapshots.has(run.runId)) frameSnapshots.set(run.runId, snapshotForRender(run, this.options.snapshotCache));
|
|
840
880
|
return frameSnapshots.get(run.runId);
|
|
841
881
|
};
|
|
842
882
|
this.refreshRuns(snapshotOnce);
|
|
@@ -27,6 +27,14 @@ export interface RunSnapshotCache extends RunSnapshotCacheBase {
|
|
|
27
27
|
preloadAllStale(runIds: string[]): Promise<void>;
|
|
28
28
|
/** Task 17 (perf/review-2026-08-24): watcher-facing coalesced async refresh. */
|
|
29
29
|
scheduleRefresh(runId: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* L5 (real-test 2026-10-07): paint-path read — cached snapshot only.
|
|
32
|
+
* NEVER sync-rebuilds: a lapsed TTL schedules the coalesced async refresh
|
|
33
|
+
* (scheduleRefresh pipeline) instead of rebuilding on the paint path, and a
|
|
34
|
+
* missing entry returns undefined without building. See the method doc on
|
|
35
|
+
* the implementation for the staleness/Tier-11a contract.
|
|
36
|
+
*/
|
|
37
|
+
readForRender(runId: string): RunUiSnapshot | undefined;
|
|
30
38
|
/**
|
|
31
39
|
* F13 (RR-018): true once dispose() has run. Callers that retain the cache
|
|
32
40
|
* (RegistrationContext.getManifestCache/getRunSnapshotCache) use this to
|
|
@@ -1149,6 +1157,30 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
|
|
|
1149
1157
|
scheduleRefresh(runId: string): void {
|
|
1150
1158
|
scheduleCoalescedRefresh(runId);
|
|
1151
1159
|
},
|
|
1160
|
+
/**
|
|
1161
|
+
* L5 (real-test 2026-10-07): read for the paint path. Returns the
|
|
1162
|
+
* CACHED snapshot (touching LRU/access) and NEVER sync-rebuilds — no
|
|
1163
|
+
* manifest/tasks parse, no agents.json read, no sha256 between paints.
|
|
1164
|
+
* When the entry's TTL has lapsed (the read-time proxy for "stamps may
|
|
1165
|
+
* look stale"), it schedules the EXISTING coalesced async refresh
|
|
1166
|
+
* (scheduleRefresh → 80ms coalesce → preloadStale); the actual stamp
|
|
1167
|
+
* comparison happens inside preloadStale, where a stamp-equal hit just
|
|
1168
|
+
* re-stamps the entry (no rebuild), so a quiet run costs one async stat
|
|
1169
|
+
* round per TTL window, off the paint path. A missing entry returns
|
|
1170
|
+
* undefined without building and without scheduling — callers render
|
|
1171
|
+
* their loading state and may call scheduleRefresh()/preloadStale()
|
|
1172
|
+
* themselves when they want the entry filled.
|
|
1173
|
+
* Tier 11a (read-your-writes) is preserved: refresh()/refreshIfStale()
|
|
1174
|
+
* below keep their sync build paths untouched for callers with a sync
|
|
1175
|
+
* reader immediately after write.
|
|
1176
|
+
*/
|
|
1177
|
+
readForRender(runId: string): RunUiSnapshot | undefined {
|
|
1178
|
+
const entry = entries.get(runId);
|
|
1179
|
+
if (!entry) return undefined;
|
|
1180
|
+
const snapshot = touch(runId, entry);
|
|
1181
|
+
if (Date.now() - entry.loadedAtMs >= ttlMs) scheduleCoalescedRefresh(runId);
|
|
1182
|
+
return snapshot;
|
|
1183
|
+
},
|
|
1152
1184
|
preloadStale,
|
|
1153
1185
|
preloadAllStale,
|
|
1154
1186
|
invalidate(runId?: string): void {
|
|
@@ -4,13 +4,34 @@
|
|
|
4
4
|
* label/value alignment, inline toggle, select submenu, and text input.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import
|
|
7
|
+
import * as piTui from "@earendil-works/pi-tui";
|
|
8
8
|
import { truncateToWidth, visibleWidth } from "../utils/visual.ts";
|
|
9
9
|
import { keyOf, matchesKey } from "./key-utils.ts";
|
|
10
10
|
import { CURSOR, canopyLine, formatHint, overflowHint, RAIL, railLine } from "./rail.ts";
|
|
11
11
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
12
12
|
import { discoverPiThemes, getActivePiTheme } from "./theme-discovery.ts";
|
|
13
13
|
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// pi-tui CURSOR_MARKER — defensive resolve (L8, policy W4/G22)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** Resolve pi-tui's zero-width IME cursor anchor (APC `ESC _ pi:c BEL`) from
|
|
19
|
+
* a pi-tui namespace object. Modeled on the hyperlink() typeof-guard at
|
|
20
|
+
* widget/widget-renderer.ts:118-128: the peer range is `*`, so a host
|
|
21
|
+
* running a pi-tui build without the export must still render instead of
|
|
22
|
+
* failing the named import at link time. Non-string exports are treated as
|
|
23
|
+
* missing (contract drift, not a marker). Exported for the fallback pin in
|
|
24
|
+
* test/unit/ui/settings-overlay-cursor-marker.test.ts. */
|
|
25
|
+
export function resolveCursorMarker(mod: unknown): string {
|
|
26
|
+
const candidate = (mod as { CURSOR_MARKER?: unknown }).CURSOR_MARKER;
|
|
27
|
+
return typeof candidate === "string" ? candidate : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** "" on hosts whose pi-tui predates CURSOR_MARKER — text-input rows then
|
|
31
|
+
* render without the IME anchor (the marker is an enhancement, never a
|
|
32
|
+
* layout dependency: zero-width, stripped by visibleWidth). */
|
|
33
|
+
const CURSOR_MARKER = resolveCursorMarker(piTui);
|
|
34
|
+
|
|
14
35
|
// ---------------------------------------------------------------------------
|
|
15
36
|
// Types
|
|
16
37
|
// ---------------------------------------------------------------------------
|
|
@@ -378,7 +399,10 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
|
|
|
378
399
|
"ui.showModel": true,
|
|
379
400
|
"ui.showTokens": true,
|
|
380
401
|
"ui.showTools": true,
|
|
381
|
-
|
|
402
|
+
// L3 (2026-10-07): mirrors DEFAULT_UI.dashboardPlacement = "right"
|
|
403
|
+
// (defaults.ts) — G17: duplicated EFFECTIVE_DEFAULTS maps must track the
|
|
404
|
+
// canonical default or the overlay shows a stale value to unset keys.
|
|
405
|
+
"ui.dashboardPlacement": "right",
|
|
382
406
|
"ui.dashboardWidth": 72,
|
|
383
407
|
"ui.autoOpenDashboard": false,
|
|
384
408
|
// G17 sync (2026-09-10 review): canonical DEFAULT_UI.widgetPlacement =
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
|
|
10
|
-
import { formatDuration, formatTokens, truncLine } from "../format-helpers.ts";
|
|
10
|
+
import { formatCount, formatDuration, formatTokens, truncLine } from "../format-helpers.ts";
|
|
11
11
|
import { spinnerClockNow } from "../spinner.ts";
|
|
12
12
|
import type { CrewTheme } from "../theme-adapter.ts";
|
|
13
13
|
|
|
@@ -100,7 +100,7 @@ function briefTeam(result: { content?: unknown[] }, theme: CrewTheme): string {
|
|
|
100
100
|
const total = records.length;
|
|
101
101
|
const duration = computeTotalDuration(records);
|
|
102
102
|
const tokens = computeTotalTokens(records);
|
|
103
|
-
return `${icon} ${completed}/${total}
|
|
103
|
+
return `${icon} ${completed}/${formatCount(total, "task")} · ${formatDuration(duration)} · ${formatTokens(tokens)} tok`;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
// Fallback: compact status line
|
|
@@ -304,7 +304,10 @@ function renderViewerBase(state: ViewerState, width: number, lines: string[], ti
|
|
|
304
304
|
// and the full-wrap branch keep the exact count).
|
|
305
305
|
const totalLines = tailWindow ? skippedCount + visible.length : visualLines.length;
|
|
306
306
|
const totalLabel = tailWindow && skippedCount > 0 ? `≥ ${totalLines}` : `${totalLines}`;
|
|
307
|
-
|
|
307
|
+
// L-plural (2026-10-08): "1 lines" grammar slip live-caught in the transcript
|
|
308
|
+
// viewer footer — pluralize only from 2 (and keep "≥" prefix intact).
|
|
309
|
+
const linesWord = totalLines === 1 ? "line" : "lines";
|
|
310
|
+
const statusLine = `${totalLabel} ${linesWord} · ${totalLines ? Math.round(((state.scroll + visible.length) / totalLines) * 100) : 100}% · auto-scroll ${state.autoScroll ? "on" : "off"}`;
|
|
308
311
|
// RAIL frame (design system §2.E): `┏ NAME ▸ SUBJECT` canopy, `┃` body rows,
|
|
309
312
|
// `┗ <outcome/hint>` close. The rounded `╭─╮│╰─╯` box is retired. This
|
|
310
313
|
// viewer is a standalone overlay that paints its own frame, so the rail
|
package/src/ui/widget/index.ts
CHANGED
|
@@ -322,13 +322,21 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
322
322
|
// Task-list variant: the run's plan above the editor (task-list.ts).
|
|
323
323
|
// No panel state, no spinner glyph — the list changes only on task
|
|
324
324
|
// transitions, which the run signature already covers.
|
|
325
|
+
// L-plan-flicker (2026-10-07): `sigBase` carries `:spin=<bucket>` while
|
|
326
|
+
// any worker runs — keying this variant on it rebuilt the whole card
|
|
327
|
+
// every 160ms spinner bucket. Strip the animation fragment: the tasks
|
|
328
|
+
// card paints no animated glyph (task-list.ts running row is a static ▶),
|
|
329
|
+
// so a bucket change can never change its output. Live effect measured
|
|
330
|
+
// in the owner session: content diffs dropped from 544/599 samples at
|
|
331
|
+
// 0.2s to state-transition-rate only.
|
|
325
332
|
if (this.variant === "tasks") {
|
|
326
|
-
|
|
333
|
+
const tasksSignature = `${sigBase.replace(/:spin=\d+$/, "")}:${this.model.notificationCount ?? 0}`;
|
|
334
|
+
if (this.cacheSignature !== tasksSignature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
|
|
327
335
|
this.cachedBaseLines = buildTaskListLines(runs, width, this.theme);
|
|
328
336
|
this.cachedLines = this.colorize(this.cachedBaseLines, width);
|
|
329
337
|
this.cachedWidth = width;
|
|
330
338
|
this.cachedTheme = this.theme;
|
|
331
|
-
this.cacheSignature =
|
|
339
|
+
this.cacheSignature = tasksSignature;
|
|
332
340
|
this.lastTruncateWidth = -1;
|
|
333
341
|
}
|
|
334
342
|
if (runs.length === 0) {
|
|
@@ -29,7 +29,6 @@ import type { TeamTaskState } from "../../state/types.ts";
|
|
|
29
29
|
import { visibleWidth } from "../../utils/visual.ts";
|
|
30
30
|
import { formatCount } from "../format-helpers.ts";
|
|
31
31
|
import { canopyLine, gaugeBar, overflowHint, RAIL, railLine, statusSlot } from "../rail.ts";
|
|
32
|
-
import { spinnerFrame } from "../spinner.ts";
|
|
33
32
|
import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
|
|
34
33
|
import { shortRunLabel } from "./widget-model.ts";
|
|
35
34
|
import type { WidgetRun } from "./widget-types.ts";
|
|
@@ -62,12 +61,21 @@ function taskTitle(task: TeamTaskState): string {
|
|
|
62
61
|
return (task.displayName ?? task.title ?? "").replace(/\s+/g, " ").trim();
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
/** pi-tasks status glyph per row: ✔ done (struck through),
|
|
66
|
-
*
|
|
64
|
+
/** pi-tasks status glyph per row: ✔ done (struck through), ▶ the actively
|
|
65
|
+
* executing task, ◼ parked/needs input, ◻ queued, ✗ dead.
|
|
66
|
+
*
|
|
67
|
+
* L-plan-flicker (2026-10-07 live probe, 600 frames @0.2s in the owner
|
|
68
|
+
* session): the running row previously painted `spinnerFrame()` — a braille
|
|
69
|
+
* frame rotating every 160ms — turning the whole card into a ~8Hz repaint
|
|
70
|
+
* above the editor ("nháy nháy liên tục"). The plan card is a LIST: per the
|
|
71
|
+
* variant's own design comment it must change only on task transitions, so
|
|
72
|
+
* the running glyph is STATIC (like ◼/◻) and the elapsed suffix carries
|
|
73
|
+
* liveness at 1s grain. The dock keeps its spinner — one animated row is
|
|
74
|
+
* intentional there. */
|
|
67
75
|
function taskStatusIcon(task: TeamTaskState): string {
|
|
68
76
|
switch (task.status) {
|
|
69
77
|
case "running":
|
|
70
|
-
return
|
|
78
|
+
return "▶";
|
|
71
79
|
case "queued":
|
|
72
80
|
return "◻";
|
|
73
81
|
case "waiting":
|
|
@@ -55,10 +55,11 @@ export function activeWidgetRuns(
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// L1 (ui-instability review 2026-10-07): the dock renders in EVERY session, so
|
|
59
|
+
// NO ownerSessionId display filter here — bystander terminals must see runs
|
|
60
|
+
// owned by other sessions (live evidence: 308 frames / 0 dock rendered).
|
|
61
|
+
// workspaceId stays in scope: crash recovery below is still session-scoped.
|
|
62
|
+
const runs = preloadedManifests ?? (manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20));
|
|
62
63
|
|
|
63
64
|
return runs
|
|
64
65
|
.map((run) => {
|