backpass 0.1.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +406 -0
  3. package/bin/backpass.js +4 -0
  4. package/package.json +62 -0
  5. package/src/acpx.js +576 -0
  6. package/src/agents.js +389 -0
  7. package/src/analyze.js +289 -0
  8. package/src/apply/lavish.js +128 -0
  9. package/src/apply/terminal.js +119 -0
  10. package/src/apply/writer.js +101 -0
  11. package/src/bootstrap.js +74 -0
  12. package/src/cli.js +261 -0
  13. package/src/commands/analyze.js +88 -0
  14. package/src/commands/apply.js +103 -0
  15. package/src/commands/bootstrap.js +172 -0
  16. package/src/commands/init.js +59 -0
  17. package/src/commands/propose.js +136 -0
  18. package/src/commands/run.js +95 -0
  19. package/src/commands/scan.js +90 -0
  20. package/src/commands/status.js +143 -0
  21. package/src/commands/usage.js +25 -0
  22. package/src/config.js +249 -0
  23. package/src/diff.js +305 -0
  24. package/src/discovery/adapters/claude.js +77 -0
  25. package/src/discovery/adapters/codex.js +162 -0
  26. package/src/discovery/adapters/cursor-cli.js +109 -0
  27. package/src/discovery/adapters/cursor-ide.js +130 -0
  28. package/src/discovery/adapters/grok.js +107 -0
  29. package/src/discovery/adapters/opencode.js +151 -0
  30. package/src/discovery/adapters/pi.js +87 -0
  31. package/src/discovery/adapters/shared.js +195 -0
  32. package/src/discovery/adapters/sqlite.js +50 -0
  33. package/src/discovery/association.js +100 -0
  34. package/src/discovery/index.js +226 -0
  35. package/src/discovery/self.js +62 -0
  36. package/src/distill.js +182 -0
  37. package/src/fold.js +214 -0
  38. package/src/gap-ledger.js +174 -0
  39. package/src/logger.js +74 -0
  40. package/src/memory.js +244 -0
  41. package/src/progress.js +29 -0
  42. package/src/prompts/analysis.md +48 -0
  43. package/src/prompts/annotate.md +48 -0
  44. package/src/prompts/synthesis.md +98 -0
  45. package/src/prompts.js +36 -0
  46. package/src/proposal.js +430 -0
  47. package/src/redact.js +36 -0
  48. package/src/repo.js +118 -0
  49. package/src/sample.js +99 -0
  50. package/src/skills.js +207 -0
  51. package/src/state.js +202 -0
  52. package/src/subprocess.js +47 -0
  53. package/src/synthesize.js +287 -0
  54. package/src/tokens.js +48 -0
  55. package/src/tui/index.js +336 -0
  56. package/src/tui/render.js +487 -0
  57. package/src/tui/term.js +130 -0
  58. package/src/tui/theme.js +111 -0
  59. package/src/workspace.js +162 -0
  60. package/templates/apply.html +928 -0
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Pure rendering for the live progress view: (state, options) -> lines.
3
+ *
4
+ * Everything here is deterministic - no timers, no terminal, no I/O - which is
5
+ * what makes the layout testable as plain text (depth-0 theme). The visual
6
+ * contract is the captain-approved mock: prompt echo is the shell's, then a
7
+ * bordered run header with the budget gauge, a four-stage rail, one detail
8
+ * panel for the stage currently spending time, and a hint footer.
9
+ *
10
+ * Layout rules from the approved behavior contract:
11
+ * - never wider than the terminal (lines are truncated, never wrapped)
12
+ * - below NARROW_COLUMNS the right-hand column (timers, notes) is dropped
13
+ * - every number shown is measured by backpass, never model-reported
14
+ */
15
+
16
+ import { NARROW_COLUMNS } from "./term.js";
17
+
18
+ export const SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
19
+ export const SPINNER_INTERVAL_MS = 80;
20
+
21
+ /** The frame glyph for a wall-clock instant - even cadence however paints are driven. */
22
+ export function spinnerFrame(now) {
23
+ return SPINNER_FRAMES[Math.floor(now / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length];
24
+ }
25
+
26
+ const MAX_WIDTH = 110;
27
+
28
+ const HARNESS_HUES = {
29
+ claude: "peach",
30
+ codex: "green",
31
+ pi: "purple",
32
+ opencode: "mint",
33
+ grok: "magenta",
34
+ cursor: "blue",
35
+ "cursor-ide": "blue",
36
+ };
37
+
38
+ /**
39
+ * Display names for the four pipeline stages, in the tool's training-loop vocabulary.
40
+ * Internal stage keys and event names stay as they are; only what the user reads changes.
41
+ */
42
+ export const STAGE_LABELS = {
43
+ discover: "collect samples",
44
+ analyze: "calculate loss",
45
+ fold: "aggregate gradients",
46
+ synthesize: "gradient descent",
47
+ };
48
+ const STAGE_LABEL_WIDTH = Math.max(...Object.values(STAGE_LABELS).map((label) => label.length)) + 1;
49
+
50
+ const TIER_LABELS = { 1: "ran in this repo", 2: "git remote match", 3: "path match (best-effort)" };
51
+
52
+ // eslint-disable-next-line no-control-regex -- matching ANSI SGR escapes is the point
53
+ const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
54
+
55
+ export function stripAnsi(text) {
56
+ return String(text).replace(ANSI_PATTERN, "");
57
+ }
58
+
59
+ export function visibleWidth(text) {
60
+ return [...stripAnsi(text)].length;
61
+ }
62
+
63
+ /** Truncate a painted line to `width` visible cells, preserving escapes and reset. */
64
+ export function clipLine(line, width) {
65
+ if (visibleWidth(line) <= width) return line;
66
+ let out = "";
67
+ let used = 0;
68
+ let sawEscape = false;
69
+ // eslint-disable-next-line no-control-regex -- splitting on ANSI SGR escapes is the point
70
+ const parts = String(line).split(/(\x1b\[[0-9;]*m)/);
71
+ for (const part of parts) {
72
+ if (part.startsWith("\x1b[")) {
73
+ out += part;
74
+ sawEscape = true;
75
+ continue;
76
+ }
77
+ for (const ch of part) {
78
+ if (used >= width - 1) return sawEscape ? `${out}\x1b[0m…` : `${out}…`;
79
+ out += ch;
80
+ used += 1;
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** Pad a plain string to `width` characters; longer strings get an ellipsis. */
87
+ export function fitPlain(text, width, align = "left") {
88
+ const chars = [...String(text)];
89
+ if (chars.length > width) return width > 0 ? `${chars.slice(0, Math.max(width - 1, 0)).join("")}…` : "";
90
+ const pad = " ".repeat(width - chars.length);
91
+ return align === "right" ? pad + chars.join("") : chars.join("") + pad;
92
+ }
93
+
94
+ /** Pad an already painted string to `width` visible cells. */
95
+ export function padVis(text, width, align = "left") {
96
+ const missing = width - visibleWidth(text);
97
+ if (missing <= 0) return text;
98
+ const pad = " ".repeat(missing);
99
+ return align === "right" ? pad + text : text + pad;
100
+ }
101
+
102
+ /** Left content with right-aligned content; the right side is dropped when it cannot fit. */
103
+ export function lr(left, right, width) {
104
+ if (!right) return left;
105
+ const gap = width - visibleWidth(left) - visibleWidth(right);
106
+ if (gap < 2) return left;
107
+ return left + " ".repeat(gap) + right;
108
+ }
109
+
110
+ export function formatCount(n) {
111
+ return Number(n || 0).toLocaleString("en-US");
112
+ }
113
+
114
+ export function formatBytes(n) {
115
+ const bytes = Number(n) || 0;
116
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
117
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
118
+ return `${bytes} B`;
119
+ }
120
+
121
+ /** m:ss for anything from a second up; bare milliseconds below that (aggregating gradients is instant). */
122
+ export function formatElapsed(ms) {
123
+ const value = Math.max(0, Math.round(Number(ms) || 0));
124
+ if (value < 1000) return `${value}ms`;
125
+ const seconds = Math.floor(value / 1000);
126
+ const minutes = Math.floor(seconds / 60);
127
+ return `${minutes}:${String(seconds % 60).padStart(2, "0")}`;
128
+ }
129
+
130
+ /** ▰▱ progress cells; the gradient marks descent, `!!` marks over-budget (as budgetBar does). */
131
+ export function gaugeBar(theme, ratio, cells) {
132
+ const value = Number.isFinite(ratio) ? Math.max(ratio, 0) : 0;
133
+ const over = value > 1;
134
+ const filled = Math.min(cells, Math.round(value * cells));
135
+ const overflow = over ? Math.min(cells - Math.min(filled, cells - 2), 2) : 0;
136
+ const solid = over ? cells - overflow : filled;
137
+ const empty = Math.max(cells - solid - overflow, 0);
138
+ return (
139
+ theme.gradient("▰".repeat(solid)) +
140
+ (overflow ? theme.paint("!".repeat(overflow), "red", { bold: true }) : "") +
141
+ theme.paint("▱".repeat(empty), "faint")
142
+ );
143
+ }
144
+
145
+ function stageElapsed(stage, now) {
146
+ if (!stage?.startedAt) return null;
147
+ return formatElapsed((stage.endedAt || now) - stage.startedAt);
148
+ }
149
+
150
+ function tierLabel(tiers = {}) {
151
+ let best = null;
152
+ for (const tier of [1, 2, 3]) {
153
+ if ((tiers[tier] || 0) > (tiers[best] || 0)) best = tier;
154
+ }
155
+ return best ? TIER_LABELS[best] : "";
156
+ }
157
+
158
+ function headerLines(state, theme, width, spin) {
159
+ const inner = width - 4;
160
+ const border = (text) => theme.paint(text, "faint");
161
+ const lines = [border(`╭${"─".repeat(width - 2)}╮`)];
162
+
163
+ const brand = `${theme.gradient("∇", { bold: true })} ${theme.paint("backpass", "text", { bold: true })} ${theme.paint(`v${state.meta.version}`, "faint")}`;
164
+ const contextParts = [state.meta.repoName];
165
+ if (state.meta.worktrees > 1) contextParts.push(`${state.meta.worktrees} worktrees`);
166
+ contextParts.push(`since ${state.meta.since}`);
167
+ lines.push(wrapBox(lr(brand, theme.paint(contextParts.join(" · "), "dim"), inner), inner, border));
168
+
169
+ const memory = state.memory;
170
+ if (memory) {
171
+ const ratio = memory.tokens / memory.budget;
172
+ const cells = state.narrow ? 16 : 24;
173
+ const over = memory.tokens > memory.budget;
174
+ const left =
175
+ `${theme.paint(memory.path, "dim")} ${gaugeBar(theme, ratio, cells)} ` +
176
+ `${theme.paint(formatCount(memory.tokens), over ? "red" : "mint")} ${theme.paint("/", "faint")} ${theme.paint(`${formatCount(memory.budget)} tok`, "text")}`;
177
+ const right = over
178
+ ? `${theme.paint("OVER", "red", { bold: true })}${theme.paint(" · shrink plan", "faint")}`
179
+ : theme.paint(`${formatCount(memory.units)} instructions · budget ${Math.round(ratio * 100)}%`, "faint");
180
+ lines.push(wrapBox(lr(left, right, inner), inner, border));
181
+ } else {
182
+ lines.push(wrapBox(theme.paint(`reading memory file${spin ? "…" : ""}`, "faint"), inner, border));
183
+ }
184
+
185
+ lines.push(border(`╰${"─".repeat(width - 2)}╯`));
186
+ return lines;
187
+ }
188
+
189
+ function wrapBox(content, inner, border) {
190
+ return `${border("│")} ${padVis(clipLine(content, inner), inner)} ${border("│")}`;
191
+ }
192
+
193
+ function mark(theme, status, spin) {
194
+ if (status === "active") return theme.paint(spin, "mint");
195
+ if (status === "done") return theme.paint("✓", "mint");
196
+ if (status === "error") return theme.paint("!", "yellow");
197
+ return theme.paint("○", "faint");
198
+ }
199
+
200
+ function railLine(theme, state, key, summary, stage, width, spin) {
201
+ const status = stage?.status || "pending";
202
+ const name = fitPlain(STAGE_LABELS[key], STAGE_LABEL_WIDTH);
203
+ const label =
204
+ status === "pending" ? theme.paint(name, "faint") : theme.paint(name, "text", { bold: status === "active" });
205
+ const left = ` ${mark(theme, status, spin)} ${label}${summary || ""}`;
206
+ const elapsed = state.narrow ? null : stageElapsed(stage, state.now);
207
+ return lr(left, elapsed ? theme.paint(elapsed, "faint") : null, width);
208
+ }
209
+
210
+ function discoverSummary(theme, d) {
211
+ if (!d || d.status === "pending") return "";
212
+ if (d.status === "active") {
213
+ return theme.paint(
214
+ `scanning ${d.storesTotal} local stores · ${formatCount(d.totalMatched)} sessions from this repo so far`,
215
+ "dim",
216
+ );
217
+ }
218
+ const parts = [`${formatCount(d.totalMatched)} sessions from this repo`, `${d.storesOk}/${d.storesTotal} stores`];
219
+ if (d.files) parts.push(`${formatCount(d.files)} files, ${formatCount(d.newFiles)} new`);
220
+ return theme.paint(parts.join(" · "), "dim");
221
+ }
222
+
223
+ function analyzeSummary(theme, a, narrow) {
224
+ if (!a || a.status === "pending") return "";
225
+ if (a.status === "active") {
226
+ const bar = gaugeBar(theme, a.pending ? a.done / a.pending : 1, narrow ? 10 : 14);
227
+ return `${bar} ${theme.paint(`${a.done}/${a.pending}`, "text", { bold: true })} ${theme.paint(
228
+ `· ${a.cached} already analyzed · jobs ${a.jobs}`,
229
+ "dim",
230
+ )}`;
231
+ }
232
+ return theme.paint(`${a.ok} ok · ${a.skipped} skipped · ${a.failed} failed · ${a.cached} reused`, "dim");
233
+ }
234
+
235
+ function foldSummary(theme, f) {
236
+ if (!f || f.status !== "done") return "";
237
+ return (
238
+ theme.paint(`${f.instructions} instructions scored · ${f.clustersFound} gaps → `, "dim") +
239
+ theme.paint(`${f.clustersKept} clusters kept`, "text") +
240
+ theme.paint(` (seen in ≥${f.minGapEvidence} sessions)`, "faint")
241
+ );
242
+ }
243
+
244
+ function synthSummary(theme, s) {
245
+ if (!s || s.status === "pending") return "";
246
+ const model = [s.agent, s.model].filter(Boolean).join(" · ");
247
+ const effort = s.effort ? ` · effort ${s.effort}` : "";
248
+ if (s.status === "done") return theme.paint(`${s.edits} edit(s) · passed validation`, "dim");
249
+ if (s.phase === "annotate" && s.attempt > 1) {
250
+ return (
251
+ theme.paint("re-prompt ", "dim") +
252
+ theme.paint(`${s.attempt - 1}`, "yellow") +
253
+ theme.paint(` · ${model}${effort}`, "dim")
254
+ );
255
+ }
256
+ const phase = s.phase === "annotate" ? "annotating" : "editing";
257
+ return theme.paint(`${phase} · ${model}${effort}`, "dim");
258
+ }
259
+
260
+ function sectionRule(theme, name, description, width) {
261
+ const left = `${theme.paint("──", "faint")} ${theme.paint(name, "text", { bold: true })}${theme.paint(` · ${description} `, "faint")}`;
262
+ const remaining = width - visibleWidth(left);
263
+ return left + theme.paint("─".repeat(Math.max(remaining, 0)), "faint");
264
+ }
265
+
266
+ function harnessDot(theme, harness) {
267
+ return theme.paint("●", HARNESS_HUES[harness] || "blue");
268
+ }
269
+
270
+ function discoverDetail(state, theme, width, spin) {
271
+ const d = state.discover;
272
+ const lines = [sectionRule(theme, STAGE_LABELS.discover, "local stores only · nothing leaves this machine", width)];
273
+ const countWidth = 15;
274
+ const howWidth = 25;
275
+ const activityWidth = width - 2 - 2 - 11 - countWidth - (state.narrow ? 0 : howWidth) - 4;
276
+
277
+ for (const harness of d.order) {
278
+ const h = d.harnesses[harness];
279
+ const dot = harnessDot(theme, harness);
280
+ const name = theme.paint(fitPlain(harness, 10), "text");
281
+
282
+ let activity;
283
+ let count;
284
+ let how = theme.paint(fitPlain(tierLabel(h.tiers), howWidth, "right"), "faint");
285
+ if (h.status === "error") {
286
+ activity = theme.paint(fitPlain("store unreadable · harness skipped, run continues", activityWidth), "yellow");
287
+ count = theme.paint(fitPlain("–", countWidth, "right"), "faint");
288
+ how = theme.paint(fitPlain("fail-soft", howWidth, "right"), "faint");
289
+ } else if (h.status === "scanning") {
290
+ const showBar = (h.total || 0) >= 500;
291
+ const text = showBar
292
+ ? `${formatCount(h.scanned)}/${formatCount(h.total)} sessions`
293
+ : `${formatCount(h.scanned)} sessions`;
294
+ const bar = showBar ? `${gaugeBar(theme, h.total ? h.scanned / h.total : 0, state.narrow ? 8 : 10)} ` : "";
295
+ activity = bar + theme.paint(fitPlain(text, activityWidth - (showBar ? (state.narrow ? 9 : 11) : 0)), "dim");
296
+ count = padVis(
297
+ `${theme.paint(formatCount(h.matched), "text", { bold: true })} ${theme.paint("so far", "faint")}`,
298
+ countWidth,
299
+ "right",
300
+ );
301
+ } else {
302
+ const scanned = `${formatCount(h.scanned)} sessions`;
303
+ const fresh = h.newCount > 0 || h.scanned > 0 ? ` · ${formatCount(h.newCount)} new` : "";
304
+ const self = h.self > 0 ? ` · ${formatCount(h.self)} self` : "";
305
+ const query = harness === "opencode" ? "1 sqlite query" : `${scanned}${fresh}${self}`;
306
+ activity = theme.paint(fitPlain(query, activityWidth), "dim");
307
+ count = padVis(
308
+ `${theme.paint(formatCount(h.matched), "text", { bold: true })} ${theme.paint("this repo", "faint")}`,
309
+ countWidth,
310
+ "right",
311
+ );
312
+ }
313
+
314
+ const glyph =
315
+ h.status === "scanning" ? theme.paint(spin, "mint") : mark(theme, h.status === "error" ? "error" : "done", spin);
316
+ const row = ` ${glyph} ${dot} ${name}${activity}${count}${state.narrow ? "" : how}`;
317
+ lines.push(row);
318
+ }
319
+
320
+ lines.push("");
321
+ lines.push(theme.paint("new = not seen by a previous scan · re-scans only read new or changed files", "faint"));
322
+ return lines;
323
+ }
324
+
325
+ /** Outcome counters with parentheticals that shrink until the line fits. */
326
+ function countersLine(a, theme, width) {
327
+ const build = (skippedNote, failedNote, reusedNote) =>
328
+ ` ${theme.paint(String(a.ok), "mint")} ${theme.paint("ok", "dim")} ${theme.paint("·", "faint")} ` +
329
+ `${theme.paint(String(a.skipped), "text")} ${theme.paint("skipped", "dim")}${skippedNote ? ` ${theme.paint(skippedNote, "faint")}` : ""} ${theme.paint("·", "faint")} ` +
330
+ `${theme.paint(String(a.failed), "yellow")} ${theme.paint("failed", "dim")}${failedNote ? ` ${theme.paint(failedNote, "faint")}` : ""} ${theme.paint("·", "faint")} ` +
331
+ `${theme.paint(String(a.cached), "blue")} ${theme.paint("reused", "dim")}${reusedNote ? ` ${theme.paint(reusedNote, "faint")}` : ""}`;
332
+
333
+ const variants = [
334
+ build("(trivial session)", "(will retry)", "(analyzed in an earlier run)"),
335
+ build("(trivial)", "(will retry)", "(earlier run)"),
336
+ build(null, null, null),
337
+ ];
338
+ return variants.find((line) => visibleWidth(line) <= width) || variants[variants.length - 1];
339
+ }
340
+
341
+ function analyzeDetail(state, theme, width, spin) {
342
+ const a = state.analyze;
343
+ const model = [a.agent, a.model].filter(Boolean).join(" · ");
344
+ const lines = [sectionRule(theme, STAGE_LABELS.analyze, `one cheap call per transcript · ${model}`, width)];
345
+
346
+ const receiptWidth = 27;
347
+ const timeWidth = 6;
348
+ const titleWidth = Math.max(width - 2 - 2 - 2 - 11 - receiptWidth - (state.narrow ? 0 : timeWidth) - 7, 10);
349
+
350
+ a.lanes.forEach((lane, index) => {
351
+ if (!lane) return;
352
+ const receipt =
353
+ lane.phase === "model"
354
+ ? padVis(
355
+ `${theme.paint("distilled", "faint")} ${theme.paint(formatBytes(lane.rawBytes), "dim")} ${theme.paint("▸", "faint")} ${theme.paint(formatBytes(lane.distilledBytes), "dim")}`,
356
+ receiptWidth,
357
+ "right",
358
+ )
359
+ : theme.paint(fitPlain("distilling…", receiptWidth, "right"), "faint");
360
+ const elapsed = state.narrow
361
+ ? ""
362
+ : theme.paint(fitPlain(formatElapsed(state.now - lane.startedAt), timeWidth, "right"), "faint");
363
+ lines.push(
364
+ ` ${theme.paint(spin, "mint")} ${theme.paint(String(index + 1), "faint")} ${harnessDot(theme, lane.harness)} ` +
365
+ `${theme.paint(fitPlain(lane.harness, 9), "text")} ` +
366
+ `${theme.paint(fitPlain(lane.title, titleWidth), "dim")} ${receipt}${elapsed}`,
367
+ );
368
+ });
369
+
370
+ lines.push("");
371
+ lines.push(countersLine(a, theme, width));
372
+ lines.push(
373
+ ` ${theme.paint("evidence so far", "dim")} ${theme.paint(`✓ ${a.evidence.positive}`, "mint")} ${theme.paint("helped", "faint")}` +
374
+ ` ${theme.paint(`✗ ${a.evidence.negative}`, "red")} ${theme.paint("violated", "faint")}` +
375
+ ` ${theme.paint(`◆ ${a.evidence.gaps}`, "yellow")} ${theme.paint("gaps", "faint")}`,
376
+ );
377
+ return lines;
378
+ }
379
+
380
+ function synthesizeDetail(state, theme, width, spin) {
381
+ const s = state.synthesize;
382
+ const lines = [
383
+ sectionRule(theme, STAGE_LABELS.synthesize, `aggregated gradients → at most ${state.meta.maxEdits} edits`, width),
384
+ ];
385
+
386
+ if (s.phase === "annotate" && s.attempt > 1 && s.violations.length) {
387
+ lines.push(
388
+ ` ${theme.paint("!", "yellow")} ${theme.paint(`synthesis violated ${s.violations.length} gate(s)`, "text")} ${theme.paint("· re-prompting with the exact breaches", "dim")}`,
389
+ );
390
+ for (const violation of s.violations.slice(0, 4)) {
391
+ lines.push(` ${theme.paint("✗", "red")} ${theme.paint(clipPlain(violation, width - 8), "dim")}`);
392
+ }
393
+ }
394
+
395
+ const doing =
396
+ s.phase === "annotate"
397
+ ? `annotating ${s.changes ?? 0} measured change(s) with evidence…`
398
+ : `weighing ${s.gapClusters} gap clusters + ${s.instructions} instruction records against the budget…`;
399
+ lines.push(
400
+ lr(
401
+ ` ${theme.paint(spin, "mint")} ${doing}`,
402
+ state.narrow || !s.sessionName ? null : theme.paint(`session ${s.sessionName}`, "faint"),
403
+ width,
404
+ ),
405
+ );
406
+
407
+ if (s.suppressed > 0) {
408
+ lines.push("");
409
+ lines.push(
410
+ theme.paint(
411
+ `${s.suppressed} previously rejected edit(s) suppressed · re-proposed only on materially new evidence`,
412
+ "faint",
413
+ ),
414
+ );
415
+ }
416
+ return lines;
417
+ }
418
+
419
+ function clipPlain(text, width) {
420
+ return fitPlain(String(text), width).trimEnd();
421
+ }
422
+
423
+ /** The active detail panel: the stage currently spending time, else the latest one. */
424
+ function activePanel(state) {
425
+ if (state.synthesize.status === "active") return "synthesize";
426
+ if (state.analyze.status === "active") return "analyze";
427
+ if (state.discover.status === "active") return "discover";
428
+ if (state.synthesize.status === "done") return "synthesize";
429
+ if (state.analyze.status === "done") return "analyze";
430
+ if (state.discover.status === "done") return "discover";
431
+ return null;
432
+ }
433
+
434
+ /**
435
+ * Render one frame. `state` is the controller's reduced event state; options
436
+ * carry the terminal width, theme, wall-clock, and current spinner glyph.
437
+ */
438
+ export function renderFrame(state, { width, theme, now, spin }) {
439
+ const cols = Math.max(Math.min(width || 80, MAX_WIDTH), 40);
440
+ const frameState = { ...state, now, narrow: (width || 80) < NARROW_COLUMNS };
441
+
442
+ const lines = [];
443
+ lines.push(...headerLines(frameState, theme, cols, spin));
444
+
445
+ lines.push(
446
+ railLine(
447
+ theme,
448
+ frameState,
449
+ "discover",
450
+ discoverSummary(theme, frameState.discover),
451
+ frameState.discover,
452
+ cols,
453
+ spin,
454
+ ),
455
+ );
456
+ lines.push(
457
+ railLine(
458
+ theme,
459
+ frameState,
460
+ "analyze",
461
+ analyzeSummary(theme, frameState.analyze, frameState.narrow),
462
+ frameState.analyze,
463
+ cols,
464
+ spin,
465
+ ),
466
+ );
467
+ lines.push(railLine(theme, frameState, "fold", foldSummary(theme, frameState.fold), frameState.fold, cols, spin));
468
+ lines.push(
469
+ railLine(
470
+ theme,
471
+ frameState,
472
+ "synthesize",
473
+ synthSummary(theme, frameState.synthesize),
474
+ frameState.synthesize,
475
+ cols,
476
+ spin,
477
+ ),
478
+ );
479
+ lines.push("");
480
+
481
+ const panel = activePanel(frameState);
482
+ if (panel === "discover") lines.push(...discoverDetail(frameState, theme, cols, spin));
483
+ if (panel === "analyze") lines.push(...analyzeDetail(frameState, theme, cols, spin));
484
+ if (panel === "synthesize") lines.push(...synthesizeDetail(frameState, theme, cols, spin));
485
+
486
+ return lines.map((line) => clipLine(line, cols).trimEnd());
487
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Terminal capability detection for the live progress view.
3
+ *
4
+ * Three questions are answered here, all decided in the approved design review:
5
+ *
6
+ * 1. Is a live view appropriate at all? (TTY, not CI, no NO_COLOR, wide enough)
7
+ * 2. How much color can we emit? Truecolor gets the house theme; anything else
8
+ * falls back to the nearest ANSI-16 colors.
9
+ * 3. Is the background dark or light? Queried via OSC 11 (~50ms budget) with
10
+ * COLORFGBG as fallback; unknown assumes dark. A config key / --theme flag
11
+ * can force either.
12
+ */
13
+
14
+ /** Minimum columns below which the TUI does not start and plain lines are used. */
15
+ export const MIN_COLUMNS = 60;
16
+
17
+ /** Columns below which the right-hand column (timers, notes) is dropped. */
18
+ export const NARROW_COLUMNS = 84;
19
+
20
+ /**
21
+ * @param {object} [options]
22
+ * @param {Record<string, string | undefined>} [options.env]
23
+ * @param {{ isTTY?: boolean, columns?: number }} [options.stderr]
24
+ * @param {boolean} [options.quiet]
25
+ * @param {boolean} [options.json]
26
+ */
27
+ export function tuiEligible({ env = process.env, stderr = process.stderr, quiet = false, json = false } = {}) {
28
+ if (quiet || json) return false;
29
+ if (!stderr.isTTY) return false;
30
+ if (env.NO_COLOR !== undefined) return false;
31
+ if (env.CI) return false;
32
+ if ((env.TERM || "") === "dumb") return false;
33
+ if ((stderr.columns || 80) < MIN_COLUMNS) return false;
34
+ return true;
35
+ }
36
+
37
+ /**
38
+ * 24 for truecolor terminals, 4 for everything else (ANSI-16), 0 when color is off.
39
+ * @param {object} [options]
40
+ * @param {Record<string, string | undefined>} [options.env]
41
+ * @param {{ isTTY?: boolean }} [options.stderr]
42
+ */
43
+ export function colorDepth({ env = process.env, stderr = process.stderr } = {}) {
44
+ if (env.NO_COLOR !== undefined || !stderr.isTTY) return 0;
45
+ const colorterm = (env.COLORTERM || "").toLowerCase();
46
+ if (colorterm.includes("truecolor") || colorterm.includes("24bit")) return 24;
47
+ return 4;
48
+ }
49
+
50
+ /**
51
+ * Classify a COLORFGBG value like "15;0" (fg;bg). By rxvt convention the last
52
+ * field is the background color index: 0-6 and 8 are dark, 7 and 9-15 light.
53
+ */
54
+ export function backgroundFromColorFgBg(value) {
55
+ if (!value) return null;
56
+ const parts = String(value).split(";");
57
+ const bg = Number(parts[parts.length - 1]);
58
+ if (!Number.isInteger(bg) || bg < 0 || bg > 255) return null;
59
+ return bg === 7 || bg >= 9 ? "light" : "dark";
60
+ }
61
+
62
+ /**
63
+ * Classify an OSC 11 response like `\x1b]11;rgb:0b0b/0e0e/1414\x07`.
64
+ * Channels may be 1-4 hex digits per the XParseColor spec.
65
+ */
66
+ export function backgroundFromOscResponse(text) {
67
+ const match = /\]11;rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})/i.exec(String(text || ""));
68
+ if (!match) return null;
69
+ const channel = (hex) => parseInt(hex, 16) / (16 ** hex.length - 1);
70
+ const [r, g, b] = [match[1], match[2], match[3]].map(channel);
71
+ const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
72
+ return luma > 0.5 ? "light" : "dark";
73
+ }
74
+
75
+ /**
76
+ * Ask the terminal for its background color. Resolves "dark" | "light".
77
+ * Never rejects; any failure falls back to COLORFGBG, then dark.
78
+ */
79
+ export function detectBackground({
80
+ env = process.env,
81
+ stdin = process.stdin,
82
+ stderr = process.stderr,
83
+ timeoutMs = 50,
84
+ } = {}) {
85
+ const fallback = backgroundFromColorFgBg(env.COLORFGBG) || "dark";
86
+
87
+ if (!stdin.isTTY || typeof stdin.setRawMode !== "function" || !stderr.isTTY) {
88
+ return Promise.resolve(fallback);
89
+ }
90
+
91
+ return new Promise((resolve) => {
92
+ let settled = false;
93
+ let response = "";
94
+ const wasRaw = stdin.isRaw === true;
95
+
96
+ const finish = (value) => {
97
+ if (settled) return;
98
+ settled = true;
99
+ clearTimeout(timer);
100
+ stdin.off("data", onData);
101
+ try {
102
+ if (!wasRaw) stdin.setRawMode(false);
103
+ stdin.pause();
104
+ } catch {
105
+ // Terminal state restoration is best-effort.
106
+ }
107
+ resolve(value);
108
+ };
109
+
110
+ const onData = (chunk) => {
111
+ response += chunk.toString("utf8");
112
+ const parsed = backgroundFromOscResponse(response);
113
+ if (parsed) finish(parsed);
114
+ // A terminator without a parseable color means the terminal answered
115
+ // something we do not understand - stop waiting.
116
+ else if (response.includes("\x07") || response.includes("\x1b\\")) finish(fallback);
117
+ };
118
+
119
+ const timer = setTimeout(() => finish(fallback), timeoutMs);
120
+
121
+ try {
122
+ stdin.setRawMode(true);
123
+ stdin.on("data", onData);
124
+ stdin.resume();
125
+ stderr.write("\x1b]11;?\x07");
126
+ } catch {
127
+ finish(fallback);
128
+ }
129
+ });
130
+ }