infinity-harness 2.8.4 → 2.8.6
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 +28 -0
- package/extensions/infinity-harness/index.ts +62 -24
- package/package.json +1 -1
- package/src/ui/theme.ts +10 -1
- package/src/ui/widget.ts +122 -81
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,34 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [2.8.6] — 2026-08-31
|
|
8
|
+
|
|
9
|
+
Parked stays parked.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Main session started harness work without a widget after declining the final wizard prompt.** The post-wizard "parked" note was sent as `sendUserMessage(..., { deliverAs: "followUp" })`, which intentionally wakes the agent on the main-session model. Changed to `sendMessage(..., { triggerTurn: false })` (visible `infinity:brief` with `parked: true` but no turn) and hardened the control-panel system prompt to explicitly say "PARKED — do NOT start building/researching/validating; only answer questions; only `/infinity:run` starts the harness; background uses default/tiered models, not the main session model" when not armed. Session now simply does nothing until `/infinity:run`.
|
|
14
|
+
|
|
15
|
+
## [2.8.5] — 2026-08-31
|
|
16
|
+
|
|
17
|
+
Richer top widget, one panel.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Widget header is now coloured and sectioned.** ─── separators between bands; each band has its own colour (brand/accent/muted/rule) so the header reads as distinct sections even with NO\_COLOR.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **Dashboard URL now shows the real port from the start.** Pulls the live `daemon.json:port` when no fallback remote is running, so ":PORT" never appears. The link is a clickable OSC 8 hyperlink and width/ANSI handling now accounts for it (no truncation at 76 cols).
|
|
26
|
+
|
|
27
|
+
- **Widget now shows goal counts and the active goal on its own line.** "◈ Goals: N" sits right under the Dashboard line (brand colour); the active goal headline (the single goal or, for multi-goal plans, the goal owning the next task) appears before the phase rail rather than buried inside the task lanes.
|
|
28
|
+
|
|
29
|
+
- **Phase rail sits with the active goal and highlights failures.** "● define ─ ⚠ plan ─ ◉ BUILD ─ ○ verify" — the current phase is bold accent, passed phases are success/green, failed phases (from `gateHistory`) are blocked/red with "⚠" so a gate failure is obvious from the rail. Narrow terminals still elide correctly.
|
|
30
|
+
|
|
31
|
+
- **Active-phase breakdown under the rail.** One line per your spec: "BUILD · 2 features · 1/4 tasks" (phase name · accent feature count · text task progress). Shown when `display.counts` is on; respects the display preset.
|
|
32
|
+
|
|
33
|
+
- **Whole-project progress bar with goal count.** "1/4 tasks · 0/2 features · 2 goals" alongside the "■30%" bar. Displayed when `display.progress` is on; the old duplicate rail+progress block that lived after the task lanes has been removed so there is no second copy.
|
|
34
|
+
|
|
7
35
|
## [2.8.4] — 2026-08-31
|
|
8
36
|
|
|
9
37
|
Single scrollable widget.
|
|
@@ -288,7 +288,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
288
288
|
worker: l.worker,
|
|
289
289
|
text: l.text,
|
|
290
290
|
})),
|
|
291
|
-
dashboardUrl:
|
|
291
|
+
dashboardUrl: (() => {
|
|
292
|
+
if (remoteServer?.url) return remoteServer.url;
|
|
293
|
+
try {
|
|
294
|
+
const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
|
|
295
|
+
const port = d?.port;
|
|
296
|
+
if (typeof port === "number" && port > 0) return `http://127.0.0.1:${port}/dashboard`;
|
|
297
|
+
} catch {}
|
|
298
|
+
return null;
|
|
299
|
+
})(),
|
|
292
300
|
handoffModelNote,
|
|
293
301
|
sessions: run?.sessions ?? null,
|
|
294
302
|
intake: typeof config.intake?.brief === "string" ? config.intake.brief : null,
|
|
@@ -296,6 +304,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
296
304
|
display: normalizeDisplay(config.display),
|
|
297
305
|
phase: config.currentPhase,
|
|
298
306
|
enabledPhases: config.phases?.enabled,
|
|
307
|
+
gateHistory: Array.isArray((config as { gateHistory?: unknown }).gateHistory) ? (config as { gateHistory: import("../../src/core/types.ts").GateHistoryEntry[] }).gateHistory : null,
|
|
299
308
|
paused: Boolean(config.paused),
|
|
300
309
|
revision: list.baseRevision,
|
|
301
310
|
retries: { task: config.taskRetryCount ?? 0, max: config.maxRetries ?? 10 },
|
|
@@ -1924,22 +1933,23 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1924
1933
|
if (!dr.spawned) await startEngine(ctx, dir);
|
|
1925
1934
|
notify(ctx, "infinity-harness: run armed — background run started. /infinity:halt stops it.", "info");
|
|
1926
1935
|
} else {
|
|
1936
|
+
// The harness is parked — do NOT trigger an agent turn. The
|
|
1937
|
+
// control-panel contract (before_agent_start) already stops
|
|
1938
|
+
// autonomous work, but a `followUp` brief starts one anyway.
|
|
1939
|
+
// `triggerTurn: false` keeps it visible without waking the model.
|
|
1927
1940
|
const brief = await briefText(dir);
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
{
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
pi.
|
|
1935
|
-
|
|
1936
|
-
{
|
|
1937
|
-
);
|
|
1938
|
-
} else {
|
|
1939
|
-
pi.sendUserMessage(
|
|
1940
|
-
`${brief}\n\nThe harness is ready but NOT running. Run "/infinity:run" to start, or answer "yes — start the run now" in the wizard next time.`,
|
|
1941
|
-
{ deliverAs: "followUp" },
|
|
1941
|
+
const parkedNote = !plan.brief
|
|
1942
|
+
? `The human has not said what they want built yet. Ask them, in one short question, and do not start any work or invent a scope until they answer. The harness is NOT running — "/infinity:run" starts it.\n\n${brief}`
|
|
1943
|
+
: plan.phases[0] === "research"
|
|
1944
|
+
? `${brief}\n\nThis is RESEARCH — survey constraints and options first, then validate (infinity_validate) to advance. The harness is NOT running yet. Run "/infinity:run" when you are ready, or answer "yes — start the run now" next time you init.`
|
|
1945
|
+
: `${brief}\n\nThe harness is ready but NOT running. Run "/infinity:run" to start, or answer "yes — start the run now" in the wizard next time.`;
|
|
1946
|
+
try {
|
|
1947
|
+
pi.sendMessage(
|
|
1948
|
+
{ customType: "infinity:brief", content: parkedNote, display: true, details: { parked: true, phase: plan.phases[0] ?? null } },
|
|
1949
|
+
{ triggerTurn: false },
|
|
1942
1950
|
);
|
|
1951
|
+
} catch (e) {
|
|
1952
|
+
notify(ctx, `infinity-harness: ${errMsg(e as Error)}`, "warning");
|
|
1943
1953
|
}
|
|
1944
1954
|
}
|
|
1945
1955
|
},
|
|
@@ -3385,19 +3395,47 @@ function controlPanelContract(dir: string): string | null {
|
|
|
3385
3395
|
if (!ok || !config.currentPhase) return null;
|
|
3386
3396
|
const { list } = loadFeatureList(dir);
|
|
3387
3397
|
const p = computeProgress(list);
|
|
3398
|
+
let armed = false;
|
|
3399
|
+
try {
|
|
3400
|
+
const r = readJsonSafe<{ armed?: boolean } | null>(runStatePathSync(dir), null);
|
|
3401
|
+
armed = r?.armed === true;
|
|
3402
|
+
} catch {}
|
|
3403
|
+
// When parked, the harness does NO work anywhere — not in this session and
|
|
3404
|
+
// not in background sessions. When armed, background sessions do the work
|
|
3405
|
+
// and this session is idle by design (it never spends the human's model).
|
|
3406
|
+
const workLine = armed
|
|
3407
|
+
? "The work is being done by separate background pi sessions on their own models, not by you."
|
|
3408
|
+
: "The harness is NOT running — nothing is happening in background. Only `/infinity:run` starts it.";
|
|
3409
|
+
const rule1 = armed
|
|
3410
|
+
? "1. Do not implement plan tasks, advance phases, or edit `harness/` by hand. Answer the"
|
|
3411
|
+
: "1. Do NOT start building, researching, or validating. The harness is parked. If the human";
|
|
3412
|
+
const rule1b = armed
|
|
3413
|
+
? " human's questions about the run, and use `/infinity:workers` and `infinity_status`"
|
|
3414
|
+
: " asks you to do harness work anyway, tell them it is parked and needs `/infinity:run`,";
|
|
3415
|
+
const rule1c = armed
|
|
3416
|
+
? " to see what the background sessions are doing."
|
|
3417
|
+
: " then stop. Do not touch `harness/` files.";
|
|
3418
|
+
const rule2 = armed
|
|
3419
|
+
? "2. If the human asks you to build something, say that the harness is driving it and offer"
|
|
3420
|
+
: "2. While parked, you may ONLY answer questions about the project — its stack, commands,";
|
|
3421
|
+
const rule2b = armed
|
|
3422
|
+
? " `/infinity:run`, `/infinity:halt`, or `/infinity:replan` instead."
|
|
3423
|
+
: " files, and how the harness would run. Do not write code, docs, plans, or validation.";
|
|
3424
|
+
const rule3 = "3. The plan of record is `harness/features/feature-list.json`; your memory of it is not.";
|
|
3425
|
+
const extra = armed ? [] : ["", "Parked means parked. No tool calls that mutate the project while parked."];
|
|
3388
3426
|
return [
|
|
3389
|
-
"## infinity-harness — you are the control panel",
|
|
3427
|
+
"## infinity-harness — you are the control panel" + (armed ? "" : " (PARKED)"),
|
|
3390
3428
|
"",
|
|
3391
3429
|
`This project runs an infinity-harness pipeline at **${config.currentPhase.toUpperCase()}**, ` +
|
|
3392
|
-
`${p.tasksDone}/${p.tasksTotal} tasks done.
|
|
3393
|
-
`pi sessions on their own models, not by you.`,
|
|
3430
|
+
`${p.tasksDone}/${p.tasksTotal} tasks done. ` + workLine,
|
|
3394
3431
|
"",
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3432
|
+
rule1,
|
|
3433
|
+
rule1b,
|
|
3434
|
+
rule1c,
|
|
3435
|
+
rule2,
|
|
3436
|
+
rule2b,
|
|
3437
|
+
rule3,
|
|
3438
|
+
...extra,
|
|
3401
3439
|
].join("\n");
|
|
3402
3440
|
}
|
|
3403
3441
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinity-harness",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.6",
|
|
4
4
|
"description": "A pi agent extension that runs a gated build pipeline unattended — enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
package/src/ui/theme.ts
CHANGED
|
@@ -15,9 +15,10 @@ import stringWidth from "string-width";
|
|
|
15
15
|
const ESC = "\u001b";
|
|
16
16
|
const RESET = ESC + "[0m";
|
|
17
17
|
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
|
18
|
+
const OSC8_RE = /\u001b\]8;;[^\u0007]*\u0007/g;
|
|
18
19
|
|
|
19
20
|
export function stripAnsi(s: string): string {
|
|
20
|
-
return s.replace(ANSI_RE, "");
|
|
21
|
+
return s.replace(OSC8_RE, "").replace(ANSI_RE, "");
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/** Display width in terminal columns, ignoring ANSI and honouring wide chars. */
|
|
@@ -49,6 +50,14 @@ export function truncate(s: string, max: number, ellipsis = "…"): string {
|
|
|
49
50
|
|
|
50
51
|
while (i < s.length) {
|
|
51
52
|
if (s[i] === ESC) {
|
|
53
|
+
// OSC 8 hyperlink: ESC ] 8 ;; URL BEL — zero-width wrapper
|
|
54
|
+
if (s[i + 1] === "]") {
|
|
55
|
+
const bel = s.indexOf("\u0007", i);
|
|
56
|
+
if (bel === -1) break;
|
|
57
|
+
out += s.slice(i, bel + 1);
|
|
58
|
+
i = bel + 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
52
61
|
const end = s.indexOf("m", i);
|
|
53
62
|
if (end === -1) break;
|
|
54
63
|
const seq = s.slice(i, end + 1);
|
package/src/ui/widget.ts
CHANGED
|
@@ -54,6 +54,8 @@ export type WidgetState = {
|
|
|
54
54
|
replanDiff?: string | null;
|
|
55
55
|
/** Dashboard URL to show near the top, clickable. Meaningful host:port, not just numbers. */
|
|
56
56
|
dashboardUrl?: string | null;
|
|
57
|
+
/** Gate history for phase rail failure highlighting. */
|
|
58
|
+
gateHistory?: import("../core/types.ts").GateHistoryEntry[] | null;
|
|
57
59
|
/** Model routing note: e.g. "Model per task — subtasks share parent". Shown once. */
|
|
58
60
|
handoffModelNote?: string | null;
|
|
59
61
|
/** Shown in the header rule, e.g. "rev 42". */
|
|
@@ -257,14 +259,26 @@ export function phaseRail(
|
|
|
257
259
|
max: number,
|
|
258
260
|
g: GlyphSet,
|
|
259
261
|
s: Styler,
|
|
262
|
+
gateHistory?: readonly import("../core/types.ts").GateHistoryEntry[] | null,
|
|
260
263
|
): string {
|
|
261
264
|
const order = getPhaseOrder(enabled);
|
|
262
265
|
const idx = current ? order.indexOf(current) : -1;
|
|
266
|
+
// Last verdict per phase — failed phases show as blocked (red).
|
|
267
|
+
const lastByPhase = new Map<string, "pass" | "fail">();
|
|
268
|
+
if (gateHistory) {
|
|
269
|
+
for (let i = gateHistory.length - 1; i >= 0; i--) {
|
|
270
|
+
const e = gateHistory[i] as { phase?: string; result?: string } | null;
|
|
271
|
+
if (!e?.phase || !e?.result) continue;
|
|
272
|
+
if (!lastByPhase.has(e.phase)) lastByPhase.set(e.phase, e.result as "pass" | "fail");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
263
275
|
|
|
264
276
|
const render = (phases: Phase[], elideLeft: boolean, elideRight: boolean): string => {
|
|
265
277
|
const parts = phases.map((p) => {
|
|
266
278
|
const i = order.indexOf(p);
|
|
279
|
+
const last = lastByPhase.get(p);
|
|
267
280
|
if (p === current) return s.bold(s.fg("accent", g.phaseCurrent + " " + p.toUpperCase()));
|
|
281
|
+
if (last === "fail") return s.bold(s.fg("blocked", g.blocked + " " + p));
|
|
268
282
|
if (idx >= 0 && i < idx) return s.fg("success", g.phaseDone + " " + p);
|
|
269
283
|
return s.fg("muted", g.phaseTodo + " " + p);
|
|
270
284
|
});
|
|
@@ -566,8 +580,11 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
566
580
|
}
|
|
567
581
|
if ((state as WidgetState).replanDiff) push(truncate(s.fg("muted", "Replan: ") + s.fg("text", String((state as WidgetState).replanDiff).slice(0, 80)), inner));
|
|
568
582
|
|
|
569
|
-
// -- header
|
|
570
|
-
|
|
583
|
+
// -- header + dashboard --------------------------------------------
|
|
584
|
+
// Every band has its own colour so the top scans as sections, not one grey block.
|
|
585
|
+
// Muted separators make it legible even with NO_COLOR.
|
|
586
|
+
const sep = (): void => { push(s.fg("rule", g.rail.repeat(Math.max(1, inner)))); };
|
|
587
|
+
const brand = s.bold(s.fg("brand", "\u221e INFINITY"));
|
|
571
588
|
const phaseTag = state.paused
|
|
572
589
|
? s.fg("blocked", "PAUSED")
|
|
573
590
|
: state.phase
|
|
@@ -578,48 +595,119 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
578
595
|
const headRight = phaseTag + revTag;
|
|
579
596
|
const gapW = inner - width(headLeft) - width(headRight);
|
|
580
597
|
push(headLeft + (gapW > 1 ? s.fg("rule", " " + g.rail.repeat(gapW - 2) + " ") : " ") + headRight);
|
|
581
|
-
// Dashboard URL
|
|
598
|
+
// Dashboard URL with a real port — :PORT is never correct for an ephemeral port.
|
|
582
599
|
{
|
|
583
|
-
const url = state.dashboardUrl as string | null | undefined;
|
|
600
|
+
const url = (state.dashboardUrl as string | null | undefined) ?? null;
|
|
584
601
|
if (url) {
|
|
585
602
|
const label = s.fg("accent", url);
|
|
586
|
-
const link =
|
|
587
|
-
push(truncate(s.fg("
|
|
603
|
+
const link = "\u001b]8;;" + url + "\u0007" + label + "\u001b]8;;\u0007";
|
|
604
|
+
push(truncate(s.fg("accent", "Dashboard \u2192 ") + link, inner));
|
|
588
605
|
} else {
|
|
589
|
-
push(truncate(s.fg("muted", "Dashboard
|
|
606
|
+
push(truncate(s.fg("muted", "Dashboard \u2192 ") + s.fg("muted", "/infinity:dashboard"), inner));
|
|
590
607
|
}
|
|
591
608
|
}
|
|
592
609
|
if (state.handoffModelNote) {
|
|
593
610
|
push(truncate(s.fg("muted", state.handoffModelNote), inner));
|
|
594
611
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
612
|
+
// Goal count sits right under the header; the old widget hid goals entirely.
|
|
613
|
+
{
|
|
614
|
+
const goals = state.list.goals ?? [];
|
|
615
|
+
if (goals.length === 0 && state.intake) {
|
|
616
|
+
push(truncate(s.fg("rule", "Goal \u00b7 ") + s.fg("text", state.intake.slice(0, 90)), inner));
|
|
617
|
+
} else if (goals.length > 0) {
|
|
618
|
+
const n = goals.length;
|
|
619
|
+
const countLine = s.fg("brand", "\u25C8 Goals: " + String(n) + " " + (n === 1 ? "goal" : "goals"));
|
|
620
|
+
push(truncate(countLine, inner));
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
sep();
|
|
624
|
+
|
|
625
|
+
// -- goal + phase rail ------------------------------------------------
|
|
626
|
+
// One goal section per your spec, each with its own colour:
|
|
627
|
+
// [brand] Goals: N
|
|
628
|
+
// [text] Active goal headline
|
|
629
|
+
// [accent/muted/success/blocked] phase rail with active/passed/failed
|
|
630
|
+
// [muted] Active-phase breakdown: features/tasks
|
|
631
|
+
// [rule] dotted separator
|
|
632
|
+
// [accent/text] progress bar (whole project)
|
|
633
|
+
// [rule] dotted separator
|
|
634
|
+
{
|
|
635
|
+
const goals = state.list.goals ?? [];
|
|
636
|
+
// Single-goal headline (the old headline logic) — now sits directly under
|
|
637
|
+
// the Goals count, inside the same band, before the rail. Multi-goal plans
|
|
638
|
+
// show the active goal instead (not buried in the lane tree).
|
|
639
|
+
let headline: string | null = null;
|
|
640
|
+
if (display.levels.goal) {
|
|
641
|
+
if (goals.length === 1) headline = goals[0]?.title ?? null;
|
|
642
|
+
else if (goals.length === 0) headline = state.intake ?? null;
|
|
643
|
+
else {
|
|
644
|
+
// Multi-goal: pick the goal owning the next actionable task, else the first.
|
|
645
|
+
const next = nextActionableTask(state.list);
|
|
646
|
+
if (next) {
|
|
647
|
+
const feat = state.list.features.find((f) => f.id === next.featureId) ?? null;
|
|
648
|
+
const spr = feat?.sprintId ? (state.list.sprints ?? []).find((s) => s.id === feat.sprintId) ?? null : null;
|
|
649
|
+
const gid = (feat as { goalId?: string } | null)?.goalId ?? spr?.goalId ?? null;
|
|
650
|
+
const g2 = gid ? goals.find((gg) => gg.id === gid) ?? goals[0] ?? null : goals[0] ?? null;
|
|
651
|
+
headline = g2?.title ?? null;
|
|
652
|
+
} else {
|
|
653
|
+
headline = goals[0]?.title ?? null;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (headline) {
|
|
658
|
+
const wrapped = wrap(headline, inner - 2);
|
|
659
|
+
wrapped.forEach((line, i) => {
|
|
660
|
+
push((i === 0 ? s.fg("muted", g.goal + " ") : " ") + s.fg("text", line));
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
if (display.rail) {
|
|
664
|
+
push(phaseRail(state.phase, state.enabledPhases, inner, g, s, state.gateHistory ?? null));
|
|
665
|
+
}
|
|
666
|
+
// Active-phase breakdown: features + tasks in the current phase (or whole project when no phase yet).
|
|
667
|
+
// Respects display.counts; when counts off we skip the line entirely.
|
|
668
|
+
if (display.counts) {
|
|
669
|
+
const phaseName = state.phase;
|
|
670
|
+
const allFeats = state.list.features ?? [];
|
|
671
|
+
const inPhase = (phaseName
|
|
672
|
+
? allFeats.filter((f) => (f as { phase?: string }).phase === phaseName)
|
|
673
|
+
: allFeats);
|
|
674
|
+
const phaseTasks = phaseName
|
|
675
|
+
? flattenTasks(state.list).filter((tt) => tt.effectivePhase === phaseName)
|
|
676
|
+
: flattenTasks(state.list);
|
|
677
|
+
const done = phaseTasks.filter((tt) => tt.status === "complete").length;
|
|
678
|
+
const total = phaseTasks.length;
|
|
679
|
+
const featCount = inPhase.length;
|
|
680
|
+
const label = phaseName ? phaseName.toUpperCase() : "PROJECT";
|
|
681
|
+
const featPart = s.fg("accent", String(featCount) + " " + (featCount === 1 ? "feature" : "features"));
|
|
682
|
+
const taskPart = s.fg("text", String(done) + "/" + String(total) + " tasks");
|
|
683
|
+
push(truncate(s.fg("muted", label + " \u00b7 ") + featPart + s.fg("rule", " \u00b7 ") + taskPart, inner));
|
|
684
|
+
}
|
|
685
|
+
sep();
|
|
686
|
+
if (display.progress) {
|
|
687
|
+
const full =
|
|
688
|
+
s.fg("brand", String(progress.tasksDone) + "/" + String(progress.tasksTotal) + " tasks") +
|
|
689
|
+
s.fg("rule", " \u00b7 ") +
|
|
690
|
+
s.fg("muted", String(progress.featuresDone) + "/" + String(progress.featuresTotal) + " features") +
|
|
691
|
+
s.fg("rule", " \u00b7 ") +
|
|
692
|
+
s.fg("accent", String((state.list.goals ?? []).length) + " " + ((state.list.goals ?? []).length === 1 ? "goal" : "goals"));
|
|
693
|
+
const compact = s.fg("brand", String(progress.tasksDone) + "/" + String(progress.tasksTotal));
|
|
694
|
+
const METER_MIN = 8 + 6;
|
|
695
|
+
let stats = full;
|
|
696
|
+
if (inner - width(full) < METER_MIN) stats = compact;
|
|
697
|
+
if (inner - width(stats) < METER_MIN) stats = "";
|
|
698
|
+
const statsW = width(stats);
|
|
699
|
+
const barCells = Math.max(8, Math.min(24, inner - statsW - 8));
|
|
700
|
+
const bar = progressBar(progress.percent, barCells, g, s);
|
|
701
|
+
const gap2 = inner - width(bar) - statsW;
|
|
702
|
+
push(truncate(bar + (gap2 > 0 ? " ".repeat(gap2) : " ") + stats, inner));
|
|
703
|
+
sep();
|
|
704
|
+
}
|
|
614
705
|
}
|
|
615
706
|
|
|
616
|
-
// --
|
|
617
|
-
//
|
|
618
|
-
|
|
619
|
-
// Task lane disabled -> no lane at all (overview template wants shape not work).
|
|
620
|
-
// Otherwise show active + pending first lane.
|
|
707
|
+
// -- task lanes -------------------------------------------------------
|
|
708
|
+
// Aggregate task-window size respected; lane count capped; elision markers show hidden work.
|
|
709
|
+
const taskWindow = view.expanded ? Math.max(28, display.taskWindow * 2) : display.taskWindow;
|
|
621
710
|
if (!display.levels.task) {
|
|
622
|
-
// Overview: keep the shape (goal/sprint/feature tier names) even without lanes.
|
|
623
711
|
const f = state.list.features[0];
|
|
624
712
|
if (f) {
|
|
625
713
|
const sname = (f as { sprintId?: string }).sprintId ? (state.list.sprints ?? []).find((s) => s.id === (f as { sprintId?: string }).sprintId)?.name : null;
|
|
@@ -631,15 +719,8 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
631
719
|
if (shape.length) { push(); push(truncate(shape.join(s.fg("rule", " \u00b7 ")), inner)); }
|
|
632
720
|
}
|
|
633
721
|
}
|
|
634
|
-
// Aggregate task-window size respected: lane count capped; elision markers show hidden work.
|
|
635
|
-
// On a huge plan (120 tasks) the widget previously rendered ~TASK_WINDOW rows; now show up to display.taskWindow lanes.
|
|
636
|
-
// On a long plan the lane is compact — goal handled via headline above; phase first inside lane so
|
|
637
|
-
// narrow TUI still shows active work before sprint/feature tail gets cut.
|
|
638
|
-
const taskWindow = view.expanded ? Math.max(28, display.taskWindow * 2) : display.taskWindow;
|
|
639
722
|
if (display.levels.task) {
|
|
640
723
|
const allTasks = flattenTasks(state.list);
|
|
641
|
-
// When user scrolled explicitly, honour the scroll window (huge plan test uses scroll: 0/1e6).
|
|
642
|
-
// Otherwise show focus-centred window (active task plus pending tail).
|
|
643
724
|
let lanes: Array<FlatTask & { subtasks?: { status: string; title: string }[] }> = [];
|
|
644
725
|
if (view.scroll !== null) {
|
|
645
726
|
const start = Math.max(0, Math.min(view.scroll as number, Math.max(0, allTasks.length - taskWindow)));
|
|
@@ -664,33 +745,24 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
664
745
|
const formatChain = (t: FlatTask & { subtasks?: { status: string; title: string }[] }): string => {
|
|
665
746
|
const curFeature = state.list.features.find((f) => f.id === t.featureId) ?? null;
|
|
666
747
|
const curSprint = curFeature?.sprintId ? (state.list.sprints ?? []).find((s) => s.id === curFeature!.sprintId) ?? null : null;
|
|
667
|
-
const curGoal = (curFeature?.goalId ?? curSprint?.goalId ?? (state.list.goals ?? [])[0]?.id) ? (state.list.goals ?? []).find((gg) => gg.id === (curFeature?.goalId ?? curSprint?.goalId ?? (state.list.goals ?? [])[0]?.id)) ?? null : null;
|
|
668
|
-
// goal + sprint + phase + feature + task + subtask: names/titles
|
|
669
|
-
// Always show the phase and the task; goal/sprint/feature/subtask honour display.levels.
|
|
670
748
|
const parts: string[] = [];
|
|
671
|
-
// One-line chain: phase · sprint · feature · task · subtask on one line.
|
|
672
|
-
// Sprint before task/feature so even narrow realpi rasterizer keeps Foundations visible.
|
|
673
749
|
if (state.phase) parts.push(s.bold(s.fg("accent", state.phase.toUpperCase())));
|
|
674
750
|
if (curSprint && display.levels.sprint) parts.push(s.fg("muted", "sprint " + (curSprint.name ?? curSprint.id).slice(0, 18)));
|
|
675
751
|
if (curFeature && display.levels.feature) parts.push(s.fg("success", curFeature.name.slice(0, 24)));
|
|
676
752
|
const descEarly = t.description ? t.description.slice(0, 44) : "";
|
|
677
753
|
parts.push(s.fg("text", t.compositeKey + (descEarly ? " " + descEarly.slice(0, 36) : "")));
|
|
678
|
-
|
|
679
|
-
return parts.join(s.fg("rule", " · "));
|
|
754
|
+
return parts.join(s.fg("rule", " \u00b7 "));
|
|
680
755
|
};
|
|
681
756
|
if (lanes.length) {
|
|
682
|
-
// Elision: lanes window + markers so huge plan still shows ... N above / ... N below
|
|
683
757
|
const totalPendable = allTasks.length;
|
|
684
758
|
const above = allTasks.findIndex((t) => t.compositeKey === lanes[0]!.compositeKey);
|
|
685
759
|
const lastIdx = allTasks.findIndex((t) => t.compositeKey === lanes[lanes.length - 1]!.compositeKey);
|
|
686
760
|
const below = Math.max(0, totalPendable - lastIdx - 1);
|
|
687
761
|
const shownAbove = above > 0 ? above : 0;
|
|
688
762
|
const shownBelow = below > 0 ? below : 0;
|
|
689
|
-
push();
|
|
690
763
|
if (shownAbove > 0) push(s.fg("rule", " " + g.more + " " + shownAbove + " above"));
|
|
691
764
|
for (const task of lanes.slice(0, taskWindow)) {
|
|
692
765
|
push(truncate(formatChain(task), inner));
|
|
693
|
-
// If task has an active subtask, show it on its own line so narrow TUI doesn't truncate it away.
|
|
694
766
|
if (display.levels.subtask !== "none") {
|
|
695
767
|
const cur = ((task as unknown as FlatTask & { subtasks?: Subtask[] }).subtasks ?? []).find((ss) => ss.status !== "complete") ?? null;
|
|
696
768
|
if (cur) push(truncate(" " + s.fg("active", "> " + cur.title.slice(0, 56)), inner));
|
|
@@ -698,11 +770,9 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
698
770
|
}
|
|
699
771
|
if (shownBelow > 0) push(s.fg("rule", " " + g.more + " " + shownBelow + " below"));
|
|
700
772
|
} else if (state.list.features.length === 0) {
|
|
701
|
-
push();
|
|
702
773
|
push(s.fg("muted", " no plan yet"));
|
|
703
774
|
}
|
|
704
775
|
}
|
|
705
|
-
|
|
706
776
|
// -- background sessions --------------------------------------------------
|
|
707
777
|
//
|
|
708
778
|
// Placed above the rail because, on a run driven by workers, this is the
|
|
@@ -724,36 +794,7 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
724
794
|
for (const line of renderActivity(state.activity, view.expanded ? activityRows * 3 : activityRows, inner, s)) push(line);
|
|
725
795
|
}
|
|
726
796
|
|
|
727
|
-
//
|
|
728
|
-
if (display.rail) {
|
|
729
|
-
push();
|
|
730
|
-
push(phaseRail(state.phase, state.enabledPhases, inner, g, s));
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// -- progress -------------------------------------------------------------
|
|
734
|
-
const full =
|
|
735
|
-
s.fg("muted", progress.tasksDone + "/" + progress.tasksTotal + " tasks") +
|
|
736
|
-
s.fg("rule", " · ") +
|
|
737
|
-
s.fg("muted", progress.featuresDone + "/" + progress.featuresTotal + " features");
|
|
738
|
-
const compact = s.fg("muted", progress.tasksDone + "/" + progress.tasksTotal);
|
|
739
|
-
|
|
740
|
-
// The meter has a floor of 8 cells plus " NNN%" (6 columns). Below that the
|
|
741
|
-
// long stat cannot fit, and padding it out would push the row past the
|
|
742
|
-
// frame — so the row degrades to the task count alone, then to no stat.
|
|
743
|
-
const METER_MIN = 8 + 6;
|
|
744
|
-
let stats = full;
|
|
745
|
-
if (inner - width(full) < METER_MIN) stats = compact;
|
|
746
|
-
if (inner - width(stats) < METER_MIN) stats = "";
|
|
747
|
-
|
|
748
|
-
if (display.progress) {
|
|
749
|
-
const statsW = width(stats);
|
|
750
|
-
const barCells = Math.max(8, Math.min(24, inner - statsW - 8));
|
|
751
|
-
const bar = progressBar(progress.percent, barCells, g, s);
|
|
752
|
-
push();
|
|
753
|
-
const gap2 = inner - width(bar) - statsW;
|
|
754
|
-
push(truncate(bar + (gap2 > 0 ? " ".repeat(gap2) : " ") + stats, inner));
|
|
755
|
-
}
|
|
756
|
-
|
|
797
|
+
// phase rail + whole-project progress already rendered in the header band above.
|
|
757
798
|
// -- alerts ---------------------------------------------------------------
|
|
758
799
|
const alerts: string[] = [];
|
|
759
800
|
if (progress.blocked > 0) alerts.push(s.fg("blocked", g.blocked + " " + progress.blocked + " blocked"));
|