cawdev-cli 1.2.1 → 1.2.2

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/runner/runner.mjs CHANGED
@@ -1750,11 +1750,22 @@ async function readingCopy(source, defaultBranch) {
1750
1750
  }
1751
1751
  }
1752
1752
 
1753
- async function prepareWorkingCopy(path, branch, defaultBranch, allowDirty, baseBranch = null) {
1753
+ async function prepareWorkingCopy(path, branch, defaultBranch, allowDirty, baseBranch = null, { resumingMerge = false } = {}) {
1754
1754
  await access(join(path, '.git')).catch(() => {
1755
1755
  throw new Error(`${path} is not a git repository.`);
1756
1756
  });
1757
1757
 
1758
+ // R621. A checkout mid-merge was left by something the daemon did not see (a
1759
+ // daemon killed between the merge and the RUNNING post, or a person). Whose
1760
+ // merge it is cannot be known, so it is said and not touched. A MERGE run
1761
+ // carrying on its own branch is the one run that is meant to find it so.
1762
+ if (!resumingMerge && await git(path, ['rev-parse', '-q', '--verify', 'MERGE_HEAD']).then(() => true, () => false)) {
1763
+ throw new Error(
1764
+ `${path} is in the middle of a merge (MERGE_HEAD is set): from a merge run on its branch that did not finish, ` +
1765
+ `or one started by hand. Finish it (resolve and \`git -C ${path} commit\`) or abandon it ` +
1766
+ `(\`git -C ${path} merge --abort\`), then start the run again.`);
1767
+ }
1768
+
1758
1769
  const dirty = await git(path, ['status', '--porcelain']);
1759
1770
  if (dirty && !allowDirty) {
1760
1771
  throw new Error(
@@ -1869,7 +1880,7 @@ async function prepareWorkingCopy(path, branch, defaultBranch, allowDirty, baseB
1869
1880
  * — an older runner that did not say why — and it is the property that makes
1870
1881
  * being wrong there cost a fetch rather than a model.
1871
1882
  */
1872
- async function prepareMerge(cwd, branch, defaultBranch) {
1883
+ async function prepareMerge(cwd, branch, defaultBranch, from = null) {
1873
1884
  const base = defaultBranch || 'main';
1874
1885
  await git(cwd, ['fetch', '--prune', 'origin']).catch((failure) => {
1875
1886
  log(` fetch skipped: ${failure.message.split('\n')[0]}`);
@@ -1881,6 +1892,10 @@ async function prepareMerge(cwd, branch, defaultBranch) {
1881
1892
  const into = await git(cwd, ['rev-parse', '--verify', `origin/${base}`])
1882
1893
  .then(() => `origin/${base}`)
1883
1894
  .catch(() => base);
1895
+ // R621. Told to the caller BEFORE the merge runs, so a throw below still leaves it known.
1896
+ if (from) {
1897
+ from.into = into;
1898
+ }
1884
1899
 
1885
1900
  let clean = true;
1886
1901
  try {
@@ -1915,6 +1930,55 @@ async function prepareMerge(cwd, branch, defaultBranch) {
1915
1930
  };
1916
1931
  }
1917
1932
 
1933
+ /**
1934
+ * R621. Puts a checkout back as `prepareMerge` found it, when the run it was
1935
+ * prepared for never started. `from` is `{ before, into, branch }`: HEAD before
1936
+ * `git merge` ran, and the ref merged in (null when not known).
1937
+ *
1938
+ * Never throws. Returns one sentence, or null when there was nothing to undo.
1939
+ * `reset --keep`, never `--hard`: an allowDirty tree's own work must survive,
1940
+ * and git had already refused any dirty file the merge touches.
1941
+ */
1942
+ async function undoPreparedMerge(cwd, from) {
1943
+ const couldNot = (why) => {
1944
+ const said = `!! could not undo the prepared merge in ${cwd}: ${String(why).split('\n')[0]}`;
1945
+ log(said);
1946
+ return said;
1947
+ };
1948
+ try {
1949
+ const midMerge = await git(cwd, ['rev-parse', '-q', '--verify', 'MERGE_HEAD']).then(() => true, () => false);
1950
+ if (midMerge) {
1951
+ await git(cwd, ['merge', '--abort']);
1952
+ return `the merge prepared in ${cwd} was undone (git merge --abort), because the run did not start`;
1953
+ }
1954
+ const before = from?.before;
1955
+ if (!before) {
1956
+ return null;
1957
+ }
1958
+ const head = await git(cwd, ['rev-parse', 'HEAD']);
1959
+ if (head === before) {
1960
+ return null;
1961
+ }
1962
+ const merged = from.into ? await git(cwd, ['rev-parse', from.into]) : null;
1963
+ // A fast-forward (the branch was strictly behind) moves HEAD onto `into` with no
1964
+ // merge commit; anything else must be a merge commit of `before` and `into`.
1965
+ const fastForward = merged !== null && head === merged
1966
+ && await git(cwd, ['merge-base', '--is-ancestor', before, head]).then(() => true, () => false);
1967
+ if (!fastForward) {
1968
+ if ((await git(cwd, ['rev-parse', 'HEAD^1'])) !== before) {
1969
+ return couldNot('HEAD is not a merge commit on top of the pre-merge head');
1970
+ }
1971
+ if (merged && (await git(cwd, ['rev-parse', 'HEAD^2'])) !== merged) {
1972
+ return couldNot(`HEAD's second parent is not ${from.into}`);
1973
+ }
1974
+ }
1975
+ await git(cwd, ['reset', '--keep', before]);
1976
+ return `the merge commit prepared on ${from.branch} was undone (reset to ${before.slice(0, 7)}), because the run did not start`;
1977
+ } catch (failure) {
1978
+ return couldNot(failure.message);
1979
+ }
1980
+ }
1981
+
1918
1982
  /**
1919
1983
  * Each live run's protected-file guard — R562: `{ globs, mode, allowed, base }`.
1920
1984
  *
@@ -2750,10 +2814,10 @@ async function scanQuality(config, run, cwd, branch) {
2750
2814
  return report({ headSha, error: ran.said.split('\n')[0] || 'the scanner failed and said nothing' });
2751
2815
  }
2752
2816
  const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
2753
- const askServer = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
2754
- const settled = await waitForAnalysis(askServer, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
2817
+ const serverUrl = (reportTask.serverUrl ?? url).replace(/\/$/, '');
2818
+ const settled = await waitForAnalysis(sonarAsker(serverUrl, token), reportTask.ceTaskId, deadline, quality.timeoutSeconds);
2755
2819
  return await reportBranchReading({
2756
- ask: askServer, reportTask, settled, key, branch, analysedAs: 'MAIN',
2820
+ ask: sonarReadAsker(serverUrl, token), reportTask, settled, key, branch, analysedAs: 'MAIN',
2757
2821
  warning: "This server does not analyse branches; the branch was analysed as the project's main analysis.",
2758
2822
  headSha, report, findings: run.findingsToProve,
2759
2823
  });
@@ -2797,10 +2861,10 @@ async function scanQuality(config, run, cwd, branch) {
2797
2861
 
2798
2862
  // What the scanner left behind: where the server queued the analysis.
2799
2863
  const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
2800
- const askServer = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
2801
- const settled = await waitForAnalysis(askServer, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
2864
+ const serverUrl = (reportTask.serverUrl ?? url).replace(/\/$/, '');
2865
+ const settled = await waitForAnalysis(sonarAsker(serverUrl, token), reportTask.ceTaskId, deadline, quality.timeoutSeconds);
2802
2866
  await reportBranchReading({
2803
- ask: askServer, reportTask, settled, key, branch, analysedAs, warning, headSha, report,
2867
+ ask: sonarReadAsker(serverUrl, token), reportTask, settled, key, branch, analysedAs, warning, headSha, report,
2804
2868
  findings: run.findingsToProve,
2805
2869
  });
2806
2870
  } catch (failure) {
@@ -2817,6 +2881,11 @@ function trimSlash(url) {
2817
2881
  return url.replace(/\/$/, '');
2818
2882
  }
2819
2883
 
2884
+ // Once the analysis has settled, the gate, measures, issue and hotspot pages and finding proofs
2885
+ // together get two minutes: not killed because the wait used up the scan's budget, not unbounded either (R622).
2886
+ const SONAR_READ_BUDGET_MS = 120_000;
2887
+ const sonarReadAsker = (serverUrl, token) => sonarAsker(serverUrl, token, { deadline: Date.now() + SONAR_READ_BUDGET_MS });
2888
+
2820
2889
  /** The gate and the new-code measures, for a BRANCH or MAIN reading — never for PROJECT, which has neither. */
2821
2890
  async function reportBranchReading({ ask, reportTask, settled, key, branch, analysedAs, warning, headSha, report, findings }) {
2822
2891
  const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
@@ -2989,8 +3058,9 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
2989
3058
  }
2990
3059
 
2991
3060
  const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
2992
- const ask = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
2993
- await waitForAnalysis(ask, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
3061
+ const serverUrl = (reportTask.serverUrl ?? url).replace(/\/$/, '');
3062
+ await waitForAnalysis(sonarAsker(serverUrl, token), reportTask.ceTaskId, deadline, quality.timeoutSeconds);
3063
+ const ask = sonarReadAsker(serverUrl, token);
2994
3064
 
2995
3065
  const changed = await changedLines(cwd, mergeBase);
2996
3066
  const counted = (component, line) => {
@@ -6935,6 +7005,9 @@ async function startRun(config, offered, workspace, source = null) {
6935
7005
  let shield = null;
6936
7006
  // R155. What `git merge` did before anything was spawned, on a MERGE run.
6937
7007
  let merge = null;
7008
+ // R621. What `undoPreparedMerge` needs, and whether RUNNING was accepted.
7009
+ let mergeFrom = null;
7010
+ let started = false;
6938
7011
 
6939
7012
  try {
6940
7013
  log(`claiming ${run.projectSlug} ${run.label} on ${run.branch}`);
@@ -7107,8 +7180,10 @@ async function startRun(config, offered, workspace, source = null) {
7107
7180
  if (!allowDirty) {
7108
7181
  await resetWorkspace(resolve(path));
7109
7182
  }
7183
+ const resumingMerge = run.profile === 'MERGE'
7184
+ && await git(resolve(path), ['rev-parse', '--abbrev-ref', 'HEAD']).then((now) => now === run.branch, () => false);
7110
7185
  const prepared = await prepareWorkingCopy(
7111
- resolve(path), run.branch, defaultBranch, allowDirty, run.baseBranch);
7186
+ resolve(path), run.branch, defaultBranch, allowDirty, run.baseBranch, { resumingMerge });
7112
7187
  branch = prepared.branch;
7113
7188
  baseCommit = prepared.base;
7114
7189
  if (handoff) {
@@ -7157,7 +7232,19 @@ async function startRun(config, offered, workspace, source = null) {
7157
7232
  // R155. The daemon does the merge, BEFORE anything is spawned, and what
7158
7233
  // git conflicts on becomes the session's whole write scope. A clean
7159
7234
  // merge needs no session at all and gets none.
7160
- merge = await prepareMerge(resolve(path), branch, run.baseBranch ?? defaultBranch);
7235
+ // R621. Recorded here and not inside `prepareMerge`, so that a throw after
7236
+ // `git merge` ran still has it. A null `before` is kept: there is nothing
7237
+ // safe to reset to, but `git merge --abort` still covers MERGE_HEAD.
7238
+ // Not set at all when the checkout is ALREADY mid-merge (a Carry-on): that
7239
+ // merge is not this attempt's to abort.
7240
+ const alreadyMerging = await git(resolve(path), ['rev-parse', '-q', '--verify', 'MERGE_HEAD'])
7241
+ .then(() => true, () => false);
7242
+ mergeFrom = alreadyMerging ? null : {
7243
+ before: await git(resolve(path), ['rev-parse', 'HEAD']).catch(() => null),
7244
+ into: null,
7245
+ branch,
7246
+ };
7247
+ merge = await prepareMerge(resolve(path), branch, run.baseBranch ?? defaultBranch, mergeFrom ?? undefined);
7161
7248
  run.conflictedFiles = merge.conflicted;
7162
7249
  log(merge.clean
7163
7250
  ? ` ${merge.into} merged into ${branch} with no conflict — nothing to resolve`
@@ -7193,9 +7280,16 @@ async function startRun(config, offered, workspace, source = null) {
7193
7280
  // run's path is worse than one showing nothing.
7194
7281
  body: { state: 'RUNNING', workspace: workspace ?? null },
7195
7282
  });
7283
+ started = true;
7196
7284
  } catch (refused) {
7197
7285
  if (PLATFORM_REFUSALS.has(refused.status)) {
7198
7286
  log(` the platform did not let this run start (${refused.message}); leaving it as the platform has it`);
7287
+ if (mergeFrom) {
7288
+ const undone = await undoPreparedMerge(resolve(path), mergeFrom);
7289
+ if (undone) {
7290
+ log(` ${undone}`);
7291
+ }
7292
+ }
7199
7293
  protectedGuards.delete(run.id);
7200
7294
  return;
7201
7295
  }
@@ -7226,9 +7320,16 @@ async function startRun(config, offered, workspace, source = null) {
7226
7320
  // Anything that goes wrong before or during the spawn is the run's failure,
7227
7321
  // and the reason belongs on the run where someone will see it.
7228
7322
  log(` failed: ${failure.message}`);
7323
+ let summary = failure.message;
7324
+ if (mergeFrom && !started) {
7325
+ const undone = await undoPreparedMerge(resolve(path), mergeFrom);
7326
+ if (undone) {
7327
+ summary = `${summary}\n\n${undone}`;
7328
+ }
7329
+ }
7229
7330
  await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
7230
7331
  method: 'POST',
7231
- body: { state: 'FAILED', summary: failure.message },
7332
+ body: { state: 'FAILED', summary },
7232
7333
  }).catch((second) => log(` could not record the failure: ${second.message}`));
7233
7334
  } finally {
7234
7335
  reader?.release();
@@ -7663,7 +7764,8 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
7663
7764
  // the tree the tests just ran against, by the daemon and not the session.
7664
7765
  // AWAITED: the workspace is released in `perform`'s finally, and a
7665
7766
  // scanner still reading files when the next run's reset runs is the
7666
- // hazard; the block's `timeoutSeconds` bounds the wait. It never throws.
7767
+ // hazard; the block's `timeoutSeconds` bounds the wait and
7768
+ // SONAR_READ_BUDGET_MS the reads after it (R622). It never throws.
7667
7769
  if (stage.stage === 'TEST' && run.branch && writesAnythingProfile(run)) {
7668
7770
  await scanQuality(config, run, cwd, run.branch);
7669
7771
  }
@@ -87,7 +87,7 @@ function spread(width, n) {
87
87
  const BADGES = {
88
88
  PLAN: ['PLAN', 'violetLight'],
89
89
  CODE: ['DEV', 'accent'],
90
- REVIEW: ['REVIEW', 'text'],
90
+ TEST: ['TEST', 'text'],
91
91
  MERGE: ['MERGE', 'text'],
92
92
  RELEASE: ['RELEASE', 'text'],
93
93
  };
@@ -109,17 +109,19 @@ export function badgeOf(profile, ink) {
109
109
  * @param waiting a word when it waits on you (`waiting for an answer`), else null
110
110
  * @param now ms, for how long a live session has run
111
111
  */
112
- export function cardStatus(meta, waiting = null, now = Date.now()) {
112
+ export function cardStatus(meta, waiting = null, now = Date.now(), tick = 0) {
113
113
  if (waiting) {
114
114
  return { key: 'waiting', mark: '◆', word: waiting };
115
115
  }
116
116
  if (!meta.ended) {
117
+ const shape = spinnerFor(meta.view?.profile ?? meta.profile, meta.stage);
117
118
  if (meta.state === 'queued' || meta.state === 'claiming') {
118
- return { key: 'queued', mark: '◌', word: meta.state };
119
+ // Nothing moves while queued: the spinner rests on its first frame, grey.
120
+ return { key: 'queued', mark: shape[0], word: meta.state };
119
121
  }
120
122
  const since = Date.parse(meta.startedAt ?? '');
121
123
  const ran = Number.isFinite(since) ? ` · ${elapsed(now - since)}` : '';
122
- return { key: 'running', mark: '●', word: `${meta.state ?? 'running'}${ran}` };
124
+ return { key: 'running', mark: spinnerFrame(shape, tick), word: `${meta.state ?? 'running'}${ran}` };
123
125
  }
124
126
  const state = meta.view?.state;
125
127
  if (!state || meta.view?.live) {
@@ -137,6 +139,36 @@ export function cardStatus(meta, waiting = null, now = Date.now()) {
137
139
  return { key: 'stopped', mark: '✕', word: String(state).toLowerCase().replace(/_/g, ' ') };
138
140
  }
139
141
 
142
+ // --- the spinner ---------------------------------------------------------------
143
+
144
+ /** A stage that writes turns the solid quadrant counter-clockwise: the rule `cawdev-actions.js` draws. */
145
+ export const SPINNER_WRITES = ['◴', '◵', '◶', '◷'];
146
+ /** A stage that only reads turns the thin arc clockwise. */
147
+ export const SPINNER_READS = ['◜', '◝', '◞', '◟'];
148
+ /** Stages that only read; every other action — plan, code, merge, release, roadmap, interview — writes. */
149
+ const READING_STAGES = new Set(['test', 'ask', 'audit', 'scope', 'show']);
150
+ const READING_PROFILES = new Set(['TEST', 'ASK', 'AUDIT', 'SCOPE']);
151
+
152
+ /** The frames a session turns through: by its stage when it walks stages, else by its profile. */
153
+ export function spinnerFor(profile, stage = null) {
154
+ const word = String(stage ?? '').toLowerCase();
155
+ if (word) return READING_STAGES.has(word) ? SPINNER_READS : SPINNER_WRITES;
156
+ return READING_PROFILES.has(String(profile ?? '').toUpperCase()) ? SPINNER_READS : SPINNER_WRITES;
157
+ }
158
+
159
+ /** One frame every two ticks, so every spinner on the screen is in phase. */
160
+ export function spinnerFrame(shape, tick = 0) {
161
+ return shape[Math.floor(Math.max(0, tick) / 2) % shape.length];
162
+ }
163
+
164
+ /** How many ticks a changed value is drawn bright before it settles. */
165
+ export const BEAT_TICKS = 4;
166
+
167
+ /** Whether a value that changed at `changedAt` is still in its beat at `tick`. */
168
+ export function beating(changedAt, tick) {
169
+ return Number.isFinite(changedAt) && tick - changedAt >= 0 && tick - changedAt < BEAT_TICKS;
170
+ }
171
+
140
172
  /** `42s`, `12m`, `2h05`. */
141
173
  export function elapsed(ms) {
142
174
  const seconds = Math.max(0, Math.floor(ms / 1000));
@@ -149,7 +181,7 @@ export function elapsed(ms) {
149
181
  /** The colour of each status — the frame's, the mark's and the rain's. */
150
182
  const STATUS_PAINT = {
151
183
  running: 'success', waiting: 'warn', queued: 'muted', done: 'success', planned: 'success', merging: 'muted',
152
- idle: 'warn', stopped: 'danger', ended: 'muted',
184
+ idle: 'warn', stopped: 'danger', ended: 'muted', landed: 'success',
153
185
  };
154
186
 
155
187
  export function statusPaint(key, ink) {
@@ -161,13 +193,15 @@ export function statusPaint(key, ink) {
161
193
  * one it is on. Too long for `width`, it says the count and the stage instead,
162
194
  * which still answers "how far has it got".
163
195
  */
164
- export function stageWalk(stages, current, width, ink) {
196
+ export function stageWalk(stages, current, width, ink, tick = null) {
165
197
  if (!Array.isArray(stages) || !stages.length || !current) return '';
166
198
  const at = stages.indexOf(current);
199
+ // The mark on the current stage turns when a tick is given, as the status's does.
200
+ const mark = tick === null ? '●' : spinnerFrame(spinnerFor(null, current), tick);
167
201
  const words = stages.map((name, i) => {
168
202
  const word = String(name).toLowerCase();
169
203
  if (at < 0 || i > at) return ink.muted(`${word} ○`);
170
- return i === at ? ink.bold(`${word} ●`) : ink.muted(`${word} ✓`);
204
+ return i === at ? ink.bold(`${word} ${mark}`) : ink.muted(`${word} ✓`);
171
205
  });
172
206
  const line = words.join(ink.muted(' ─ '));
173
207
  if (visibleWidth(line) <= width) return line;
@@ -227,8 +261,12 @@ export const RAIN = {
227
261
  ended: { speed: 0, density: 0.12, trail: 1 },
228
262
  };
229
263
 
230
- /** Half-width katakana and digits: one column each, which is what the rain is measured in. */
231
- const GLYPHS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789';
264
+ /**
265
+ * Plain ASCII: one column in every terminal and every font. Half-width
266
+ * katakana are East Asian "halfwidth" — a terminal or font that draws them two
267
+ * columns wide pushed every rain row past the card's right border.
268
+ */
269
+ export const GLYPHS = '0123456789=+*<>:ZXJ';
232
270
 
233
271
  /** A small integer hash — the same inputs, the same drop. */
234
272
  function mix(...values) {
@@ -384,6 +422,168 @@ export function mergingRobotLines(width, rows, { tick = 0, still = false, base =
384
422
  return [...new Array(Math.max(0, rows - drawn.length)).fill(' '.repeat(width)), ...drawn].slice(-rows);
385
423
  }
386
424
 
425
+ // --- the robots' moments ----------------------------------------------------------
426
+ //
427
+ // Each is a pure function of `since`, the ticks since the card's status changed
428
+ // (attach.mjs records the tick when it marks a session ended), so a test can
429
+ // name the frame it wants. The pacing and merging robots above are unchanged.
430
+
431
+ /** The tick the walk-in of a finished plan takes: from x = -6, a column every two ticks, in by x = 0. */
432
+ export const WALK_IN_TICKS = 14;
433
+
434
+ /** `text` placed at column `x` of a row `width` wide; a negative `x` cuts the text's front off. */
435
+ function placed(text, x, width) {
436
+ const cut = x < 0 ? text.slice(Math.min(text.length, -x)) : text;
437
+ const left = Math.max(0, x);
438
+ return (' '.repeat(left) + cut).slice(0, width).padEnd(width);
439
+ }
440
+
441
+ /** Overlays plain `text` on the plain row `base` at `x`; spaces in `text` leave `base` alone. */
442
+ function overlay(base, text, x) {
443
+ const chars = [...base];
444
+ [...text].forEach((ch, i) => {
445
+ const at = x + i;
446
+ if (at >= 0 && at < chars.length && ch !== ' ') chars[at] = ch;
447
+ });
448
+ return chars.join('');
449
+ }
450
+
451
+ /** Three plain rows → exactly `rows` painted ones, bottom-aligned, as the other robots are. */
452
+ function stand(rowsOf, width, rows, paintRow) {
453
+ const drawn = (rows >= 3 ? rowsOf : rows === 2 ? [rowsOf[0], rowsOf[2]] : [rowsOf[0]])
454
+ .map((row, i, all) => paintRow(row, i, all.length));
455
+ return [...new Array(Math.max(0, rows - drawn.length)).fill(' '.repeat(width)), ...drawn].slice(-rows);
456
+ }
457
+
458
+ /** A finished plan, the moment it finishes: the robot walks in from the left carrying `✓ plan done`, then paces. */
459
+ export function planWalkInLines(width, rows, { since = 0, tick = 0, still = false } = {}, ink) {
460
+ if (still || since >= WALK_IN_TICKS) {
461
+ return robotLines(width, rows, { tick: still ? 0 : Math.max(0, since - WALK_IN_TICKS), still }, ink);
462
+ }
463
+ const x = -6 + Math.floor(since / 2);
464
+ const sign = ` ${ROBOT_SIGN}`;
465
+ const legs = since % 2 ? ' / \\ ' : ' | | ';
466
+ const rowsOf = [`[o_o]${sign}`, '\\[_]/', legs];
467
+ return stand(rowsOf, width, rows, (row, i) => {
468
+ const plain = placed(row, x, width);
469
+ return i === 0 && row.length > 5
470
+ ? ink.bold(ink.success(plain.slice(0, Math.max(0, x + 5)))) + ink.success(plain.slice(Math.max(0, x + 5)))
471
+ : ink.bold(ink.success(plain));
472
+ });
473
+ }
474
+
475
+ /** Where a development's robot sets the branch down: just left of the dock's sign. */
476
+ function dockOf(width, word) {
477
+ const dock = ` ▶ ${word}`;
478
+ return { dock, dockAt: Math.max(0, width - dock.length), branchX: Math.max(8, width - dock.length - 4) };
479
+ }
480
+
481
+ /** A plain row painted in `[from, to, paint]` segments, the rest in `base`. */
482
+ function segments(plain, spans, base) {
483
+ let out = '';
484
+ let at = 0;
485
+ for (const [from, to, paint] of [...spans].sort((a, b) => a[0] - b[0])) {
486
+ if (from > at) out += base(plain.slice(at, from));
487
+ out += paint(plain.slice(from, to));
488
+ at = to;
489
+ }
490
+ return out + (at < plain.length ? base(plain.slice(at)) : '');
491
+ }
492
+
493
+ /** The dock's sign set on the row the branch sits on (the middle one, or the last when there is no room for three). */
494
+ function withDock(rowsOf, rows, dock, width) {
495
+ const at = rows >= 3 ? 1 : rowsOf.length - 1;
496
+ return rowsOf.map((row, i) => (i === at && width > dock.length
497
+ ? row.slice(0, width - dock.length) + dock : row));
498
+ }
499
+
500
+ /**
501
+ * A finished development, the moment it finishes: the robot walks in carrying
502
+ * the branch (`[>_>]`, `/[_]=[■]`) to `▶ review`, sets it down (the `[■]` stays
503
+ * by the dock), steps back three columns and waits beside it — `[o_o]`, a blink
504
+ * every 23 ticks, a look at the branch (`[>_>]`) three ticks in every forty —
505
+ * under the sign `✓ built · /pr`.
506
+ */
507
+ export function devDoneRobotLines(width, rows, { since = 0, still = false } = {}, ink) {
508
+ const { dock, dockAt, branchX } = dockOf(width, 'review');
509
+ const endX = Math.max(0, branchX - 5);
510
+ const walk = 2 * (endX + 8);
511
+ const t = still ? walk + 4 : since;
512
+ const carrying = t < walk + 1;
513
+ const back = carrying ? 0 : Math.min(3, t - walk - 1);
514
+ const x = carrying ? Math.min(endX, -8 + Math.floor(t / 2)) : Math.max(0, endX - back);
515
+ const waited = t - walk - 4;
516
+ const blink = !still && waited >= 0 && waited % 23 === 22;
517
+ const looking = !still && waited >= 0 && waited % 40 >= 37;
518
+ const head = carrying || looking ? '[>_>]' : blink ? '[-_-]' : '[o_o]';
519
+ const legs = !carrying || still ? ' | | ' : Math.floor(t / 2) % 2 ? ' / \\ ' : ' | | ';
520
+ const sign = '✓ built · /pr';
521
+ const signed = !carrying && x >= sign.length + 2;
522
+ let top = placed(head, x, width);
523
+ if (signed) top = overlay(top, sign, 1);
524
+ const plain = withDock([
525
+ top,
526
+ carrying ? placed('/[_]=[■]', x, width) : overlay(placed('/[_]\\', x, width), '[■]', branchX),
527
+ placed(legs, x, width),
528
+ ], rows, dock, width);
529
+ return stand(plain, width, rows, (row, i, n) => {
530
+ const onDock = (n >= 3 ? i === 1 : i === n - 1);
531
+ const spans = [];
532
+ if (onDock) spans.push([dockAt, width, ink.muted]);
533
+ if (signed && (n >= 3 ? i === 0 : i === 0)) spans.push([1, 1 + sign.length, ink.success]);
534
+ return segments(row, spans, (text) => ink.bold(ink.accent(text)));
535
+ });
536
+ }
537
+
538
+ /** Nothing built: the robot walks in empty-handed, amber, stands at the left and shrugs. */
539
+ export function nothingBuiltRobotLines(width, rows, { since = 0, still = false, ref = null } = {}, ink) {
540
+ const paint = (text) => ink.bold(ink.warn(text));
541
+ const x = still ? 1 : Math.min(1, -6 + Math.floor(since / 2));
542
+ const waited = still ? 0 : since - 14;
543
+ const shrug = waited >= 0 && waited % 30 >= 24;
544
+ const blink = waited >= 0 && waited % 23 === 22;
545
+ const head = blink ? '[-_-]' : '[o_o]';
546
+ const arms = shrug ? '¯[_]¯' : '/[_]\\';
547
+ const sign = ` ✗ nothing built · /new plan ${ref ?? '<card>'}`;
548
+ const shown = width - 6 - 1 >= sign.length ? sign : ' ✗ nothing built';
549
+ const rowsOf = [head + (x >= 0 ? shown : ''), arms, ' | | '];
550
+ return stand(rowsOf, width, rows, (row, i) => {
551
+ const plain = placed(row, x, width);
552
+ return i === 0 && x >= 0
553
+ ? paint(plain.slice(0, x + 5)) + ink.warn(plain.slice(x + 5))
554
+ : paint(plain);
555
+ });
556
+ }
557
+
558
+ /** Ticks the robot waves for after a landing, and ticks it then takes to walk off a card `width` wide. */
559
+ export const WAVE_TICKS = 12;
560
+ /** A landed card closes itself this many ticks after it lands (3 s at 120 ms). */
561
+ export const CLOSE_AFTER_TICKS = 25;
562
+
563
+ /**
564
+ * The landing: the branch is set down at `▶ main`, the robot waves (`[^_^]`,
565
+ * arms alternating) for twelve ticks and walks off the right edge.
566
+ */
567
+ export function landingRobotLines(width, rows, { since = 0, still = false, base = 'main' } = {}, ink) {
568
+ const { dock, dockAt, branchX } = dockOf(width, base);
569
+ const stands = Math.max(0, branchX - 8);
570
+ const t = still ? 0 : since;
571
+ const waving = t < WAVE_TICKS;
572
+ // Off the edge before the card closes itself, however wide the card is.
573
+ const pace = Math.max(1, Math.ceil((width - stands) / (CLOSE_AFTER_TICKS - WAVE_TICKS - 1)));
574
+ const x = stands + Math.max(0, t - WAVE_TICKS) * pace;
575
+ const arms = !waving ? '/[_]\\' : Math.floor(t / 2) % 2 ? '\\[_]/' : '/[_]\\';
576
+ const head = waving ? '[^_^]' : '[>_>]';
577
+ const legs = !waving && Math.floor(t / 2) % 2 ? ' / \\ ' : ' | | ';
578
+ const plain = withDock([
579
+ placed(head, x, width),
580
+ overlay(placed(arms, x, width), '[■]', branchX),
581
+ placed(legs, x, width),
582
+ ], rows, dock, width);
583
+ return stand(plain, width, rows, (row, i, n) => segments(
584
+ row, (n >= 3 ? i === 1 : i === n - 1) ? [[dockAt, width, ink.muted]] : [], (text) => ink.bold(ink.accent(text))));
585
+ }
586
+
387
587
  // --- a card --------------------------------------------------------------------
388
588
 
389
589
  /**
@@ -420,7 +620,10 @@ export function sessionCardLines(rect, card, ink) {
420
620
  const title = titleRoom >= 4 ? ` ${ellipsed(card.title ?? '', titleRoom - 1)} ` : ' ';
421
621
  const label = `${left}${badge}${title}`;
422
622
  const fill = Math.max(0, inner - 1 - visibleWidth(label) - visibleWidth(status) - 1);
423
- const shownStatus = visibleWidth(label) + visibleWidth(status) + 2 <= inner ? paint(status) : '';
623
+ // A status that changed is drawn reversed for a few ticks, then settles.
624
+ const beat = card.changedAt !== undefined && beating(card.changedAt, card.tick ?? 0);
625
+ const shownStatus = visibleWidth(label) + visibleWidth(status) + 2 <= inner
626
+ ? (beat ? ink.reverse(paint(status)) : paint(status)) : '';
424
627
  const top = paint(tl + h) + (card.focused ? ink.bold(label) : label)
425
628
  + paint(h.repeat(shownStatus ? fill : Math.max(0, inner - 1 - visibleWidth(label)))) + shownStatus
426
629
  + paint((shownStatus ? h : '') + tr);
@@ -432,15 +635,24 @@ export function sessionCardLines(rect, card, ink) {
432
635
  const room = Math.max(0, height - 2 - footer);
433
636
  const words = (card.lines ?? []).filter((line) => line !== null && line !== undefined).slice(0, room);
434
637
  const spare = room - words.length;
638
+ // The robots move whether the rain is on or off: they are not the matrix.
639
+ const since = Number.isFinite(card.since) ? card.since : null;
435
640
  const rain = spare <= 0 ? []
436
- // A planned card has its robot whatever `/matrix` says; off, it stands still.
437
641
  : card.status.key === 'planned'
438
- ? robotLines(inner, spare, { tick: card.tick, still: card.rain === false }, ink)
642
+ ? (since === null ? robotLines(inner, spare, { tick: card.tick }, ink)
643
+ : planWalkInLines(inner, spare, { since }, ink))
439
644
  : card.status.key === 'merging'
440
- ? mergingRobotLines(inner, spare, { tick: card.tick, still: card.rain === false, base: card.base }, ink)
441
- : card.rain === false
442
- ? new Array(spare).fill('')
443
- : rainLines(inner, spare, { seed: card.seed, tick: card.tick, mood: card.status.key }, ink);
645
+ ? mergingRobotLines(inner, spare, { tick: card.tick, base: card.base }, ink)
646
+ : card.status.key === 'done'
647
+ ? devDoneRobotLines(inner, spare, { since: since ?? 1e6 }, ink)
648
+ : card.status.key === 'idle'
649
+ ? nothingBuiltRobotLines(inner, spare, { since: since ?? 1e6, ref: card.ref }, ink)
650
+ : card.status.key === 'landed'
651
+ ? landingRobotLines(inner, spare, { since: since ?? 1e6, base: card.base }, ink)
652
+ // Failed, running, waiting, queued, stopped: no robot. The rain only when asked for.
653
+ : card.rain !== true || card.status.key === 'failed'
654
+ ? new Array(spare).fill('')
655
+ : rainLines(inner, spare, { seed: card.seed, tick: card.tick, mood: card.status.key }, ink);
444
656
  const lines = [top, ...words.map(said), ...rain.map(row)];
445
657
  if (footer) lines.push(said(ink.muted(card.footer)));
446
658
  lines.push(paint(bl + h.repeat(inner) + br));
package/runner/split.mjs CHANGED
@@ -1,15 +1,19 @@
1
1
  // Advanced mode — the attached terminal split into one window per session,
2
2
  // emacs-style, with the client's own prompt at the bottom.
3
3
  //
4
- // **R81 decided against a pane manager, and this does not reopen that.** The
5
- // reason there was that a full-screen repaint EATS THE SCROLLBACK: the wheel,
6
- // search and copy stop working and the transcript above the fold is gone. So
7
- // the default is still `scrollback.mjs`, untouched. This is a mode somebody
8
- // ASKS for with `/advanced-mode`, and it is drawn on the terminal's ALTERNATE
4
+ // **R81 chose the scrollback as the default, and R624 changes the default.**
5
+ // R81's reason was that a full-screen repaint EATS THE SCROLLBACK: the wheel,
6
+ // search and copy stop working and the transcript above the fold is gone. With
7
+ // several sessions and a roadmap on one machine that reason no longer wins, so
8
+ // `cawdev` opens HERE wherever the terminal can draw it (`advancedBlocker`: a
9
+ // TTY, not TERM=dumb, 80×24 at least); `cawdev --simple` and `/simple` are the
10
+ // scrollback terminal (`scrollback.mjs`, untouched), one command away, and the
11
+ // choice is remembered in prefs.mjs. It is drawn on the terminal's ALTERNATE
9
12
  // screen — the buffer vim and emacs use — which leaves the normal screen and
10
13
  // its scrollback exactly as they were. Leaving the mode puts them back, and
11
14
  // what was said on the watched session meanwhile is laid into the scrollback
12
- // then, so nothing is lost by having looked at it this way.
15
+ // then (`/export` does it on demand), so nothing is lost by having looked at it
16
+ // this way.
13
17
  //
14
18
  // Everything that decides a picture is a pure function — the layout, one
15
19
  // window, the whole frame — so it can be rendered at four sizes and READ,
@@ -133,7 +137,10 @@ export function windowLines(rect, {
133
137
  const label = title ? ` ${clip(title, Math.max(0, inner - 3))} ` : '';
134
138
  const top = paint(tl + h) + label + paint(h.repeat(Math.max(0, inner - 1 - visibleWidth(label))) + tr);
135
139
 
136
- const footerRows = footer ? 1 : 0;
140
+ // A footer is a row, or several: keys that do not fit one wrap onto the next
141
+ // rather than being cut off.
142
+ const footers = (Array.isArray(footer) ? footer : [footer]).filter(Boolean);
143
+ const footerRows = footers.length;
137
144
  const subtitleRows = subtitle ? 1 : 0;
138
145
  const rows = Math.max(0, height - 2 - footerRows - subtitleRows);
139
146
  const tail = tailOf(body, inner, rows);
@@ -146,8 +153,8 @@ export function windowLines(rect, {
146
153
  for (let i = 0; i < rows; i++) {
147
154
  lines.push(paint(v) + padVisible(clip(tail[i] ?? '', inner), inner) + RESET + paint(v));
148
155
  }
149
- if (footer) {
150
- lines.push(paint(v) + padVisible(clip(footer, inner), inner) + RESET + paint(v));
156
+ for (const row of footers) {
157
+ lines.push(paint(v) + padVisible(clip(row, inner), inner) + RESET + paint(v));
151
158
  }
152
159
  lines.push(paint(bl + h.repeat(inner) + br));
153
160
  return lines.slice(0, height);
@@ -486,7 +493,7 @@ export function actionsFor(meta) {
486
493
  } else if (isAutoCheckout(view?.workspace) && view?.state !== 'RUNNING' && !view?.live) {
487
494
  parts.push('/drop');
488
495
  }
489
- parts.push('/close');
496
+ parts.push('w closes');
490
497
  return parts.join(' · ');
491
498
  }
492
499
 
@@ -1201,3 +1208,21 @@ export function cardLines(card, ink, url = null) {
1201
1208
  }
1202
1209
  return lines;
1203
1210
  }
1211
+
1212
+ /** Advanced mode's smallest terminal. */
1213
+ export const ADVANCED_MIN_COLUMNS = 80;
1214
+ export const ADVANCED_MIN_ROWS = 24;
1215
+
1216
+ /**
1217
+ * Why a terminal cannot draw advanced mode, in a few words — or null when it
1218
+ * can: a TTY, not `TERM=dumb`, at least 80×24. Pure, so the default is a
1219
+ * test and not a guess.
1220
+ */
1221
+ export function advancedBlocker({ tty, term, columns, rows }) {
1222
+ if (!tty) return 'no terminal to draw on (a pipe) · /advanced-mode needs a TTY';
1223
+ if (term === 'dumb') return 'TERM=dumb cannot draw it';
1224
+ if ((columns ?? 0) < ADVANCED_MIN_COLUMNS || (rows ?? 0) < ADVANCED_MIN_ROWS) {
1225
+ return `the terminal is ${columns ?? '?'}×${rows ?? '?'}, advanced mode needs ${ADVANCED_MIN_COLUMNS}×${ADVANCED_MIN_ROWS}`;
1226
+ }
1227
+ return null;
1228
+ }