sequant 2.1.2 → 2.3.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +73 -0
- package/dist/bin/cli.js +95 -9
- package/dist/src/commands/doctor.d.ts +25 -0
- package/dist/src/commands/doctor.js +36 -1
- package/dist/src/commands/init.d.ts +1 -0
- package/dist/src/commands/init.js +118 -0
- package/dist/src/commands/locks.d.ts +67 -0
- package/dist/src/commands/locks.js +290 -0
- package/dist/src/commands/merge.js +11 -0
- package/dist/src/commands/prompt.d.ts +39 -0
- package/dist/src/commands/prompt.js +179 -0
- package/dist/src/commands/run-display.d.ts +26 -0
- package/dist/src/commands/run-display.js +150 -0
- package/dist/src/commands/run-progress.d.ts +32 -0
- package/dist/src/commands/run-progress.js +76 -0
- package/dist/src/commands/run.js +83 -73
- package/dist/src/commands/stats.d.ts +2 -0
- package/dist/src/commands/stats.js +94 -8
- package/dist/src/commands/status.js +27 -1
- package/dist/src/commands/watch.d.ts +16 -0
- package/dist/src/commands/watch.js +147 -0
- package/dist/src/lib/ac-linter.d.ts +1 -1
- package/dist/src/lib/ac-linter.js +81 -0
- package/dist/src/lib/assess-collision-detect.d.ts +91 -0
- package/dist/src/lib/assess-collision-detect.js +217 -0
- package/dist/src/lib/assess-comment-parser.d.ts +59 -1
- package/dist/src/lib/assess-comment-parser.js +124 -2
- package/dist/src/lib/cli-ui/format.d.ts +19 -0
- package/dist/src/lib/cli-ui/format.js +34 -0
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +181 -0
- package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
- package/dist/src/lib/cli-ui/run-renderer.d.ts +239 -0
- package/dist/src/lib/cli-ui/run-renderer.js +1173 -0
- package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
- package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
- package/dist/src/lib/locks/index.d.ts +7 -0
- package/dist/src/lib/locks/index.js +5 -0
- package/dist/src/lib/locks/lock-manager.d.ts +168 -0
- package/dist/src/lib/locks/lock-manager.js +433 -0
- package/dist/src/lib/locks/types.d.ts +59 -0
- package/dist/src/lib/locks/types.js +31 -0
- package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
- package/dist/src/lib/qa/markdown-only-ci.js +74 -0
- package/dist/src/lib/relay/activation.d.ts +60 -0
- package/dist/src/lib/relay/activation.js +122 -0
- package/dist/src/lib/relay/archive.d.ts +34 -0
- package/dist/src/lib/relay/archive.js +106 -0
- package/dist/src/lib/relay/frame.d.ts +20 -0
- package/dist/src/lib/relay/frame.js +76 -0
- package/dist/src/lib/relay/index.d.ts +13 -0
- package/dist/src/lib/relay/index.js +13 -0
- package/dist/src/lib/relay/paths.d.ts +43 -0
- package/dist/src/lib/relay/paths.js +59 -0
- package/dist/src/lib/relay/pid.d.ts +34 -0
- package/dist/src/lib/relay/pid.js +72 -0
- package/dist/src/lib/relay/reader.d.ts +35 -0
- package/dist/src/lib/relay/reader.js +115 -0
- package/dist/src/lib/relay/types.d.ts +68 -0
- package/dist/src/lib/relay/types.js +76 -0
- package/dist/src/lib/relay/writer.d.ts +48 -0
- package/dist/src/lib/relay/writer.js +113 -0
- package/dist/src/lib/settings.d.ts +31 -1
- package/dist/src/lib/settings.js +18 -3
- package/dist/src/lib/skill-version.d.ts +19 -0
- package/dist/src/lib/skill-version.js +68 -0
- package/dist/src/lib/templates.d.ts +1 -0
- package/dist/src/lib/templates.js +1 -1
- package/dist/src/lib/version-check.d.ts +60 -5
- package/dist/src/lib/version-check.js +97 -9
- package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
- package/dist/src/lib/workflow/batch-executor.js +249 -176
- package/dist/src/lib/workflow/config-resolver.js +4 -0
- package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
- package/dist/src/lib/workflow/heartbeat.js +194 -0
- package/dist/src/lib/workflow/phase-executor.d.ts +88 -3
- package/dist/src/lib/workflow/phase-executor.js +276 -52
- package/dist/src/lib/workflow/phase-mapper.d.ts +3 -2
- package/dist/src/lib/workflow/phase-mapper.js +17 -20
- package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
- package/dist/src/lib/workflow/platforms/github.js +20 -3
- package/dist/src/lib/workflow/pr-status.d.ts +18 -2
- package/dist/src/lib/workflow/pr-status.js +41 -9
- package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
- package/dist/src/lib/workflow/qa-stagnation.js +179 -0
- package/dist/src/lib/workflow/run-orchestrator.d.ts +76 -0
- package/dist/src/lib/workflow/run-orchestrator.js +382 -29
- package/dist/src/lib/workflow/run-reflect.js +1 -1
- package/dist/src/lib/workflow/run-state.d.ts +71 -0
- package/dist/src/lib/workflow/run-state.js +14 -0
- package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
- package/dist/src/lib/workflow/state-cleanup.js +17 -5
- package/dist/src/lib/workflow/state-manager.d.ts +12 -1
- package/dist/src/lib/workflow/state-manager.js +37 -0
- package/dist/src/lib/workflow/state-schema.d.ts +62 -0
- package/dist/src/lib/workflow/state-schema.js +35 -1
- package/dist/src/lib/workflow/types.d.ts +74 -1
- package/dist/src/lib/workflow/worktree-manager.d.ts +12 -4
- package/dist/src/lib/workflow/worktree-manager.js +76 -17
- package/dist/src/mcp/tools/run.d.ts +44 -0
- package/dist/src/mcp/tools/run.js +104 -13
- package/dist/src/ui/tui/App.d.ts +14 -0
- package/dist/src/ui/tui/App.js +41 -0
- package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
- package/dist/src/ui/tui/ElapsedTimer.js +31 -0
- package/dist/src/ui/tui/Header.d.ts +6 -0
- package/dist/src/ui/tui/Header.js +15 -0
- package/dist/src/ui/tui/IssueBox.d.ts +16 -0
- package/dist/src/ui/tui/IssueBox.js +68 -0
- package/dist/src/ui/tui/Spinner.d.ts +9 -0
- package/dist/src/ui/tui/Spinner.js +18 -0
- package/dist/src/ui/tui/index.d.ts +15 -0
- package/dist/src/ui/tui/index.js +29 -0
- package/dist/src/ui/tui/theme.d.ts +29 -0
- package/dist/src/ui/tui/theme.js +52 -0
- package/dist/src/ui/tui/truncate.d.ts +11 -0
- package/dist/src/ui/tui/truncate.js +31 -0
- package/package.json +10 -3
- package/templates/agents/sequant-explorer.md +1 -0
- package/templates/agents/sequant-qa-checker.md +2 -1
- package/templates/agents/sequant-testgen.md +1 -0
- package/templates/hooks/post-tool.sh +11 -0
- package/templates/hooks/pre-tool.sh +18 -9
- package/templates/hooks/relay-check.sh +107 -0
- package/templates/relay/frame.txt +11 -0
- package/templates/scripts/cleanup-worktree.sh +25 -3
- package/templates/scripts/new-feature.sh +6 -0
- package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
- package/templates/skills/_shared/references/subagent-types.md +21 -8
- package/templates/skills/assess/SKILL.md +261 -94
- package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
- package/templates/skills/docs/SKILL.md +141 -22
- package/templates/skills/exec/SKILL.md +10 -49
- package/templates/skills/fullsolve/SKILL.md +80 -32
- package/templates/skills/loop/SKILL.md +28 -0
- package/templates/skills/merger/SKILL.md +621 -0
- package/templates/skills/qa/SKILL.md +746 -8
- package/templates/skills/qa/scripts/quality-checks.sh +47 -1
- package/templates/skills/setup/SKILL.md +6 -0
- package/templates/skills/spec/SKILL.md +217 -964
- package/templates/skills/spec/references/parallel-groups.md +7 -0
- package/templates/skills/spec/references/quality-checklist.md +75 -0
- package/templates/skills/spec/references/recommended-workflow.md +4 -2
- package/templates/skills/test/SKILL.md +0 -27
- package/templates/skills/testgen/SKILL.md +24 -44
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunRenderer — single coordinator for all `sequant run` stdout output.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the dual-output regression where `PhaseSpinner` (legacy, #244) and
|
|
5
|
+
* the parallel-mode `▸/✔` lines (#458) both wrote to stdout for single-issue
|
|
6
|
+
* runs and produced overwritten / missing-duration lines.
|
|
7
|
+
*
|
|
8
|
+
* Three modes implement the same `RunRenderer` interface:
|
|
9
|
+
* - TTYRenderer: live grid (top, redrawn ~1Hz) + events log (below)
|
|
10
|
+
* - NonTTYRenderer: append-only `[HH:MM:SS]` events + 60s heartbeat
|
|
11
|
+
* - OrchestratorRenderer: no-op when SEQUANT_ORCHESTRATOR is set so MCP's
|
|
12
|
+
* `emitProgressLine` JSON is the only stdout
|
|
13
|
+
*
|
|
14
|
+
* See issue #618.
|
|
15
|
+
*/
|
|
16
|
+
import chalk from "chalk";
|
|
17
|
+
import logUpdate from "log-update";
|
|
18
|
+
import stringWidth from "string-width";
|
|
19
|
+
import { formatElapsedTime, formatTimestamp } from "./format.js";
|
|
20
|
+
const DEFAULT_LIVE_TICK_MS = 1000;
|
|
21
|
+
const DEFAULT_NON_TTY_HEARTBEAT_MS = 60_000;
|
|
22
|
+
const NARROW_TERMINAL_THRESHOLD = 80;
|
|
23
|
+
const DEFAULT_MULTI_ISSUE_ROW_CAP = 10;
|
|
24
|
+
// Generous default: in production, TTY mode always has `process.stdout.rows`
|
|
25
|
+
// set, so this only matters in tests and detached stdout. A high default avoids
|
|
26
|
+
// over-constraining multi-issue test scenarios while keeping the height cap
|
|
27
|
+
// active enough to catch pathological frames.
|
|
28
|
+
const DEFAULT_TERMINAL_ROWS = 100;
|
|
29
|
+
const DEFAULT_MAX_LOOP_ITERATIONS = 3;
|
|
30
|
+
const SUMMARY_COLUMN_CAP = 110;
|
|
31
|
+
const FAILURE_SIGNATURE_LENGTH = 80;
|
|
32
|
+
const FAIL_REASON_TRUNCATE_LENGTH = 40;
|
|
33
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
34
|
+
// Colour helpers — bypassed cleanly when `noColor` is true.
|
|
35
|
+
function colorize(noColor) {
|
|
36
|
+
if (noColor) {
|
|
37
|
+
const id = (s) => s;
|
|
38
|
+
return {
|
|
39
|
+
dim: id,
|
|
40
|
+
green: id,
|
|
41
|
+
red: id,
|
|
42
|
+
yellow: id,
|
|
43
|
+
cyan: id,
|
|
44
|
+
gray: id,
|
|
45
|
+
bold: id,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
dim: chalk.dim,
|
|
50
|
+
green: chalk.green,
|
|
51
|
+
red: chalk.red,
|
|
52
|
+
yellow: chalk.yellow,
|
|
53
|
+
cyan: chalk.cyan,
|
|
54
|
+
gray: chalk.gray,
|
|
55
|
+
bold: chalk.bold,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// Shared helpers (#624)
|
|
60
|
+
// ============================================================================
|
|
61
|
+
/**
|
|
62
|
+
* #624 Item 4: normalized failure signature for dedup decisions.
|
|
63
|
+
*
|
|
64
|
+
* Strips ANSI escape sequences, lowercases, trims whitespace, and truncates to
|
|
65
|
+
* the first 80 visible chars. The plan deliberately chose a length-bounded
|
|
66
|
+
* prefix over a crypto hash so debugging can match signatures by eye.
|
|
67
|
+
*/
|
|
68
|
+
export function failureSignature(error) {
|
|
69
|
+
if (!error)
|
|
70
|
+
return "";
|
|
71
|
+
// eslint-disable-next-line no-control-regex
|
|
72
|
+
const stripped = error.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
73
|
+
return stripped.trim().toLowerCase().slice(0, FAILURE_SIGNATURE_LENGTH);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* #624 Item 3 / Derived AC-D2: shared suffix builder used by all three retry
|
|
77
|
+
* sites (NonTTY events log, TTY events log, TTY status header). Centralizes
|
|
78
|
+
* the `(attempt N/M)` / `loop N/M` literals so they cannot drift between paths.
|
|
79
|
+
*
|
|
80
|
+
* `kind` selects the surface:
|
|
81
|
+
* - "events" → events-log line: leading space + parentheses
|
|
82
|
+
* - "header" → status cell: leading space, no parentheses
|
|
83
|
+
*
|
|
84
|
+
* Returns the empty string when the attempt counter does not apply
|
|
85
|
+
* (no iteration, iteration === 1, or non-positive).
|
|
86
|
+
*/
|
|
87
|
+
export function formatRetrySuffix(iteration, maxIterations, kind) {
|
|
88
|
+
if (!iteration || iteration <= 1)
|
|
89
|
+
return "";
|
|
90
|
+
const counter = `${iteration}/${maxIterations}`;
|
|
91
|
+
if (kind === "events")
|
|
92
|
+
return ` (attempt ${counter})`;
|
|
93
|
+
return ` ${counter}`;
|
|
94
|
+
}
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// Shared state machine
|
|
97
|
+
// ============================================================================
|
|
98
|
+
class BaseRenderer {
|
|
99
|
+
issues = new Map();
|
|
100
|
+
stdoutWrite;
|
|
101
|
+
stderrWrite;
|
|
102
|
+
now;
|
|
103
|
+
wallClock;
|
|
104
|
+
noColor;
|
|
105
|
+
runStartedAt;
|
|
106
|
+
paused = false;
|
|
107
|
+
disposed = false;
|
|
108
|
+
constructor(options) {
|
|
109
|
+
this.stdoutWrite =
|
|
110
|
+
options.stdoutWrite ?? ((s) => void process.stdout.write(s));
|
|
111
|
+
this.stderrWrite =
|
|
112
|
+
options.stderrWrite ?? ((s) => void process.stderr.write(s));
|
|
113
|
+
this.now = options.now ?? Date.now;
|
|
114
|
+
this.wallClock = options.wallClock ?? (() => new Date());
|
|
115
|
+
this.noColor = Boolean(options.noColor) || Boolean(process.env.NO_COLOR);
|
|
116
|
+
this.runStartedAt = this.now();
|
|
117
|
+
}
|
|
118
|
+
registerIssue(reg) {
|
|
119
|
+
if (this.issues.has(reg.issueNumber))
|
|
120
|
+
return;
|
|
121
|
+
this.issues.set(reg.issueNumber, {
|
|
122
|
+
issueNumber: reg.issueNumber,
|
|
123
|
+
title: reg.title,
|
|
124
|
+
worktreePath: reg.worktreePath,
|
|
125
|
+
branch: reg.branch,
|
|
126
|
+
autoDetect: reg.autoDetect,
|
|
127
|
+
status: "queued",
|
|
128
|
+
phases: [],
|
|
129
|
+
});
|
|
130
|
+
this.afterStateChange();
|
|
131
|
+
}
|
|
132
|
+
onEvent(event) {
|
|
133
|
+
if (this.disposed)
|
|
134
|
+
return;
|
|
135
|
+
let state = this.issues.get(event.issue);
|
|
136
|
+
if (!state) {
|
|
137
|
+
state = {
|
|
138
|
+
issueNumber: event.issue,
|
|
139
|
+
status: "queued",
|
|
140
|
+
phases: [],
|
|
141
|
+
};
|
|
142
|
+
this.issues.set(event.issue, state);
|
|
143
|
+
}
|
|
144
|
+
this.applyEvent(state, event);
|
|
145
|
+
this.afterEvent(event, state);
|
|
146
|
+
}
|
|
147
|
+
setPullRequest(issue, prNumber, prUrl) {
|
|
148
|
+
const state = this.issues.get(issue);
|
|
149
|
+
if (!state)
|
|
150
|
+
return;
|
|
151
|
+
state.prNumber = prNumber;
|
|
152
|
+
state.prUrl = prUrl;
|
|
153
|
+
this.afterStateChange();
|
|
154
|
+
}
|
|
155
|
+
pause() {
|
|
156
|
+
this.paused = true;
|
|
157
|
+
this.onPause();
|
|
158
|
+
}
|
|
159
|
+
resume() {
|
|
160
|
+
if (!this.paused)
|
|
161
|
+
return;
|
|
162
|
+
this.paused = false;
|
|
163
|
+
this.onResume();
|
|
164
|
+
}
|
|
165
|
+
dispose() {
|
|
166
|
+
if (this.disposed)
|
|
167
|
+
return;
|
|
168
|
+
this.disposed = true;
|
|
169
|
+
this.onDispose();
|
|
170
|
+
}
|
|
171
|
+
// ------------ State machine ------------
|
|
172
|
+
applyEvent(state, event) {
|
|
173
|
+
const phaseName = event.phase;
|
|
174
|
+
let phase = state.phases.find((p) => p.name === phaseName);
|
|
175
|
+
if (!phase) {
|
|
176
|
+
phase = { name: phaseName, status: "pending" };
|
|
177
|
+
state.phases.push(phase);
|
|
178
|
+
}
|
|
179
|
+
if (event.event === "start") {
|
|
180
|
+
if (state.startedAt === undefined)
|
|
181
|
+
state.startedAt = this.now();
|
|
182
|
+
state.status = "running";
|
|
183
|
+
state.currentPhase = phaseName;
|
|
184
|
+
phase.status = "running";
|
|
185
|
+
phase.startedAt = this.now();
|
|
186
|
+
if (event.iteration !== undefined) {
|
|
187
|
+
phase.loopIteration = event.iteration;
|
|
188
|
+
}
|
|
189
|
+
// Clear any sub-status from a prior phase.
|
|
190
|
+
state.subStatus = undefined;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (event.event === "complete") {
|
|
194
|
+
phase.status = "done";
|
|
195
|
+
if (event.durationSeconds !== undefined) {
|
|
196
|
+
phase.durationMs = event.durationSeconds * 1000;
|
|
197
|
+
}
|
|
198
|
+
else if (phase.startedAt !== undefined) {
|
|
199
|
+
phase.durationMs = this.now() - phase.startedAt;
|
|
200
|
+
}
|
|
201
|
+
if (state.currentPhase === phaseName)
|
|
202
|
+
state.currentPhase = undefined;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// failed
|
|
206
|
+
phase.status = "failed";
|
|
207
|
+
if (event.durationSeconds !== undefined) {
|
|
208
|
+
phase.durationMs = event.durationSeconds * 1000;
|
|
209
|
+
}
|
|
210
|
+
else if (phase.startedAt !== undefined) {
|
|
211
|
+
phase.durationMs = this.now() - phase.startedAt;
|
|
212
|
+
}
|
|
213
|
+
state.status = "failed";
|
|
214
|
+
state.completedAt = this.now();
|
|
215
|
+
state.currentPhase = undefined;
|
|
216
|
+
if (event.error !== undefined)
|
|
217
|
+
state.failureReason = event.error;
|
|
218
|
+
// #624 Item 4: update failure dedup metadata on the PHASE (not the issue).
|
|
219
|
+
// Per-phase tracking ensures "same failure as attempt N" only references
|
|
220
|
+
// prior attempts of THIS phase — exec failing with "boom" followed by qa
|
|
221
|
+
// failing with "boom" no longer abbreviates qa as "same failure as attempt 1"
|
|
222
|
+
// when attempt 1 was an exec failure.
|
|
223
|
+
const sig = failureSignature(event.error);
|
|
224
|
+
const currentAttempt = phase.loopIteration ?? 1;
|
|
225
|
+
if (phase.lastFailureSignature !== sig) {
|
|
226
|
+
phase.lastFailureSignature = sig;
|
|
227
|
+
phase.firstAttemptForSignature = currentAttempt;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** Mark an issue done after PR is recorded — derived from phase completion. */
|
|
231
|
+
maybeMarkIssueDone(state) {
|
|
232
|
+
if (state.status === "failed")
|
|
233
|
+
return;
|
|
234
|
+
const allTerminal = state.phases.every((p) => p.status === "done" || p.status === "failed");
|
|
235
|
+
if (allTerminal && state.phases.length > 0) {
|
|
236
|
+
state.status = state.phases.some((p) => p.status === "failed")
|
|
237
|
+
? "failed"
|
|
238
|
+
: "done";
|
|
239
|
+
state.completedAt = this.now();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// ------------ Hooks for subclasses ------------
|
|
243
|
+
afterEvent(_event, state) {
|
|
244
|
+
if (state.status !== "failed")
|
|
245
|
+
this.maybeMarkIssueDone(state);
|
|
246
|
+
this.afterStateChange();
|
|
247
|
+
}
|
|
248
|
+
afterStateChange() {
|
|
249
|
+
/* default: no-op */
|
|
250
|
+
}
|
|
251
|
+
onPause() {
|
|
252
|
+
/* default: no-op */
|
|
253
|
+
}
|
|
254
|
+
onResume() {
|
|
255
|
+
/* default: no-op */
|
|
256
|
+
}
|
|
257
|
+
onDispose() {
|
|
258
|
+
/* default: no-op */
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// ============================================================================
|
|
262
|
+
// Orchestrator (MCP) renderer — fully suppressed
|
|
263
|
+
// ============================================================================
|
|
264
|
+
/**
|
|
265
|
+
* No-op renderer used when `SEQUANT_ORCHESTRATOR` is set.
|
|
266
|
+
*
|
|
267
|
+
* The orchestrator (e.g. MCP server) consumes `emitProgressLine` JSON from
|
|
268
|
+
* batch-executor directly. Rendering anything else from the CLI would be
|
|
269
|
+
* double-emission. We still track state so `renderSummary` could be useful
|
|
270
|
+
* if explicitly invoked, but neither stdout nor stderr is touched.
|
|
271
|
+
*/
|
|
272
|
+
export class OrchestratorRenderer extends BaseRenderer {
|
|
273
|
+
renderSummary() {
|
|
274
|
+
/* AC-18: orchestrator path emits no human-readable summary. */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// ============================================================================
|
|
278
|
+
// Non-TTY renderer — append-only with timestamps + 60s heartbeat
|
|
279
|
+
// ============================================================================
|
|
280
|
+
export class NonTTYRenderer extends BaseRenderer {
|
|
281
|
+
heartbeatTimer = null;
|
|
282
|
+
heartbeatMs;
|
|
283
|
+
columnsOverride;
|
|
284
|
+
maxLoopIterations;
|
|
285
|
+
lastEventAt;
|
|
286
|
+
constructor(options) {
|
|
287
|
+
super(options);
|
|
288
|
+
this.heartbeatMs =
|
|
289
|
+
options.nonTtyHeartbeatMs ?? DEFAULT_NON_TTY_HEARTBEAT_MS;
|
|
290
|
+
this.columnsOverride = options.columns;
|
|
291
|
+
this.maxLoopIterations =
|
|
292
|
+
options.maxLoopIterations ?? DEFAULT_MAX_LOOP_ITERATIONS;
|
|
293
|
+
this.lastEventAt = this.now();
|
|
294
|
+
this.startHeartbeat();
|
|
295
|
+
}
|
|
296
|
+
getColumns() {
|
|
297
|
+
return (this.columnsOverride ??
|
|
298
|
+
(process.stdout.columns && process.stdout.columns > 0
|
|
299
|
+
? process.stdout.columns
|
|
300
|
+
: 100));
|
|
301
|
+
}
|
|
302
|
+
startHeartbeat() {
|
|
303
|
+
if (this.heartbeatMs <= 0)
|
|
304
|
+
return;
|
|
305
|
+
this.heartbeatTimer = setInterval(() => this.tickHeartbeat(), this.heartbeatMs);
|
|
306
|
+
if (typeof this.heartbeatTimer.unref === "function") {
|
|
307
|
+
this.heartbeatTimer.unref();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Test hook: drive a heartbeat without waiting on real timers. */
|
|
311
|
+
tickHeartbeatNow() {
|
|
312
|
+
this.tickHeartbeat();
|
|
313
|
+
}
|
|
314
|
+
tickHeartbeat() {
|
|
315
|
+
if (this.disposed || this.paused)
|
|
316
|
+
return;
|
|
317
|
+
if (this.now() - this.lastEventAt < this.heartbeatMs)
|
|
318
|
+
return;
|
|
319
|
+
const running = [...this.issues.values()].filter((s) => s.status === "running" && s.currentPhase);
|
|
320
|
+
if (running.length === 0)
|
|
321
|
+
return;
|
|
322
|
+
const parts = running.map((s) => {
|
|
323
|
+
const elapsedSec = s.startedAt !== undefined ? (this.now() - s.startedAt) / 1000 : 0;
|
|
324
|
+
return `#${s.issueNumber} ${s.currentPhase} (${formatElapsedTime(elapsedSec)})`;
|
|
325
|
+
});
|
|
326
|
+
this.emitLine(`⏱ still running: ${parts.join(", ")}`);
|
|
327
|
+
}
|
|
328
|
+
afterEvent(event, state) {
|
|
329
|
+
super.afterEvent(event, state);
|
|
330
|
+
this.lastEventAt = this.now();
|
|
331
|
+
this.emitEventLine(event, state);
|
|
332
|
+
}
|
|
333
|
+
emitEventLine(event, state) {
|
|
334
|
+
const c = colorize(this.noColor);
|
|
335
|
+
const phase = state.phases.find((p) => p.name === event.phase);
|
|
336
|
+
// #624 Item 3: non-loop phase events on retry get `(attempt N/M)`.
|
|
337
|
+
// The loop phase itself stays unannotated in the events log (the live zone
|
|
338
|
+
// shows `loop N/M · last fail: …` already; double-counting would be noise).
|
|
339
|
+
const retrySuffix = event.phase === "loop"
|
|
340
|
+
? ""
|
|
341
|
+
: formatRetrySuffix(phase?.loopIteration, this.maxLoopIterations, "events");
|
|
342
|
+
if (event.event === "start") {
|
|
343
|
+
this.emitLine(`${c.cyan("▸")} #${event.issue} ${event.phase}${retrySuffix}`);
|
|
344
|
+
}
|
|
345
|
+
else if (event.event === "complete") {
|
|
346
|
+
const durStr = event.durationSeconds !== undefined
|
|
347
|
+
? ` ${formatElapsedTime(event.durationSeconds)}`
|
|
348
|
+
: "";
|
|
349
|
+
this.emitLine(`${c.green("✔")} #${event.issue} ${event.phase}${retrySuffix}${durStr}`);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
// #624 Item 4: failure dedup. The third tier (final attempt) emits the
|
|
353
|
+
// full text even when the signature repeats, so divergent failures stay
|
|
354
|
+
// visible right up to max-iter. Dedup state is per-phase so cross-phase
|
|
355
|
+
// signature collisions don't produce misleading "attempt N" references.
|
|
356
|
+
const attempt = phase?.loopIteration ?? 1;
|
|
357
|
+
const dedup = phase
|
|
358
|
+
? decideDedup(phase, attempt, this.maxLoopIterations)
|
|
359
|
+
: "full";
|
|
360
|
+
if (dedup === "abbreviated" && phase) {
|
|
361
|
+
this.emitLine(`${c.red("✘")} #${event.issue} ${event.phase}${retrySuffix} (same failure as attempt ${phase.firstAttemptForSignature})`);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
const errStr = event.error ? ` ${c.red(event.error)}` : "";
|
|
365
|
+
this.emitLine(`${c.red("✘")} #${event.issue} ${event.phase}${retrySuffix}${errStr}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Append a single `\n`-terminated line with `[HH:MM:SS]` prefix. */
|
|
370
|
+
emitLine(text) {
|
|
371
|
+
const ts = formatTimestamp(this.wallClock());
|
|
372
|
+
const c = colorize(this.noColor);
|
|
373
|
+
this.stdoutWrite(`${c.dim(`[${ts}]`)} ${text}\n`);
|
|
374
|
+
}
|
|
375
|
+
setPullRequest(issue, prNumber, prUrl) {
|
|
376
|
+
super.setPullRequest(issue, prNumber, prUrl);
|
|
377
|
+
const c = colorize(this.noColor);
|
|
378
|
+
this.emitLine(`${c.green("→")} #${issue} PR #${prNumber} ${c.dim(prUrl)}`);
|
|
379
|
+
}
|
|
380
|
+
renderSummary(input) {
|
|
381
|
+
if (this.disposed)
|
|
382
|
+
return;
|
|
383
|
+
// #624 Item 2 (AC-2.3): share the same column source/cap as the TTY path
|
|
384
|
+
// so the summary table is never rendered at a divergent width that pushes
|
|
385
|
+
// the rightmost border off-screen.
|
|
386
|
+
renderSummaryBlock(input, {
|
|
387
|
+
stdoutWrite: this.stdoutWrite,
|
|
388
|
+
noColor: this.noColor,
|
|
389
|
+
columns: Math.min(this.getColumns(), SUMMARY_COLUMN_CAP),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
onDispose() {
|
|
393
|
+
if (this.heartbeatTimer !== null) {
|
|
394
|
+
clearInterval(this.heartbeatTimer);
|
|
395
|
+
this.heartbeatTimer = null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// ============================================================================
|
|
400
|
+
// Failure dedup shared decision (#624 Item 4)
|
|
401
|
+
// ============================================================================
|
|
402
|
+
/**
|
|
403
|
+
* Three-state machine for failure dedup. Returns "abbreviated" when the
|
|
404
|
+
* incoming failure signature matches a prior attempt of THIS phase AND we
|
|
405
|
+
* haven't yet reached the final allowed attempt; otherwise "full" so
|
|
406
|
+
* divergence and last-chance failures stay fully visible in the events log.
|
|
407
|
+
*
|
|
408
|
+
* Per-phase (not per-issue) so cross-phase signature collisions don't produce
|
|
409
|
+
* misleading "same failure as attempt N" text — N would otherwise point at a
|
|
410
|
+
* different phase's attempt.
|
|
411
|
+
*/
|
|
412
|
+
function decideDedup(phase, currentAttempt, maxIterations) {
|
|
413
|
+
// `applyEvent` already updated `phase.lastFailureSignature` and
|
|
414
|
+
// `phase.firstAttemptForSignature` before the emit code runs. So we dedup
|
|
415
|
+
// when the first-seen attempt is strictly earlier than the current one.
|
|
416
|
+
const firstAttempt = phase.firstAttemptForSignature;
|
|
417
|
+
if (firstAttempt === undefined || firstAttempt >= currentAttempt) {
|
|
418
|
+
return "full";
|
|
419
|
+
}
|
|
420
|
+
if (currentAttempt >= maxIterations) {
|
|
421
|
+
return "full";
|
|
422
|
+
}
|
|
423
|
+
return "abbreviated";
|
|
424
|
+
}
|
|
425
|
+
export class TTYRenderer extends BaseRenderer {
|
|
426
|
+
liveTickMs;
|
|
427
|
+
liveTimer = null;
|
|
428
|
+
spinnerFrame = 0;
|
|
429
|
+
columnsOverride;
|
|
430
|
+
rowsOverride;
|
|
431
|
+
noSignalListeners;
|
|
432
|
+
stallThresholdMs;
|
|
433
|
+
multiIssueRowCap;
|
|
434
|
+
maxLoopIterations;
|
|
435
|
+
logUpdateImpl;
|
|
436
|
+
logUpdateClear;
|
|
437
|
+
logUpdateDone;
|
|
438
|
+
resizeListener = null;
|
|
439
|
+
banner = null;
|
|
440
|
+
_testStub;
|
|
441
|
+
constructor(options) {
|
|
442
|
+
super(options);
|
|
443
|
+
this.liveTickMs = options.liveTickMs ?? DEFAULT_LIVE_TICK_MS;
|
|
444
|
+
this.columnsOverride = options.columns;
|
|
445
|
+
this.rowsOverride = options.rows;
|
|
446
|
+
this.noSignalListeners = Boolean(options.noSignalListeners);
|
|
447
|
+
// AC-26: stall threshold disabled by default (Number.POSITIVE_INFINITY); the
|
|
448
|
+
// wiring layer derives a real value from settings.run.timeout when available.
|
|
449
|
+
this.stallThresholdMs =
|
|
450
|
+
options.stallThresholdMs ?? Number.POSITIVE_INFINITY;
|
|
451
|
+
this.multiIssueRowCap =
|
|
452
|
+
options.multiIssueRowCap ?? DEFAULT_MULTI_ISSUE_ROW_CAP;
|
|
453
|
+
this.maxLoopIterations =
|
|
454
|
+
options.maxLoopIterations ?? DEFAULT_MAX_LOOP_ITERATIONS;
|
|
455
|
+
// log-update writes to process.stdout via a mutable global instance. When
|
|
456
|
+
// tests inject `stdoutWrite`, route renders through it instead so capture
|
|
457
|
+
// works deterministically.
|
|
458
|
+
if (options.stdoutWrite) {
|
|
459
|
+
// #624 Derived AC-D1: replacement-aware test stub. Tracks each frame
|
|
460
|
+
// replacement so tests can assert on frame churn without parsing buf.out.
|
|
461
|
+
// `clearCalls` / `doneCalls` verify the renderer actually invokes the
|
|
462
|
+
// teardown methods (not just resets local state).
|
|
463
|
+
const stub = {
|
|
464
|
+
replacementCount: 0,
|
|
465
|
+
lastFrame: "",
|
|
466
|
+
clearCalls: 0,
|
|
467
|
+
doneCalls: 0,
|
|
468
|
+
};
|
|
469
|
+
this._testStub = stub;
|
|
470
|
+
this.logUpdateImpl = (text) => {
|
|
471
|
+
if (stub.lastFrame)
|
|
472
|
+
stub.replacementCount++;
|
|
473
|
+
stub.lastFrame = text;
|
|
474
|
+
options.stdoutWrite(text + "\n");
|
|
475
|
+
};
|
|
476
|
+
this.logUpdateClear = () => {
|
|
477
|
+
stub.clearCalls++;
|
|
478
|
+
stub.lastFrame = "";
|
|
479
|
+
};
|
|
480
|
+
this.logUpdateDone = () => {
|
|
481
|
+
stub.doneCalls++;
|
|
482
|
+
stub.lastFrame = "";
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
this._testStub = null;
|
|
487
|
+
this.logUpdateImpl = (text) => logUpdate(text);
|
|
488
|
+
this.logUpdateClear = () => logUpdate.clear();
|
|
489
|
+
this.logUpdateDone = () => logUpdate.done();
|
|
490
|
+
}
|
|
491
|
+
this.startLiveTimer();
|
|
492
|
+
this.installSignalListeners();
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* #624 Derived AC-D1: expose the test-only log-update stub. Returns `null`
|
|
496
|
+
* when not in test mode (production renders go through real `log-update`).
|
|
497
|
+
*/
|
|
498
|
+
getTestStub() {
|
|
499
|
+
return this._testStub;
|
|
500
|
+
}
|
|
501
|
+
startLiveTimer() {
|
|
502
|
+
if (this.liveTickMs <= 0)
|
|
503
|
+
return;
|
|
504
|
+
this.liveTimer = setInterval(() => this.tick(), this.liveTickMs);
|
|
505
|
+
if (typeof this.liveTimer.unref === "function") {
|
|
506
|
+
this.liveTimer.unref();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
installSignalListeners() {
|
|
510
|
+
if (this.noSignalListeners)
|
|
511
|
+
return;
|
|
512
|
+
this.resizeListener = () => this.redraw();
|
|
513
|
+
process.on("SIGWINCH", this.resizeListener);
|
|
514
|
+
}
|
|
515
|
+
/** Test hook: drive a tick without waiting on real timers. */
|
|
516
|
+
tickNow() {
|
|
517
|
+
this.tick();
|
|
518
|
+
}
|
|
519
|
+
tick() {
|
|
520
|
+
if (this.disposed || this.paused)
|
|
521
|
+
return;
|
|
522
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
523
|
+
this.redraw();
|
|
524
|
+
}
|
|
525
|
+
afterEvent(event, state) {
|
|
526
|
+
super.afterEvent(event, state);
|
|
527
|
+
if (this.paused || this.disposed)
|
|
528
|
+
return;
|
|
529
|
+
// Append the event line first (above the live zone in append order),
|
|
530
|
+
// then redraw the live zone below it.
|
|
531
|
+
this.appendEventLine(event, state);
|
|
532
|
+
this.redraw();
|
|
533
|
+
}
|
|
534
|
+
afterStateChange() {
|
|
535
|
+
if (this.paused || this.disposed)
|
|
536
|
+
return;
|
|
537
|
+
this.redraw();
|
|
538
|
+
}
|
|
539
|
+
/** Set a banner that renders above the live grid (e.g. worktree-loss). */
|
|
540
|
+
setBanner(text) {
|
|
541
|
+
this.banner = text;
|
|
542
|
+
this.redraw();
|
|
543
|
+
}
|
|
544
|
+
appendEventLine(event, state) {
|
|
545
|
+
// Clear the live zone so the appended event becomes a real `console.log`
|
|
546
|
+
// line above it; the live zone redraws below.
|
|
547
|
+
this.logUpdateClear();
|
|
548
|
+
const c = colorize(this.noColor);
|
|
549
|
+
const phase = state.phases.find((p) => p.name === event.phase);
|
|
550
|
+
// #624 Item 3: shared retry-suffix helper. The `loop` phase has its own
|
|
551
|
+
// running indicator in the live zone, so we don't double-annotate it here.
|
|
552
|
+
const retrySuffix = event.phase === "loop"
|
|
553
|
+
? ""
|
|
554
|
+
: formatRetrySuffix(phase?.loopIteration, this.maxLoopIterations, "events");
|
|
555
|
+
let line;
|
|
556
|
+
if (event.event === "start") {
|
|
557
|
+
line = ` ${c.cyan("▸")} #${event.issue} ${event.phase}${retrySuffix}`;
|
|
558
|
+
}
|
|
559
|
+
else if (event.event === "complete") {
|
|
560
|
+
const durStr = event.durationSeconds !== undefined
|
|
561
|
+
? ` ${formatElapsedTime(event.durationSeconds)}`
|
|
562
|
+
: "";
|
|
563
|
+
const prSuffix = state.prUrl ? ` → PR #${state.prNumber}` : "";
|
|
564
|
+
line = ` ${c.green("✔")} #${event.issue} ${event.phase}${retrySuffix}${durStr}${prSuffix}`;
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
// #624 Item 4: failure dedup. Abbreviated form only fires when the
|
|
568
|
+
// signature matches a *prior* attempt of THIS phase and we are not at
|
|
569
|
+
// the final allowed iteration (preserves divergence visibility).
|
|
570
|
+
const attempt = phase?.loopIteration ?? 1;
|
|
571
|
+
const dedup = phase
|
|
572
|
+
? decideDedup(phase, attempt, this.maxLoopIterations)
|
|
573
|
+
: "full";
|
|
574
|
+
if (dedup === "abbreviated" && phase) {
|
|
575
|
+
line = ` ${c.red("✘")} #${event.issue} ${event.phase}${retrySuffix} (same failure as attempt ${phase.firstAttemptForSignature})`;
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
const errStr = event.error ? ` ${c.red(event.error)}` : "";
|
|
579
|
+
line = ` ${c.red("✘")} #${event.issue} ${event.phase}${retrySuffix}${errStr}`;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
this.stdoutWrite(line + "\n");
|
|
583
|
+
}
|
|
584
|
+
setPullRequest(issue, prNumber, prUrl) {
|
|
585
|
+
super.setPullRequest(issue, prNumber, prUrl);
|
|
586
|
+
if (this.paused || this.disposed)
|
|
587
|
+
return;
|
|
588
|
+
this.logUpdateClear();
|
|
589
|
+
const c = colorize(this.noColor);
|
|
590
|
+
this.stdoutWrite(` ${c.green("→")} #${issue} PR #${prNumber} ${c.dim(prUrl)}\n`);
|
|
591
|
+
this.redraw();
|
|
592
|
+
}
|
|
593
|
+
renderSummary(input) {
|
|
594
|
+
if (this.disposed)
|
|
595
|
+
return;
|
|
596
|
+
// #624 Item 2: tear down the live zone *before* writing the summary so any
|
|
597
|
+
// subsequent `console.log` from displaySummary (reflection block, merge
|
|
598
|
+
// tip) cannot overlap with the trailing border of the summary table.
|
|
599
|
+
this.logUpdateClear();
|
|
600
|
+
this.logUpdateDone();
|
|
601
|
+
if (this.liveTimer !== null) {
|
|
602
|
+
clearInterval(this.liveTimer);
|
|
603
|
+
this.liveTimer = null;
|
|
604
|
+
}
|
|
605
|
+
// #624 Item 2 (AC-2.3): clamp summary columns to SUMMARY_COLUMN_CAP so wide
|
|
606
|
+
// terminals don't produce a grid that overflows narrower readers (CI logs,
|
|
607
|
+
// VS Code terminal panes).
|
|
608
|
+
renderSummaryBlock(input, {
|
|
609
|
+
stdoutWrite: this.stdoutWrite,
|
|
610
|
+
noColor: this.noColor,
|
|
611
|
+
columns: Math.min(this.getColumns(), SUMMARY_COLUMN_CAP),
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
onPause() {
|
|
615
|
+
// Clear the live zone so verbose streaming has clean stdout.
|
|
616
|
+
this.logUpdateClear();
|
|
617
|
+
}
|
|
618
|
+
onResume() {
|
|
619
|
+
// Live zone redraws on next tick / event automatically.
|
|
620
|
+
this.redraw();
|
|
621
|
+
}
|
|
622
|
+
onDispose() {
|
|
623
|
+
if (this.liveTimer !== null) {
|
|
624
|
+
clearInterval(this.liveTimer);
|
|
625
|
+
this.liveTimer = null;
|
|
626
|
+
}
|
|
627
|
+
if (this.resizeListener) {
|
|
628
|
+
process.removeListener("SIGWINCH", this.resizeListener);
|
|
629
|
+
this.resizeListener = null;
|
|
630
|
+
}
|
|
631
|
+
this.logUpdateClear();
|
|
632
|
+
this.logUpdateDone();
|
|
633
|
+
}
|
|
634
|
+
// ---------------- Layout ----------------
|
|
635
|
+
getColumns() {
|
|
636
|
+
return (this.columnsOverride ??
|
|
637
|
+
(process.stdout.columns && process.stdout.columns > 0
|
|
638
|
+
? process.stdout.columns
|
|
639
|
+
: 100));
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* #624 Item 1: terminal row count, with a safe default for piped / detached
|
|
643
|
+
* stdout where `process.stdout.rows` is undefined.
|
|
644
|
+
*/
|
|
645
|
+
getRows() {
|
|
646
|
+
if (this.rowsOverride !== undefined)
|
|
647
|
+
return this.rowsOverride;
|
|
648
|
+
const r = process.stdout.rows;
|
|
649
|
+
return r && r > 0 ? r : DEFAULT_TERMINAL_ROWS;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* #624 Item 1: hard ceiling on live-zone height. The cap is
|
|
653
|
+
* `max(8, rows - 5)`, dropping to `max(8, rows - 7)` when a banner is active
|
|
654
|
+
* so the banner + a few separator rows still fit. The floor of 8 prevents
|
|
655
|
+
* the live zone from collapsing on tiny terminals.
|
|
656
|
+
*/
|
|
657
|
+
getMaxLiveRows() {
|
|
658
|
+
const reservation = this.banner ? 7 : 5;
|
|
659
|
+
return Math.max(8, this.getRows() - reservation);
|
|
660
|
+
}
|
|
661
|
+
redraw() {
|
|
662
|
+
if (this.disposed || this.paused)
|
|
663
|
+
return;
|
|
664
|
+
const cols = this.getColumns();
|
|
665
|
+
const text = this.renderLiveFrame(cols);
|
|
666
|
+
if (!text) {
|
|
667
|
+
this.logUpdateClear();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
this.logUpdateImpl(text);
|
|
671
|
+
}
|
|
672
|
+
/** Public for tests — render the live zone to a string without emitting. */
|
|
673
|
+
renderLiveFrame(columns) {
|
|
674
|
+
const cols = columns ?? this.getColumns();
|
|
675
|
+
if (this.issues.size === 0)
|
|
676
|
+
return "";
|
|
677
|
+
const text = cols < NARROW_TERMINAL_THRESHOLD
|
|
678
|
+
? this.renderNarrowFrame()
|
|
679
|
+
: this.issues.size === 1
|
|
680
|
+
? this.renderSingleIssueFrame(cols)
|
|
681
|
+
: this.renderMultiIssueFrame(cols);
|
|
682
|
+
return this.clampFrameHeight(text);
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* #624 Item 1 (AC-1.1): hard ceiling on rendered frame height. The interior
|
|
686
|
+
* `applyRowCap` already collapses excess issues into the rollup row, but the
|
|
687
|
+
* frame can still drift over the cap if many issues have multi-line status
|
|
688
|
+
* cells (sub-status + phase sequence). This is the belt-and-braces clamp
|
|
689
|
+
* that guarantees `log-update` never sees a frame taller than the terminal.
|
|
690
|
+
*
|
|
691
|
+
* Always engages — when `rows` isn't explicitly provided, `getRows()` returns
|
|
692
|
+
* `DEFAULT_TERMINAL_ROWS` (100) so the cap is generous but never disengaged.
|
|
693
|
+
*/
|
|
694
|
+
clampFrameHeight(text) {
|
|
695
|
+
const lines = text.split("\n");
|
|
696
|
+
const maxRows = this.getMaxLiveRows();
|
|
697
|
+
if (lines.length <= maxRows)
|
|
698
|
+
return text;
|
|
699
|
+
const c = colorize(this.noColor);
|
|
700
|
+
// Reserve the last visible row for an overflow indicator so the cap is
|
|
701
|
+
// observable from the rendered output.
|
|
702
|
+
const truncated = lines.slice(0, Math.max(1, maxRows - 1));
|
|
703
|
+
const hidden = lines.length - truncated.length;
|
|
704
|
+
truncated.push(c.dim(` … ${hidden} more line${hidden === 1 ? "" : "s"} (terminal too short)`));
|
|
705
|
+
return truncated.join("\n");
|
|
706
|
+
}
|
|
707
|
+
renderNarrowFrame() {
|
|
708
|
+
// AC-25: <80 columns → indented key:value pairs, no box-drawing.
|
|
709
|
+
const c = colorize(this.noColor);
|
|
710
|
+
const header = `SEQUANT WORKFLOW · ${this.runHeader()}`;
|
|
711
|
+
const lines = [c.bold(header), ""];
|
|
712
|
+
if (this.banner)
|
|
713
|
+
lines.push(c.yellow(this.banner), "");
|
|
714
|
+
const { rolledUpDoneCount, visibleStates, totalCount } = this.applyRowCap();
|
|
715
|
+
if (rolledUpDoneCount > 0) {
|
|
716
|
+
lines.push(` ${c.green(`✔ ${rolledUpDoneCount} done`)}`);
|
|
717
|
+
}
|
|
718
|
+
for (const state of visibleStates) {
|
|
719
|
+
lines.push(` #${state.issueNumber} ${this.statusHeader(state)}`);
|
|
720
|
+
const subLines = this.statusSubLines(state);
|
|
721
|
+
for (const line of subLines)
|
|
722
|
+
lines.push(` ${line}`);
|
|
723
|
+
}
|
|
724
|
+
if (rolledUpDoneCount > 0) {
|
|
725
|
+
lines.push("", c.dim(` (${visibleStates.length} of ${totalCount} shown)`));
|
|
726
|
+
}
|
|
727
|
+
lines.push("", this.rollupLine());
|
|
728
|
+
return lines.join("\n");
|
|
729
|
+
}
|
|
730
|
+
renderSingleIssueFrame(cols) {
|
|
731
|
+
// AC-11: Single-issue runs use a key:value full-grid table.
|
|
732
|
+
const state = [...this.issues.values()][0];
|
|
733
|
+
const c = colorize(this.noColor);
|
|
734
|
+
const header = `SEQUANT WORKFLOW · #${state.issueNumber} · ${formatElapsedTime((this.now() - this.runStartedAt) / 1000)} elapsed`;
|
|
735
|
+
const labelWidth = 10;
|
|
736
|
+
const innerWidth = Math.max(40, Math.min(cols, 110) - labelWidth - 7);
|
|
737
|
+
const valueWidth = innerWidth;
|
|
738
|
+
const rows = [];
|
|
739
|
+
const titleSuffix = state.title ? ` — ${state.title}` : "";
|
|
740
|
+
rows.push([
|
|
741
|
+
"Issue",
|
|
742
|
+
[`#${state.issueNumber}${titleSuffix}`.slice(0, valueWidth)],
|
|
743
|
+
]);
|
|
744
|
+
if (state.worktreePath) {
|
|
745
|
+
rows.push(["Worktree", [truncate(state.worktreePath, valueWidth)]]);
|
|
746
|
+
}
|
|
747
|
+
if (state.branch) {
|
|
748
|
+
rows.push(["Branch", [truncate(state.branch, valueWidth)]]);
|
|
749
|
+
}
|
|
750
|
+
rows.push(["Status", this.statusCellLines(state)]);
|
|
751
|
+
const lines = [c.bold(header), ""];
|
|
752
|
+
if (this.banner)
|
|
753
|
+
lines.push(c.yellow(this.banner), "");
|
|
754
|
+
lines.push(this.drawKeyValueTable(rows, labelWidth, valueWidth));
|
|
755
|
+
return lines.join("\n");
|
|
756
|
+
}
|
|
757
|
+
renderMultiIssueFrame(cols) {
|
|
758
|
+
// AC-5/6/7: per-issue grid with active expanded, done collapsed.
|
|
759
|
+
// AC-28: when total issues exceed the row cap, oldest done issues collapse
|
|
760
|
+
// into a single `✔ {N} done` row at the top and a `(M of N shown)` indicator
|
|
761
|
+
// is appended.
|
|
762
|
+
const c = colorize(this.noColor);
|
|
763
|
+
const header = `SEQUANT WORKFLOW · ${this.runHeader()}`;
|
|
764
|
+
const issueColW = 8;
|
|
765
|
+
const innerWidth = Math.max(50, Math.min(cols, 110) - issueColW - 7);
|
|
766
|
+
const statusColW = innerWidth;
|
|
767
|
+
const lines = [c.bold(header), ""];
|
|
768
|
+
if (this.banner)
|
|
769
|
+
lines.push(c.yellow(this.banner), "");
|
|
770
|
+
const { rolledUpDoneCount, visibleStates, totalCount } = this.applyRowCap();
|
|
771
|
+
const rows = [];
|
|
772
|
+
if (rolledUpDoneCount > 0) {
|
|
773
|
+
rows.push({
|
|
774
|
+
issueLabel: c.green(`✔ ${rolledUpDoneCount}`),
|
|
775
|
+
statusLines: [c.green(`${rolledUpDoneCount} done · rolled up`)],
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
for (const state of visibleStates) {
|
|
779
|
+
rows.push({
|
|
780
|
+
issueLabel: `#${state.issueNumber}`,
|
|
781
|
+
statusLines: this.statusCellLines(state),
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
lines.push(this.drawIssueGrid(rows, issueColW, statusColW));
|
|
785
|
+
lines.push("");
|
|
786
|
+
if (rolledUpDoneCount > 0) {
|
|
787
|
+
lines.push(c.dim(` (${visibleStates.length} of ${totalCount} shown)`));
|
|
788
|
+
}
|
|
789
|
+
lines.push(" " + this.rollupLine());
|
|
790
|
+
return lines.join("\n");
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* AC-28 + #624 Item 1: enforce the per-frame issue row cap, derived from
|
|
794
|
+
* the smaller of `multiIssueRowCap` (static config) and a dynamic ceiling
|
|
795
|
+
* computed from terminal height. The dynamic ceiling reserves ~3 lines per
|
|
796
|
+
* issue (status header + sub-status + separator) and a fixed overhead for
|
|
797
|
+
* the frame header, blank lines, grid borders, and the rollup line.
|
|
798
|
+
*
|
|
799
|
+
* If the cap is not exceeded, returns all issues unchanged. Otherwise: keep
|
|
800
|
+
* all non-done rows, then fill remaining slots with the most recently
|
|
801
|
+
* completed done rows; older done rows roll up into a single summary entry.
|
|
802
|
+
*/
|
|
803
|
+
applyRowCap() {
|
|
804
|
+
const all = [...this.issues.values()];
|
|
805
|
+
const totalCount = all.length;
|
|
806
|
+
const cap = this.effectiveRowCap();
|
|
807
|
+
if (totalCount <= cap) {
|
|
808
|
+
return { rolledUpDoneCount: 0, visibleStates: all, totalCount };
|
|
809
|
+
}
|
|
810
|
+
const active = all.filter((s) => s.status !== "done");
|
|
811
|
+
const done = all
|
|
812
|
+
.filter((s) => s.status === "done")
|
|
813
|
+
.sort((a, b) => (b.completedAt ?? 0) - (a.completedAt ?? 0));
|
|
814
|
+
// Reserve one row for the rollup line, leave the rest for visible issues.
|
|
815
|
+
const visibleSlots = Math.max(1, cap - 1);
|
|
816
|
+
const remainingSlotsForDone = Math.max(0, visibleSlots - active.length);
|
|
817
|
+
const visibleDone = done.slice(0, remainingSlotsForDone);
|
|
818
|
+
const rolledUpDoneCount = done.length - visibleDone.length;
|
|
819
|
+
return {
|
|
820
|
+
rolledUpDoneCount,
|
|
821
|
+
visibleStates: [...active, ...visibleDone],
|
|
822
|
+
totalCount,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* #624 Item 1 (AC-1.1): smaller of the configured static cap and the
|
|
827
|
+
* dynamic terminal-height-derived cap. Each issue row takes roughly 3 grid
|
|
828
|
+
* lines (header, sub-status, separator); the fixed overhead covers the
|
|
829
|
+
* frame title, blank lines, grid borders, and the rollup row.
|
|
830
|
+
*
|
|
831
|
+
* Always engages — when `rows` isn't explicitly provided, `getRows()` returns
|
|
832
|
+
* `DEFAULT_TERMINAL_ROWS` (100) which produces a dynamic cap of ~30, well
|
|
833
|
+
* above the static `multiIssueRowCap` default of 10, so the static cap stays
|
|
834
|
+
* in charge for normal-height terminals.
|
|
835
|
+
*/
|
|
836
|
+
effectiveRowCap() {
|
|
837
|
+
const LINES_PER_ISSUE = 3;
|
|
838
|
+
const FIXED_OVERHEAD = 8;
|
|
839
|
+
const maxLiveRows = this.getMaxLiveRows();
|
|
840
|
+
const dynamicCap = Math.max(2, Math.floor((maxLiveRows - FIXED_OVERHEAD) / LINES_PER_ISSUE));
|
|
841
|
+
return Math.min(this.multiIssueRowCap, dynamicCap);
|
|
842
|
+
}
|
|
843
|
+
// ---------------- Per-issue status content ----------------
|
|
844
|
+
statusHeader(state) {
|
|
845
|
+
const c = colorize(this.noColor);
|
|
846
|
+
if (state.status === "done") {
|
|
847
|
+
const total = state.startedAt !== undefined && state.completedAt !== undefined
|
|
848
|
+
? formatElapsedTime((state.completedAt - state.startedAt) / 1000)
|
|
849
|
+
: "";
|
|
850
|
+
const phaseSeq = state.phases
|
|
851
|
+
.map((p) => p.name)
|
|
852
|
+
.filter((n) => n !== "loop")
|
|
853
|
+
.join("→");
|
|
854
|
+
const prSuffix = state.prNumber ? ` · PR #${state.prNumber}` : "";
|
|
855
|
+
return c.green(`✔ done · ${total} · ${phaseSeq}${prSuffix}`);
|
|
856
|
+
}
|
|
857
|
+
if (state.status === "failed") {
|
|
858
|
+
const total = state.startedAt !== undefined
|
|
859
|
+
? formatElapsedTime(((state.completedAt ?? this.now()) - state.startedAt) / 1000)
|
|
860
|
+
: "";
|
|
861
|
+
return c.red(`✘ failed${total ? ` · ${total}` : ""}${state.failureReason ? ` · ${state.failureReason}` : ""}`);
|
|
862
|
+
}
|
|
863
|
+
if (state.status === "queued") {
|
|
864
|
+
return c.gray("· queued");
|
|
865
|
+
}
|
|
866
|
+
// running
|
|
867
|
+
const cur = state.currentPhase ?? "starting";
|
|
868
|
+
const phase = state.phases.find((p) => p.name === cur);
|
|
869
|
+
const elapsedMs = phase?.startedAt !== undefined ? this.now() - phase.startedAt : 0;
|
|
870
|
+
const elapsed = formatElapsedTime(elapsedMs / 1000);
|
|
871
|
+
const spinner = SPINNER_FRAMES[this.spinnerFrame];
|
|
872
|
+
// AC-23: in auto-detect mode, render `Phase: detecting…` while spec is
|
|
873
|
+
// running and no other phase has started yet (no resolved plan known).
|
|
874
|
+
if (state.autoDetect &&
|
|
875
|
+
cur === "spec" &&
|
|
876
|
+
phase?.status === "running" &&
|
|
877
|
+
state.phases.length === 1) {
|
|
878
|
+
return c.cyan(`${spinner} Phase: detecting… · ${elapsed}`);
|
|
879
|
+
}
|
|
880
|
+
// AC-26: when a phase has been running past the stall threshold, flip the
|
|
881
|
+
// status header to a yellow stalled marker. The phase keeps ticking; this
|
|
882
|
+
// is informational only.
|
|
883
|
+
if (elapsedMs > this.stallThresholdMs) {
|
|
884
|
+
return c.yellow(`⚠ stalled · ${cur} · ${elapsed}`);
|
|
885
|
+
}
|
|
886
|
+
// #624 Item 3 (AC-3.3): while in the `loop` phase, surface both the loop
|
|
887
|
+
// iteration counter and the last failure reason so the user can see what
|
|
888
|
+
// the loop is reacting to. The first loop iteration starts at 1/M.
|
|
889
|
+
if (cur === "loop") {
|
|
890
|
+
const iter = phase?.loopIteration ?? 1;
|
|
891
|
+
const failReason = state.failureReason
|
|
892
|
+
? ` · last fail: ${truncate(state.failureReason, FAIL_REASON_TRUNCATE_LENGTH)}`
|
|
893
|
+
: "";
|
|
894
|
+
return c.cyan(`${spinner} loop ${iter}/${this.maxLoopIterations}${failReason} · ${elapsed}`);
|
|
895
|
+
}
|
|
896
|
+
// #624 Derived AC-D2: shared retry-suffix helper. Eliminates the hardcoded
|
|
897
|
+
// `/3` literal so `maxLoopIterations` from settings flows through.
|
|
898
|
+
const loopLabel = formatRetrySuffix(phase?.loopIteration, this.maxLoopIterations, "header");
|
|
899
|
+
const loopPrefix = loopLabel ? ` loop${loopLabel}` : "";
|
|
900
|
+
return c.cyan(`${spinner} ${cur}${loopPrefix} · ${elapsed}`);
|
|
901
|
+
}
|
|
902
|
+
statusSubLines(state) {
|
|
903
|
+
const c = colorize(this.noColor);
|
|
904
|
+
const lines = [];
|
|
905
|
+
if (state.status === "running" || state.status === "queued") {
|
|
906
|
+
const seq = state.phases
|
|
907
|
+
.filter((p) => p.name !== "loop")
|
|
908
|
+
.map((p) => {
|
|
909
|
+
if (p.status === "done")
|
|
910
|
+
return c.green(`${p.name} ✔${p.durationMs ? ` ${formatElapsedTime(p.durationMs / 1000)}` : ""}`);
|
|
911
|
+
if (p.status === "failed")
|
|
912
|
+
return c.red(`${p.name} ✘`);
|
|
913
|
+
if (p.status === "running")
|
|
914
|
+
return c.cyan(`${p.name} running`);
|
|
915
|
+
return c.gray(`${p.name} queued`);
|
|
916
|
+
})
|
|
917
|
+
.join(" → ");
|
|
918
|
+
if (seq)
|
|
919
|
+
lines.push(seq);
|
|
920
|
+
if (state.subStatus)
|
|
921
|
+
lines.push(c.dim(state.subStatus));
|
|
922
|
+
}
|
|
923
|
+
return lines;
|
|
924
|
+
}
|
|
925
|
+
statusCellLines(state) {
|
|
926
|
+
if (state.status === "done")
|
|
927
|
+
return [this.statusHeader(state)];
|
|
928
|
+
return [this.statusHeader(state), ...this.statusSubLines(state)];
|
|
929
|
+
}
|
|
930
|
+
// ---------------- Rollup / header ----------------
|
|
931
|
+
runHeader() {
|
|
932
|
+
const elapsed = formatElapsedTime((this.now() - this.runStartedAt) / 1000);
|
|
933
|
+
const issues = this.issues.size;
|
|
934
|
+
return `${issues} issue${issues === 1 ? "" : "s"} · ${elapsed}`;
|
|
935
|
+
}
|
|
936
|
+
rollupLine() {
|
|
937
|
+
const c = colorize(this.noColor);
|
|
938
|
+
let done = 0, running = 0, queued = 0, failed = 0;
|
|
939
|
+
for (const s of this.issues.values()) {
|
|
940
|
+
switch (s.status) {
|
|
941
|
+
case "done":
|
|
942
|
+
done++;
|
|
943
|
+
break;
|
|
944
|
+
case "running":
|
|
945
|
+
running++;
|
|
946
|
+
break;
|
|
947
|
+
case "queued":
|
|
948
|
+
queued++;
|
|
949
|
+
break;
|
|
950
|
+
case "failed":
|
|
951
|
+
failed++;
|
|
952
|
+
break;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return c.dim(`${done} done · ${running} running · ${queued} queued · ${failed} failed`);
|
|
956
|
+
}
|
|
957
|
+
// ---------------- Box drawing ----------------
|
|
958
|
+
drawKeyValueTable(rows, labelW, valueW) {
|
|
959
|
+
const c = colorize(this.noColor);
|
|
960
|
+
const dim = c.dim;
|
|
961
|
+
const total = labelW + valueW + 3;
|
|
962
|
+
const top = dim(" ┌" + "─".repeat(labelW + 2) + "┬" + "─".repeat(valueW + 2) + "┐");
|
|
963
|
+
const sep = dim(" ├" + "─".repeat(labelW + 2) + "┼" + "─".repeat(valueW + 2) + "┤");
|
|
964
|
+
const bottom = dim(" └" + "─".repeat(labelW + 2) + "┴" + "─".repeat(valueW + 2) + "┘");
|
|
965
|
+
const out = [top];
|
|
966
|
+
rows.forEach(([label, lines], i) => {
|
|
967
|
+
const labelPadded = padEndVisible(label, labelW);
|
|
968
|
+
lines.forEach((line, idx) => {
|
|
969
|
+
const labelCell = idx === 0 ? c.cyan(labelPadded) : " ".repeat(labelW);
|
|
970
|
+
const valuePadded = padEndVisible(line, valueW);
|
|
971
|
+
out.push(` ${dim("│")} ${labelCell} ${dim("│")} ${valuePadded} ${dim("│")}`);
|
|
972
|
+
});
|
|
973
|
+
if (i < rows.length - 1)
|
|
974
|
+
out.push(sep);
|
|
975
|
+
});
|
|
976
|
+
out.push(bottom);
|
|
977
|
+
void total;
|
|
978
|
+
return out.join("\n");
|
|
979
|
+
}
|
|
980
|
+
drawIssueGrid(rows, issueW, statusW) {
|
|
981
|
+
const c = colorize(this.noColor);
|
|
982
|
+
const dim = c.dim;
|
|
983
|
+
const top = dim(" ┌" + "─".repeat(issueW + 2) + "┬" + "─".repeat(statusW + 2) + "┐");
|
|
984
|
+
const sep = dim(" ├" + "─".repeat(issueW + 2) + "┼" + "─".repeat(statusW + 2) + "┤");
|
|
985
|
+
const bottom = dim(" └" + "─".repeat(issueW + 2) + "┴" + "─".repeat(statusW + 2) + "┘");
|
|
986
|
+
const headerRow = ` ${dim("│")} ${c.cyan(padEndVisible("Issue", issueW))} ${dim("│")} ${c.cyan(padEndVisible("Status", statusW))} ${dim("│")}`;
|
|
987
|
+
const out = [top, headerRow, sep];
|
|
988
|
+
rows.forEach((row, i) => {
|
|
989
|
+
row.statusLines.forEach((line, idx) => {
|
|
990
|
+
const issueCell = idx === 0
|
|
991
|
+
? padEndVisible(row.issueLabel, issueW)
|
|
992
|
+
: " ".repeat(issueW);
|
|
993
|
+
const statusPadded = padEndVisible(line, statusW);
|
|
994
|
+
out.push(` ${dim("│")} ${issueCell} ${dim("│")} ${statusPadded} ${dim("│")}`);
|
|
995
|
+
});
|
|
996
|
+
if (i < rows.length - 1)
|
|
997
|
+
out.push(sep);
|
|
998
|
+
});
|
|
999
|
+
out.push(bottom);
|
|
1000
|
+
return out.join("\n");
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
function renderSummaryBlock(input, ctx) {
|
|
1004
|
+
const { issues } = input;
|
|
1005
|
+
if (issues.length === 0 && !input.dryRun)
|
|
1006
|
+
return;
|
|
1007
|
+
const c = colorize(ctx.noColor);
|
|
1008
|
+
const passed = issues.filter((i) => i.success).length;
|
|
1009
|
+
const failed = issues.filter((i) => !i.success).length;
|
|
1010
|
+
const totalStr = input.totalDurationSeconds !== undefined
|
|
1011
|
+
? ` · ${formatElapsedTime(input.totalDurationSeconds)}`
|
|
1012
|
+
: "";
|
|
1013
|
+
const out = [];
|
|
1014
|
+
out.push("");
|
|
1015
|
+
out.push(c.bold(`SUMMARY · ${issues.length} issue${issues.length === 1 ? "" : "s"}${totalStr} · ${passed} passed · ${failed} failed`));
|
|
1016
|
+
out.push("");
|
|
1017
|
+
if (ctx.columns < NARROW_TERMINAL_THRESHOLD) {
|
|
1018
|
+
// AC-25 fallback: indented key:value pairs.
|
|
1019
|
+
for (const r of issues) {
|
|
1020
|
+
const status = r.success ? c.green("✔ passed") : c.red("✘ failed");
|
|
1021
|
+
const detail = renderSummaryDetail(r, ctx);
|
|
1022
|
+
out.push(` ${status} #${r.issueNumber} ${detail.summary}`);
|
|
1023
|
+
for (const extra of detail.extras)
|
|
1024
|
+
out.push(` ${extra}`);
|
|
1025
|
+
if (r.durationSeconds !== undefined) {
|
|
1026
|
+
out.push(` ${c.dim(formatElapsedTime(r.durationSeconds))}`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
out.push(renderSummaryGrid(issues, ctx));
|
|
1032
|
+
}
|
|
1033
|
+
out.push("");
|
|
1034
|
+
out.push(` ${c.green(`${passed} passed`)} · ${c.red(`${failed} failed`)}`);
|
|
1035
|
+
if (input.logPath) {
|
|
1036
|
+
out.push(` ${c.dim(`Log: ${input.logPath}`)}`);
|
|
1037
|
+
}
|
|
1038
|
+
out.push("");
|
|
1039
|
+
ctx.stdoutWrite(out.join("\n"));
|
|
1040
|
+
}
|
|
1041
|
+
function renderSummaryDetail(r, ctx) {
|
|
1042
|
+
const c = colorize(ctx.noColor);
|
|
1043
|
+
if (r.success) {
|
|
1044
|
+
const phaseSeq = r.phases
|
|
1045
|
+
.map((p) => (p.success ? c.green(p.name) : c.red(p.name)))
|
|
1046
|
+
.join(" → ");
|
|
1047
|
+
const pr = r.prNumber ? ` · PR #${r.prNumber}` : "";
|
|
1048
|
+
return { summary: `${phaseSeq}${pr}`, extras: [] };
|
|
1049
|
+
}
|
|
1050
|
+
// Failed → multi-line detail.
|
|
1051
|
+
const reason = r.failureReason ?? "failure";
|
|
1052
|
+
const extras = [];
|
|
1053
|
+
if (r.qaVerdict) {
|
|
1054
|
+
const unmet = r.unmetCount !== undefined ? ` (${r.unmetCount} unmet)` : "";
|
|
1055
|
+
extras.push(c.red(`${r.qaVerdict}${unmet}`));
|
|
1056
|
+
}
|
|
1057
|
+
return { summary: c.red(reason), extras };
|
|
1058
|
+
}
|
|
1059
|
+
function renderSummaryGrid(issues, ctx) {
|
|
1060
|
+
const c = colorize(ctx.noColor);
|
|
1061
|
+
const dim = c.dim;
|
|
1062
|
+
const issueW = 8;
|
|
1063
|
+
const resultW = 10;
|
|
1064
|
+
const totalW = 10;
|
|
1065
|
+
const detailW = Math.max(28, Math.min(ctx.columns, 100) - issueW - resultW - totalW - 11);
|
|
1066
|
+
const top = dim(" ┌" +
|
|
1067
|
+
"─".repeat(issueW + 2) +
|
|
1068
|
+
"┬" +
|
|
1069
|
+
"─".repeat(resultW + 2) +
|
|
1070
|
+
"┬" +
|
|
1071
|
+
"─".repeat(detailW + 2) +
|
|
1072
|
+
"┬" +
|
|
1073
|
+
"─".repeat(totalW + 2) +
|
|
1074
|
+
"┐");
|
|
1075
|
+
const sep = dim(" ├" +
|
|
1076
|
+
"─".repeat(issueW + 2) +
|
|
1077
|
+
"┼" +
|
|
1078
|
+
"─".repeat(resultW + 2) +
|
|
1079
|
+
"┼" +
|
|
1080
|
+
"─".repeat(detailW + 2) +
|
|
1081
|
+
"┼" +
|
|
1082
|
+
"─".repeat(totalW + 2) +
|
|
1083
|
+
"┤");
|
|
1084
|
+
const bottom = dim(" └" +
|
|
1085
|
+
"─".repeat(issueW + 2) +
|
|
1086
|
+
"┴" +
|
|
1087
|
+
"─".repeat(resultW + 2) +
|
|
1088
|
+
"┴" +
|
|
1089
|
+
"─".repeat(detailW + 2) +
|
|
1090
|
+
"┴" +
|
|
1091
|
+
"─".repeat(totalW + 2) +
|
|
1092
|
+
"┘");
|
|
1093
|
+
const headerRow = ` ${dim("│")} ${c.cyan(padEndVisible("Issue", issueW))} ${dim("│")} ${c.cyan(padEndVisible("Result", resultW))} ${dim("│")} ${c.cyan(padEndVisible("Detail", detailW))} ${dim("│")} ${c.cyan(padEndVisible("Total", totalW))} ${dim("│")}`;
|
|
1094
|
+
const out = [top, headerRow, sep];
|
|
1095
|
+
issues.forEach((r, i) => {
|
|
1096
|
+
const result = r.success ? c.green("✔ passed") : c.red("✘ failed");
|
|
1097
|
+
const detail = renderSummaryDetail(r, ctx);
|
|
1098
|
+
const detailLines = [detail.summary, ...detail.extras];
|
|
1099
|
+
const total = r.durationSeconds !== undefined
|
|
1100
|
+
? formatElapsedTime(r.durationSeconds)
|
|
1101
|
+
: "";
|
|
1102
|
+
detailLines.forEach((line, idx) => {
|
|
1103
|
+
const issueCell = idx === 0
|
|
1104
|
+
? padEndVisible(`#${r.issueNumber}`, issueW)
|
|
1105
|
+
: " ".repeat(issueW);
|
|
1106
|
+
const resultCell = idx === 0 ? padEndVisible(result, resultW) : " ".repeat(resultW);
|
|
1107
|
+
const totalCell = idx === 0 ? padEndVisible(total, totalW) : " ".repeat(totalW);
|
|
1108
|
+
out.push(` ${dim("│")} ${issueCell} ${dim("│")} ${resultCell} ${dim("│")} ${padEndVisible(line, detailW)} ${dim("│")} ${totalCell} ${dim("│")}`);
|
|
1109
|
+
});
|
|
1110
|
+
if (i < issues.length - 1)
|
|
1111
|
+
out.push(sep);
|
|
1112
|
+
});
|
|
1113
|
+
out.push(bottom);
|
|
1114
|
+
return out.join("\n");
|
|
1115
|
+
}
|
|
1116
|
+
// ============================================================================
|
|
1117
|
+
// Helpers
|
|
1118
|
+
// ============================================================================
|
|
1119
|
+
/**
|
|
1120
|
+
* Pad/truncate a string to a visible-width column, ignoring ANSI escape
|
|
1121
|
+
* sequences. Uses `string-width` (already a dependency) so wide CJK and
|
|
1122
|
+
* emoji characters don't break alignment.
|
|
1123
|
+
*/
|
|
1124
|
+
function padEndVisible(s, width) {
|
|
1125
|
+
const visible = stringWidth(s);
|
|
1126
|
+
if (visible >= width)
|
|
1127
|
+
return truncate(s, width);
|
|
1128
|
+
return s + " ".repeat(width - visible);
|
|
1129
|
+
}
|
|
1130
|
+
function truncate(s, max) {
|
|
1131
|
+
// Strip nothing — assumes input is plain text, not ANSI. Visible truncation
|
|
1132
|
+
// is best-effort; box drawing happens after this so ANSI is added later.
|
|
1133
|
+
if (s.length <= max)
|
|
1134
|
+
return s;
|
|
1135
|
+
return s.slice(0, Math.max(1, max - 1)) + "…";
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Create a `RunRenderer` matching the current execution context.
|
|
1139
|
+
*
|
|
1140
|
+
* Detection order:
|
|
1141
|
+
* 1. Explicit `mode` override.
|
|
1142
|
+
* 2. `SEQUANT_ORCHESTRATOR` env var → orchestrator (no-op).
|
|
1143
|
+
* 3. `process.stdout.isTTY` (or `options.isTTY`) → TTY renderer.
|
|
1144
|
+
* 4. Otherwise → non-TTY renderer.
|
|
1145
|
+
*/
|
|
1146
|
+
export function createRunRenderer(options = {}) {
|
|
1147
|
+
const mode = options.mode ??
|
|
1148
|
+
(process.env.SEQUANT_ORCHESTRATOR
|
|
1149
|
+
? "orchestrator"
|
|
1150
|
+
: (options.isTTY ?? Boolean(process.stdout.isTTY))
|
|
1151
|
+
? "tty"
|
|
1152
|
+
: "non-tty");
|
|
1153
|
+
if (mode === "orchestrator")
|
|
1154
|
+
return new OrchestratorRenderer(options);
|
|
1155
|
+
if (mode === "tty")
|
|
1156
|
+
return new TTYRenderer(options);
|
|
1157
|
+
return new NonTTYRenderer(options);
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Public summary helper — used by the legacy displaySummary path so callers
|
|
1161
|
+
* that bypass the renderer still get the new grid layout.
|
|
1162
|
+
*/
|
|
1163
|
+
export function renderRunSummary(input, options = {}) {
|
|
1164
|
+
// #624 Item 2 (AC-2.3): apply the same SUMMARY_COLUMN_CAP as the TTY and
|
|
1165
|
+
// non-TTY paths so the legacy renderless path can't produce a divergent
|
|
1166
|
+
// wide grid that overflows.
|
|
1167
|
+
const rawColumns = options.columns ?? process.stdout.columns ?? 100;
|
|
1168
|
+
renderSummaryBlock(input, {
|
|
1169
|
+
stdoutWrite: options.stdoutWrite ?? ((s) => void process.stdout.write(s)),
|
|
1170
|
+
noColor: Boolean(options.noColor) || Boolean(process.env.NO_COLOR),
|
|
1171
|
+
columns: Math.min(rawColumns, SUMMARY_COLUMN_CAP),
|
|
1172
|
+
});
|
|
1173
|
+
}
|