infinity-harness 2.8.9 → 2.8.11
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 +18 -0
- package/dist/extensions/infinity-harness/index.js +3614 -0
- package/dist/extensions/infinity-harness/index.js.map +1 -0
- package/dist/src/approval.js +154 -0
- package/dist/src/approval.js.map +1 -0
- package/dist/src/core/brief.js +309 -0
- package/dist/src/core/brief.js.map +1 -0
- package/dist/src/core/config.js +539 -0
- package/dist/src/core/config.js.map +1 -0
- package/dist/src/core/exec.js +106 -0
- package/dist/src/core/exec.js.map +1 -0
- package/dist/src/core/featureList.js +365 -0
- package/dist/src/core/featureList.js.map +1 -0
- package/dist/src/core/fsx.js +123 -0
- package/dist/src/core/fsx.js.map +1 -0
- package/dist/src/core/gates.js +490 -0
- package/dist/src/core/gates.js.map +1 -0
- package/dist/src/core/init.js +480 -0
- package/dist/src/core/init.js.map +1 -0
- package/dist/src/core/lock.js +183 -0
- package/dist/src/core/lock.js.map +1 -0
- package/dist/src/core/modelRouter.js +185 -0
- package/dist/src/core/modelRouter.js.map +1 -0
- package/dist/src/core/paths.js +129 -0
- package/dist/src/core/paths.js.map +1 -0
- package/dist/src/core/phases.js +254 -0
- package/dist/src/core/phases.js.map +1 -0
- package/dist/src/core/plan.js +15 -0
- package/dist/src/core/plan.js.map +1 -0
- package/dist/src/core/runState.js +126 -0
- package/dist/src/core/runState.js.map +1 -0
- package/dist/src/core/settings.js +877 -0
- package/dist/src/core/settings.js.map +1 -0
- package/dist/src/core/skills.js +319 -0
- package/dist/src/core/skills.js.map +1 -0
- package/dist/src/core/skillsAudit.js +194 -0
- package/dist/src/core/skillsAudit.js.map +1 -0
- package/dist/src/core/types.js +77 -0
- package/dist/src/core/types.js.map +1 -0
- package/dist/src/daemon/budget.js +85 -0
- package/dist/src/daemon/budget.js.map +1 -0
- package/dist/src/daemon/guard.js +122 -0
- package/dist/src/daemon/guard.js.map +1 -0
- package/dist/src/daemon/index.js +461 -0
- package/dist/src/daemon/index.js.map +1 -0
- package/dist/src/daemon/isolation.js +84 -0
- package/dist/src/daemon/isolation.js.map +1 -0
- package/dist/src/daemon/preflight.js +131 -0
- package/dist/src/daemon/preflight.js.map +1 -0
- package/dist/src/daemon/server.js +174 -0
- package/dist/src/daemon/server.js.map +1 -0
- package/dist/src/daemon/supervisorState.js +42 -0
- package/dist/src/daemon/supervisorState.js.map +1 -0
- package/dist/src/daemon/worker.js +239 -0
- package/dist/src/daemon/worker.js.map +1 -0
- package/dist/src/daemon/worktree.js +102 -0
- package/dist/src/daemon/worktree.js.map +1 -0
- package/dist/src/escalate.js +312 -0
- package/dist/src/escalate.js.map +1 -0
- package/dist/src/exec/piWorker.js +630 -0
- package/dist/src/exec/piWorker.js.map +1 -0
- package/dist/src/goal.js +317 -0
- package/dist/src/goal.js.map +1 -0
- package/dist/src/goalLoop.js +374 -0
- package/dist/src/goalLoop.js.map +1 -0
- package/dist/src/goalSpec.js +397 -0
- package/dist/src/goalSpec.js.map +1 -0
- package/dist/src/goalState.js +255 -0
- package/dist/src/goalState.js.map +1 -0
- package/dist/src/handoff.js +205 -0
- package/dist/src/handoff.js.map +1 -0
- package/dist/src/intake.js +203 -0
- package/dist/src/intake.js.map +1 -0
- package/dist/src/loop.js +592 -0
- package/dist/src/loop.js.map +1 -0
- package/dist/src/modelRouter.js +216 -0
- package/dist/src/modelRouter.js.map +1 -0
- package/dist/src/remote.js +246 -0
- package/dist/src/remote.js.map +1 -0
- package/dist/src/replan.js +262 -0
- package/dist/src/replan.js.map +1 -0
- package/dist/src/review.js +54 -0
- package/dist/src/review.js.map +1 -0
- package/dist/src/rework.js +241 -0
- package/dist/src/rework.js.map +1 -0
- package/dist/src/runState.js +4 -0
- package/dist/src/runState.js.map +1 -0
- package/dist/src/scheduler.js +270 -0
- package/dist/src/scheduler.js.map +1 -0
- package/dist/src/supervisor.js +787 -0
- package/dist/src/supervisor.js.map +1 -0
- package/dist/src/taskList.js +430 -0
- package/dist/src/taskList.js.map +1 -0
- package/dist/src/ui/config.js +268 -0
- package/dist/src/ui/config.js.map +1 -0
- package/dist/src/ui/dashboard.js +1288 -0
- package/dist/src/ui/dashboard.js.map +1 -0
- package/dist/src/ui/display.js +229 -0
- package/dist/src/ui/display.js.map +1 -0
- package/dist/src/ui/planTree.js +219 -0
- package/dist/src/ui/planTree.js.map +1 -0
- package/dist/src/ui/theme.js +298 -0
- package/dist/src/ui/theme.js.map +1 -0
- package/dist/src/ui/viewState.js +65 -0
- package/dist/src/ui/viewState.js.map +1 -0
- package/dist/src/ui/widget.js +721 -0
- package/dist/src/ui/widget.js.map +1 -0
- package/dist/src/ui/wizard.js +540 -0
- package/dist/src/ui/wizard.js.map +1 -0
- package/dist/src/unstuck.js +210 -0
- package/dist/src/unstuck.js.map +1 -0
- package/dist/src/worker.js +267 -0
- package/dist/src/worker.js.map +1 -0
- package/dist/src/workflow.js +250 -0
- package/dist/src/workflow.js.map +1 -0
- package/dist/tests/approval.test.js +144 -0
- package/dist/tests/approval.test.js.map +1 -0
- package/dist/tests/brief.test.js +271 -0
- package/dist/tests/brief.test.js.map +1 -0
- package/dist/tests/config.test.js +320 -0
- package/dist/tests/config.test.js.map +1 -0
- package/dist/tests/dashboard.test.js +277 -0
- package/dist/tests/dashboard.test.js.map +1 -0
- package/dist/tests/display.test.js +229 -0
- package/dist/tests/display.test.js.map +1 -0
- package/dist/tests/escalate.test.js +272 -0
- package/dist/tests/escalate.test.js.map +1 -0
- package/dist/tests/featureList.test.js +429 -0
- package/dist/tests/featureList.test.js.map +1 -0
- package/dist/tests/gates.test.js +333 -0
- package/dist/tests/gates.test.js.map +1 -0
- package/dist/tests/goal.test.js +229 -0
- package/dist/tests/goal.test.js.map +1 -0
- package/dist/tests/goalLoop.test.js +170 -0
- package/dist/tests/goalLoop.test.js.map +1 -0
- package/dist/tests/goalSpec.test.js +146 -0
- package/dist/tests/goalSpec.test.js.map +1 -0
- package/dist/tests/goalState.test.js +202 -0
- package/dist/tests/goalState.test.js.map +1 -0
- package/dist/tests/handoff.test.js +127 -0
- package/dist/tests/handoff.test.js.map +1 -0
- package/dist/tests/init.test.js +264 -0
- package/dist/tests/init.test.js.map +1 -0
- package/dist/tests/intake.test.js +288 -0
- package/dist/tests/intake.test.js.map +1 -0
- package/dist/tests/loop.test.js +453 -0
- package/dist/tests/loop.test.js.map +1 -0
- package/dist/tests/modelRouter.test.js +131 -0
- package/dist/tests/modelRouter.test.js.map +1 -0
- package/dist/tests/phases.test.js +244 -0
- package/dist/tests/phases.test.js.map +1 -0
- package/dist/tests/planTree.test.js +190 -0
- package/dist/tests/planTree.test.js.map +1 -0
- package/dist/tests/remote.test.js +380 -0
- package/dist/tests/remote.test.js.map +1 -0
- package/dist/tests/replan.test.js +113 -0
- package/dist/tests/replan.test.js.map +1 -0
- package/dist/tests/review.test.js +77 -0
- package/dist/tests/review.test.js.map +1 -0
- package/dist/tests/rework.test.js +109 -0
- package/dist/tests/rework.test.js.map +1 -0
- package/dist/tests/routing-live.test.js +121 -0
- package/dist/tests/routing-live.test.js.map +1 -0
- package/dist/tests/runState.test.js +73 -0
- package/dist/tests/runState.test.js.map +1 -0
- package/dist/tests/settings.test.js +311 -0
- package/dist/tests/settings.test.js.map +1 -0
- package/dist/tests/skills.test.js +387 -0
- package/dist/tests/skills.test.js.map +1 -0
- package/dist/tests/supervisor.test.js +437 -0
- package/dist/tests/supervisor.test.js.map +1 -0
- package/dist/tests/surface.test.js +203 -0
- package/dist/tests/surface.test.js.map +1 -0
- package/dist/tests/taskList.test.js +376 -0
- package/dist/tests/taskList.test.js.map +1 -0
- package/dist/tests/theme.test.js +149 -0
- package/dist/tests/theme.test.js.map +1 -0
- package/dist/tests/unstuck.test.js +268 -0
- package/dist/tests/unstuck.test.js.map +1 -0
- package/dist/tests/widget.test.js +424 -0
- package/dist/tests/widget.test.js.map +1 -0
- package/dist/tests/worker.test.js +306 -0
- package/dist/tests/worker.test.js.map +1 -0
- package/dist/tests/workflow.test.js +187 -0
- package/dist/tests/workflow.test.js.map +1 -0
- package/extensions/infinity-harness/index.ts +39 -12
- package/package.json +5 -2
- package/src/core/modelRouter.ts +23 -1
- package/src/daemon/index.ts +3 -3
- package/src/daemon/worker.ts +28 -5
- package/src/ui/config.ts +25 -1
- package/src/ui/wizard.ts +10 -5
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — the plan widget.
|
|
3
|
+
*
|
|
4
|
+
* This is what the human watches while the loop runs unattended for hours. It
|
|
5
|
+
* has to answer three questions in one glance, without scrolling:
|
|
6
|
+
*
|
|
7
|
+
* Where are we? the phase rail
|
|
8
|
+
* How far along? the progress meter
|
|
9
|
+
* What is happening? the task window, centred on the active task
|
|
10
|
+
*
|
|
11
|
+
* Everything is derived from state on disk, so the widget is always truthful
|
|
12
|
+
* even if the agent's own narration has drifted.
|
|
13
|
+
*/
|
|
14
|
+
import { computeProgress, flattenTasks, nextActionableTask } from "../core/featureList.js";
|
|
15
|
+
import { getPhaseOrder } from "../core/phases.js";
|
|
16
|
+
import { focusRowIndex } from "./planTree.js";
|
|
17
|
+
import { defaultDisplay, normalizeDisplay } from "./display.js";
|
|
18
|
+
import { createStyler, detectGlyphs, padEnd, truncate, width, wrap, } from "./theme.js";
|
|
19
|
+
export const DEFAULT_WIDTH = 76;
|
|
20
|
+
/** Plan rows shown at once. Enough for context, short enough to stay glanceable. */
|
|
21
|
+
export const TASK_WINDOW = 9;
|
|
22
|
+
/** Completed rows kept above the active task so progress stays visible. */
|
|
23
|
+
export const COMPLETED_CONTEXT = 3;
|
|
24
|
+
/** How many rows one scroll step moves. */
|
|
25
|
+
export const SCROLL_STEP = 3;
|
|
26
|
+
/** Rows shown when the human expands the widget. */
|
|
27
|
+
export const EXPANDED_WINDOW = 28;
|
|
28
|
+
export function defaultView() {
|
|
29
|
+
return { scroll: null, expanded: false };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Move the view. Returns a new view; the caller stores it.
|
|
33
|
+
*
|
|
34
|
+
* Scrolling to the very top is how the human gets back to "following the run":
|
|
35
|
+
* a widget that has to be reset from a menu is a widget that stays stuck.
|
|
36
|
+
*/
|
|
37
|
+
export function scrollView(view, delta, rowCount, windowRows) {
|
|
38
|
+
const max = Math.max(0, rowCount - windowRows);
|
|
39
|
+
const current = view.scroll ?? 0;
|
|
40
|
+
const next = Math.max(0, Math.min(max, current + delta));
|
|
41
|
+
return { ...view, scroll: next };
|
|
42
|
+
}
|
|
43
|
+
const STATUS_ROLE = {
|
|
44
|
+
pending: "pending",
|
|
45
|
+
in_progress: "active",
|
|
46
|
+
complete: "success",
|
|
47
|
+
blocked: "blocked",
|
|
48
|
+
rework: "rework",
|
|
49
|
+
};
|
|
50
|
+
export function statusGlyph(status, g = detectGlyphs()) {
|
|
51
|
+
switch (status) {
|
|
52
|
+
case "complete":
|
|
53
|
+
case "done":
|
|
54
|
+
case "closed":
|
|
55
|
+
case "passed":
|
|
56
|
+
return g.complete;
|
|
57
|
+
case "in_progress":
|
|
58
|
+
case "in-progress":
|
|
59
|
+
case "active":
|
|
60
|
+
return g.inProgress;
|
|
61
|
+
case "blocked":
|
|
62
|
+
return g.blocked;
|
|
63
|
+
case "rework":
|
|
64
|
+
case "waiting":
|
|
65
|
+
return g.rework;
|
|
66
|
+
default:
|
|
67
|
+
return g.pending;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function statusRole(status) {
|
|
71
|
+
return STATUS_ROLE[status] ?? "pending";
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Pick the slice of tasks to display: centred on the active task, biased so a
|
|
75
|
+
* few completed rows stay visible above it. Clamps at both ends so the window
|
|
76
|
+
* is always exactly `limit` rows when there are enough tasks.
|
|
77
|
+
*/
|
|
78
|
+
export function taskWindowBounds(tasks, limit = TASK_WINDOW, context = COMPLETED_CONTEXT) {
|
|
79
|
+
const total = tasks.length;
|
|
80
|
+
if (total <= limit)
|
|
81
|
+
return { start: 0, end: total };
|
|
82
|
+
let active = tasks.findIndex((t) => t.status === "in_progress");
|
|
83
|
+
if (active === -1)
|
|
84
|
+
active = tasks.findIndex((t) => t.status === "rework");
|
|
85
|
+
if (active === -1)
|
|
86
|
+
active = tasks.findIndex((t) => t.status === "blocked");
|
|
87
|
+
if (active === -1)
|
|
88
|
+
active = tasks.findIndex((t) => t.status === "pending");
|
|
89
|
+
if (active === -1)
|
|
90
|
+
active = total - 1; // everything done: show the tail
|
|
91
|
+
if (active < limit - context)
|
|
92
|
+
return { start: 0, end: limit };
|
|
93
|
+
if (active >= total - (limit - context))
|
|
94
|
+
return { start: total - limit, end: total };
|
|
95
|
+
const start = Math.max(0, Math.min(active - context, total - limit));
|
|
96
|
+
return { start, end: start + limit };
|
|
97
|
+
}
|
|
98
|
+
/** `▰▰▰▰▱▱▱▱ 62%` — a fixed-width meter that never reflows the line. */
|
|
99
|
+
export function progressBar(percent, cells, g, s) {
|
|
100
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
101
|
+
const filled = Math.round((clamped / 100) * cells);
|
|
102
|
+
const bar = s.fg("success", g.barFull.repeat(filled)) + s.fg("rule", g.barEmpty.repeat(Math.max(0, cells - filled)));
|
|
103
|
+
return bar + " " + s.bold(String(clamped).padStart(3) + "%");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* `define ─ plan ─ ◉ BUILD ─ verify ─ review ─ ship`
|
|
107
|
+
*
|
|
108
|
+
* Collapses to just the current phase plus its neighbours when the terminal is
|
|
109
|
+
* too narrow to hold the whole pipeline.
|
|
110
|
+
*/
|
|
111
|
+
export function phaseRail(current, enabled, max, g, s, gateHistory) {
|
|
112
|
+
const order = getPhaseOrder(enabled);
|
|
113
|
+
const idx = current ? order.indexOf(current) : -1;
|
|
114
|
+
// Last verdict per phase — failed phases show as blocked (red).
|
|
115
|
+
const lastByPhase = new Map();
|
|
116
|
+
if (gateHistory) {
|
|
117
|
+
for (let i = gateHistory.length - 1; i >= 0; i--) {
|
|
118
|
+
const e = gateHistory[i];
|
|
119
|
+
if (!e?.phase || !e?.result)
|
|
120
|
+
continue;
|
|
121
|
+
if (!lastByPhase.has(e.phase))
|
|
122
|
+
lastByPhase.set(e.phase, e.result);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const render = (phases, elideLeft, elideRight) => {
|
|
126
|
+
const parts = phases.map((p) => {
|
|
127
|
+
const i = order.indexOf(p);
|
|
128
|
+
const last = lastByPhase.get(p);
|
|
129
|
+
if (p === current)
|
|
130
|
+
return s.bold(s.fg("accent", g.phaseCurrent + " " + p.toUpperCase()));
|
|
131
|
+
if (last === "fail")
|
|
132
|
+
return s.bold(s.fg("blocked", g.blocked + " " + p));
|
|
133
|
+
if (idx >= 0 && i < idx)
|
|
134
|
+
return s.fg("success", g.phaseDone + " " + p);
|
|
135
|
+
return s.fg("muted", g.phaseTodo + " " + p);
|
|
136
|
+
});
|
|
137
|
+
const joiner = s.fg("rule", " " + g.rail + " ");
|
|
138
|
+
const body = parts.join(joiner);
|
|
139
|
+
const lead = elideLeft ? s.fg("rule", g.more + " ") : "";
|
|
140
|
+
const tail = elideRight ? s.fg("rule", " " + g.more) : "";
|
|
141
|
+
return lead + body + tail;
|
|
142
|
+
};
|
|
143
|
+
const full = render(order, false, false);
|
|
144
|
+
if (width(full) <= max || idx === -1)
|
|
145
|
+
return truncate(full, max);
|
|
146
|
+
// Narrow: keep the current phase and one neighbour each side.
|
|
147
|
+
const lo = Math.max(0, idx - 1);
|
|
148
|
+
const hi = Math.min(order.length, idx + 2);
|
|
149
|
+
const windowed = render(order.slice(lo, hi), lo > 0, hi < order.length);
|
|
150
|
+
return truncate(windowed, max);
|
|
151
|
+
}
|
|
152
|
+
function depLabel(deps, indexByKey, g, s) {
|
|
153
|
+
if (!deps || deps.length === 0)
|
|
154
|
+
return "";
|
|
155
|
+
const nums = deps.map((d) => {
|
|
156
|
+
const i = indexByKey.get(d);
|
|
157
|
+
return i === undefined ? d : "#" + i;
|
|
158
|
+
});
|
|
159
|
+
return s.fg("muted", g.arrow + " " + nums.join(", "));
|
|
160
|
+
}
|
|
161
|
+
/** `2/5` — how much of a grouping row's branch is finished. */
|
|
162
|
+
function countTag(row, s) {
|
|
163
|
+
if (row.total === 0)
|
|
164
|
+
return s.fg("rule", "empty");
|
|
165
|
+
const role = row.done === row.total ? "success" : row.done > 0 ? "active" : "muted";
|
|
166
|
+
return s.fg(role, row.done + "/" + row.total);
|
|
167
|
+
}
|
|
168
|
+
const LEVEL_ROLE = {
|
|
169
|
+
goal: "brand",
|
|
170
|
+
sprint: "accent",
|
|
171
|
+
feature: "muted",
|
|
172
|
+
task: "text",
|
|
173
|
+
subtask: "muted",
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* One plan row, drawn.
|
|
177
|
+
*
|
|
178
|
+
* Indentation is by level, so the five levels of the plan are five columns on
|
|
179
|
+
* screen and the shape of the plan is readable without counting ids. The
|
|
180
|
+
* grouping rows carry a `done/total` tag on the right, which is what turns a
|
|
181
|
+
* long plan into something you can judge at a glance instead of reading.
|
|
182
|
+
*/
|
|
183
|
+
function renderRow(row, inner, indexByKey, g, s, display) {
|
|
184
|
+
const indent = " ".repeat(row.depth);
|
|
185
|
+
const tagFor = (r) => (display.counts ? countTag(r, s) : "");
|
|
186
|
+
if (row.level === "goal" || row.level === "sprint") {
|
|
187
|
+
const icon = s.fg(LEVEL_ROLE[row.level], row.level === "goal" ? g.goal : g.sprint);
|
|
188
|
+
const tag = tagFor(row);
|
|
189
|
+
const prefix = indent + icon + " ";
|
|
190
|
+
const head = s.bold(s.fg(LEVEL_ROLE[row.level], row.title)) +
|
|
191
|
+
(row.label && row.label !== row.title ? s.fg("rule", " " + row.label) : "");
|
|
192
|
+
const body = truncate(prefix + head, tag ? Math.max(8, inner - width(tag) - 1) : inner);
|
|
193
|
+
if (!tag)
|
|
194
|
+
return [body];
|
|
195
|
+
const gap = Math.max(1, inner - width(body) - width(tag));
|
|
196
|
+
return [body + " ".repeat(gap) + tag];
|
|
197
|
+
}
|
|
198
|
+
if (row.level === "feature") {
|
|
199
|
+
const tag = tagFor(row);
|
|
200
|
+
const prefix = indent + s.fg("muted", g.branch + " ");
|
|
201
|
+
const head = s.fg("muted", row.label) + s.fg("rule", " · ") + s.fg("text", row.title);
|
|
202
|
+
const body = truncate(prefix + head, tag ? Math.max(8, inner - width(tag) - 1) : inner);
|
|
203
|
+
if (!tag)
|
|
204
|
+
return [body];
|
|
205
|
+
const gap = Math.max(1, inner - width(body) - width(tag));
|
|
206
|
+
return [body + " ".repeat(gap) + tag];
|
|
207
|
+
}
|
|
208
|
+
if (row.level === "subtask") {
|
|
209
|
+
const icon = row.status === "complete"
|
|
210
|
+
? s.fg("success", g.subDone)
|
|
211
|
+
: row.status === "in_progress"
|
|
212
|
+
? s.fg("active", g.subActive)
|
|
213
|
+
: s.fg("rule", g.subPending);
|
|
214
|
+
const prefix = indent + icon + " ";
|
|
215
|
+
return [truncate(prefix + s.fg("muted", row.title), inner)];
|
|
216
|
+
}
|
|
217
|
+
// -- a task ---------------------------------------------------------------
|
|
218
|
+
const role = statusRole(row.status ?? "pending");
|
|
219
|
+
const icon = s.fg(role, statusGlyph(row.status ?? "pending", g));
|
|
220
|
+
const num = s.fg("rule", row.label);
|
|
221
|
+
const dep = display.dependencies ? depLabel(row.dependsOn, indexByKey, g, s) : "";
|
|
222
|
+
const prefix = indent + icon + " " + num + " ";
|
|
223
|
+
const prefixW = width(prefix);
|
|
224
|
+
const depW = dep ? width(dep) + 1 : 0;
|
|
225
|
+
const titleMax = Math.max(8, inner - prefixW - depW);
|
|
226
|
+
const titleLines = wrap(row.title || row.id, titleMax);
|
|
227
|
+
const first = titleLines[0] ?? row.id;
|
|
228
|
+
const head = row.active
|
|
229
|
+
? s.bold(s.fg("text", first))
|
|
230
|
+
: s.fg(role === "success" ? "muted" : "text", first);
|
|
231
|
+
let line = prefix + head;
|
|
232
|
+
if (dep) {
|
|
233
|
+
const spacer = Math.max(1, inner - width(line) - width(dep));
|
|
234
|
+
line += " ".repeat(spacer) + dep;
|
|
235
|
+
}
|
|
236
|
+
const out = [line];
|
|
237
|
+
for (const extra of titleLines.slice(1))
|
|
238
|
+
out.push(" ".repeat(prefixW) + s.fg("muted", extra));
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Which slice of rows to show.
|
|
243
|
+
*
|
|
244
|
+
* With no explicit scroll the window follows the run: centred on the active
|
|
245
|
+
* task, biased so a few finished rows stay above it, because "what just got
|
|
246
|
+
* done" is most of what makes progress legible.
|
|
247
|
+
*/
|
|
248
|
+
export function rowWindow(rows, limit, scroll, context = COMPLETED_CONTEXT) {
|
|
249
|
+
const total = rows.length;
|
|
250
|
+
if (total <= limit)
|
|
251
|
+
return { start: 0, end: total };
|
|
252
|
+
if (scroll !== null) {
|
|
253
|
+
const start = Math.max(0, Math.min(scroll, total - limit));
|
|
254
|
+
return { start, end: start + limit };
|
|
255
|
+
}
|
|
256
|
+
const focus = focusRowIndex(rows);
|
|
257
|
+
if (focus < limit - context)
|
|
258
|
+
return { start: 0, end: limit };
|
|
259
|
+
if (focus >= total - (limit - context))
|
|
260
|
+
return { start: total - limit, end: total };
|
|
261
|
+
const start = Math.max(0, Math.min(focus - context, total - limit));
|
|
262
|
+
return { start, end: start + limit };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Render the widget as terminal lines.
|
|
266
|
+
*
|
|
267
|
+
* Returns plain strings so the host can hand them straight to
|
|
268
|
+
* `ctx.ui.setWidget`. Colour is embedded as ANSI when the styler is colouring.
|
|
269
|
+
*/
|
|
270
|
+
/** Rows of background log drawn by default. Enough to see the shape of a minute. */
|
|
271
|
+
export const ACTIVITY_ROWS = 5;
|
|
272
|
+
const ACTIVITY_ROLE = {
|
|
273
|
+
info: "muted",
|
|
274
|
+
work: "text",
|
|
275
|
+
warn: "rework",
|
|
276
|
+
error: "blocked",
|
|
277
|
+
good: "success",
|
|
278
|
+
};
|
|
279
|
+
const WORKER_ROLE = {
|
|
280
|
+
starting: "active",
|
|
281
|
+
working: "active",
|
|
282
|
+
idle: "muted",
|
|
283
|
+
closed: "muted",
|
|
284
|
+
failed: "blocked",
|
|
285
|
+
};
|
|
286
|
+
/** `HH:MM` in local time — a full ISO stamp is six columns of nothing. */
|
|
287
|
+
function clock(iso) {
|
|
288
|
+
const d = new Date(iso);
|
|
289
|
+
if (Number.isNaN(d.getTime()))
|
|
290
|
+
return "--:--";
|
|
291
|
+
return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The background sessions, one line each.
|
|
295
|
+
*
|
|
296
|
+
* The model is on the line on purpose. The whole point of routing work out of
|
|
297
|
+
* the human's session is that a different model does it, and a claim you
|
|
298
|
+
* cannot see is a claim nobody believes — least of all after the version
|
|
299
|
+
* where it silently did not happen.
|
|
300
|
+
*/
|
|
301
|
+
export function renderWorkers(workers, inner, g, s) {
|
|
302
|
+
const out = [];
|
|
303
|
+
for (const w of workers) {
|
|
304
|
+
const role = WORKER_ROLE[w.state] ?? "muted";
|
|
305
|
+
const dot = s.fg(role, w.state === "working" || w.state === "starting" ? g.inProgress : g.pending);
|
|
306
|
+
const head = dot +
|
|
307
|
+
" " +
|
|
308
|
+
s.bold(s.fg(role, w.name)) +
|
|
309
|
+
s.fg("rule", " · ") +
|
|
310
|
+
s.fg("text", w.unit) +
|
|
311
|
+
(w.difficulty ? s.fg("muted", " (" + w.difficulty + ")") : "");
|
|
312
|
+
const model = s.fg("accent", w.model || "pi default");
|
|
313
|
+
const ctx = typeof w.contextRatio === "number" ? s.fg(w.contextRatio > 0.75 ? "rework" : "muted", " " + Math.round(w.contextRatio * 100) + "%") : "";
|
|
314
|
+
const right = model + ctx;
|
|
315
|
+
const gap = Math.max(1, inner - width(head) - width(right));
|
|
316
|
+
out.push(truncate(head + " ".repeat(gap) + right, inner));
|
|
317
|
+
if (w.doing)
|
|
318
|
+
out.push(truncate(" " + s.fg("muted", g.arrow + " " + w.doing), inner));
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
/** The background log: the only place a human sees what the workers did. */
|
|
323
|
+
export function renderActivity(lines, rows, inner, s) {
|
|
324
|
+
const tail = lines.slice(-Math.max(0, rows));
|
|
325
|
+
return tail.map((l) => truncate(s.fg("rule", clock(l.at)) +
|
|
326
|
+
" " +
|
|
327
|
+
(l.worker ? s.fg("accent", l.worker) + " " : "") +
|
|
328
|
+
s.fg(ACTIVITY_ROLE[l.level] ?? "muted", l.text), inner));
|
|
329
|
+
}
|
|
330
|
+
export function renderWidget(state, options = {}) {
|
|
331
|
+
const total = options.width ?? DEFAULT_WIDTH;
|
|
332
|
+
const s = options.styler ?? createStyler();
|
|
333
|
+
const g = options.glyphs ?? detectGlyphs();
|
|
334
|
+
const boxed = options.boxed ?? false;
|
|
335
|
+
const pad = boxed ? 2 : 0;
|
|
336
|
+
const inner = Math.max(24, total - pad * 2);
|
|
337
|
+
const view = state.view ?? defaultView();
|
|
338
|
+
const display = normalizeDisplay(state.display ?? defaultDisplay());
|
|
339
|
+
const limit = options.taskWindow ?? (view.expanded ? Math.max(EXPANDED_WINDOW, display.taskWindow * 2) : display.taskWindow);
|
|
340
|
+
const out = [];
|
|
341
|
+
const push = (line = "") => {
|
|
342
|
+
out.push(line);
|
|
343
|
+
};
|
|
344
|
+
const progress = computeProgress(state.list);
|
|
345
|
+
const tasks = flattenTasks(state.list);
|
|
346
|
+
const viewSnap = state.viewState ?? null;
|
|
347
|
+
// -- viewState banner -----------------------------------------------------
|
|
348
|
+
// Reads daemon.json first. Widget never renders dead run as live.
|
|
349
|
+
if (viewSnap && viewSnap.state !== "running" && viewSnap.state !== "awaiting-approval") {
|
|
350
|
+
const hb = viewSnap.daemon?.heartbeatAt ?? null;
|
|
351
|
+
const ts = hb ? s.fg("muted", ` (as of ${String(hb).slice(11, 16)})`) : "";
|
|
352
|
+
const msg = viewSnap.state === "stale"
|
|
353
|
+
? s.fg("rework", `\u26A0 Daemon unresponsive since ${hb ? String(hb).slice(11, 16) : "?"}`) + ts
|
|
354
|
+
: viewSnap.state === "not-running"
|
|
355
|
+
? s.fg("blocked", "Daemon not running \u2014 /infinity:run to start.") + ts
|
|
356
|
+
: viewSnap.state === "never-armed"
|
|
357
|
+
? s.fg("muted", viewSnap.reason ?? "Never armed \u2014 run the wizard.")
|
|
358
|
+
: viewSnap.state === "stopped"
|
|
359
|
+
? s.fg("blocked", `STOPPED: ${viewSnap.reason ?? viewSnap.run?.stopReason ?? "stopped"}`) + ts
|
|
360
|
+
: s.fg("muted", viewSnap.reason ?? "");
|
|
361
|
+
push(truncate(msg, inner));
|
|
362
|
+
}
|
|
363
|
+
if (viewSnap && viewSnap.state === "awaiting-approval") {
|
|
364
|
+
const phase2 = (viewSnap.supervisor?.phase ?? state.phase ?? "?");
|
|
365
|
+
push(truncate(s.fg("active", `\u23F8 Awaiting approval: ${String(phase2).toUpperCase()} \u2192 /infinity:approve`) + s.fg("muted", " (run is healthy, not stuck)"), inner));
|
|
366
|
+
}
|
|
367
|
+
// -- pilot badge + per-tier spend + rework queue ---------------------------
|
|
368
|
+
if (state.pilot) {
|
|
369
|
+
const pilot = state.pilot;
|
|
370
|
+
const tag = s.bold(s.fg(pilot === "full" ? "rework" : pilot === "autopilot" ? "success" : "accent", pilot.toUpperCase()));
|
|
371
|
+
const pModes = state.phaseModes ?? null;
|
|
372
|
+
const modesStr = pModes ? Object.entries(pModes).map(([ph, m]) => s.fg(m === "autopilot" ? "success" : "active", `${ph}:${m}`)).join(s.fg("rule", " \u00b7 ")) : "";
|
|
373
|
+
push(truncate(s.fg("muted", "Pilot ") + tag + (modesStr ? s.fg("rule", " ") + modesStr : ""), inner));
|
|
374
|
+
}
|
|
375
|
+
{
|
|
376
|
+
const ts2 = state.tierSpend ?? null;
|
|
377
|
+
if (ts2) {
|
|
378
|
+
const parts = [];
|
|
379
|
+
for (const tier of ["A", "B", "C", "D", "X"]) {
|
|
380
|
+
const t = ts2[tier];
|
|
381
|
+
if (!t)
|
|
382
|
+
continue;
|
|
383
|
+
const label = `${tier}:${t.input + t.output}`;
|
|
384
|
+
parts.push(tier === "X" && (t.cost > 0 || t.input + t.output > 0) ? s.bold(s.fg("blocked", label)) : s.fg("muted", label));
|
|
385
|
+
}
|
|
386
|
+
if (parts.length)
|
|
387
|
+
push(truncate(s.fg("muted", "Spend ") + parts.join(s.fg("rule", " \u00b7 ")), inner));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (typeof state.reworkDepth === "number" && state.reworkDepth > 0) {
|
|
391
|
+
push(truncate(s.fg("rework", `\u21BB Rework queue: ${state.reworkDepth} item(s) holding gate open`), inner));
|
|
392
|
+
}
|
|
393
|
+
if (state.replanDiff)
|
|
394
|
+
push(truncate(s.fg("muted", "Replan: ") + s.fg("text", String(state.replanDiff).slice(0, 80)), inner));
|
|
395
|
+
// -- header + dashboard --------------------------------------------
|
|
396
|
+
// Every band has its own colour so the top scans as sections, not one grey block.
|
|
397
|
+
// Muted separators make it legible even with NO_COLOR.
|
|
398
|
+
const sep = () => { push(s.fg("rule", g.rail.repeat(Math.max(1, inner)))); };
|
|
399
|
+
const brand = s.bold(s.fg("brand", "\u221e INFINITY"));
|
|
400
|
+
const phaseTag = state.paused
|
|
401
|
+
? s.fg("blocked", "PAUSED")
|
|
402
|
+
: state.phase
|
|
403
|
+
? s.fg("accent", state.phase.toUpperCase())
|
|
404
|
+
: s.fg("muted", "NOT STARTED");
|
|
405
|
+
const revTag = state.revision === undefined ? "" : s.fg("muted", " rev " + state.revision);
|
|
406
|
+
const headLeft = brand;
|
|
407
|
+
const headRight = phaseTag + revTag;
|
|
408
|
+
const gapW = inner - width(headLeft) - width(headRight);
|
|
409
|
+
push(headLeft + (gapW > 1 ? s.fg("rule", " " + g.rail.repeat(gapW - 2) + " ") : " ") + headRight);
|
|
410
|
+
// Dashboard URL with a real port — :PORT is never correct for an ephemeral port.
|
|
411
|
+
{
|
|
412
|
+
const url = state.dashboardUrl ?? null;
|
|
413
|
+
if (url) {
|
|
414
|
+
const label = s.fg("accent", url);
|
|
415
|
+
const link = "\u001b]8;;" + url + "\u0007" + label + "\u001b]8;;\u0007";
|
|
416
|
+
push(truncate(s.fg("accent", "Dashboard \u2192 ") + link, inner));
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
push(truncate(s.fg("muted", "Dashboard \u2192 ") + s.fg("muted", "/infinity:dashboard"), inner));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (state.handoffModelNote) {
|
|
423
|
+
push(truncate(s.fg("muted", state.handoffModelNote), inner));
|
|
424
|
+
}
|
|
425
|
+
// Goal count sits right under the header; the old widget hid goals entirely.
|
|
426
|
+
{
|
|
427
|
+
const goals = state.list.goals ?? [];
|
|
428
|
+
if (goals.length === 0 && state.intake) {
|
|
429
|
+
push(truncate(s.fg("rule", "Goal \u00b7 ") + s.fg("text", state.intake.slice(0, 90)), inner));
|
|
430
|
+
}
|
|
431
|
+
else if (goals.length > 0) {
|
|
432
|
+
const n = goals.length;
|
|
433
|
+
const countLine = s.fg("brand", "\u25C8 Goals: " + String(n) + " " + (n === 1 ? "goal" : "goals"));
|
|
434
|
+
push(truncate(countLine, inner));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
sep();
|
|
438
|
+
// -- goal + phase rail ------------------------------------------------
|
|
439
|
+
// One goal section per your spec, each with its own colour:
|
|
440
|
+
// [brand] Goals: N
|
|
441
|
+
// [text] Active goal headline
|
|
442
|
+
// [accent/muted/success/blocked] phase rail with active/passed/failed
|
|
443
|
+
// [muted] Active-phase breakdown: features/tasks
|
|
444
|
+
// [rule] dotted separator
|
|
445
|
+
// [accent/text] progress bar (whole project)
|
|
446
|
+
// [rule] dotted separator
|
|
447
|
+
{
|
|
448
|
+
const goals = state.list.goals ?? [];
|
|
449
|
+
// Single-goal headline (the old headline logic) — now sits directly under
|
|
450
|
+
// the Goals count, inside the same band, before the rail. Multi-goal plans
|
|
451
|
+
// show the active goal instead (not buried in the lane tree).
|
|
452
|
+
let headline = null;
|
|
453
|
+
if (display.levels.goal) {
|
|
454
|
+
if (goals.length === 1)
|
|
455
|
+
headline = goals[0]?.title ?? null;
|
|
456
|
+
else if (goals.length === 0)
|
|
457
|
+
headline = state.intake ?? null;
|
|
458
|
+
else {
|
|
459
|
+
// Multi-goal: pick the goal owning the next actionable task, else the first.
|
|
460
|
+
const next = nextActionableTask(state.list);
|
|
461
|
+
if (next) {
|
|
462
|
+
const feat = state.list.features.find((f) => f.id === next.featureId) ?? null;
|
|
463
|
+
const spr = feat?.sprintId ? (state.list.sprints ?? []).find((s) => s.id === feat.sprintId) ?? null : null;
|
|
464
|
+
const gid = feat?.goalId ?? spr?.goalId ?? null;
|
|
465
|
+
const g2 = gid ? goals.find((gg) => gg.id === gid) ?? goals[0] ?? null : goals[0] ?? null;
|
|
466
|
+
headline = g2?.title ?? null;
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
headline = goals[0]?.title ?? null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (headline) {
|
|
474
|
+
const wrapped = wrap(headline, inner - 2);
|
|
475
|
+
wrapped.forEach((line, i) => {
|
|
476
|
+
push((i === 0 ? s.fg("muted", g.goal + " ") : " ") + s.fg("text", line));
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
if (display.rail) {
|
|
480
|
+
push(phaseRail(state.phase, state.enabledPhases, inner, g, s, state.gateHistory ?? null));
|
|
481
|
+
}
|
|
482
|
+
// Active-phase breakdown: features + tasks in the current phase (or whole project when no phase yet).
|
|
483
|
+
// Respects display.counts; when counts off we skip the line entirely.
|
|
484
|
+
if (display.counts) {
|
|
485
|
+
const phaseName = state.phase;
|
|
486
|
+
const allFeats = state.list.features ?? [];
|
|
487
|
+
const inPhase = (phaseName
|
|
488
|
+
? allFeats.filter((f) => f.phase === phaseName)
|
|
489
|
+
: allFeats);
|
|
490
|
+
const phaseTasks = phaseName
|
|
491
|
+
? flattenTasks(state.list).filter((tt) => tt.effectivePhase === phaseName)
|
|
492
|
+
: flattenTasks(state.list);
|
|
493
|
+
const done = phaseTasks.filter((tt) => tt.status === "complete").length;
|
|
494
|
+
const total = phaseTasks.length;
|
|
495
|
+
const featCount = inPhase.length;
|
|
496
|
+
const label = phaseName ? phaseName.toUpperCase() : "PROJECT";
|
|
497
|
+
const featPart = s.fg("accent", String(featCount) + " " + (featCount === 1 ? "feature" : "features"));
|
|
498
|
+
const taskPart = s.fg("text", String(done) + "/" + String(total) + " tasks");
|
|
499
|
+
push(truncate(s.fg("muted", label + " \u00b7 ") + featPart + s.fg("rule", " \u00b7 ") + taskPart, inner));
|
|
500
|
+
}
|
|
501
|
+
sep();
|
|
502
|
+
if (display.progress) {
|
|
503
|
+
const full = s.fg("brand", String(progress.tasksDone) + "/" + String(progress.tasksTotal) + " tasks") +
|
|
504
|
+
s.fg("rule", " \u00b7 ") +
|
|
505
|
+
s.fg("muted", String(progress.featuresDone) + "/" + String(progress.featuresTotal) + " features") +
|
|
506
|
+
s.fg("rule", " \u00b7 ") +
|
|
507
|
+
s.fg("accent", String((state.list.goals ?? []).length) + " " + ((state.list.goals ?? []).length === 1 ? "goal" : "goals"));
|
|
508
|
+
const compact = s.fg("brand", String(progress.tasksDone) + "/" + String(progress.tasksTotal));
|
|
509
|
+
const METER_MIN = 8 + 6;
|
|
510
|
+
let stats = full;
|
|
511
|
+
if (inner - width(full) < METER_MIN)
|
|
512
|
+
stats = compact;
|
|
513
|
+
if (inner - width(stats) < METER_MIN)
|
|
514
|
+
stats = "";
|
|
515
|
+
const statsW = width(stats);
|
|
516
|
+
const barCells = Math.max(8, Math.min(24, inner - statsW - 8));
|
|
517
|
+
const bar = progressBar(progress.percent, barCells, g, s);
|
|
518
|
+
const gap2 = inner - width(bar) - statsW;
|
|
519
|
+
push(truncate(bar + (gap2 > 0 ? " ".repeat(gap2) : " ") + stats, inner));
|
|
520
|
+
sep();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// -- task lanes -------------------------------------------------------
|
|
524
|
+
// Aggregate task-window size respected; lane count capped; elision markers show hidden work.
|
|
525
|
+
const taskWindow = view.expanded ? Math.max(28, display.taskWindow * 2) : display.taskWindow;
|
|
526
|
+
if (!display.levels.task) {
|
|
527
|
+
const f = state.list.features[0];
|
|
528
|
+
if (f) {
|
|
529
|
+
const sname = f.sprintId ? (state.list.sprints ?? []).find((s) => s.id === f.sprintId)?.name : null;
|
|
530
|
+
const gname = f.goalId ? (state.list.goals ?? []).find((g) => g.id === f.goalId)?.title : null;
|
|
531
|
+
const shape = [];
|
|
532
|
+
if (display.levels.goal && gname)
|
|
533
|
+
shape.push(s.fg("muted", "goal " + gname.slice(0, 28)));
|
|
534
|
+
if (display.levels.sprint && sname)
|
|
535
|
+
shape.push(s.fg("muted", "sprint " + sname.slice(0, 22)));
|
|
536
|
+
if (display.levels.feature)
|
|
537
|
+
shape.push(s.fg("success", f.name.slice(0, 28)));
|
|
538
|
+
if (shape.length) {
|
|
539
|
+
push();
|
|
540
|
+
push(truncate(shape.join(s.fg("rule", " \u00b7 ")), inner));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (display.levels.task) {
|
|
545
|
+
const allTasks = flattenTasks(state.list);
|
|
546
|
+
let lanes = [];
|
|
547
|
+
if (view.scroll !== null) {
|
|
548
|
+
const start = Math.max(0, Math.min(view.scroll, Math.max(0, allTasks.length - taskWindow)));
|
|
549
|
+
lanes = allTasks.slice(start, start + taskWindow).map((t) => t);
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
const activeTasks = allTasks.filter((t) => t.status === "in_progress" || t.status === "rework");
|
|
553
|
+
const focus = nextActionableTask(state.list) ?? activeTasks[0] ?? null;
|
|
554
|
+
if (focus)
|
|
555
|
+
lanes.push(focus);
|
|
556
|
+
for (const t of activeTasks)
|
|
557
|
+
if (focus && t.compositeKey !== focus.compositeKey && lanes.length < taskWindow)
|
|
558
|
+
lanes.push(t);
|
|
559
|
+
if (!lanes.length) {
|
|
560
|
+
const pending = nextActionableTask(state.list);
|
|
561
|
+
if (pending)
|
|
562
|
+
lanes.push(pending);
|
|
563
|
+
}
|
|
564
|
+
if (lanes.length < taskWindow) {
|
|
565
|
+
const pendingQ = allTasks.filter((t) => t.status === "pending" && !lanes.some((l) => l.compositeKey === t.compositeKey));
|
|
566
|
+
for (const t of pendingQ) {
|
|
567
|
+
if (lanes.length >= taskWindow)
|
|
568
|
+
break;
|
|
569
|
+
lanes.push(t);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const formatChain = (t) => {
|
|
574
|
+
const curFeature = state.list.features.find((f) => f.id === t.featureId) ?? null;
|
|
575
|
+
const curSprint = curFeature?.sprintId ? (state.list.sprints ?? []).find((s) => s.id === curFeature.sprintId) ?? null : null;
|
|
576
|
+
const parts = [];
|
|
577
|
+
if (state.phase)
|
|
578
|
+
parts.push(s.bold(s.fg("accent", state.phase.toUpperCase())));
|
|
579
|
+
if (curSprint && display.levels.sprint)
|
|
580
|
+
parts.push(s.fg("muted", "sprint " + (curSprint.name ?? curSprint.id).slice(0, 18)));
|
|
581
|
+
if (curFeature && display.levels.feature)
|
|
582
|
+
parts.push(s.fg("success", curFeature.name.slice(0, 24)));
|
|
583
|
+
const descEarly = t.description ? t.description.slice(0, 44) : "";
|
|
584
|
+
parts.push(s.fg("text", t.compositeKey + (descEarly ? " " + descEarly.slice(0, 36) : "")));
|
|
585
|
+
return parts.join(s.fg("rule", " \u00b7 "));
|
|
586
|
+
};
|
|
587
|
+
if (lanes.length) {
|
|
588
|
+
const totalPendable = allTasks.length;
|
|
589
|
+
const above = allTasks.findIndex((t) => t.compositeKey === lanes[0].compositeKey);
|
|
590
|
+
const lastIdx = allTasks.findIndex((t) => t.compositeKey === lanes[lanes.length - 1].compositeKey);
|
|
591
|
+
const below = Math.max(0, totalPendable - lastIdx - 1);
|
|
592
|
+
const shownAbove = above > 0 ? above : 0;
|
|
593
|
+
const shownBelow = below > 0 ? below : 0;
|
|
594
|
+
if (shownAbove > 0)
|
|
595
|
+
push(s.fg("rule", " " + g.more + " " + shownAbove + " above"));
|
|
596
|
+
for (const task of lanes.slice(0, taskWindow)) {
|
|
597
|
+
push(truncate(formatChain(task), inner));
|
|
598
|
+
if (display.levels.subtask !== "none") {
|
|
599
|
+
const cur = (task.subtasks ?? []).find((ss) => ss.status !== "complete") ?? null;
|
|
600
|
+
if (cur)
|
|
601
|
+
push(truncate(" " + s.fg("active", "> " + cur.title.slice(0, 56)), inner));
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (shownBelow > 0)
|
|
605
|
+
push(s.fg("rule", " " + g.more + " " + shownBelow + " below"));
|
|
606
|
+
}
|
|
607
|
+
else if (state.list.features.length === 0) {
|
|
608
|
+
push(s.fg("muted", " no plan yet"));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// -- background sessions --------------------------------------------------
|
|
612
|
+
//
|
|
613
|
+
// Placed above the rail because, on a run driven by workers, this is the
|
|
614
|
+
// part of the widget that changes second by second. A human glancing at the
|
|
615
|
+
// terminal is checking that something is alive.
|
|
616
|
+
const workers = state.workers ?? [];
|
|
617
|
+
if (workers.length) {
|
|
618
|
+
push();
|
|
619
|
+
push(s.fg("rule", "background") + s.fg("rule", " " + g.rail.repeat(Math.max(1, inner - 12))));
|
|
620
|
+
for (const line of renderWorkers(workers, inner, g, s))
|
|
621
|
+
push(line);
|
|
622
|
+
}
|
|
623
|
+
else if (state.engine === "background" && state.list.features.length > 0) {
|
|
624
|
+
push();
|
|
625
|
+
push(truncate(s.fg("muted", "background · no worker running — /infinity:run starts one"), inner));
|
|
626
|
+
}
|
|
627
|
+
const activityRows = state.activityRows ?? (state.activity?.length ? ACTIVITY_ROWS : 0);
|
|
628
|
+
if (activityRows > 0 && state.activity?.length) {
|
|
629
|
+
push();
|
|
630
|
+
for (const line of renderActivity(state.activity, view.expanded ? activityRows * 3 : activityRows, inner, s))
|
|
631
|
+
push(line);
|
|
632
|
+
}
|
|
633
|
+
// phase rail + whole-project progress already rendered in the header band above.
|
|
634
|
+
// -- alerts ---------------------------------------------------------------
|
|
635
|
+
const alerts = [];
|
|
636
|
+
if (progress.blocked > 0)
|
|
637
|
+
alerts.push(s.fg("blocked", g.blocked + " " + progress.blocked + " blocked"));
|
|
638
|
+
if (progress.rework > 0)
|
|
639
|
+
alerts.push(s.fg("rework", g.rework + " " + progress.rework + " rework"));
|
|
640
|
+
if (state.retries && state.retries.task > 0) {
|
|
641
|
+
const role = state.retries.task >= state.retries.max ? "blocked" : "active";
|
|
642
|
+
alerts.push(s.fg(role, "retry " + state.retries.task + "/" + state.retries.max));
|
|
643
|
+
}
|
|
644
|
+
if (state.goalPass && state.goalPass.max > 1) {
|
|
645
|
+
const role = state.goalPass.current >= state.goalPass.max ? "blocked" : "active";
|
|
646
|
+
alerts.push(s.fg(role, "pass " + state.goalPass.current + "/" + state.goalPass.max));
|
|
647
|
+
}
|
|
648
|
+
if (state.escalation?.strategy) {
|
|
649
|
+
alerts.push(s.fg("rework", g.rework + " " + state.escalation.strategy));
|
|
650
|
+
}
|
|
651
|
+
if (state.awaitingApproval) {
|
|
652
|
+
alerts.unshift(s.bold(s.fg("active", g.rework + " " + state.awaitingApproval.toUpperCase() + " needs your OK")));
|
|
653
|
+
}
|
|
654
|
+
if (typeof state.sessions === "number" && state.sessions > 1) {
|
|
655
|
+
// Proof the handoff is working. Without it a run that quietly stopped
|
|
656
|
+
// starting fresh sessions looks exactly like one that never did.
|
|
657
|
+
alerts.push(s.fg("muted", "session " + state.sessions));
|
|
658
|
+
}
|
|
659
|
+
if (state.gate && !state.gate.overall) {
|
|
660
|
+
alerts.push(s.fg("blocked", "gate: " + state.gate.failures.slice(0, 3).join(", ")));
|
|
661
|
+
}
|
|
662
|
+
if (display.alerts && alerts.length)
|
|
663
|
+
push(truncate(alerts.join(s.fg("rule", " · ")), inner));
|
|
664
|
+
// keyboard hint — always visible so scroll is discoverable without reading the README
|
|
665
|
+
{
|
|
666
|
+
const scrollable = flattenTasks(state.list).length > taskWindow;
|
|
667
|
+
const baseHint = hintKeys(g);
|
|
668
|
+
const followPart = view.scroll !== null ? s.fg("accent", " · following: /infinity:scroll follow") : "";
|
|
669
|
+
const hasWorker = (state.workers?.length ?? 0) > 0 || state.engine === "background";
|
|
670
|
+
// Keep hint one line in narrow terminals; otherwise add a second affordance line.
|
|
671
|
+
const hintLine = baseHint + followPart;
|
|
672
|
+
push(truncate(scrollable || view.scroll !== null ? s.fg("muted", hintLine) : s.fg("rule", hintLine), inner));
|
|
673
|
+
if (hasWorker) {
|
|
674
|
+
push(truncate(s.fg("muted", ` ${g.more} tip: ${s.fg("text", "/infinity:workers")} and ${s.fg("text", "/infinity:dashboard")} show live detail`), inner));
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// footer only — scroll tree removed to keep TUI readable on narrow term
|
|
678
|
+
push(s.fg("rule", " " + g.rail.repeat(Math.max(1, inner - 2))));
|
|
679
|
+
return frame(out, total, boxed, s, g);
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* The keys that move the window, said once, where the window runs out.
|
|
683
|
+
*
|
|
684
|
+
* `alt+` rather than `ctrl+`: pi binds ctrl+j, ctrl+k and ctrl+o in the editor
|
|
685
|
+
* already, and a widget is not worth shadowing an editor key for.
|
|
686
|
+
*/
|
|
687
|
+
export function hintKeys(_g) {
|
|
688
|
+
return "↕ alt+j/k scroll · alt+o expand · /infinity:scroll";
|
|
689
|
+
}
|
|
690
|
+
function frame(lines, total, boxed, s, g) {
|
|
691
|
+
// Unboxed still has to honour the requested width: the caller sized the
|
|
692
|
+
// widget to a terminal, and a row that overruns wraps and breaks the layout.
|
|
693
|
+
if (!boxed)
|
|
694
|
+
return lines.map((l) => truncate(l, total));
|
|
695
|
+
const inner = total - 4;
|
|
696
|
+
const top = s.fg("rule", "╭" + "─".repeat(total - 2) + "╮");
|
|
697
|
+
const bottom = s.fg("rule", "╰" + "─".repeat(total - 2) + "╯");
|
|
698
|
+
const side = s.fg("rule", "│");
|
|
699
|
+
const body = lines.map((l) => side + " " + padEnd(truncate(l, inner), inner) + " " + side);
|
|
700
|
+
return [top, ...body, bottom];
|
|
701
|
+
}
|
|
702
|
+
/** Compact one-liner for the status bar: `BUILD 13/21 ◐`. */
|
|
703
|
+
export function renderStatusLine(state, g = detectGlyphs()) {
|
|
704
|
+
const p = computeProgress(state.list);
|
|
705
|
+
if (state.paused)
|
|
706
|
+
return "paused";
|
|
707
|
+
if (state.awaitingApproval)
|
|
708
|
+
return `${state.awaitingApproval} · needs your OK`;
|
|
709
|
+
if (p.tasksTotal === 0)
|
|
710
|
+
return state.phase ?? "idle";
|
|
711
|
+
const mark = p.blocked > 0
|
|
712
|
+
? g.blocked
|
|
713
|
+
: p.inProgress > 0
|
|
714
|
+
? g.inProgress
|
|
715
|
+
: p.tasksDone === p.tasksTotal
|
|
716
|
+
? g.complete
|
|
717
|
+
: g.pending;
|
|
718
|
+
const phase = state.phase ? state.phase + " " : "";
|
|
719
|
+
return phase + p.tasksDone + "/" + p.tasksTotal + " " + mark;
|
|
720
|
+
}
|
|
721
|
+
//# sourceMappingURL=widget.js.map
|