infinity-harness 2.8.3 → 2.8.5

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 CHANGED
@@ -4,6 +4,33 @@ 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.5] — 2026-08-31
8
+
9
+ Richer top widget, one panel.
10
+
11
+ ### Changed
12
+
13
+ - **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.
14
+
15
+ ### Fixed
16
+
17
+ - **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).
18
+
19
+ - **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.
20
+
21
+ - **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.
22
+
23
+ - **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.
24
+
25
+ - **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.
26
+
27
+ ## [2.8.4] — 2026-08-31
28
+
29
+ Single scrollable widget.
30
+
31
+ ### Fixed
32
+
33
+ - **Two infinity widgets — only the top one scrolled.** Pi renders widgets as string arrays clamped at MAX_WIDGET_LINES=10 (\"... (widget truncated)\"), so the bottom panel was the same content stuck at 10 lines and unscrollable. Switched to the factory overload (no 10-line clamp) for the full scrollable view and explicitly cleared the stale belowEditor placement so only the single aboveEditor panel remains.
7
34
  ## [2.8.3] — 2026-08-30
8
35
 
9
36
  Scroll that actually scrolls, and a wizard you can stand to look at.
@@ -18,6 +45,7 @@ Scroll that actually scrolls, and a wizard you can stand to look at.
18
45
 
19
46
  - **Wizard is colorful, structured, and guided.** 9-step progress rail with `\u221e wizard N/9` + emoji banners before each section, per-answer check marks, and a framed `Summary` with warnings. Chrome lives in `notify` (ANSI-safe, degrades gracefully) so `select` titles/options stay scriptable for tests/E2E.
20
47
 
48
+
21
49
  ## [2.8.2] — 2026-08-30
22
50
 
23
51
  Republishes 2.8.1. The 2.8.1 tarball on disk and on the registry was built from 2.8.0 content while `package.json` already said 2.8.1, so `/infinity:init` still auto-started RESEARCH in your session and the wizard never asked `Start the run now?`. No code changes since `v2.8.1` (`81d16590`).
@@ -160,6 +160,7 @@ function notify(ctx: unknown, message: string, level: "info" | "warning" | "erro
160
160
  }
161
161
 
162
162
  export default function (pi: ExtensionAPI): void {
163
+
163
164
  // -- session-scoped state -------------------------------------------------
164
165
  //
165
166
  // Everything a *run* needs outlives this session and lives in `harness/`.
@@ -287,7 +288,15 @@ export default function (pi: ExtensionAPI): void {
287
288
  worker: l.worker,
288
289
  text: l.text,
289
290
  })),
290
- dashboardUrl: remoteServer?.url ?? null,
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
+ })(),
291
300
  handoffModelNote,
292
301
  sessions: run?.sessions ?? null,
293
302
  intake: typeof config.intake?.brief === "string" ? config.intake.brief : null,
@@ -295,6 +304,7 @@ export default function (pi: ExtensionAPI): void {
295
304
  display: normalizeDisplay(config.display),
296
305
  phase: config.currentPhase,
297
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,
298
308
  paused: Boolean(config.paused),
299
309
  revision: list.baseRevision,
300
310
  retries: { task: config.taskRetryCount ?? 0, max: config.maxRetries ?? 10 },
@@ -309,19 +319,41 @@ export default function (pi: ExtensionAPI): void {
309
319
  }
310
320
  };
311
321
 
312
- const refreshWidget = (ctx: ExtensionContext): void => {
322
+ let widgetCtx: ExtensionContext | null = null;
323
+ const refreshWidget = (ctx?: ExtensionContext): void => {
324
+ if (ctx) widgetCtx = ctx;
325
+ const useCtx = ctx ?? widgetCtx;
326
+ if (!useCtx) return;
313
327
  try {
314
- const dir = projectDir(ctx);
328
+ const dir = projectDir(useCtx);
315
329
  const state = widgetStateFor(dir);
316
330
  if (!state) return;
317
331
  const lines = renderWidget(state, { width: 76, styler, glyphs });
318
- ctx.ui.setWidget(WIDGET_KEY, lines);
319
- ctx.ui.setStatus(STATUS_KEY, renderStatusLine(state, glyphs));
332
+ // Factory overload bypasses pi's string[] MAX_WIDGET_LINES=10 clamp
333
+ // ("... (widget truncated)") — needed for the full scrollable view.
334
+ // Single panel aboveEditor; belowEditor intentionally left empty so
335
+ // there is only one infinity panel on screen (the bottom one was the
336
+ // same widget rendered as belowEditor in a prior install and truncated).
337
+ type WidgetFactory = () => { render: () => string[]; invalidate(): void; handleInput(): void };
338
+ const factory: WidgetFactory = () => ({
339
+ render: () => lines,
340
+ invalidate() {},
341
+ handleInput() {},
342
+ });
343
+ (useCtx.ui.setWidget as unknown as (k: string, f: WidgetFactory) => void)(WIDGET_KEY, factory);
344
+ // Explicitly clear any stale belowEditor instance from a prior install.
345
+ try {
346
+ (useCtx.ui.setWidget as unknown as (k: string, v: undefined, opts: { placement: string }) => void)(
347
+ WIDGET_KEY,
348
+ undefined,
349
+ { placement: "belowEditor" },
350
+ );
351
+ } catch {}
352
+ useCtx.ui.setStatus(STATUS_KEY, renderStatusLine(state, glyphs));
320
353
  } catch {
321
354
  /* the widget is never worth breaking a turn over */
322
355
  }
323
356
  };
324
-
325
357
  /** How many rows the plan currently has — the bound for scrolling. */
326
358
  const planRowCount = (dir: string): number => {
327
359
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.8.3",
3
+ "version": "2.8.5",
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
- const brand = s.bold(s.fg("brand", "∞ INFINITY"));
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: always visible (user never has to type /infinity:dashboard to discover it).
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 = `\u001b]8;;${url}\u0007${label}\u001b]8;;\u0007`;
587
- push(truncate(s.fg("muted", "Dashboard: ") + link, inner));
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: ") + s.fg("muted", "/infinity:dashboard → http://127.0.0.1:PORT"), inner));
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
- // -- goal -----------------------------------------------------------------
597
- //
598
- // A single goal is the run's headline and belongs at the top, not buried in
599
- // the tree `buildPlanRows` collapses it there for exactly this reason.
600
- // Several goals are structure, and structure belongs in the tree.
601
- const _goals = state.list.goals ?? [];
602
- const _headline = !display.levels.goal
603
- ? null
604
- : _goals.length === 1
605
- ? (_goals[0]?.title ?? null)
606
- : _goals.length === 0
607
- ? (state.intake ?? null)
608
- : null;
609
- if (_headline) {
610
- const wrapped = wrap(_headline, inner - 2);
611
- wrapped.forEach((line, i) => {
612
- push((i === 0 ? s.fg("muted", g.goal + " ") : " ") + s.fg("text", line));
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
- // -- current chain: one line per lane: phase · task · feature (+ sprint) · subtask
617
- // On an empty plan with no task yet, just show phase/goal.
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
- // subtask handled as separate line so width budget doesn't cut it off; drop from chain
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
- // -- phase rail -----------------------------------------------------------
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"));