infinity-harness 2.3.1 → 2.4.0
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 +64 -0
- package/README.md +87 -22
- package/extensions/infinity-harness/index.ts +195 -39
- package/package.json +1 -1
- package/src/approval.ts +15 -5
- package/src/core/config.ts +44 -1
- package/src/core/init.ts +22 -1
- package/src/core/paths.ts +25 -0
- package/src/core/settings.ts +141 -12
- package/src/core/types.ts +49 -1
- package/src/intake.ts +124 -115
- package/src/remote.ts +7 -1
- package/src/ui/config.ts +6 -0
- package/src/ui/dashboard.ts +90 -27
- package/src/ui/display.ts +267 -0
- package/src/ui/planTree.ts +38 -16
- package/src/ui/widget.ts +50 -18
- package/src/ui/wizard.ts +257 -90
- package/src/workflow.ts +309 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — display templates: what the surfaces actually draw.
|
|
3
|
+
*
|
|
4
|
+
* The plan has five levels, and shipping all five to everyone was the wrong
|
|
5
|
+
* answer for the same reason shipping two was: two people watching the same
|
|
6
|
+
* run want different things on screen. One works in sprints and never opens a
|
|
7
|
+
* subtask. The next has no sprints at all and lives in the subtask list. A run
|
|
8
|
+
* on a forty-task plan wants the shape; a run on one feature wants the detail.
|
|
9
|
+
*
|
|
10
|
+
* So the levels are a setting, three templates cover the common shapes, and
|
|
11
|
+
* anything else is a template the person builds and names. The terminal widget
|
|
12
|
+
* and the web dashboard read the same one, so what you configure once is what
|
|
13
|
+
* you see in both.
|
|
14
|
+
*
|
|
15
|
+
* Templates live with the *person* — `~/.pi/agent/infinity-harness/` — because
|
|
16
|
+
* how you like to read a plan does not change when you change project.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { DisplayPolicy } from "../core/types.ts";
|
|
20
|
+
import { userDisplayPath } from "../core/paths.ts";
|
|
21
|
+
import { readJsonSafe, writeJsonAtomic, ensureDir } from "../core/fsx.ts";
|
|
22
|
+
import { dirname } from "node:path";
|
|
23
|
+
|
|
24
|
+
export type DisplayTemplate = {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
builtIn: boolean;
|
|
29
|
+
policy: DisplayPolicy;
|
|
30
|
+
savedAt?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Everything on, at a size a terminal can hold. */
|
|
34
|
+
export function defaultDisplay(): DisplayPolicy {
|
|
35
|
+
return {
|
|
36
|
+
preset: "focus",
|
|
37
|
+
levels: { goal: true, sprint: true, feature: true, task: true, subtask: "active" },
|
|
38
|
+
counts: true,
|
|
39
|
+
dependencies: true,
|
|
40
|
+
rail: true,
|
|
41
|
+
progress: true,
|
|
42
|
+
alerts: true,
|
|
43
|
+
criteria: true,
|
|
44
|
+
taskWindow: 9,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const focus: DisplayPolicy = defaultDisplay();
|
|
49
|
+
|
|
50
|
+
const everything: DisplayPolicy = {
|
|
51
|
+
...defaultDisplay(),
|
|
52
|
+
preset: "everything",
|
|
53
|
+
levels: { goal: true, sprint: true, feature: true, task: true, subtask: "all" },
|
|
54
|
+
taskWindow: 14,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The shape of the run, without the work.
|
|
59
|
+
*
|
|
60
|
+
* For a plan too big to read: goals and sprints and features with their
|
|
61
|
+
* counts, and no tasks at all. You come here to see which branch is stuck.
|
|
62
|
+
*/
|
|
63
|
+
const overview: DisplayPolicy = {
|
|
64
|
+
...defaultDisplay(),
|
|
65
|
+
preset: "overview",
|
|
66
|
+
levels: { goal: true, sprint: true, feature: true, task: false, subtask: "none" },
|
|
67
|
+
dependencies: false,
|
|
68
|
+
criteria: false,
|
|
69
|
+
taskWindow: 12,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The work, and nothing else.
|
|
74
|
+
*
|
|
75
|
+
* For someone who already knows the plan and wants the next thing to do.
|
|
76
|
+
* No grouping rows, no meters, no rail.
|
|
77
|
+
*/
|
|
78
|
+
const worklist: DisplayPolicy = {
|
|
79
|
+
...defaultDisplay(),
|
|
80
|
+
preset: "worklist",
|
|
81
|
+
levels: { goal: false, sprint: false, feature: false, task: true, subtask: "active" },
|
|
82
|
+
counts: false,
|
|
83
|
+
rail: false,
|
|
84
|
+
progress: true,
|
|
85
|
+
alerts: true,
|
|
86
|
+
criteria: false,
|
|
87
|
+
taskWindow: 12,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const BUILTIN_DISPLAYS: DisplayTemplate[] = [
|
|
91
|
+
{
|
|
92
|
+
id: "focus",
|
|
93
|
+
name: "focus",
|
|
94
|
+
description: "The default: every level, with subtasks on the task being worked.",
|
|
95
|
+
builtIn: true,
|
|
96
|
+
policy: focus,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "everything",
|
|
100
|
+
name: "everything",
|
|
101
|
+
description: "All five levels, every subtask on every task, and a taller window.",
|
|
102
|
+
builtIn: true,
|
|
103
|
+
policy: everything,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "overview",
|
|
107
|
+
name: "overview",
|
|
108
|
+
description: "Goals, sprints and features with their counts. No tasks — the shape, not the work.",
|
|
109
|
+
builtIn: true,
|
|
110
|
+
policy: overview,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "worklist",
|
|
114
|
+
name: "worklist",
|
|
115
|
+
description: "Tasks only. No grouping rows, no rail — for when you already know the plan.",
|
|
116
|
+
builtIn: true,
|
|
117
|
+
policy: worklist,
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
export function builtInDisplay(id: string): DisplayTemplate | null {
|
|
122
|
+
return BUILTIN_DISPLAYS.find((d) => d.id === id) ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── the saved store ─────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
type SavedStore = { version: string; displays: DisplayTemplate[] };
|
|
128
|
+
|
|
129
|
+
export function slugify(name: string): string {
|
|
130
|
+
return String(name ?? "")
|
|
131
|
+
.trim()
|
|
132
|
+
.toLowerCase()
|
|
133
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
134
|
+
.replace(/^-+|-+$/g, "")
|
|
135
|
+
.slice(0, 48);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function loadSavedDisplays(env?: NodeJS.ProcessEnv): DisplayTemplate[] {
|
|
139
|
+
const store = readJsonSafe<SavedStore | null>(userDisplayPath(env), null);
|
|
140
|
+
const list = Array.isArray(store?.displays) ? store.displays : [];
|
|
141
|
+
return list
|
|
142
|
+
.filter((d): d is DisplayTemplate => typeof d?.id === "string" && typeof d?.name === "string")
|
|
143
|
+
.map((d) => ({ ...d, builtIn: false, policy: normalizeDisplay(d.policy) }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function listDisplays(env?: NodeJS.ProcessEnv): DisplayTemplate[] {
|
|
147
|
+
return [...BUILTIN_DISPLAYS, ...loadSavedDisplays(env)];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function findDisplay(id: string, env?: NodeJS.ProcessEnv): DisplayTemplate | null {
|
|
151
|
+
return listDisplays(env).find((d) => d.id === id) ?? null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type SaveResult = { ok: boolean; error: string | null; template?: DisplayTemplate };
|
|
155
|
+
|
|
156
|
+
export function saveDisplay(
|
|
157
|
+
input: { name: string; description?: string; policy: DisplayPolicy },
|
|
158
|
+
env?: NodeJS.ProcessEnv,
|
|
159
|
+
): SaveResult {
|
|
160
|
+
const name = String(input.name ?? "").trim();
|
|
161
|
+
if (!name) return { ok: false, error: "A template needs a name." };
|
|
162
|
+
const id = slugify(name);
|
|
163
|
+
if (!id) return { ok: false, error: `"${name}" has no letters or digits in it.` };
|
|
164
|
+
if (builtInDisplay(id)) {
|
|
165
|
+
return { ok: false, error: `"${name}" is a built-in template. Pick another name.` };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const policy = { ...normalizeDisplay(input.policy), preset: id };
|
|
169
|
+
const template: DisplayTemplate = {
|
|
170
|
+
id,
|
|
171
|
+
name,
|
|
172
|
+
description: (input.description ?? "").trim() || describeDisplay(policy),
|
|
173
|
+
builtIn: false,
|
|
174
|
+
policy,
|
|
175
|
+
savedAt: new Date().toISOString(),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const existing = loadSavedDisplays(env).filter((d) => d.id !== id);
|
|
179
|
+
const path = userDisplayPath(env);
|
|
180
|
+
try {
|
|
181
|
+
ensureDir(dirname(path));
|
|
182
|
+
writeJsonAtomic(path, { version: "1", displays: [...existing, template] } satisfies SavedStore);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
185
|
+
}
|
|
186
|
+
return { ok: true, error: null, template };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function deleteDisplay(id: string, env?: NodeJS.ProcessEnv): SaveResult {
|
|
190
|
+
if (builtInDisplay(id)) return { ok: false, error: "Built-in templates cannot be deleted." };
|
|
191
|
+
const remaining = loadSavedDisplays(env).filter((d) => d.id !== id);
|
|
192
|
+
const path = userDisplayPath(env);
|
|
193
|
+
try {
|
|
194
|
+
ensureDir(dirname(path));
|
|
195
|
+
writeJsonAtomic(path, { version: "1", displays: remaining } satisfies SavedStore);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, error: null };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── normalising ─────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
const SUBTASK_MODES = new Set(["none", "active", "all"]);
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Repair a policy read from disk.
|
|
208
|
+
*
|
|
209
|
+
* Every field is read on every render, in a lifecycle hook, so a hand-edited
|
|
210
|
+
* config with `levels: "yes"` in it must produce a widget rather than an
|
|
211
|
+
* exception that takes the session down.
|
|
212
|
+
*/
|
|
213
|
+
export function normalizeDisplay(raw: unknown): DisplayPolicy {
|
|
214
|
+
const base = defaultDisplay();
|
|
215
|
+
if (typeof raw !== "object" || raw === null) return base;
|
|
216
|
+
const r = raw as Partial<DisplayPolicy> & { levels?: Partial<DisplayPolicy["levels"]> };
|
|
217
|
+
const bool = (v: unknown, fallback: boolean): boolean => (typeof v === "boolean" ? v : fallback);
|
|
218
|
+
|
|
219
|
+
const window = typeof r.taskWindow === "number" && Number.isFinite(r.taskWindow) ? r.taskWindow : base.taskWindow;
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
preset: typeof r.preset === "string" && r.preset.trim() ? r.preset.trim() : base.preset,
|
|
223
|
+
levels: {
|
|
224
|
+
goal: bool(r.levels?.goal, base.levels.goal),
|
|
225
|
+
sprint: bool(r.levels?.sprint, base.levels.sprint),
|
|
226
|
+
feature: bool(r.levels?.feature, base.levels.feature),
|
|
227
|
+
task: bool(r.levels?.task, base.levels.task),
|
|
228
|
+
subtask: SUBTASK_MODES.has(String(r.levels?.subtask))
|
|
229
|
+
? (r.levels?.subtask as DisplayPolicy["levels"]["subtask"])
|
|
230
|
+
: base.levels.subtask,
|
|
231
|
+
},
|
|
232
|
+
counts: bool(r.counts, base.counts),
|
|
233
|
+
dependencies: bool(r.dependencies, base.dependencies),
|
|
234
|
+
rail: bool(r.rail, base.rail),
|
|
235
|
+
progress: bool(r.progress, base.progress),
|
|
236
|
+
alerts: bool(r.alerts, base.alerts),
|
|
237
|
+
criteria: bool(r.criteria, base.criteria),
|
|
238
|
+
// A window of zero is a widget with no plan in it, which nobody wants and
|
|
239
|
+
// which a typo can produce.
|
|
240
|
+
taskWindow: Math.max(3, Math.min(60, Math.round(window))),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Which template a policy currently matches, if any. */
|
|
245
|
+
export function matchDisplay(policy: DisplayPolicy, env?: NodeJS.ProcessEnv): DisplayTemplate | null {
|
|
246
|
+
const same = (a: DisplayPolicy, b: DisplayPolicy): boolean =>
|
|
247
|
+
JSON.stringify({ ...normalizeDisplay(a), preset: "" }) ===
|
|
248
|
+
JSON.stringify({ ...normalizeDisplay(b), preset: "" });
|
|
249
|
+
return listDisplays(env).find((d) => same(d.policy, policy)) ?? null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function describeDisplay(policy: DisplayPolicy): string {
|
|
253
|
+
const p = normalizeDisplay(policy);
|
|
254
|
+
const on = (["goal", "sprint", "feature", "task"] as const).filter((k) => p.levels[k]);
|
|
255
|
+
const subtasks =
|
|
256
|
+
p.levels.subtask === "all" ? "all subtasks" : p.levels.subtask === "active" ? "active subtasks" : null;
|
|
257
|
+
const shown = [...on, ...(subtasks ? [subtasks] : [])];
|
|
258
|
+
return shown.length ? shown.join(" · ") : "nothing — every level is hidden";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** One line for a menu row: `focus · goal · sprint · feature · task · active subtasks`. */
|
|
262
|
+
export function summarizeDisplay(policy: DisplayPolicy, env?: NodeJS.ProcessEnv): string {
|
|
263
|
+
const p = normalizeDisplay(policy);
|
|
264
|
+
const named = matchDisplay(p, env);
|
|
265
|
+
const label = named ? named.name : `${p.preset} (edited)`;
|
|
266
|
+
return `${label} · ${describeDisplay(p)}`;
|
|
267
|
+
}
|
package/src/ui/planTree.ts
CHANGED
|
@@ -55,6 +55,14 @@ export type PlanTreeOptions = {
|
|
|
55
55
|
* goal and no sprints should not spend two rows saying so.
|
|
56
56
|
*/
|
|
57
57
|
collapseTrivial?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Which levels the reader has asked to see.
|
|
60
|
+
*
|
|
61
|
+
* A hidden level does not hide what is under it: turning off sprints on a
|
|
62
|
+
* plan organised into sprints must still show the features, one indent
|
|
63
|
+
* shallower, or the setting silently deletes half the plan from view.
|
|
64
|
+
*/
|
|
65
|
+
levels?: Partial<Record<PlanLevel, boolean>>;
|
|
58
66
|
};
|
|
59
67
|
|
|
60
68
|
function taskCounts(tasks: Task[] | undefined): { done: number; total: number } {
|
|
@@ -185,35 +193,48 @@ export function buildPlanRows(
|
|
|
185
193
|
): PlanRow[] {
|
|
186
194
|
const collapse = options.collapseTrivial !== false;
|
|
187
195
|
const groups = groupPlan(list);
|
|
196
|
+
const wants = (level: PlanLevel): boolean => options.levels?.[level] !== false;
|
|
188
197
|
|
|
189
198
|
// A single goal is the run's headline and the surface draws it separately.
|
|
190
|
-
const showGoals =
|
|
191
|
-
|
|
199
|
+
const showGoals =
|
|
200
|
+
wants("goal") &&
|
|
201
|
+
(collapse ? groups.filter((g) => g.goal !== null).length > 1 : groups.some((g) => g.goal));
|
|
202
|
+
const showSprints = wants("sprint") && groups.some((g) => g.sprints.some((sg) => sg.sprint !== null));
|
|
192
203
|
|
|
193
204
|
const rows: PlanRow[] = [];
|
|
194
205
|
let taskIndex = 0;
|
|
195
206
|
|
|
196
207
|
const emitFeature = (feature: Feature, depth: number): void => {
|
|
197
208
|
const counts = taskCounts(feature.tasks);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
+
let taskDepth = depth;
|
|
210
|
+
if (wants("feature")) {
|
|
211
|
+
rows.push({
|
|
212
|
+
level: "feature",
|
|
213
|
+
depth,
|
|
214
|
+
id: feature.id,
|
|
215
|
+
label: feature.id,
|
|
216
|
+
title: feature.name ?? feature.id,
|
|
217
|
+
status: null,
|
|
218
|
+
done: counts.done,
|
|
219
|
+
total: counts.total,
|
|
220
|
+
criteria: Array.isArray(feature.criteria) ? feature.criteria : undefined,
|
|
221
|
+
});
|
|
222
|
+
} else {
|
|
223
|
+
// Hiding the feature row must not hide its tasks — nor indent them as
|
|
224
|
+
// though the row were still there.
|
|
225
|
+
taskDepth = Math.max(0, depth - 1);
|
|
226
|
+
}
|
|
209
227
|
|
|
210
228
|
for (const task of feature.tasks ?? []) {
|
|
229
|
+
// Numbered even when hidden: `← #3` has to keep meaning the same task
|
|
230
|
+
// whatever the reader has chosen to look at.
|
|
211
231
|
taskIndex += 1;
|
|
232
|
+
if (!wants("task")) continue;
|
|
212
233
|
const id = task.key ?? `${feature.id}/${task.id}`;
|
|
213
234
|
const active = activeTaskId !== null && (id === activeTaskId || task.id === activeTaskId);
|
|
214
235
|
rows.push({
|
|
215
236
|
level: "task",
|
|
216
|
-
depth:
|
|
237
|
+
depth: taskDepth + 1,
|
|
217
238
|
id,
|
|
218
239
|
label: String(taskIndex),
|
|
219
240
|
title: task.description ?? id,
|
|
@@ -233,8 +254,8 @@ export function buildPlanRows(
|
|
|
233
254
|
// one, so a task that is plainly in progress counts too.
|
|
234
255
|
const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
|
|
235
256
|
const working = task.status === "in_progress" || task.status === "rework";
|
|
236
|
-
const show = options.expandSubtasks || active || working;
|
|
237
|
-
if (show && subs.length) rows.push(...subtaskRows(task, id,
|
|
257
|
+
const show = wants("subtask") && (options.expandSubtasks || active || working);
|
|
258
|
+
if (show && subs.length) rows.push(...subtaskRows(task, id, taskDepth + 2, subs));
|
|
238
259
|
}
|
|
239
260
|
};
|
|
240
261
|
|
|
@@ -255,6 +276,7 @@ export function buildPlanRows(
|
|
|
255
276
|
}
|
|
256
277
|
for (const sg of group.sprints) {
|
|
257
278
|
let featureDepth = depth;
|
|
279
|
+
// Every feature row hidden means the tasks sit where the features were.
|
|
258
280
|
if (showSprints && sg.sprint) {
|
|
259
281
|
rows.push({
|
|
260
282
|
level: "sprint",
|
package/src/ui/widget.ts
CHANGED
|
@@ -16,6 +16,8 @@ import type { FeatureList, Phase, TaskStatus } from "../core/types.ts";
|
|
|
16
16
|
import { computeProgress, flattenTasks, nextActionableTask } from "../core/featureList.ts";
|
|
17
17
|
import { getPhaseOrder } from "../core/phases.ts";
|
|
18
18
|
import { buildPlanRows, focusRowIndex, type PlanRow } from "./planTree.ts";
|
|
19
|
+
import { defaultDisplay, normalizeDisplay } from "./display.ts";
|
|
20
|
+
import type { DisplayPolicy } from "../core/types.ts";
|
|
19
21
|
import {
|
|
20
22
|
createStyler,
|
|
21
23
|
detectGlyphs,
|
|
@@ -73,6 +75,13 @@ export type WidgetState = {
|
|
|
73
75
|
view?: WidgetView | null;
|
|
74
76
|
/** Sessions this run has spent. Only meaningful once handoff is on. */
|
|
75
77
|
sessions?: number | null;
|
|
78
|
+
/**
|
|
79
|
+
* What this reader has asked to see.
|
|
80
|
+
*
|
|
81
|
+
* The same policy drives the dashboard, so a level turned off here is off
|
|
82
|
+
* there too — configuring how you read a plan once, rather than twice.
|
|
83
|
+
*/
|
|
84
|
+
display?: DisplayPolicy | null;
|
|
76
85
|
/**
|
|
77
86
|
* What the human asked for, before a plan exists to hold a goal.
|
|
78
87
|
*
|
|
@@ -268,26 +277,30 @@ function renderRow(
|
|
|
268
277
|
indexByKey: Map<string, number>,
|
|
269
278
|
g: GlyphSet,
|
|
270
279
|
s: Styler,
|
|
280
|
+
display: DisplayPolicy,
|
|
271
281
|
): string[] {
|
|
272
282
|
const indent = " ".repeat(row.depth);
|
|
283
|
+
const tagFor = (r: PlanRow): string => (display.counts ? countTag(r, s) : "");
|
|
273
284
|
|
|
274
285
|
if (row.level === "goal" || row.level === "sprint") {
|
|
275
286
|
const icon = s.fg(LEVEL_ROLE[row.level], row.level === "goal" ? g.goal : g.sprint);
|
|
276
|
-
const tag =
|
|
287
|
+
const tag = tagFor(row);
|
|
277
288
|
const prefix = indent + icon + " ";
|
|
278
289
|
const head =
|
|
279
290
|
s.bold(s.fg(LEVEL_ROLE[row.level], row.title)) +
|
|
280
291
|
(row.label && row.label !== row.title ? s.fg("rule", " " + row.label) : "");
|
|
281
|
-
const body = truncate(prefix + head, Math.max(8, inner - width(tag) - 1));
|
|
292
|
+
const body = truncate(prefix + head, tag ? Math.max(8, inner - width(tag) - 1) : inner);
|
|
293
|
+
if (!tag) return [body];
|
|
282
294
|
const gap = Math.max(1, inner - width(body) - width(tag));
|
|
283
295
|
return [body + " ".repeat(gap) + tag];
|
|
284
296
|
}
|
|
285
297
|
|
|
286
298
|
if (row.level === "feature") {
|
|
287
|
-
const tag =
|
|
299
|
+
const tag = tagFor(row);
|
|
288
300
|
const prefix = indent + s.fg("muted", g.branch + " ");
|
|
289
301
|
const head = s.fg("muted", row.label) + s.fg("rule", " · ") + s.fg("text", row.title);
|
|
290
|
-
const body = truncate(prefix + head, Math.max(8, inner - width(tag) - 1));
|
|
302
|
+
const body = truncate(prefix + head, tag ? Math.max(8, inner - width(tag) - 1) : inner);
|
|
303
|
+
if (!tag) return [body];
|
|
291
304
|
const gap = Math.max(1, inner - width(body) - width(tag));
|
|
292
305
|
return [body + " ".repeat(gap) + tag];
|
|
293
306
|
}
|
|
@@ -307,7 +320,7 @@ function renderRow(
|
|
|
307
320
|
const role = statusRole(row.status ?? "pending");
|
|
308
321
|
const icon = s.fg(role, statusGlyph(row.status ?? "pending", g));
|
|
309
322
|
const num = s.fg("rule", row.label);
|
|
310
|
-
const dep = depLabel(row.dependsOn, indexByKey, g, s);
|
|
323
|
+
const dep = display.dependencies ? depLabel(row.dependsOn, indexByKey, g, s) : "";
|
|
311
324
|
const prefix = indent + icon + " " + num + " ";
|
|
312
325
|
const prefixW = width(prefix);
|
|
313
326
|
const depW = dep ? width(dep) + 1 : 0;
|
|
@@ -369,7 +382,9 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
369
382
|
const pad = boxed ? 2 : 0;
|
|
370
383
|
const inner = Math.max(24, total - pad * 2);
|
|
371
384
|
const view = state.view ?? defaultView();
|
|
372
|
-
const
|
|
385
|
+
const display = normalizeDisplay(state.display ?? defaultDisplay());
|
|
386
|
+
const limit =
|
|
387
|
+
options.taskWindow ?? (view.expanded ? Math.max(EXPANDED_WINDOW, display.taskWindow * 2) : display.taskWindow);
|
|
373
388
|
|
|
374
389
|
const out: string[] = [];
|
|
375
390
|
const push = (line = ""): void => {
|
|
@@ -398,7 +413,13 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
398
413
|
// the tree — `buildPlanRows` collapses it there for exactly this reason.
|
|
399
414
|
// Several goals are structure, and structure belongs in the tree.
|
|
400
415
|
const goals = state.list.goals ?? [];
|
|
401
|
-
const headline =
|
|
416
|
+
const headline = !display.levels.goal
|
|
417
|
+
? null
|
|
418
|
+
: goals.length === 1
|
|
419
|
+
? (goals[0]?.title ?? null)
|
|
420
|
+
: goals.length === 0
|
|
421
|
+
? (state.intake ?? null)
|
|
422
|
+
: null;
|
|
402
423
|
if (headline) {
|
|
403
424
|
const wrapped = wrap(headline, inner - 2);
|
|
404
425
|
wrapped.forEach((line, i) => {
|
|
@@ -409,8 +430,10 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
409
430
|
}
|
|
410
431
|
|
|
411
432
|
// -- phase rail -----------------------------------------------------------
|
|
412
|
-
|
|
413
|
-
|
|
433
|
+
if (display.rail) {
|
|
434
|
+
push();
|
|
435
|
+
push(phaseRail(state.phase, state.enabledPhases, inner, g, s));
|
|
436
|
+
}
|
|
414
437
|
|
|
415
438
|
// -- progress -------------------------------------------------------------
|
|
416
439
|
const full =
|
|
@@ -427,12 +450,14 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
427
450
|
if (inner - width(full) < METER_MIN) stats = compact;
|
|
428
451
|
if (inner - width(stats) < METER_MIN) stats = "";
|
|
429
452
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
453
|
+
if (display.progress) {
|
|
454
|
+
const statsW = width(stats);
|
|
455
|
+
const barCells = Math.max(8, Math.min(24, inner - statsW - 8));
|
|
456
|
+
const bar = progressBar(progress.percent, barCells, g, s);
|
|
457
|
+
push();
|
|
458
|
+
const gap2 = inner - width(bar) - statsW;
|
|
459
|
+
push(truncate(bar + (gap2 > 0 ? " ".repeat(gap2) : " ") + stats, inner));
|
|
460
|
+
}
|
|
436
461
|
|
|
437
462
|
// -- alerts ---------------------------------------------------------------
|
|
438
463
|
const alerts: string[] = [];
|
|
@@ -462,7 +487,7 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
462
487
|
if (state.gate && !state.gate.overall) {
|
|
463
488
|
alerts.push(s.fg("blocked", "gate: " + state.gate.failures.slice(0, 3).join(", ")));
|
|
464
489
|
}
|
|
465
|
-
if (alerts.length) push(truncate(alerts.join(s.fg("rule", " · ")), inner));
|
|
490
|
+
if (display.alerts && alerts.length) push(truncate(alerts.join(s.fg("rule", " · ")), inner));
|
|
466
491
|
|
|
467
492
|
// -- the plan -------------------------------------------------------------
|
|
468
493
|
//
|
|
@@ -484,7 +509,14 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
484
509
|
|
|
485
510
|
const active = nextActionableTask(state.list);
|
|
486
511
|
const rows = buildPlanRows(state.list, active?.compositeKey ?? null, {
|
|
487
|
-
expandSubtasks: view.expanded,
|
|
512
|
+
expandSubtasks: view.expanded || display.levels.subtask === "all",
|
|
513
|
+
levels: {
|
|
514
|
+
goal: display.levels.goal,
|
|
515
|
+
sprint: display.levels.sprint,
|
|
516
|
+
feature: display.levels.feature,
|
|
517
|
+
task: display.levels.task,
|
|
518
|
+
subtask: display.levels.subtask !== "none",
|
|
519
|
+
},
|
|
488
520
|
});
|
|
489
521
|
|
|
490
522
|
const bounds = rowWindow(rows, limit, view.scroll);
|
|
@@ -497,7 +529,7 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
497
529
|
}
|
|
498
530
|
|
|
499
531
|
for (const row of rows.slice(bounds.start, bounds.end)) {
|
|
500
|
-
for (const line of renderRow(row, inner, indexByKey, g, s)) push(line);
|
|
532
|
+
for (const line of renderRow(row, inner, indexByKey, g, s, display)) push(line);
|
|
501
533
|
}
|
|
502
534
|
|
|
503
535
|
if (hiddenAfter > 0) {
|