cawdev-cli 1.2.0 → 1.2.1

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/attach.mjs CHANGED
@@ -46,9 +46,11 @@
46
46
  // curses library.
47
47
 
48
48
  import { spawn } from 'node:child_process';
49
- import { rm } from 'node:fs/promises';
49
+ import { existsSync } from 'node:fs';
50
+ import { readFile, rm } from 'node:fs/promises';
51
+ import { homedir } from 'node:os';
50
52
  import { connect } from 'node:net';
51
- import { basename, dirname } from 'node:path';
53
+ import { basename, dirname, join } from 'node:path';
52
54
  import { listSockets, socketPathFor } from './control.mjs';
53
55
  import {
54
56
  RESTART_FROM_ATTACH, addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
@@ -60,21 +62,31 @@ import {
60
62
  import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
61
63
  import { restartNeededFor } from '../lib/runner-config.mjs';
62
64
  import {
63
- BOARD_COLUMNS, MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, readerFrame, tabsLine, visibleSessions, actionsFor, composeFrame,
64
- carriesOnHere, builtNothing, cardLines, columnOf, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, splitLayout, titleFor, toneFor,
65
- windowLines,
65
+ BOARD_COLUMNS, PROMPT_ROWS, SplitScreen, boardPreviewLines, moveInStrip, readerFrame, stripColumns, tabsLine, visibleSessions, actionsFor, composeFrame,
66
+ batchStartedLines, carriesOnHere, builtNothing, cardLines, columnOf, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, titleFor,
67
+ windowLines, showWaitingLine,
66
68
  } from './split.mjs';
69
+ import {
70
+ CARD_MIN_HEIGHT, cardGrid, cardStatus, nowLine, seedOf, sessionCardLines, stageWalk,
71
+ } from './session-cards.mjs';
67
72
  import { KNOWN_AGENTS, agentOfCommand } from '../lib/agents.mjs';
68
73
  import { loadPrefs, savePrefs } from './prefs.mjs';
69
74
  import { oneLine } from './brand.mjs';
70
75
  import { Scrollback } from './scrollback.mjs';
76
+ import { removeStaleLock, staleLockOf } from './git-lock.mjs';
71
77
  import { Session, signInThroughBrowser, storedSession } from './sign-in.mjs';
72
78
  import { clearSession } from './session-store.mjs';
79
+ import { branchDiff, fileAt, foldedByDefault, layoutDiff } from './diff-view.mjs';
80
+ import { cardsFrom, importCards, resultLines } from './card-import.mjs';
73
81
  import { clearHistory, loadHistory, pushHistory } from './history.mjs';
74
82
  import {
75
83
  History, KeyStream, Line, Pastes, commonPrefix, completionLines, completions, keysIn,
76
84
  } from './input.mjs';
77
85
  import { Select, WRITE_MY_OWN, pickFromLine, plainLines } from './select.mjs';
86
+ import {
87
+ isImage, localCopy, openerFor, parseComments, printable, roundListing, shotsDir, showGate,
88
+ thumbnailEscape, thumbnailProtocol,
89
+ } from './shots.mjs';
78
90
 
79
91
  // Re-exported because they were this file's before R81 moved them into the
80
92
  // shared ANSI helpers — and `keysIn` and `keyList` before R83 moved them beside
@@ -84,6 +96,15 @@ export { clip, keyList, keysIn, stripAnsi, visibleWidth, wrap };
84
96
 
85
97
  const ESC = '\x1b';
86
98
 
99
+ /** How many times `/review`'s merge is read again (five seconds apart) before it stops following. */
100
+ const REVIEW_FOLLOW_TRIES = 120;
101
+ /**
102
+ * What a session merging its card refuses (`refuseWhileMerging`): every act on
103
+ * its branch or its checkout. `/view`, `/open` and the commands that name
104
+ * another card stay.
105
+ */
106
+ const DISABLED_WHILE_MERGING = new Set(['pr', 'continue', 'clean', 'drop', 'close', 'cancel', 'develop', 'review']);
107
+
87
108
  /** Colours for a run's state. Muted, because the transcript is the content. */
88
109
  const STATE_COLOUR = {
89
110
  running: 'success',
@@ -216,6 +237,19 @@ export function permissionDecision(approval, key, machine = {}) {
216
237
  }
217
238
  }
218
239
 
240
+ /** What the "started" line adds when `--show` or `--no-show` was given. */
241
+ function shownWord(show) {
242
+ return show === true ? ', shown before its PR' : show === false ? ', not shown before its PR' : '';
243
+ }
244
+
245
+ const A_SHOW_GATE = 'a SHOW gate is decided after looking — a, /accept or /comments';
246
+
247
+ /** What a window's title says a session is stopped on. */
248
+ function waitingWord(kind) {
249
+ return kind === 'show' ? 'waiting for you'
250
+ : kind === 'question' ? 'waiting for an answer' : 'waiting for permission';
251
+ }
252
+
219
253
  /**
220
254
  * A pending permission request, drawn — R51 and R60's three answers.
221
255
  *
@@ -243,6 +277,22 @@ export function permissionBanner(pending, width, ink = painter(3), machine = {})
243
277
  // Called with a pending record by the client, and with the approval itself by
244
278
  // the rule's own tests — the banner is about the request either way.
245
279
  const approval = pending.approval ?? pending;
280
+ // R605. A SHOW gate is not a permission: nobody is asked to allow a call, they
281
+ // are asked to look. None of y / s / Y / M / n does anything on it.
282
+ const gate = showGate(approval);
283
+ if (gate) {
284
+ const count = gate.ids.length;
285
+ return [
286
+ `${ink.bold(ink.warn(' show '))} ${clip(`waiting for you — ${count} screenshot${count === 1 ? '' : 's'}, round ${gate.round}`, Math.max(8, width - 8))}`,
287
+ ink.muted(` ${approval.toolName} · waiting since ${approval.askedAt?.slice(11, 19) ?? ''}`),
288
+ ...wrapChoices([
289
+ `${ink.success('a')} look and decide`,
290
+ `${ink.success('/accept')}`,
291
+ `${ink.success('/comments')} …`,
292
+ `${ink.muted('/open on cawdev')}`,
293
+ ], width, ' · '),
294
+ ];
295
+ }
246
296
  const covers = approval.suggestion ?? `every ${approval.toolName}`;
247
297
 
248
298
  // The two standing-rule keys, asked here exactly as `permissionDecision`
@@ -301,7 +351,7 @@ export function permissionBanner(pending, width, ink = painter(3), machine = {})
301
351
  // A stage gate's summary is the whole PLAN, many lines long: one row here,
302
352
  // and how much more there is — `/view` reads it whole. Clipped as one
303
353
  // string, its newlines went through and filled the prompt.
304
- const [first = '', ...more] = String(approval.summary ?? '').split('\n');
354
+ let [first = '', ...more] = String(approval.summary ?? '').split('\n');
305
355
  const rest = more.filter((line) => line.trim()).length;
306
356
  const tail = rest ? ` (+${rest} lines)` : '';
307
357
  return [
@@ -823,6 +873,20 @@ function withAgent(body, agent) {
823
873
  return agent ? { ...body, agent } : body;
824
874
  }
825
875
 
876
+ /**
877
+ * The note a branch is sent back with when its merge hit a conflict — the
878
+ * order the next session on the card reads first. What to do, in that order,
879
+ * and what not to touch.
880
+ */
881
+ export function conflictOrder(review, why) {
882
+ return [
883
+ `The merge of ${review.branch} into ${review.base} stopped on a conflict: ${why}`,
884
+ '',
885
+ `Merge origin/${review.base} into ${review.branch} and resolve every conflict, keeping what both sides meant.`,
886
+ 'Change nothing else on this card. Run the tests, then commit the merge and push the branch.',
887
+ ].join('\n');
888
+ }
889
+
826
890
  /** What was started, saying with which agent when one was named. */
827
891
  function withWho(what, agent) {
828
892
  return agent ? `${what} with ${agent}` : what;
@@ -838,6 +902,8 @@ function refOf(word) {
838
902
 
839
903
  /** How many matching commands advanced mode's prompt shows at once. */
840
904
  const SPLIT_COMPLETIONS = 6;
905
+ /** How often the cards' rain moves a step (session-cards.mjs `RAIN`): eight a second. */
906
+ const RAIN_STEP_MS = 120;
841
907
 
842
908
  /** The slash commands, and the one place they are described. */
843
909
  const COMMANDS = [
@@ -848,17 +914,25 @@ const COMMANDS = [
848
914
  ['/config', "this machine's config — the same as c"],
849
915
  ['/cancel', 'cancel the session you are watching'],
850
916
  ['/log', "the daemon's own log, on or off"],
851
- ['/advanced-mode', 'one window per session around the prompt, for the projects you tick (@slug names them), or back'],
852
- ['/new', 'start a session here: /new plan|dev R3, /new plan|dev <the task>, /new ask … (@project)'],
853
- ['/card', 'add an idea to Considering — no session: /card <the idea> (@project)'],
917
+ ['/advanced-mode', 'a card per session above the prompt, for the projects you tick (@slug names them), or back; /view shows one session\'s log'],
918
+ ['/new', 'start a session here: /new plan|dev R3, /new plan|dev <the task>, /new ask … (@project); --show / --no-show on dev: shown before its PR, or not; several cards in order: /new plan|dev|plan+dev R3 R5 R7 (dev: --in-order [--branch name] for one branch)'],
919
+ ['/import', 'file the cards of a JSON file: /import <file.json> (@project); --dry-run only checks; --status <S> where cards naming none land; a taken title is skipped, --allow-duplicates files it'],
920
+ ['/card', 'add an idea to Considering — no session: /card <the idea> (@project) (--show: shown before its PR)'],
854
921
  ['/agent', 'which agent /new uses: /agent claude|codex|agy, /agent default, /agent to see it'],
855
922
  ['/develop', 'start the development of the card a finished plan was about, or of one named: /develop R30 (--codex) (@project)'],
856
923
  ['/pr', "open the pull request of a finished session's branch"],
924
+ ['/review', "the branch's diff, here: /review for the session's card, /review R42 (@project); then d Done, c changes requested, m merge — or /review done|changes <note>|merge|agent-merge"],
857
925
  ['/continue', 'pick a failed or stopped session back up on this machine — the console\'s Carry on'],
926
+ ['/clean', 'remove the git lock a crash left in a stopped session\'s checkout (index.lock: File exists), then /continue'],
858
927
  ['/projects', 'which projects advanced mode shows — tick one or several'],
928
+ ['/board', 'focus advanced mode\'s roadmap strip (b too): arrows browse cards and show their full title and description'],
859
929
  ['/refresh', "read the cards and their statuses from cawdev again — advanced mode's roadmap"],
860
- ['/open', "open the session's pull request in your browser — on a finished plan, its card and plan; on one waiting for you, its page on cawdev"],
861
- ['/view', "a card here in the terminal: /view R29, /view <words of its title> (@project), /view card for the session's; /view alone reads what a session waits on in full, or opens it on cawdev"],
930
+ ['/open', "open the session's pull request in your browser — on a finished plan, its card and plan; on one waiting for you or at SHOW, its page on cawdev"],
931
+ ['/view', "in advanced mode, a session's log over the whole screen: /view for the focused one, /view 2 for card 2, /view R29 for the session on R29 (v too); a card here in the terminal: /view R29 with no session on it, /view <words of its title> (@project), /view card for the session's; /view alone reads what a session waits on in full (at SHOW, looks and decides), or, outside advanced mode, opens it on cawdev"],
932
+ ['/matrix', 'the rain on advanced mode\'s cards, on or off: /matrix on|off, remembered'],
933
+ ['/shots', 'the screenshots a session waiting at SHOW took: /shots, /shots 2, /shots all, /shots round 1'],
934
+ ['/accept', 'accept the screenshots — the pull request opens'],
935
+ ['/comments', 'send comments on the screenshots: /comments <text>, #2 for one screenshot'],
862
936
  ['/drop', 'delete the checkout a finished session was made on demand for'],
863
937
  ['/close', "close an ended session's window"],
864
938
  ['/reload', 'quit and start again on the code on disk — the runner too, when nothing is running'],
@@ -977,6 +1051,15 @@ export class Attached {
977
1051
  * otherwise make a picker titled with the first one's command look current.
978
1052
  */
979
1053
  this.deciding = null;
1054
+ /**
1055
+ * The round a SHOW gate names, as the platform's sealed list confirms it —
1056
+ * R605. `{runId, approvalId, slug, round, shots, confirmed, rounds, select}`.
1057
+ * Read when the person asks to look, not before: nobody is asked about
1058
+ * screenshots on a terminal that never opens them.
1059
+ */
1060
+ this.showing = null;
1061
+ /** The comment lines written so far at a SHOW gate — R605 — `{lines}`. */
1062
+ this.commenting = null;
980
1063
  this.status = '';
981
1064
  this.showLog = false;
982
1065
  this.asked = new Set();
@@ -1068,7 +1151,9 @@ export class Attached {
1068
1151
  this.screen.open();
1069
1152
  this.history = new History(await loadHistory(this.session.url).catch(() => []));
1070
1153
  // `/agent`'s choice, from last time — see prefs.mjs.
1071
- this.agent = (await loadPrefs(this.session.url).catch(() => ({}))).agent ?? null;
1154
+ const prefs = await loadPrefs(this.session.url).catch(() => ({}));
1155
+ this.agent = prefs.agent ?? null;
1156
+ this.matrix = prefs.matrix !== false;
1072
1157
  // Raw mode only where there is a terminal to put in it. Through a pipe
1073
1158
  // there are no keys, the transcript is the whole output, and that is the
1074
1159
  // honest degradation rather than a broken one.
@@ -1124,6 +1209,15 @@ export class Attached {
1124
1209
  // busy session emits hundreds of lines a second, and a write per line means
1125
1210
  // an erase-and-redraw of the whole footer per line.
1126
1211
  const tick = setInterval(() => {
1212
+ // The cards' rain moves one step every RAIN_STEP_MS, and only while
1213
+ // somebody can see it: not under the reader, not with `/matrix off`.
1214
+ if (this.split && !this.reader && this.matrix !== false) {
1215
+ const step = Math.floor(Date.now() / RAIN_STEP_MS);
1216
+ if (step !== this.rainTick) {
1217
+ this.rainTick = step;
1218
+ this.dirty = true;
1219
+ }
1220
+ }
1127
1221
  if (this.dirty || this.pending.length) {
1128
1222
  this.flush();
1129
1223
  }
@@ -1441,6 +1535,12 @@ export class Attached {
1441
1535
  return run ? this.approvals.get(run.id) ?? null : null;
1442
1536
  }
1443
1537
 
1538
+ /** The pending approval, when it is a SHOW gate — R605 — else null. */
1539
+ showOn(run) {
1540
+ const pending = this.pendingOn(run);
1541
+ return pending && showGate(pending.approval) ? pending : null;
1542
+ }
1543
+
1444
1544
  /**
1445
1545
  * What the watched session is stopped on — R58, and since R137 both shapes.
1446
1546
  *
@@ -1606,7 +1706,9 @@ export class Attached {
1606
1706
  this.dirty = true;
1607
1707
 
1608
1708
  const mine = this.deciding?.approvalId === pending.approval.id;
1609
- const onScreen = this.select?.kind === 'permission' || this.input?.kind === 'reason';
1709
+ const onScreen = this.select?.kind === 'permission' || this.input?.kind === 'reason'
1710
+ || this.select?.kind === 'show' || this.select?.kind === 'show-round'
1711
+ || this.input?.kind === 'comments';
1610
1712
  if (mine && onScreen) {
1611
1713
  this.mode = 'keys';
1612
1714
  this.select = null;
@@ -1614,6 +1716,10 @@ export class Attached {
1614
1716
  this.history.reset();
1615
1717
  this.deciding = null;
1616
1718
  }
1719
+ if (mine) {
1720
+ this.showing = null;
1721
+ this.commenting = null;
1722
+ }
1617
1723
 
1618
1724
  const by = record?.decidedByEmail;
1619
1725
  const verb = record?.state === 'DENIED' ? 'refused'
@@ -1665,9 +1771,23 @@ export class Attached {
1665
1771
  this.confirming = null;
1666
1772
  }
1667
1773
 
1774
+ if (this.split?.boardFocus && this.mode === 'keys') {
1775
+ return this.onBoardKey(key);
1776
+ }
1777
+ if (this.split && key === 'b') {
1778
+ return void this.toggleBoard();
1779
+ }
1668
1780
  if (this.split && (key === '\t' || key === `${ESC}[Z`)) {
1669
1781
  return this.cycleFocus(key === '\t' ? 1 : -1);
1670
1782
  }
1783
+ if (['\r', '\n', 'i', 'd', 'x'].includes(key) && this.refuseWhileMerging()) {
1784
+ return undefined;
1785
+ }
1786
+ if (this.split && key === 'v') {
1787
+ // A card's log — `/view`'s, over the whole screen.
1788
+ const meta = this.commandTarget();
1789
+ return meta ? this.viewSession(meta) : this.note('no session yet — /new starts one');
1790
+ }
1671
1791
  if (this.split && key === 'd') {
1672
1792
  // On a finished plan's window, `d` is that plan's `/develop`: the card
1673
1793
  // it planned, by the agent that planned it, in the window it leaves.
@@ -1711,6 +1831,12 @@ export class Attached {
1711
1831
  this.showLog = !this.showLog;
1712
1832
  return this.note(this.showLog ? "printing the daemon's log" : 'printing the session');
1713
1833
  case 'a': {
1834
+ // R605: at a SHOW gate `a` is look-and-decide, not an answer.
1835
+ const shown = this.showOn(this.current());
1836
+ if (shown) {
1837
+ if (!this.requireSignIn('look and decide')) return undefined;
1838
+ return void this.lookAndDecide(this.current());
1839
+ }
1714
1840
  // R58: the refusal is here rather than at the API, so nobody types an
1715
1841
  // answer into a session that is not going to take it.
1716
1842
  const asking = this.askingOn(this.current());
@@ -1727,8 +1853,12 @@ export class Attached {
1727
1853
  case 's':
1728
1854
  case 'Y':
1729
1855
  case 'M':
1856
+ // R605: `s` on a SHOW gate used to accept the round and open the pull
1857
+ // request in one key, without anybody having looked.
1858
+ if (this.showOn(this.current())) return this.note(A_SHOW_GATE);
1730
1859
  return void this.allow(key);
1731
1860
  case 'n': {
1861
+ if (this.showOn(this.current())) return this.note(A_SHOW_GATE);
1732
1862
  if (!this.pendingOn(this.current())) {
1733
1863
  return this.note('nothing is waiting for permission on this one');
1734
1864
  }
@@ -1783,13 +1913,19 @@ export class Attached {
1783
1913
 
1784
1914
  /** Everything open, closed. Returns whether there was anything. */
1785
1915
  closeEverything() {
1786
- const open = this.mode !== 'keys' || Boolean(this.reader);
1916
+ const open = this.mode !== 'keys' || Boolean(this.reader) || Boolean(this.split?.boardFocus);
1787
1917
  this.reader = null;
1918
+ if (this.split?.boardFocus) {
1919
+ this.split.lastBoardFocus = this.split.boardFocus;
1920
+ this.split.boardFocus = null;
1921
+ }
1788
1922
  this.mode = 'keys';
1789
1923
  this.select = null;
1790
1924
  this.input = null;
1791
1925
  this.answering = null;
1792
1926
  this.deciding = null;
1927
+ this.showing = null;
1928
+ this.commenting = null;
1793
1929
  this.history.reset();
1794
1930
  this.dirty = true;
1795
1931
  // A config question closed this way is cancelled, not left waiting — R288.
@@ -2032,6 +2168,15 @@ export class Attached {
2032
2168
  this.mode = 'keys';
2033
2169
  this.history.reset();
2034
2170
 
2171
+ if (kind === 'comments') {
2172
+ // R605. Before the empty-line rule, on purpose: here an empty line SENDS.
2173
+ return this.commentLine(this.pastes.expand(typed), back);
2174
+ }
2175
+ if (kind === 'review-note') {
2176
+ // Empty sends nothing and goes back to the diff: a verdict with no note is refused anyway.
2177
+ if (!typed) return this.reopenReview();
2178
+ return void this.reviewChanges(this.pastes.expand(typed));
2179
+ }
2035
2180
  if (kind === 'config') {
2036
2181
  // Before the empty-line rule, on purpose: every config question shows a
2037
2182
  // default in its label, and enter on an empty line TAKES it — R288.
@@ -2114,7 +2259,8 @@ export class Attached {
2114
2259
  // A question's or a permission's list holds only the start of what is
2115
2260
  // asked; `/` leaves it for a command — `/view` reads it whole, `/open`
2116
2261
  // answers it on cawdev — and `a` (or `y`/`n`) brings the choice back.
2117
- if (key === '/' && (select.kind === 'question' || select.kind === 'permission')) {
2262
+ if (key === '/' && (select.kind === 'question' || select.kind === 'permission'
2263
+ || select.kind === 'show' || select.kind === 'show-round')) {
2118
2264
  this.mode = 'keys';
2119
2265
  this.select = null;
2120
2266
  this.answering = null;
@@ -2138,6 +2284,9 @@ export class Attached {
2138
2284
  // is being written when none is is one the next reader will believe.
2139
2285
  this.answering = null;
2140
2286
  this.deciding = null;
2287
+ if (select.kind === 'show' || select.kind === 'show-round') {
2288
+ this.commenting = null;
2289
+ }
2141
2290
  if (select.kind === 'config') {
2142
2291
  this.configuring?.cancel?.();
2143
2292
  }
@@ -2191,6 +2340,9 @@ export class Attached {
2191
2340
  }
2192
2341
  return void this.answer(row.label);
2193
2342
  }
2343
+ if (select.kind === 'show' || select.kind === 'show-round') {
2344
+ return this.choseShow(select, row, typed);
2345
+ }
2194
2346
  if (select.kind === 'permission') {
2195
2347
  if (row.id === 'refuse') {
2196
2348
  return this.type('reason', 'refuse, because ▸', '', select);
@@ -2206,7 +2358,9 @@ export class Attached {
2206
2358
  }
2207
2359
  if (select.kind === 'planned') {
2208
2360
  const [slug, ref] = String(row.id).split(' ');
2209
- return void this.startNew(['dev', ref, `@${slug}`]).then(() => this.refreshPlanned());
2361
+ const flags = this.plannedFlags ?? [];
2362
+ this.plannedFlags = null;
2363
+ return void this.startNew(['dev', ref, `@${slug}`, ...flags]).then(() => this.refreshPlanned());
2210
2364
  }
2211
2365
  if (select.kind === 'card-project') {
2212
2366
  // `/card` asked which project; the same card, with it named.
@@ -2244,6 +2398,13 @@ export class Attached {
2244
2398
  this.configuring?.answer?.(typed);
2245
2399
  return undefined;
2246
2400
  }
2401
+ if (this.input?.kind === 'comments' && !this.select) {
2402
+ // The plain path's half of R605: lines are held until an empty one.
2403
+ const back = this.input.back;
2404
+ this.input = null;
2405
+ this.mode = 'keys';
2406
+ return this.commentLine(String(text), back);
2407
+ }
2247
2408
  if (this.select) {
2248
2409
  const picked = pickFromLine(this.select, typed);
2249
2410
  if (picked) {
@@ -2403,6 +2564,22 @@ export class Attached {
2403
2564
  if (!run) return;
2404
2565
 
2405
2566
  const pending = this.pendingOn(run);
2567
+ if (pending && !this.announced.has(pending.approval.id) && showGate(pending.approval)) {
2568
+ // R605. Not a permission: nobody is shown three lengths of yes. It says
2569
+ // what is waiting and, in a terminal that can draw a picker, opens the
2570
+ // one that looks and decides.
2571
+ this.announced.add(pending.approval.id);
2572
+ const ink = this.ink;
2573
+ const about = pending.approval.id;
2574
+ const gate = showGate(pending.approval);
2575
+ this.sayAbout(about, '');
2576
+ this.sayAbout(about, `${ink.bold(ink.warn(' show '))} ${ink.muted('from')} ${ink.bold(this.tagOf(run))}`);
2577
+ this.sayAbout(about, ` ${ink.text(`${gate.ids.length} screenshot${gate.ids.length === 1 ? '' : 's'}, round ${gate.round} — a looks and decides`)}`);
2578
+ if (this.mode === 'keys' && this.session.signedIn) {
2579
+ void this.lookAndDecide(run);
2580
+ }
2581
+ return;
2582
+ }
2406
2583
  if (pending && !this.announced.has(pending.approval.id)) {
2407
2584
  this.announced.add(pending.approval.id);
2408
2585
  const ink = this.ink;
@@ -2838,6 +3015,10 @@ export class Attached {
2838
3015
 
2839
3016
  async runCommand(text) {
2840
3017
  const [word, ...rest] = commandWords(text.slice(1));
3018
+ // A merge asked for once is confirmed by the very next command, or not at all.
3019
+ if (this.reviewing && !(word === 'review' && rest[0] === 'merge')) this.reviewing.confirmingMerge = false;
3020
+ // `/develop R30` and `/review R30` name another card; bare, they are this one's.
3021
+ if (DISABLED_WHILE_MERGING.has(word) && !rest.length && this.refuseWhileMerging()) return undefined;
2841
3022
  switch (word) {
2842
3023
  case 'help':
2843
3024
  this.say('');
@@ -2848,7 +3029,7 @@ export class Attached {
2848
3029
  this.say('');
2849
3030
  this.say(this.ink.bold(' keys'));
2850
3031
  this.say(this.ink.muted(' enter prompt · / command · L runs · 1-9 pick a run · c config'));
2851
- this.say(this.ink.muted(' a answer · y/s/Y/n permission · x cancel · g log · q quit'));
3032
+ this.say(this.ink.muted(' a answer, or look and decide at SHOW · y/s/Y/n permission · x cancel · g log · q quit'));
2852
3033
  this.say(this.ink.muted(' in a list: ↑↓ move · 1-9 pick · enter choose · esc leave'));
2853
3034
  this.say(this.ink.muted(' while typing: ↑↓ history · tab complete · esc back · ctrl+c twice quits'));
2854
3035
  this.say('');
@@ -2883,25 +3064,48 @@ export class Attached {
2883
3064
  }
2884
3065
  case 'projects':
2885
3066
  return this.askSplitProjects();
3067
+ case 'board': {
3068
+ const want = rest[0] === 'off' ? false : rest[0] === 'on' ? true : undefined;
3069
+ return this.toggleBoard(want);
3070
+ }
2886
3071
  case 'refresh':
2887
3072
  return void this.refreshCards();
2888
3073
  case 'new':
2889
3074
  return void this.startNew(rest);
2890
3075
  case 'card':
2891
3076
  return void this.addCard(rest);
3077
+ case 'import':
3078
+ return void this.importFile(rest);
2892
3079
  case 'agent':
2893
3080
  return void this.chooseAgent(rest[0]);
2894
3081
  case 'develop':
2895
3082
  return void this.develop(rest);
2896
3083
  case 'pr':
2897
3084
  return void this.openPullRequest();
3085
+ case 'review':
3086
+ return void this.review(rest);
2898
3087
  case 'continue':
2899
3088
  return void this.carryOn();
3089
+ case 'clean':
3090
+ return void this.cleanLock();
2900
3091
  case 'open':
2901
3092
  return void this.openInBrowser();
2902
- case 'view':
3093
+ case 'shots':
3094
+ case 'accept':
3095
+ case 'comments':
3096
+ return void this.showCommand(word, rest);
3097
+ case 'view': {
3098
+ // In advanced mode a window's number, or a ref one of them is on, is
3099
+ // that session's log; any other words are a card, as everywhere.
3100
+ const session = this.split ? this.windowNamed(rest) : null;
3101
+ if (session) return this.viewSession(session);
2903
3102
  if (rest.length) return void this.viewCard(rest);
3103
+ if (this.showOn(this.runOf(this.commandTarget()))) return this.showCommand('view', []);
3104
+ if (this.split && this.commandTarget()) return this.viewSession(this.commandTarget());
2904
3105
  return this.waitingOn(this.commandTarget()) ? this.openReader() : this.viewOnCawdev();
3106
+ }
3107
+ case 'matrix':
3108
+ return void this.chooseMatrix(rest[0]);
2905
3109
  case 'drop':
2906
3110
  return void this.dropCheckout();
2907
3111
  case 'close':
@@ -3006,9 +3210,17 @@ export class Attached {
3006
3210
  // what was known.
3007
3211
  agent: run.agent !== undefined ? run.agent : meta.agent ?? null,
3008
3212
  model: run.model !== undefined ? run.model : meta.model ?? null,
3213
+ // The stage it is on and the walk it is part of, for its card's
3214
+ // progress line; a daemon older than the field sends neither.
3215
+ stage: run.stage ?? null,
3216
+ stages: run.stages ?? meta.stages ?? null,
3217
+ // A stage is a process with its own start; the card's clock is the
3218
+ // session's, from the first one this terminal saw.
3219
+ startedAt: meta.startedAt ?? run.startedAt ?? null,
3009
3220
  ended: false,
3010
3221
  // A carried-on run the daemon has taken: the ask is answered.
3011
3222
  carryAsked: false,
3223
+ lockCleaned: false,
3012
3224
  });
3013
3225
  if (!known) {
3014
3226
  this.sessions.set(run.id, meta);
@@ -3231,6 +3443,9 @@ export class Attached {
3231
3443
  if (!this.windows().some((meta) => meta.id === this.split.focus)) {
3232
3444
  this.split.focus = this.windows()[0]?.id ?? null;
3233
3445
  }
3446
+ if (this.split.boardFocus && !this.split.projects.has(this.split.boardFocus.slug)) {
3447
+ this.leaveBoard();
3448
+ }
3234
3449
  this.leaveHiddenWatch();
3235
3450
  this.say(this.ink.muted(`showing ${slugs.join(', ')} — /projects changes it`));
3236
3451
  this.dirty = true;
@@ -3251,6 +3466,11 @@ export class Attached {
3251
3466
  // The projects this mode shows; null is every one this machine serves.
3252
3467
  projects: shown,
3253
3468
  focus: null,
3469
+ boardFocus: null,
3470
+ lastBoardFocus: null,
3471
+ boardScroll: 0,
3472
+ cardCache: new Map(),
3473
+ cardLoading: new Set(),
3254
3474
  messages: [],
3255
3475
  enteredWatching: this.watching,
3256
3476
  mark: new Map([...this.lines].map(([id, lines]) => [id, lines.length])),
@@ -3265,8 +3485,8 @@ export class Attached {
3265
3485
  void this.refreshPlanned();
3266
3486
  this.split.plannedTimer = setInterval(() => void this.refreshPlanned(), 30_000);
3267
3487
  this.split.plannedTimer.unref?.();
3268
- this.say(this.ink.muted('advanced mode — tab moves between windows · /new starts a session'
3269
- + ' · /projects picks what is shown · /advanced-mode leaves'));
3488
+ this.say(this.ink.muted('advanced mode — tab moves between cards · b browses the roadmap · v shows one\'s log'
3489
+ + ' · /new starts a session · /projects picks what is shown · /advanced-mode leaves'));
3270
3490
  if (shown) {
3271
3491
  this.say(this.ink.muted(`showing ${[...shown].join(', ')}`));
3272
3492
  }
@@ -3310,12 +3530,235 @@ export class Attached {
3310
3530
  }
3311
3531
 
3312
3532
  cycleFocus(step) {
3533
+ if (this.split?.boardFocus) this.leaveBoard();
3313
3534
  const windows = this.windows();
3314
3535
  if (!windows.length) return this.note('no session windows yet — /new starts one');
3315
3536
  const at = windows.findIndex((meta) => meta.id === this.split.focus);
3316
3537
  return this.focusWindow(windows[(at + step + windows.length) % windows.length].id);
3317
3538
  }
3318
3539
 
3540
+ /**
3541
+ * `b` or `/board`: put focus into the roadmap strip at the top so arrows walk
3542
+ * its columns and cards, showing each card's full title and description in a
3543
+ * preview panel directly below the strip.
3544
+ */
3545
+ async toggleBoard(want = !this.split?.boardFocus) {
3546
+ if (!this.split) {
3547
+ return this.note('the roadmap strip is in advanced mode — /advanced-mode starts it');
3548
+ }
3549
+ if (!want) {
3550
+ return this.leaveBoard();
3551
+ }
3552
+ const width = this.split.screen.width;
3553
+ let columns = stripColumns(width, this.cards, this.shownProjects());
3554
+ if (!columns.length && this.session.signedIn) {
3555
+ await this.refreshPlanned();
3556
+ if (!this.split) return undefined;
3557
+ columns = stripColumns(width, this.cards, this.shownProjects());
3558
+ }
3559
+ if (!columns.length) {
3560
+ return this.note('no cards on the roadmap strip here yet');
3561
+ }
3562
+ const picked = moveInStrip(columns, this.split.lastBoardFocus, 0, 0);
3563
+ this.split.boardFocus = picked;
3564
+ this.split.boardScroll = 0;
3565
+ this.dirty = true;
3566
+ void this.loadBoardCard(picked.slug, picked.ref);
3567
+ return this.note('');
3568
+ }
3569
+
3570
+ /** Return focus from the roadmap strip to the session cards. */
3571
+ leaveBoard() {
3572
+ if (!this.split?.boardFocus) return this.note('');
3573
+ this.split.lastBoardFocus = this.split.boardFocus;
3574
+ this.split.boardFocus = null;
3575
+ this.split.boardScroll = 0;
3576
+ this.dirty = true;
3577
+ return this.note('');
3578
+ }
3579
+
3580
+ /** Step `(dCol, dRow)` in the roadmap strip and fetch the newly focused card's description. */
3581
+ moveBoardFocus(dCol, dRow) {
3582
+ if (!this.split) return undefined;
3583
+ const columns = stripColumns(this.split.screen.width, this.cards, this.shownProjects());
3584
+ const next = moveInStrip(columns, this.split.boardFocus, dCol, dRow);
3585
+ if (!next) {
3586
+ return this.leaveBoard();
3587
+ }
3588
+ this.split.boardFocus = next;
3589
+ this.split.lastBoardFocus = next;
3590
+ this.split.boardScroll = 0;
3591
+ this.dirty = true;
3592
+ void this.loadBoardCard(next.slug, next.ref);
3593
+ return this.note('');
3594
+ }
3595
+
3596
+ /** Scroll the focused card's description inside the preview panel. */
3597
+ scrollBoard(by) {
3598
+ if (!this.split?.boardFocus) return undefined;
3599
+ const max = this.split.boardMax ?? 0;
3600
+ this.split.boardScroll = Math.min(Math.max(0, (this.split.boardScroll ?? 0) + by), max);
3601
+ this.dirty = true;
3602
+ return undefined;
3603
+ }
3604
+
3605
+ /** Read and cache a card's full record (`body`, metadata) for the board preview panel. */
3606
+ async loadBoardCard(slug, ref) {
3607
+ if (!this.split) return;
3608
+ const key = `${slug}:${ref}`;
3609
+ if (this.split.cardCache.has(key) || this.split.cardLoading.has(key)) return;
3610
+ const existing = this.cards.find((card) => card.slug === slug && card.ref === ref);
3611
+ if (existing?.body !== undefined) {
3612
+ this.split.cardCache.set(key, existing);
3613
+ return;
3614
+ }
3615
+ if (!this.session.signedIn) return;
3616
+ this.split.cardLoading.add(key);
3617
+ this.dirty = true;
3618
+ try {
3619
+ const detail = await this.session.request(`/api/projects/${slug}/roadmap/${ref}`);
3620
+ if (detail && this.split) {
3621
+ this.split.cardCache.set(key, detail);
3622
+ }
3623
+ } catch {
3624
+ // The strip's own title and status remain shown even if reading the body fails.
3625
+ } finally {
3626
+ if (this.split) {
3627
+ this.split.cardLoading.delete(key);
3628
+ this.dirty = true;
3629
+ }
3630
+ }
3631
+ }
3632
+
3633
+ /** The focused roadmap card, merged with its cached full record (`body`, `section`, …). */
3634
+ boardCardOf(focus) {
3635
+ if (!focus) return null;
3636
+ const base = this.cards.find((card) => card.slug === focus.slug && card.ref === focus.ref);
3637
+ const cached = this.split?.cardCache?.get(`${focus.slug}:${focus.ref}`);
3638
+ if (!base && !cached) return null;
3639
+ return { ...base, ...cached, slug: focus.slug, ref: focus.ref };
3640
+ }
3641
+
3642
+ /** Keys while focus is in the top roadmap strip (`this.split.boardFocus`). */
3643
+ onBoardKey(key) {
3644
+ const focus = this.split.boardFocus;
3645
+ switch (key) {
3646
+ case `${ESC}[A`: case `${ESC}OA`: case 'k':
3647
+ return this.moveBoardFocus(0, -1);
3648
+ case `${ESC}[B`: case `${ESC}OB`: case 'j':
3649
+ return this.moveBoardFocus(0, 1);
3650
+ case `${ESC}[D`: case `${ESC}OD`: case 'h':
3651
+ return this.moveBoardFocus(-1, 0);
3652
+ case `${ESC}[C`: case `${ESC}OC`: case 'l':
3653
+ return this.moveBoardFocus(1, 0);
3654
+ case `${ESC}[6~`: case ' ':
3655
+ return this.scrollBoard(this.split.boardRoom ?? 1);
3656
+ case `${ESC}[5~`:
3657
+ return this.scrollBoard(-(this.split.boardRoom ?? 1));
3658
+ case 'p':
3659
+ this.leaveBoard();
3660
+ return void this.startNew(['plan', focus.ref, `@${focus.slug}`]);
3661
+ case 'd':
3662
+ this.leaveBoard();
3663
+ return void this.startNew(['dev', focus.ref, `@${focus.slug}`]);
3664
+ case 'v': case '\r': case '\n':
3665
+ return void this.showCard(focus.slug, focus.ref);
3666
+ case ESC: case 'b':
3667
+ return this.leaveBoard();
3668
+ case '\t': case `${ESC}[Z`:
3669
+ this.leaveBoard();
3670
+ return this.cycleFocus(key === '\t' ? 1 : -1);
3671
+ case '/':
3672
+ return this.type('command', 'cawdev ▸', '/');
3673
+ default:
3674
+ if (/^[1-9]$/.test(key)) {
3675
+ this.leaveBoard();
3676
+ return this.onKey(key);
3677
+ }
3678
+ return undefined;
3679
+ }
3680
+ }
3681
+
3682
+ /**
3683
+ * What a session's card says (session-cards.mjs `cardLines`): its kind, its
3684
+ * card, where it stands, the agent and checkout, the stage it is on, and ONE
3685
+ * line of what it is doing — or, while it waits on you, what it waits on.
3686
+ * Its log is `/view`'s.
3687
+ */
3688
+ cardOf(meta, n, rect, now = Date.now()) {
3689
+ const ink = this.ink;
3690
+ const run = this.runs.find((each) => each.id === meta.id);
3691
+ const shownGate = this.showOn(run);
3692
+ // A SHOW gate is an approval too, but not one to allow: it is looked at.
3693
+ const pending = shownGate ? null : this.pendingOn(run);
3694
+ const asking = this.askingOn(run);
3695
+ const view = meta.view ?? {};
3696
+ const room = rect.width - 4;
3697
+ const waiting = shownGate ? 'waiting · screenshots'
3698
+ : pending ? 'waiting · permission'
3699
+ : asking ? (asking.yours ? 'waiting on you' : 'waiting · a question') : null;
3700
+ const who = agentModelFor(meta);
3701
+ // R605. At SHOW the card says what it is waiting for, in amber; the agent,
3702
+ // model and path come back once the gate is settled.
3703
+ const gate = shownGate ? showGate(shownGate.approval) : null;
3704
+ const subtitle = gate
3705
+ ? ink.warn(showWaitingLine(gate, gate.ids.length))
3706
+ : (who ? `${ink.strong(who)} ` : '') + ink.muted(subtitleFor(meta, room - (who ? who.length + 1 : 0)));
3707
+ const progress = !meta.ended
3708
+ ? stageWalk(meta.stages, meta.stage, room, ink)
3709
+ : meta.commits !== undefined && (view.profile ?? 'CODE') === 'CODE' && view.state === 'FINISHED'
3710
+ ? ink.muted(`${meta.commits} commit${meta.commits === 1 ? '' : 's'}${view.branch ? ` on ${view.branch}` : ''}`)
3711
+ : '';
3712
+ const doing = pending
3713
+ ? ink.warn(`◆ ${pending.approval?.toolName ?? 'a tool'}: ${String(pending.approval?.summary ?? '').split('\n')[0]}`)
3714
+ : asking
3715
+ ? ink.warn(`◆ ${String(asking.question?.question ?? '').split('\n')[0]}`)
3716
+ : nowLine(meta, this.lines.get(meta.id) ?? []);
3717
+ const keys = pending ? 'y once · s session · n refuse · /view reads it'
3718
+ : asking?.yours ? 'a answers · /view reads it whole'
3719
+ : shownGate ? 'a to look and decide' : actionsFor(meta);
3720
+ const merging = meta.merging;
3721
+ if (merging) {
3722
+ const failed = merging.state === 'failed';
3723
+ const resolving = merging.state === 'resolving';
3724
+ return {
3725
+ n,
3726
+ profile: view.profile ?? meta.profile ?? null,
3727
+ title: `${merging.ref} ${view.entryTitle ?? view.label ?? meta.label ?? ''}`.trim(),
3728
+ status: failed ? { key: 'stopped', mark: '✕', word: 'merge failed' }
3729
+ : resolving ? { key: 'waiting', mark: '⟳', word: 'conflict · back to dev' }
3730
+ : { key: 'merging', mark: '⇢', word: 'merging' },
3731
+ lines: [
3732
+ ink.muted(`${merging.branch} → ${merging.base}`),
3733
+ failed ? ink.danger(`why: ${merging.why}`)
3734
+ : resolving ? ink.warn(`why: ${merging.why}`)
3735
+ : ink.muted('nothing to do here: it closes once it lands'),
3736
+ ],
3737
+ footer: failed ? '/review to look again'
3738
+ : resolving ? `its development starts again here to resolve it` : '',
3739
+ focused: meta.id === this.split.focus,
3740
+ seed: seedOf(meta.id),
3741
+ tick: this.rainTick ?? 0,
3742
+ rain: this.matrix !== false,
3743
+ base: merging.base,
3744
+ };
3745
+ }
3746
+ return {
3747
+ n,
3748
+ profile: view.profile ?? meta.profile ?? null,
3749
+ title: view.entryRef
3750
+ ? `${view.entryRef} ${view.entryTitle ?? view.label ?? meta.label ?? ''}`.trim()
3751
+ : (view.label ?? meta.label ?? 'a session'),
3752
+ status: cardStatus(meta, waiting, now),
3753
+ lines: [subtitle, progress || null, doing ? (pending || asking ? doing : ink.text(doing)) : null],
3754
+ footer: keys,
3755
+ focused: !this.split.boardFocus && meta.id === this.split.focus,
3756
+ seed: seedOf(meta.id),
3757
+ tick: this.rainTick ?? 0,
3758
+ rain: this.matrix !== false,
3759
+ };
3760
+ }
3761
+
3319
3762
  /** The whole alternate screen, as rows — see split.mjs. */
3320
3763
  splitFrame() {
3321
3764
  const { width, height } = this.split.screen;
@@ -3339,7 +3782,7 @@ export class Attached {
3339
3782
  ? `${tag}${this.input.label} ${this.input.line.render(
3340
3783
  Math.max(8, inner - visibleWidth(tag) - visibleWidth(this.input.label) - 1), ink)}`
3341
3784
  : tag + ink.muted(`enter prompts ${current ? `“${current.label}”` : 'the focused session'}`
3342
- + ' · / commands · tab next window');
3785
+ + ' · / commands · b board · tab next card · v its log');
3343
3786
  // A question or a request on screen says which window it came from.
3344
3787
  const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
3345
3788
  // Under the banner and right above the line, so a long one never pushes
@@ -3361,56 +3804,76 @@ export class Attached {
3361
3804
  // The roadmap across the top: as tall as its fullest column needs, never
3362
3805
  // more than a third of the screen, and never so tall that no window fits
3363
3806
  // under it — a short terminal drops it before it drops the sessions.
3364
- const stripRoom = Math.min(Math.max(4, Math.floor(height / 3)), height - promptRows - MIN_ZONE_HEIGHT);
3807
+ const stripRoom = Math.min(Math.max(4, Math.floor(height / 3)), height - promptRows - CARD_MIN_HEIGHT);
3808
+ if (this.split.boardFocus) {
3809
+ this.split.boardFocus = moveInStrip(
3810
+ stripColumns(width, this.cards, this.shownProjects()),
3811
+ this.split.boardFocus,
3812
+ 0,
3813
+ 0,
3814
+ );
3815
+ }
3365
3816
  // A rule under it, from the strip's own room: the roadmap is the board and
3366
3817
  // the windows are the sessions, and without it the strip's last card line
3367
3818
  // ran straight into the first window's border.
3368
3819
  const stripLines = stripRoom >= 4
3369
- ? [...roadmapStrip(width, stripRoom - 1, this.cards, ink, this.shownProjects()),
3820
+ ? [...roadmapStrip(width, stripRoom - 1, this.cards, ink, this.shownProjects(), this.split.boardFocus),
3370
3821
  ink.muted('─'.repeat(width))]
3371
3822
  : [];
3372
3823
  const strip = stripLines.length <= Math.max(0, stripRoom) ? stripLines.length : 0;
3373
- // Four windows at most; the rest are named on one line above the prompt,
3374
- // and Tab onto one of them brings it up in place of the least recent.
3375
- const shown = visibleSessions(windows, this.split.recent ?? [], this.split.focus);
3824
+
3825
+ // When focus is in the roadmap strip (`b` / `/board`), a preview box
3826
+ // directly under it shows the focused card's full title and description.
3827
+ const avail = Math.max(0, height - strip - promptRows);
3828
+ const focusedCard = this.split.boardFocus ? this.boardCardOf(this.split.boardFocus) : null;
3829
+ const previewMax = windows.length && avail >= CARD_MIN_HEIGHT + 5
3830
+ ? Math.max(5, avail - CARD_MIN_HEIGHT)
3831
+ : Math.max(5, avail);
3832
+ let previewLines = [];
3833
+ if (focusedCard && avail >= 5) {
3834
+ const loading = Boolean(this.split.cardLoading?.has(`${focusedCard.slug}:${focusedCard.ref}`));
3835
+ const drawn = boardPreviewLines(width, previewMax, focusedCard, ink, {
3836
+ top: this.split.boardScroll ?? 0,
3837
+ loading,
3838
+ });
3839
+ this.split.boardScroll = drawn.top;
3840
+ this.split.boardMax = drawn.max;
3841
+ this.split.boardRoom = drawn.room;
3842
+ previewLines = drawn.lines;
3843
+ }
3844
+ const preview = previewLines.length && previewLines.length <= avail ? previewLines.length : 0;
3845
+
3846
+ // The sessions are CARDS (session-cards.mjs): as many as fit, the rest
3847
+ // named on one line above the prompt, and Tab onto one of them brings it
3848
+ // up in place of the least recent. The prompt has every row they leave.
3849
+ const room = Math.max(0, height - strip - preview - promptRows);
3850
+ let grid = cardGrid(width, room, windows.length);
3851
+ if (grid.hidden) grid = cardGrid(width, room - 1, windows.length);
3852
+ const shown = visibleSessions(windows, this.split.recent ?? [], this.split.focus, grid.zones.length);
3376
3853
  const hidden = windows.filter((meta) => !shown.includes(meta));
3377
3854
  const tabs = hidden.length ? 1 : 0;
3378
- const layout = splitLayout(width, height - strip - tabs, shown.length, promptRows);
3379
- for (const rect of layout.zones) {
3380
- rect.top += strip;
3381
- }
3382
- layout.prompt.top += strip + tabs;
3855
+ const layout = {
3856
+ zones: grid.zones.map((rect) => ({ ...rect, top: rect.top + strip + preview })),
3857
+ prompt: {
3858
+ top: strip + preview + grid.height + tabs,
3859
+ left: 0,
3860
+ width,
3861
+ height: height - strip - preview - grid.height - tabs,
3862
+ },
3863
+ hidden: 0,
3864
+ };
3383
3865
 
3866
+ const now = Date.now();
3384
3867
  const pieces = layout.zones.map((rect, i) => {
3385
3868
  const meta = shown[i];
3386
- const n = windows.indexOf(meta) + 1;
3387
- const asking = this.askingOn(this.runs.find((run) => run.id === meta.id));
3388
- const waiting = this.pendingOn(this.runs.find((run) => run.id === meta.id))
3389
- ? 'waiting for permission'
3390
- : asking ? 'waiting for an answer' : null;
3391
- const lines = (this.lines.get(meta.id) ?? []).map((line) => this.render(line));
3392
- const body = lines.length ? lines : [ink.muted(meta.state === 'queued'
3393
- ? ` waiting — ${meta.why ?? 'no reason recorded'}`
3394
- : meta.ended ? ' nothing was said on this session here' : ' nothing said yet')];
3395
- return {
3396
- rect,
3397
- lines: windowLines(rect, {
3398
- title: `${n} ${titleFor(meta, waiting)}`,
3399
- // The agent and its model first, bold and underlined — which CLI and
3400
- // which model is what somebody comparing two windows looks for.
3401
- subtitle: (() => {
3402
- const who = agentModelFor(meta);
3403
- const lead = who ? `${ink.strong(who)} ` : '';
3404
- return lead + ink.muted(subtitleFor(meta, rect.width - 4 - (who ? who.length + 1 : 0)));
3405
- })(),
3406
- body,
3407
- footer: actionsFor(meta),
3408
- focused: meta.id === this.split.focus,
3409
- dotted: meta.id !== this.split.focus,
3410
- tone: toneFor(meta),
3411
- }, ink),
3412
- };
3869
+ return { rect, lines: sessionCardLines(rect, this.cardOf(meta, windows.indexOf(meta) + 1, rect, now), ink) };
3413
3870
  });
3871
+ if (preview) {
3872
+ pieces.push({
3873
+ rect: { top: strip, left: 0, width, height: preview },
3874
+ lines: previewLines,
3875
+ });
3876
+ }
3414
3877
 
3415
3878
  if (tabs) {
3416
3879
  pieces.push({
@@ -3479,7 +3942,10 @@ export class Attached {
3479
3942
  try {
3480
3943
  const cards = await this.session.request(`/api/projects/${slug}/roadmap?brief=true`);
3481
3944
  for (const card of Array.isArray(cards) ? cards : []) {
3482
- all.push({ ref: card.ref, title: card.title, slug, status: card.status, live: Boolean(card.activeRun) });
3945
+ all.push({
3946
+ ref: card.ref, title: card.title, slug, status: card.status, live: Boolean(card.activeRun),
3947
+ ...(card.body !== undefined ? { body: card.body } : {}),
3948
+ });
3483
3949
  }
3484
3950
  // The issues are another list on the platform (R85), and were never
3485
3951
  // read here: the strip drew a project's ideas and not one of its bugs.
@@ -3488,6 +3954,7 @@ export class Attached {
3488
3954
  all.push({
3489
3955
  ref: issue.ref, title: issue.title, slug, status: issue.status,
3490
3956
  live: Boolean(issue.activeRun), severity: issue.severity ?? 'MINOR',
3957
+ ...(issue.body !== undefined ? { body: issue.body } : {}),
3491
3958
  });
3492
3959
  }
3493
3960
  read.push(slug);
@@ -3588,9 +4055,12 @@ export class Attached {
3588
4055
  if (shown.length === 1 && !words.some((word) => word.startsWith('@'))) {
3589
4056
  words = [...words, `@${shown[0]}`];
3590
4057
  }
3591
- const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway' && word !== '--again');
4058
+ const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway' && word !== '--again'
4059
+ && word !== '--show' && word !== '--no-show');
3592
4060
  if (bare.length === 1 && ['dev', 'code'].includes(bare[0].toLowerCase())) {
3593
4061
  const named = words.find((word) => word.startsWith('@'));
4062
+ // R605: the choice of card comes later; the answer about SHOW does not wait for it.
4063
+ this.plannedFlags = words.filter((word) => word === '--show' || word === '--no-show');
3594
4064
  await this.pickPlanned(named ? named.slice(1) : null);
3595
4065
  return;
3596
4066
  }
@@ -3598,10 +4068,20 @@ export class Attached {
3598
4068
  // R369: the answer to "a session already finished here" — `--anyway`'s
3599
4069
  // shape, for a different question.
3600
4070
  const again = words.includes('--again');
4071
+ // R605: this run is shown before its PR, or is not, whatever the card says.
4072
+ // Absent is the card's own switch (R604).
4073
+ const asked = words.includes('--show');
4074
+ const refused = words.includes('--no-show');
4075
+ if (asked && refused) {
4076
+ this.note('--show or --no-show, not both');
4077
+ return;
4078
+ }
4079
+ const show = asked ? true : refused ? false : undefined;
3601
4080
  // `--codex`, `--claude`, `--agy`: this session's agent, over `/agent`'s.
3602
4081
  const flagged = words.find((word) => AGENT_FLAGS.has(word));
3603
4082
  let agent = flagged ? flagged.slice(2) : this.agent;
3604
- const parsed = parseNew(words.filter((word) => word !== '--anyway' && word !== '--again' && !AGENT_FLAGS.has(word)), {
4083
+ const parsed = parseNew(words.filter((word) => word !== '--anyway' && word !== '--again'
4084
+ && word !== '--show' && word !== '--no-show' && !AGENT_FLAGS.has(word)), {
3605
4085
  // In advanced mode, among the projects it shows.
3606
4086
  projects: this.shownProjects(),
3607
4087
  });
@@ -3622,13 +4102,21 @@ export class Attached {
3622
4102
  this.note(parsed.error);
3623
4103
  return;
3624
4104
  }
4105
+ if (parsed.refs) {
4106
+ await this.startBatch(parsed, { anyway, again, show, agent });
4107
+ return;
4108
+ }
4109
+ if (show !== undefined && parsed.kind !== 'dev') {
4110
+ this.note('--show is for a dev session — a plan or a question shows nothing');
4111
+ return;
4112
+ }
3625
4113
  if (parsed.kind === 'dev' && !parsed.ref) {
3626
4114
  // A dev from words writes its CARD first, then develops it exactly as
3627
4115
  // `/new dev R12` would. It used to be a manual session on a `task-…`
3628
4116
  // branch with no card: no number, nothing on the development board,
3629
4117
  // and a prompt that never said "commit" — so the work ended
3630
4118
  // uncommitted and `/pr` had nothing to propose.
3631
- await this.developNewCard(parsed.slug, parsed.text, anyway, agent);
4119
+ await this.developNewCard(parsed.slug, parsed.text, anyway, agent, show);
3632
4120
  return;
3633
4121
  }
3634
4122
  // Developing a card whose finished plan has a window here: the agent that
@@ -3648,7 +4136,8 @@ export class Attached {
3648
4136
  ? ['runs/plan', { entryNumber: parsed.ref }, `a plan of ${parsed.ref}`]
3649
4137
  // R150: the session writes the card first, then plans it.
3650
4138
  : ['runs/plan', { prompt: parsed.text }, 'a plan — it writes its card first']
3651
- : ['runs', { entryNumber: parsed.ref }, `the development of ${parsed.ref}`];
4139
+ : ['runs', { entryNumber: parsed.ref, ...(show === undefined ? {} : { show }) },
4140
+ `the development of ${parsed.ref}${shownWord(show)}`];
3652
4141
  const started = await this.startSession(parsed.slug, path, withAgent(body, agent), withWho(what, agent), anyway, again);
3653
4142
  if (started && plan) {
3654
4143
  this.closeWindowOf(plan);
@@ -3656,6 +4145,55 @@ export class Attached {
3656
4145
  }
3657
4146
  }
3658
4147
 
4148
+ /**
4149
+ * `/new plan|dev|plan+dev R3 R5 R7` — several cards as ONE batch, on this
4150
+ * machine (R606). The platform holds the order (the runs' chain), so the
4151
+ * terminal can be closed and the cards still go one after another; posting
4152
+ * N single starts would hold nothing.
4153
+ *
4154
+ * A dev batch is a branch each, queued here, or `--in-order` one branch;
4155
+ * a plan batch is in order; `plan+dev` builds each card after its plan
4156
+ * before the next is planned. `--again` and `--show` are one card's answers,
4157
+ * so a batch refuses them rather than applying them to every card.
4158
+ */
4159
+ async startBatch(parsed, { anyway, again, show, agent }) {
4160
+ if (again) {
4161
+ this.note('--again is for one card — a batch skips a card whose session already finished, and names it');
4162
+ return;
4163
+ }
4164
+ if (show !== undefined) {
4165
+ this.note("--show is for one card — on several, each card's own switch decides");
4166
+ return;
4167
+ }
4168
+ const body = {
4169
+ strategy: parsed.inOrder ? 'ONE_BRANCH_IN_ORDER' : 'QUEUED_ON_ONE',
4170
+ cards: parsed.refs.map((ref) => ({ entryNumber: ref })),
4171
+ ...(parsed.kind === 'dev' ? {} : { profile: 'PLAN' }),
4172
+ ...(parsed.kind === 'plan+dev' ? { thenDevelop: true } : {}),
4173
+ ...(parsed.branch ? { branch: parsed.branch } : {}),
4174
+ ...(agent ? { agent } : {}),
4175
+ ...(this.runner?.id ? { targetRunnerId: this.runner.id } : {}),
4176
+ ...(anyway ? { acknowledgeUsage: true } : {}),
4177
+ };
4178
+ try {
4179
+ const outcome = await this.session.request(`/api/projects/${parsed.slug}/runs/batch`, {
4180
+ method: 'POST', body,
4181
+ });
4182
+ const [first, ...skipped] = batchStartedLines(parsed, outcome, parsed.slug, agent);
4183
+ this.say(outcome?.started?.length
4184
+ ? this.ink.success(` ${first} — each window opens when this machine takes it`)
4185
+ : this.ink.warn(` ${first}`));
4186
+ for (const line of skipped) {
4187
+ this.say(this.ink.warn(` ${line}`));
4188
+ }
4189
+ this.note('');
4190
+ } catch (failure) {
4191
+ this.note(/usage|window/i.test(failure.message)
4192
+ ? `${failure.message} — add --anyway to start them regardless`
4193
+ : failure.message);
4194
+ }
4195
+ }
4196
+
3659
4197
  /** A FINISHED plan on a card, ended here — what `d` and `/develop` develop. */
3660
4198
  finishedPlan(meta) {
3661
4199
  return Boolean(meta?.ended && meta.view?.state === 'FINISHED'
@@ -3711,7 +4249,8 @@ export class Attached {
3711
4249
  async addCard(words) {
3712
4250
  if (!this.requireSignIn('write a card')) return;
3713
4251
  const named = words.find((word) => word.startsWith('@'))?.slice(1) ?? null;
3714
- const text = wordsText(words.filter((word) => word && !word.startsWith('@')));
4252
+ const show = words.includes('--show');
4253
+ const text = wordsText(words.filter((word) => word && !word.startsWith('@') && word !== '--show'));
3715
4254
  if (!text) {
3716
4255
  this.note('/card what? — /card <the idea in words> (@project)');
3717
4256
  return;
@@ -3740,25 +4279,74 @@ export class Attached {
3740
4279
  try {
3741
4280
  card = await this.session.request(`/api/projects/${slug}/roadmap`, {
3742
4281
  method: 'POST',
3743
- body: { title: cardTitleFor(text), body: text, status: 'CONSIDERING' },
4282
+ body: { title: cardTitleFor(text), body: text, status: 'CONSIDERING', ...(show ? { showBeforePr: true } : {}) },
3744
4283
  });
3745
4284
  } catch (failure) {
3746
4285
  this.note(`could not write the card: ${failure.message}`);
3747
4286
  return;
3748
4287
  }
3749
4288
  const ref = card?.ref ?? (card?.number ? `R${card.number}` : 'the card');
3750
- this.say(this.ink.success(` wrote ${ref} ${card?.title ?? cardTitleFor(text)} in Considering on ${slug}`));
4289
+ this.say(this.ink.success(` wrote ${ref} ${card?.title ?? cardTitleFor(text)} in Considering on ${slug}${show ? ' · shown before its PR' : ''}`));
3751
4290
  this.note('');
3752
4291
  void this.refreshPlanned();
3753
4292
  }
3754
4293
 
4294
+ /**
4295
+ * `/import <file.json> (@project)` — the cards of a JSON file, filed as you
4296
+ * (`card-import.mjs`). The project is `@slug`, else advanced mode narrowed to
4297
+ * one, else the one project served here; with several it is asked for by
4298
+ * name, never guessed. `--dry-run` says what would be filed and files nothing.
4299
+ */
4300
+ async importFile(words) {
4301
+ if (!this.requireSignIn('import cards')) return;
4302
+ const named = words.find((word) => word.startsWith('@'))?.slice(1) ?? null;
4303
+ const dryRun = words.includes('--dry-run');
4304
+ const allowDuplicates = words.includes('--allow-duplicates');
4305
+ const statusAt = words.indexOf('--status');
4306
+ const status = statusAt === -1 ? undefined : words[statusAt + 1];
4307
+ const path = words.find((word, at) => word && !word.startsWith('@') && !word.startsWith('--')
4308
+ && at !== statusAt + 1);
4309
+ if (!path) {
4310
+ this.note('/import what? — /import <file.json> (@project) (--dry-run)');
4311
+ return;
4312
+ }
4313
+ const served = this.servedProjects();
4314
+ const shown = this.shownProjects();
4315
+ const slug = named ?? (shown.length === 1 ? shown[0] : null);
4316
+ if (!slug) {
4317
+ this.note(`which project? — /import ${path} @${shown[0] ?? 'project'}`);
4318
+ return;
4319
+ }
4320
+ if (named && !served.includes(named)) {
4321
+ this.note(`this machine does not serve ${named} — ${served.join(', ') || 'no project'}`);
4322
+ return;
4323
+ }
4324
+ let cards;
4325
+ try {
4326
+ cards = cardsFrom(await readFile(absolute(path), 'utf8'));
4327
+ } catch (failure) {
4328
+ this.note(`could not import ${path}: ${failure.message}`);
4329
+ return;
4330
+ }
4331
+ let result;
4332
+ try {
4333
+ result = await importCards(this.session, slug, cards, { dryRun, status, allowDuplicates });
4334
+ } catch (failure) {
4335
+ this.note(`could not import ${path}: ${failure.message}`);
4336
+ return;
4337
+ }
4338
+ for (const line of resultLines(result, slug, this.ink)) this.say(line);
4339
+ this.note('');
4340
+ if (!dryRun) void this.refreshPlanned();
4341
+ }
4342
+
3755
4343
  /**
3756
4344
  * `/new dev <the task in words>`: the card, then its development. Two
3757
4345
  * requests, and the first is said on its own line — a card that was written
3758
4346
  * but whose run was refused (a usage window, say) is still on the board,
3759
4347
  * and the person should know which half happened.
3760
4348
  */
3761
- async developNewCard(slug, text, anyway, agent = this.agent) {
4349
+ async developNewCard(slug, text, anyway, agent = this.agent, show = undefined) {
3762
4350
  let card;
3763
4351
  try {
3764
4352
  card = await this.session.request(`/api/projects/${slug}/roadmap`, {
@@ -3775,8 +4363,9 @@ export class Attached {
3775
4363
  return;
3776
4364
  }
3777
4365
  this.say(this.ink.success(` wrote ${ref} ${card.title ?? ''} on ${slug}`));
3778
- await this.startSession(slug, 'runs', withAgent({ entryNumber: ref }, agent),
3779
- withWho(`the development of ${ref}`, agent), anyway);
4366
+ await this.startSession(slug, 'runs',
4367
+ withAgent({ entryNumber: ref, ...(show === undefined ? {} : { show }) }, agent),
4368
+ withWho(`the development of ${ref}${shownWord(show)}`, agent), anyway);
3780
4369
  void this.refreshPlanned();
3781
4370
  }
3782
4371
 
@@ -3831,6 +4420,9 @@ export class Attached {
3831
4420
  this.say(this.ink.muted(this.split
3832
4421
  ? ' focus its window and type /continue'
3833
4422
  : ' /continue picks up the latest — /advanced-mode gives each one a window'));
4423
+ if (found.some((meta) => staleLockOf(meta.view?.exitSummary, meta.view?.workspace))) {
4424
+ this.say(this.ink.muted(' a git lock a crash left behind stopped it — /clean removes it first'));
4425
+ }
3834
4426
  this.dirty = true;
3835
4427
  }
3836
4428
 
@@ -3872,6 +4464,43 @@ export class Attached {
3872
4464
  }
3873
4465
  }
3874
4466
 
4467
+ /**
4468
+ * `/clean` — remove the git lock a crash left in the focused (or last ended)
4469
+ * session's checkout, so `/continue` does not fail on it again. The checks
4470
+ * are git-lock.mjs's: the lock must be inside the run's own checkout and old
4471
+ * enough to be nobody's. Done here, not by the daemon, because the control
4472
+ * socket changes nothing (R52); the lock is a file on this machine, and the
4473
+ * run must have been HERE (its runner is this one) for that to be true.
4474
+ * Every session stopped on the same lock is freed by one /clean.
4475
+ */
4476
+ async cleanLock() {
4477
+ const meta = this.commandTarget();
4478
+ const view = meta?.view;
4479
+ if (!meta || !view || !meta.ended) {
4480
+ this.note('/clean needs a session that stopped — focus its window');
4481
+ return;
4482
+ }
4483
+ const lock = staleLockOf(view.exitSummary, view.workspace ?? meta.workspace);
4484
+ if (!lock) {
4485
+ this.note('that session did not stop on a git lock — nothing to clean');
4486
+ return;
4487
+ }
4488
+ if (view.runnerId && this.runner?.id && view.runnerId !== this.runner.id) {
4489
+ this.note(`that checkout is on ${view.runnerName ?? 'another machine'} — clean it there`);
4490
+ return;
4491
+ }
4492
+ const done = await (this.options.removeLock ?? removeStaleLock)(lock);
4493
+ if (!done.removed) {
4494
+ this.note(done.said);
4495
+ return;
4496
+ }
4497
+ for (const other of this.sessions.values()) {
4498
+ if (staleLockOf(other.view?.exitSummary, other.view?.workspace ?? other.workspace) === lock) other.lockCleaned = true;
4499
+ }
4500
+ this.say(this.ink.success(` ${done.said} — /continue picks it back up`));
4501
+ this.note('');
4502
+ }
4503
+
3875
4504
  /**
3876
4505
  * `/develop` — start the development of a card on this machine: the card a
3877
4506
  * finished plan was about (the focused session's), or the one named —
@@ -4097,47 +4726,528 @@ export class Attached {
4097
4726
  opener(url);
4098
4727
  }
4099
4728
 
4100
- /** What a session is stopped on — a question or a permission request — or null. */
4101
- waitingOn(meta) {
4102
- const run = meta ? this.runs.find((each) => each.id === meta.id) : null;
4103
- if (!run) return null;
4104
- const pending = this.pendingOn(run);
4105
- if (pending) return { kind: 'permission', pending };
4106
- const asking = this.askingOn(run);
4107
- return asking ? { kind: 'question', asking } : null;
4108
- }
4729
+ // --- /review: the branch's diff, its verdict and its merge, here ---------------------------
4109
4730
 
4110
4731
  /**
4111
- * `/view` on a session waiting for you: what it is waiting on, in FULL. In
4112
- * advanced mode the reader — the session's output and the request under
4113
- * it, over the whole screen, scrolled with the arrows (the wheel arrives as
4114
- * arrows there), PgUp/PgDn, g/G; esc closes it, and any other key closes it
4115
- * and does what it does (`a` answers, `y`/`n` decide). In the scrollback
4116
- * the request is printed whole, where the terminal scrolls it.
4732
+ * `/review` — the diff of a card's branch in this terminal, and the three
4733
+ * acts a reviewer takes on it, each the platform's own endpoint: Done
4734
+ * (`work-items/{id}/verdict` APPROVED), changes requested (the same, with the
4735
+ * note the next session is given), and merge (`work-items/{id}/merge`, which a
4736
+ * machine of yours performs). The diff is local git (`diff-view.mjs`), so
4737
+ * nothing about a branch's contents crosses the API.
4738
+ *
4739
+ * The card is the focused session's, or one named (`/review R42 @slug`). In
4740
+ * advanced mode the diff is the reader over the whole screen and the acts are
4741
+ * keys; otherwise it is printed, and the acts are `/review done`,
4742
+ * `/review changes <note>`, `/review merge`. Merge asks twice either way: it
4743
+ * is the one act here nothing undoes.
4117
4744
  */
4118
- openReader() {
4745
+ async review(words) {
4746
+ if (!this.requireSignIn('review a branch')) return undefined;
4747
+ const [verb, ...rest] = words;
4748
+ switch (verb) {
4749
+ case 'done':
4750
+ return this.reviewDone();
4751
+ case 'changes':
4752
+ return rest.length ? this.reviewChanges(wordsText(rest)) : this.askReviewNote();
4753
+ case 'merge':
4754
+ return this.reviewMerge();
4755
+ case 'agent-merge':
4756
+ return this.reviewAgentMerge();
4757
+ default:
4758
+ return this.openReview(words);
4759
+ }
4760
+ }
4761
+
4762
+ /** The card a review is of: named, else the focused session's. Null after saying why. */
4763
+ async reviewTarget(words) {
4764
+ const named = words.filter((word) => word.startsWith('@')).map((word) => word.slice(1));
4765
+ const bare = words.filter((word) => word && !word.startsWith('@'));
4766
+ if (bare.length) {
4767
+ if (bare.length > 1 || !/^[ri]?\d+$/i.test(bare[0])) {
4768
+ this.note('/review which card? /review R42 (@project), or /review alone for the session\'s');
4769
+ return null;
4770
+ }
4771
+ const ref = refOf(bare[0]);
4772
+ const projects = named.length ? named
4773
+ : this.shownProjects().length ? this.shownProjects() : this.servedProjects();
4774
+ const reads = await Promise.all(projects.map((slug) => this.session
4775
+ .request(`/api/projects/${slug}/roadmap/${ref}`)
4776
+ .then((card) => (card ? { slug, card } : null))
4777
+ .catch(() => null)));
4778
+ const found = reads.filter(Boolean);
4779
+ const building = found.filter((each) => each.card.workItem);
4780
+ const pick = building.length ? building : found;
4781
+ if (pick.length > 1) {
4782
+ this.note(`${ref} is in ${pick.map((each) => each.slug).join(', ')} — name one: /review ${ref} @${pick[0].slug}`);
4783
+ return null;
4784
+ }
4785
+ if (!pick.length) {
4786
+ this.note(`no ${ref} in ${projects.join(', ') || 'any project here'}`);
4787
+ return null;
4788
+ }
4789
+ return { ...pick[0], meta: null };
4790
+ }
4119
4791
  const meta = this.commandTarget();
4792
+ const ref = meta?.view?.entryRef;
4793
+ const slug = meta?.projectSlug ?? meta?.view?.projectSlug;
4794
+ if (!ref || !slug) {
4795
+ this.note(meta ? 'this session has no card — /review R42 names one' : '/review needs a session — focus one, or name a card: /review R42');
4796
+ return null;
4797
+ }
4798
+ try {
4799
+ return { slug, card: await this.session.request(`/api/projects/${slug}/roadmap/${ref}`), meta };
4800
+ } catch (failure) {
4801
+ this.note(`could not read ${ref}: ${failure.message}`);
4802
+ return null;
4803
+ }
4804
+ }
4805
+
4806
+ /** A checkout of the project on this machine: the session's own, else one the config names. */
4807
+ async checkoutFor(slug, meta) {
4808
+ const candidates = [];
4809
+ if (meta?.view?.workspace) candidates.push(meta.view.workspace);
4810
+ if (this.runner?.configPath) {
4811
+ const file = await readConfigFile(this.runner.configPath).catch(() => null);
4812
+ candidates.push(...pathsOf(file?.projects?.[slug]).map((path) => absolute(path)));
4813
+ }
4814
+ return candidates.find((path) => existsSync(join(path, '.git'))) ?? null;
4815
+ }
4816
+
4817
+ async openReview(words) {
4818
+ const target = await this.reviewTarget(words);
4819
+ if (!target) return undefined;
4820
+ const { slug, card, meta } = target;
4821
+ const ref = card.ref ?? '';
4822
+ const item = card.workItem;
4823
+ if (!item?.id || !item.branch) {
4824
+ return this.note(`${ref} has no branch in development${card.status ? ` — it is ${card.status}` : ''}`);
4825
+ }
4826
+ const cwd = await this.checkoutFor(slug, meta);
4827
+ if (!cwd) {
4828
+ return this.note(`no checkout of ${slug} on this machine to read the diff in — c → Add a workspace`);
4829
+ }
4830
+ const project = await this.session.request(`/api/projects/${slug}`).catch(() => null);
4831
+ const base = item.baseBranch ?? project?.defaultBranch ?? 'main';
4832
+ this.note(`reading ${item.branch} against ${base}…`);
4833
+ let diff;
4834
+ try {
4835
+ diff = await branchDiff(cwd, { branch: item.branch, base });
4836
+ } catch (failure) {
4837
+ return this.note(`could not read the diff: ${failure.message}`);
4838
+ }
4839
+ const files = diff.files;
4840
+ this.reviewing = {
4841
+ slug,
4842
+ ref,
4843
+ title: card.title ?? '',
4844
+ item,
4845
+ branch: item.branch,
4846
+ base,
4847
+ files,
4848
+ cut: diff.cut,
4849
+ stale: diff.stale,
4850
+ folded: new Set(files.filter(foldedByDefault).map((file) => file.path)),
4851
+ // A merge may be pressed only by somebody who holds Merge; absent, the API decides.
4852
+ mayMerge: Array.isArray(project?.yourPrivileges) ? project.yourPrivileges.includes('MERGE') : true,
4853
+ layout: null,
4854
+ confirmingMerge: false,
4855
+ };
4120
4856
  if (!this.split) {
4857
+ const width = Math.max(20, (this.screen?.width ?? 80) - 2);
4121
4858
  this.say('');
4122
- for (const line of this.requestLines(this.waitingOn(meta), this.screen.width)) this.say(line);
4859
+ this.say(this.ink.bold(` review ${ref} ${printable(this.reviewing.title)}`));
4860
+ for (const row of layoutDiff(this.reviewing, width, this.ink).rows) this.say(` ${row}`);
4123
4861
  this.say('');
4862
+ this.say(` ${this.reviewActions()}`);
4124
4863
  return this.note('');
4125
4864
  }
4126
- this.reader = { runId: meta.id, top: null, room: 1, max: 0 };
4865
+ return this.reopenReview();
4866
+ }
4867
+
4868
+ /** The reader on the review, where it was; also where a cancelled note goes back to. */
4869
+ reopenReview() {
4870
+ if (!this.reviewing) return this.note('');
4871
+ if (!this.split) return this.note('');
4872
+ this.reader = { kind: 'review', top: this.reviewing.top ?? 0, room: 1, max: 0 };
4127
4873
  this.dirty = true;
4128
4874
  return this.note('');
4129
4875
  }
4130
4876
 
4131
- /** The request a session waits on, every word of it. */
4132
- requestLines(waiting, width) {
4877
+ /** What may be done next, in words — the scrollback's footer. */
4878
+ reviewActions() {
4133
4879
  const ink = this.ink;
4134
- if (!waiting) {
4135
- return [ink.muted(' no longer waiting — answered, decided, or gone')];
4136
- }
4137
- const room = Math.max(20, width - 4);
4138
- if (waiting.kind === 'question') {
4139
- const { question, yours, waitingOn } = waiting.asking;
4140
- return [
4880
+ const item = this.reviewing?.item;
4881
+ const acts = [`${ink.success('/review done')} ${ink.muted('approves')}`,
4882
+ `${ink.warn('/review changes <note>')} ${ink.muted('sends it back')}`];
4883
+ if (item?.status === 'DONE' && this.reviewing.mayMerge) acts.push(`${ink.accent('/review merge')} ${ink.muted('lands it')}`);
4884
+ return acts.join(ink.muted(' · '));
4885
+ }
4886
+
4887
+ reviewScreen(width, height) {
4888
+ const review = this.reviewing;
4889
+ const ink = this.ink;
4890
+ const inner = Math.max(1, width - 2);
4891
+ if (!review.layout || review.layout.width !== inner) {
4892
+ review.layout = { width: inner, ...layoutDiff(review, inner, ink) };
4893
+ }
4894
+ const item = review.item;
4895
+ const keys = ['↑↓ PgUp/PgDn g/G', 'n/p file', 'f fold', 'd done', 'c changes'];
4896
+ if (item.status === 'DONE' && review.mayMerge) keys.push(review.confirmingMerge ? 'm AGAIN merges' : 'm merge');
4897
+ keys.push('esc close');
4898
+ const drawn = readerFrame(width, height, {
4899
+ title: `review ${review.ref} ${printable(review.title)} · ${item.statusDisplay ?? item.status ?? ''}`,
4900
+ lines: review.layout.rows,
4901
+ requestAt: 0,
4902
+ top: this.reader.top ?? 0,
4903
+ footer: keyList(keys, '', width - 2, ink),
4904
+ }, ink);
4905
+ Object.assign(this.reader, { top: drawn.top, max: drawn.max, room: drawn.room });
4906
+ review.top = drawn.top;
4907
+ return drawn.lines;
4908
+ }
4909
+
4910
+ onReviewKey(key) {
4911
+ const reader = this.reader;
4912
+ const review = this.reviewing;
4913
+ const move = (to) => {
4914
+ reader.top = Math.min(Math.max(0, to), reader.max);
4915
+ this.dirty = true;
4916
+ return undefined;
4917
+ };
4918
+ const at = reader.top ?? 0;
4919
+ const fileRows = review.layout?.fileRows ?? [];
4920
+ if (key !== 'm' && review.confirmingMerge) {
4921
+ review.confirmingMerge = false;
4922
+ this.note('not merged');
4923
+ }
4924
+ switch (key) {
4925
+ case `${ESC}[A`: case `${ESC}OA`: case 'k':
4926
+ return move(at - 1);
4927
+ case `${ESC}[B`: case `${ESC}OB`: case 'j':
4928
+ return move(at + 1);
4929
+ case `${ESC}[5~`: case 'b':
4930
+ return move(at - reader.room);
4931
+ case `${ESC}[6~`: case ' ':
4932
+ return move(at + reader.room);
4933
+ case `${ESC}[H`: case `${ESC}[1~`: case 'g':
4934
+ return move(0);
4935
+ case `${ESC}[F`: case `${ESC}[4~`: case 'G':
4936
+ return move(reader.max);
4937
+ case 'n': {
4938
+ const next = fileRows.find((row) => row > at);
4939
+ return next === undefined ? this.note('the last file') : move(next);
4940
+ }
4941
+ case 'p': {
4942
+ const previous = [...fileRows].reverse().find((row) => row < at);
4943
+ return previous === undefined ? move(0) : move(previous);
4944
+ }
4945
+ case 'f': {
4946
+ const index = Math.max(0, fileAt(fileRows, at));
4947
+ const file = review.files[index];
4948
+ if (!file) return undefined;
4949
+ if (review.folded.has(file.path)) review.folded.delete(file.path);
4950
+ else review.folded.add(file.path);
4951
+ review.layout = null;
4952
+ // Stay on the file: its header row is where it was, whatever folded under it.
4953
+ reader.top = fileRows[index] ?? at;
4954
+ this.dirty = true;
4955
+ return undefined;
4956
+ }
4957
+ case 'd':
4958
+ return void this.reviewDone();
4959
+ case 'c':
4960
+ return this.askReviewNote();
4961
+ case 'm':
4962
+ return void this.reviewMerge();
4963
+ case '/':
4964
+ this.reader = null;
4965
+ this.dirty = true;
4966
+ return this.type('command', 'cawdev ▸', '/');
4967
+ case ESC: case 'q':
4968
+ this.reader = null;
4969
+ this.dirty = true;
4970
+ return this.note('');
4971
+ default:
4972
+ return undefined;
4973
+ }
4974
+ }
4975
+
4976
+ /** The note a changes-requested verdict carries, asked in the prompt. */
4977
+ askReviewNote() {
4978
+ if (!this.reviewing) return this.note('/review a branch first');
4979
+ this.reader = null;
4980
+ this.dirty = true;
4981
+ return this.type('review-note', `changes for ${this.reviewing.ref} — what should the next session do? (empty goes back) ▸`, '');
4982
+ }
4983
+
4984
+ /** The work item as the platform has it now, read again after an act. */
4985
+ async rereadReview() {
4986
+ const review = this.reviewing;
4987
+ const card = await this.session.request(`/api/projects/${review.slug}/roadmap/${review.ref}`).catch(() => null);
4988
+ if (card?.workItem) review.item = card.workItem;
4989
+ this.dirty = true;
4990
+ return card;
4991
+ }
4992
+
4993
+ async reviewVerdict(verdict, note) {
4994
+ const review = this.reviewing;
4995
+ if (!review) return this.note('/review a branch first');
4996
+ try {
4997
+ await this.session.request(`/api/projects/${review.slug}/work-items/${review.item.id}/verdict`, {
4998
+ method: 'POST',
4999
+ body: { verdict, ...(note ? { note } : {}) },
5000
+ });
5001
+ } catch (failure) {
5002
+ return this.note(`could not decide ${review.ref}: ${failure.message}`);
5003
+ }
5004
+ await this.rereadReview();
5005
+ return true;
5006
+ }
5007
+
5008
+ async reviewDone() {
5009
+ if (await this.reviewVerdict('APPROVED') !== true) return undefined;
5010
+ const review = this.reviewing;
5011
+ this.say(this.ink.success(` ${review.ref} ${review.branch}: Done`));
5012
+ return this.note(review.mayMerge ? `Done — m merges it${this.split ? '' : ' (/review merge)'}` : 'Done');
5013
+ }
5014
+
5015
+ async reviewChanges(note) {
5016
+ if (await this.reviewVerdict('CHANGES_REQUESTED', note) !== true) return undefined;
5017
+ const review = this.reviewing;
5018
+ this.say(this.ink.warn(` ${review.ref} ${review.branch}: changes requested`));
5019
+ this.say(this.ink.muted(` ${printable(note)}`));
5020
+ // The verdict is given: the diff is no longer what anybody acts on.
5021
+ this.reader = null;
5022
+ this.dirty = true;
5023
+ return this.note(`sent back — the next session on ${review.ref} is given the note`);
5024
+ }
5025
+
5026
+ /** Twice: the first press says what will land where, the second lands it. */
5027
+ async reviewMerge() {
5028
+ const review = this.reviewing;
5029
+ if (!review) return this.note('/review a branch first');
5030
+ if (review.item.status !== 'DONE') {
5031
+ return this.note(`${review.ref} is ${review.item.statusDisplay ?? review.item.status} — say Done first (d)`);
5032
+ }
5033
+ if (!review.mayMerge) return this.note(`merging needs Merge on ${review.slug}, which you do not hold`);
5034
+ if (!review.confirmingMerge) {
5035
+ review.confirmingMerge = true;
5036
+ this.dirty = true;
5037
+ return this.note(`merge ${review.branch} into ${review.base}? ${this.split ? 'm again' : '/review merge again'} lands it — anything else keeps it`);
5038
+ }
5039
+ review.confirmingMerge = false;
5040
+ try {
5041
+ await this.session.request(`/api/projects/${review.slug}/work-items/${review.item.id}/merge`, { method: 'POST' });
5042
+ } catch (failure) {
5043
+ return this.note(`could not merge ${review.ref}: ${failure.message}`);
5044
+ }
5045
+ this.reader = null;
5046
+ this.markMerging(review, { state: 'merging' });
5047
+ this.say(this.ink.accent(` ${review.ref}: merge of ${review.branch} queued on a machine of yours`));
5048
+ void this.followMerge(review);
5049
+ return this.note('merging — said here when it lands');
5050
+ }
5051
+
5052
+ /**
5053
+ * The sessions on the card being merged: their cards say so, and refuse
5054
+ * every act on the branch until it lands (`refuseWhileMerging`). `state` is
5055
+ * `merging`, or `failed` with `why`, which gives the acts back.
5056
+ */
5057
+ markMerging(review, { state, why = null }) {
5058
+ for (const meta of this.mergingCards(review)) {
5059
+ meta.merging = { state, why, ref: review.ref, branch: review.branch, base: review.base };
5060
+ }
5061
+ this.dirty = true;
5062
+ }
5063
+
5064
+ /**
5065
+ * A merge that hit a conflict goes back to the card's development: the
5066
+ * branch is sent back (Not done) with the order as its note, which the next
5067
+ * session on the card is handed (`RunReviews.latestRejection`, `task_current`),
5068
+ * and that session is started here, again, with the agent that built it.
5069
+ * The card is IN DEVELOPMENT again, the old session's card gives way to the
5070
+ * new one's, and the merge is asked for once it is Done again. Two endpoints
5071
+ * this terminal already uses; nothing new on the platform.
5072
+ */
5073
+ async resolveConflict(review, why) {
5074
+ const ink = this.ink;
5075
+ const sessions = this.mergingCards(review);
5076
+ this.markMerging(review, { state: 'resolving', why });
5077
+ this.say(ink.warn(` ${review.ref}: ${review.branch} conflicts with ${review.base} — sent back to its development to resolve it`));
5078
+ try {
5079
+ await this.session.request(`/api/projects/${review.slug}/work-items/${review.item.id}/verdict`, {
5080
+ method: 'POST',
5081
+ body: { verdict: 'CHANGES_REQUESTED', note: conflictOrder(review, why) },
5082
+ });
5083
+ } catch (failure) {
5084
+ this.markMerging(review, { state: 'failed', why: `${why} — and it could not be sent back: ${failure.message}` });
5085
+ return this.note(`${review.ref} is not merged — /review to look again`);
5086
+ }
5087
+ const agent = sessions.map((meta) => meta.agent ?? meta.view?.agent).find(Boolean) ?? null;
5088
+ this.focusOnArrival = review.slug;
5089
+ const started = await this.startSession(review.slug, 'runs', withAgent({ entryNumber: review.ref }, agent),
5090
+ withWho(`the development of ${review.ref}, to resolve the conflict with ${review.base}`, agent), false, true);
5091
+ if (!started) {
5092
+ this.focusOnArrival = null;
5093
+ this.markMerging(review, { state: 'failed', why: `${why} — sent back, but its development did not start: ${this.status}` });
5094
+ return this.note(`/new dev ${review.ref} --again starts it`);
5095
+ }
5096
+ // The development's own card takes over when this machine claims it.
5097
+ for (const meta of sessions) {
5098
+ meta.merging = null;
5099
+ if (meta.ended) this.closeWindowOf(meta);
5100
+ }
5101
+ this.dirty = true;
5102
+ return this.note(`${review.ref} is back in development — /review it again once it is Done`);
5103
+ }
5104
+
5105
+ /** The sessions here whose card is `review`'s. */
5106
+ mergingCards(review) {
5107
+ const ref = String(review.ref).toLowerCase();
5108
+ return [...this.sessions.values()].filter((meta) => meta.projectSlug === review.slug
5109
+ && String(meta.view?.entryRef ?? '').toLowerCase() === ref);
5110
+ }
5111
+
5112
+ /** True, having said why, when the session acted on is merging its card. */
5113
+ refuseWhileMerging() {
5114
+ const merging = this.commandTarget()?.merging;
5115
+ if (merging?.state !== 'merging' && merging?.state !== 'resolving') return false;
5116
+ this.note(`${merging.ref} is merging into ${merging.base} — its card closes by itself once it lands`);
5117
+ return true;
5118
+ }
5119
+
5120
+ async reviewAgentMerge() {
5121
+ const review = this.reviewing;
5122
+ if (!review) return this.note('/review a branch first');
5123
+ try {
5124
+ await this.session.request(`/api/projects/${review.slug}/work-items/${review.item.id}/agent-merge`, { method: 'POST' });
5125
+ } catch (failure) {
5126
+ return this.note(`could not start the agent merge: ${failure.message}`);
5127
+ }
5128
+ this.say(this.ink.accent(` ${review.ref}: an agent merges ${review.base} into ${review.branch} and resolves the conflict`));
5129
+ return this.note('agent merge started — its session opens like any other');
5130
+ }
5131
+
5132
+ /**
5133
+ * The merge, followed until the machine says: the card MERGED, or the merge
5134
+ * FAILED (a conflict offers the agent). Polls the card, which is where the
5135
+ * outcome is written; gives up saying so after `REVIEW_FOLLOW_TRIES`.
5136
+ */
5137
+ async followMerge(review, tries = REVIEW_FOLLOW_TRIES) {
5138
+ const card = await this.session.request(`/api/projects/${review.slug}/roadmap/${review.ref}`).catch(() => null);
5139
+ const merge = card?.workItem?.merge;
5140
+ if (card?.status === 'MERGED' || card?.status === 'SHIPPED' || merge?.state === 'DONE') {
5141
+ this.say(this.ink.success(` ${review.ref} landed: ${review.branch} is in ${review.base}${merge?.result ? ` (${printable(merge.result)})` : ''}`));
5142
+ // Landed: the card's work is over, and so are its cards here.
5143
+ for (const meta of this.mergingCards(review)) {
5144
+ meta.merging = null;
5145
+ if (meta.ended) this.closeWindowOf(meta);
5146
+ }
5147
+ this.dirty = true;
5148
+ return this.note(`${review.ref} merged`);
5149
+ }
5150
+ if (merge?.state === 'FAILED') {
5151
+ // `failure` is the KIND (R155: CONFLICT, NO_PULL_REQUEST…); `result` the machine's words.
5152
+ const why = printable(merge.result ?? merge.failure ?? 'no reason given');
5153
+ // An older runner names no kind: its words are all there is to go on.
5154
+ if (merge.failure === 'CONFLICT' || (!merge.failure && /conflict/i.test(merge.result ?? ''))) {
5155
+ return this.resolveConflict(review, why);
5156
+ }
5157
+ this.say(this.ink.danger(` ${review.ref}: the merge failed — ${why}`));
5158
+ this.markMerging(review, { state: 'failed', why });
5159
+ return this.note(`${review.ref} is not merged — /review to look again`);
5160
+ }
5161
+ if (tries <= 0) {
5162
+ return this.note(`${review.ref}: still merging — the board says when it lands`);
5163
+ }
5164
+ const again = setTimeout(() => void this.followMerge(review, tries - 1), this.options.reviewFollowMs ?? 5_000);
5165
+ again.unref?.();
5166
+ return undefined;
5167
+ }
5168
+
5169
+ /** What a session is stopped on — a question or a permission request — or null. */
5170
+ waitingOn(meta) {
5171
+ const run = meta ? this.runs.find((each) => each.id === meta.id) : null;
5172
+ if (!run) return null;
5173
+ const shown = this.showOn(run);
5174
+ if (shown) return { kind: 'show', pending: shown };
5175
+ const pending = this.pendingOn(run);
5176
+ if (pending) return { kind: 'permission', pending };
5177
+ const asking = this.askingOn(run);
5178
+ return asking ? { kind: 'question', asking } : null;
5179
+ }
5180
+
5181
+ /**
5182
+ * `/view` on a session waiting for you: what it is waiting on, in FULL. In
5183
+ * advanced mode the reader — the session's output and the request under
5184
+ * it, over the whole screen, scrolled with the arrows (the wheel arrives as
5185
+ * arrows there), PgUp/PgDn, g/G; esc closes it, and any other key closes it
5186
+ * and does what it does (`a` answers, `y`/`n` decide). In the scrollback
5187
+ * the request is printed whole, where the terminal scrolls it.
5188
+ */
5189
+ /**
5190
+ * The window `/view 2` or `/view R42` names: a number is a card's, a ref
5191
+ * is the session on that card (the focused one first, then the latest). Null
5192
+ * for anything else, which is a card to print.
5193
+ */
5194
+ windowNamed(words) {
5195
+ if (words.length !== 1) return null;
5196
+ const windows = this.windows();
5197
+ const [word] = words;
5198
+ if (/^\d+$/.test(word)) return windows[Number(word) - 1] ?? null;
5199
+ const ref = word.toLowerCase();
5200
+ const on = windows.filter((meta) => String(meta.view?.entryRef ?? '').toLowerCase() === ref);
5201
+ return on.find((meta) => meta.id === this.split.focus) ?? on.at(-1) ?? null;
5202
+ }
5203
+
5204
+ /**
5205
+ * `/view` in advanced mode: the session takes the screen — its log, live,
5206
+ * following the end until it is scrolled up, and what it waits on under it
5207
+ * when it waits on you. Esc gives the cards back; tab goes to the next
5208
+ * session without leaving.
5209
+ */
5210
+ viewSession(meta) {
5211
+ this.focusWindow(meta.id);
5212
+ const waiting = Boolean(this.waitingOn(meta));
5213
+ this.reader = { runId: meta.id, top: null, room: 1, max: 0, session: true, follow: !waiting };
5214
+ this.dirty = true;
5215
+ return this.note('');
5216
+ }
5217
+
5218
+ /** `/matrix on|off` — the cards' rain, remembered beside `/agent` (prefs.mjs). */
5219
+ async chooseMatrix(word) {
5220
+ const wanted = String(word ?? '').toLowerCase();
5221
+ if (!['on', 'off'].includes(wanted)) {
5222
+ return this.note(`the rain is ${this.matrix === false ? 'off' : 'on'} — /matrix on|off`);
5223
+ }
5224
+ this.matrix = wanted === 'on';
5225
+ this.dirty = true;
5226
+ await savePrefs(this.session.url, { matrix: this.matrix ? null : false }).catch(() => undefined);
5227
+ return this.note(this.matrix ? 'the cards rain again' : 'the cards are still');
5228
+ }
5229
+
5230
+ openReader() {
5231
+ const meta = this.commandTarget();
5232
+ if (!this.split) {
5233
+ this.say('');
5234
+ for (const line of this.requestLines(this.waitingOn(meta), this.screen.width)) this.say(line);
5235
+ this.say('');
5236
+ return this.note('');
5237
+ }
5238
+ return this.viewSession(meta);
5239
+ }
5240
+
5241
+ /** The request a session waits on, every word of it. */
5242
+ requestLines(waiting, width) {
5243
+ const ink = this.ink;
5244
+ if (!waiting) {
5245
+ return [ink.muted(' no longer waiting — answered, decided, or gone')];
5246
+ }
5247
+ const room = Math.max(20, width - 4);
5248
+ if (waiting.kind === 'question') {
5249
+ const { question, yours, waitingOn } = waiting.asking;
5250
+ return [
4141
5251
  ink.bold(ink.accent(' question ')),
4142
5252
  ...String(question.question ?? '').split('\n').flatMap((part) => wordWrap(part, room)).map((line) => ` ${line}`),
4143
5253
  ...(question.options?.length
@@ -4150,6 +5260,14 @@ export class Attached {
4150
5260
  ];
4151
5261
  }
4152
5262
  const approval = waiting.pending.approval ?? waiting.pending;
5263
+ if (waiting.kind === 'show') {
5264
+ return [
5265
+ ...permissionBanner(waiting.pending, width, ink, this.runner ?? {}).slice(0, 2),
5266
+ ...String(approval.summary ?? '').split('\n').flatMap((part) => wordWrap(printable(part), room)).map((line) => ` ${line}`),
5267
+ '',
5268
+ ...permissionBanner(waiting.pending, width, ink, this.runner ?? {}).slice(2),
5269
+ ];
5270
+ }
4153
5271
  return [
4154
5272
  `${ink.bold(ink.warn(' permission '))} ${ink.muted(`${approval.toolName ?? ''} · waiting since ${approval.askedAt?.slice(11, 19) ?? ''}`)}`,
4155
5273
  ...String(approval.summary ?? '').split('\n').flatMap((part) => wordWrap(part, room)).map((line) => ` ${line}`),
@@ -4163,22 +5281,31 @@ export class Attached {
4163
5281
 
4164
5282
  /** The reader, drawn over the whole screen — see `readerFrame` in split.mjs. */
4165
5283
  readerScreen(width, height) {
5284
+ if (this.reader.kind === 'review') return this.reviewScreen(width, height);
4166
5285
  const ink = this.ink;
4167
5286
  const meta = this.windows().find((each) => each.id === this.reader.runId)
4168
5287
  ?? this.sessions.get(this.reader.runId) ?? { id: this.reader.runId };
4169
5288
  const output = (this.lines.get(this.reader.runId) ?? []).map((line) => this.render(line));
4170
5289
  const waiting = this.waitingOn(meta);
4171
- const request = this.requestLines(waiting, width - 2);
5290
+ // What it waits on goes under its log; a session waiting on nobody is its
5291
+ // log alone, followed at the end as it grows.
5292
+ const request = waiting || !this.reader.session ? this.requestLines(waiting, width - 2) : [];
4172
5293
  const n = this.windowNumberOf(meta.id);
4173
- const keys = waiting?.kind === 'permission'
4174
- ? 'y once · s session · n refuse'
4175
- : waiting?.asking?.yours ? 'a answer' : '';
5294
+ const keys = waiting?.kind === 'show'
5295
+ ? 'a look and decide'
5296
+ : waiting?.kind === 'permission'
5297
+ ? 'y once · s session · n refuse'
5298
+ : waiting?.asking?.yours ? 'a answer' : '';
4176
5299
  const drawn = readerFrame(width, height, {
4177
- title: `${n ? `${n} ` : ''}${titleFor(meta, waiting ? `waiting for ${waiting.kind === 'question' ? 'an answer' : 'permission'}` : null)}`,
4178
- lines: [...output, ink.muted('─'.repeat(Math.min(40, width - 2))), ...request],
5300
+ title: `${n ? `${n} ` : ''}${titleFor(meta, waiting ? waitingWord(waiting.kind) : null)}`,
5301
+ lines: [
5302
+ ...(output.length ? output : [ink.muted(' nothing said on this session here yet')]),
5303
+ ...(request.length ? [ink.muted('─'.repeat(Math.min(40, width - 2))), ...request] : []),
5304
+ ],
4179
5305
  requestAt: output.length,
4180
- top: this.reader.top,
4181
- footer: keyList(['↑↓ scroll', 'PgUp/PgDn page', 'g/G top/end', 'esc close',
5306
+ top: this.reader.follow ? Infinity : this.reader.top,
5307
+ footer: keyList(['↑↓ scroll', 'PgUp/PgDn page', 'g/G top/end',
5308
+ ...(this.reader.session ? ['tab next session'] : []), 'esc close',
4182
5309
  ...(keys ? [keys] : []), '/open on cawdev'], '', width - 2, ink),
4183
5310
  }, ink);
4184
5311
  Object.assign(this.reader, { top: drawn.top, max: drawn.max, room: drawn.room });
@@ -4187,9 +5314,12 @@ export class Attached {
4187
5314
 
4188
5315
  /** Keys while the reader is open. */
4189
5316
  onReaderKey(key) {
5317
+ if (this.reader.kind === 'review') return this.onReviewKey(key);
4190
5318
  const reader = this.reader;
4191
5319
  const move = (to) => {
4192
5320
  reader.top = Math.min(Math.max(0, to), reader.max);
5321
+ // At the end a session's log is followed again; above it, it holds still.
5322
+ reader.follow = Boolean(reader.session) && reader.top >= reader.max;
4193
5323
  this.dirty = true;
4194
5324
  return undefined;
4195
5325
  };
@@ -4212,12 +5342,20 @@ export class Attached {
4212
5342
  this.dirty = true;
4213
5343
  return this.note('');
4214
5344
  default:
4215
- // Anything else is what it always is — `a` answers, `y`/`n` decide,
4216
- // `/` a command — with the reader closed first.
4217
- this.reader = null;
4218
- this.dirty = true;
4219
- return this.onKey(key);
5345
+ break;
4220
5346
  }
5347
+ if (reader.session && (key === '\t' || key === `${ESC}[Z`)) {
5348
+ // The next session's log, without going back to the cards.
5349
+ const windows = this.windows();
5350
+ const here = windows.findIndex((meta) => meta.id === reader.runId);
5351
+ const next = windows[(here + (key === '\t' ? 1 : -1) + windows.length) % windows.length];
5352
+ return next ? this.viewSession(next) : undefined;
5353
+ }
5354
+ // Anything else is what it always is — `a` answers, `y`/`n` decide,
5355
+ // `/` a command — with the reader closed first.
5356
+ this.reader = null;
5357
+ this.dirty = true;
5358
+ return this.onKey(key);
4221
5359
  }
4222
5360
 
4223
5361
  /**
@@ -4489,6 +5627,7 @@ export class Attached {
4489
5627
  if (!pending) {
4490
5628
  return this.note('nothing is waiting for permission on this one');
4491
5629
  }
5630
+ if (showGate(pending.approval)) return this.note(A_SHOW_GATE);
4492
5631
  const decision = permissionDecision(pending.approval, key, this.runner ?? {});
4493
5632
  if (!decision) {
4494
5633
  // Two ways to get here and they need different sentences. `Y` or `M` on a
@@ -4548,6 +5687,438 @@ export class Attached {
4548
5687
  return undefined;
4549
5688
  }
4550
5689
 
5690
+ // --- looking and deciding at a SHOW gate — R605 -----------------------------
5691
+
5692
+ /** The daemon's record of a window's session, or null. */
5693
+ runOf(meta) {
5694
+ return meta ? this.runs.find((each) => each.id === meta.id) ?? null : null;
5695
+ }
5696
+
5697
+ /** This round's attachments live under the run; every URL here is built from it. */
5698
+ attachmentsPath(run) {
5699
+ return `/api/projects/${run.projectSlug}/runs/${run.id}/attachments`;
5700
+ }
5701
+
5702
+ /**
5703
+ * The round a gate names, confirmed against what the platform SEALED.
5704
+ *
5705
+ * A gate's `toolInput` is the session's own claim — the run token can raise
5706
+ * a `stage:SHOW` approval naming anything — so a screenshot is shown only
5707
+ * when the sealed list for that round holds its id, and a gate with an id
5708
+ * that does not match offers no Accept.
5709
+ */
5710
+ async loadRound(run, pending) {
5711
+ const gate = showGate(pending.approval);
5712
+ const base = `/api/projects/${run.projectSlug}/runs/${run.id}`;
5713
+ const listed = await this.session.request(`${base}/attachments`);
5714
+ const all = (Array.isArray(listed) ? listed : []).map((each) => ({ ...each, runId: run.id }));
5715
+ const byId = new Map(all.filter((each) => each.round === gate.round).map((each) => [each.id, each]));
5716
+ const shots = gate.ids.map((id) => byId.get(id)).filter(Boolean);
5717
+ // Confirmed only when the gate names exactly what the platform sealed for
5718
+ // that round: no id that was never sealed, none twice, none left out, and
5719
+ // something to look at. A claim naming some of the shots is a claim.
5720
+ const confirmed = Number.isInteger(gate.round) && gate.round > 0
5721
+ && gate.ids.length > 0 && new Set(gate.ids).size === gate.ids.length
5722
+ && shots.length === gate.ids.length && byId.size === gate.ids.length;
5723
+
5724
+ const seen = await this.session.request(`${base}/approvals`).catch(() => []);
5725
+ const rounds = (Array.isArray(seen) ? seen : [])
5726
+ .filter((each) => each.state === 'DENIED' && showGate(each))
5727
+ .map((each) => ({
5728
+ round: showGate(each).round,
5729
+ comments: each.reason ?? '',
5730
+ by: each.decidedByEmail ? printable(each.decidedByEmail) : null,
5731
+ shots: all.filter((shot) => shot.round === showGate(each).round),
5732
+ }))
5733
+ .filter((each) => each.round < gate.round)
5734
+ .sort((a, b) => b.round - a.round);
5735
+
5736
+ return {
5737
+ runId: run.id, approvalId: pending.approval.id, slug: run.projectSlug, round: gate.round,
5738
+ shots, confirmed, rounds, select: null,
5739
+ };
5740
+ }
5741
+
5742
+ /** The loaded round for this gate — read once, kept while it is the same gate. */
5743
+ async roundFor(run, pending) {
5744
+ if (this.showing?.approvalId !== pending.approval.id) {
5745
+ this.showing = await this.loadRound(run, pending);
5746
+ }
5747
+ return this.showing;
5748
+ }
5749
+
5750
+ /** `a`, and `/view`: the picker that looks and decides. */
5751
+ async lookAndDecide(run) {
5752
+ const pending = this.showOn(run);
5753
+ if (!pending) return this.note('nothing is waiting at SHOW on this one');
5754
+ // So R137's `decidedElsewhere` can close this picker when somebody decides
5755
+ // the gate in the console first.
5756
+ this.deciding = { runId: run.id, approvalId: pending.approval.id };
5757
+ try {
5758
+ await this.roundFor(run, pending);
5759
+ } catch (failure) {
5760
+ if (this.deciding?.approvalId === pending.approval.id) this.deciding = null;
5761
+ return this.note(printable(failure.message));
5762
+ }
5763
+ // The gate may have been decided, or the person may have started typing,
5764
+ // while the round loaded: a picker for a settled gate is a lie.
5765
+ if (this.showOn(run)?.approval.id !== pending.approval.id || this.mode !== 'keys') {
5766
+ return undefined;
5767
+ }
5768
+ return this.openShowPicker();
5769
+ }
5770
+
5771
+ openShowPicker() {
5772
+ const round = this.showing;
5773
+ const ink = this.ink;
5774
+ const count = round.shots.length;
5775
+ const rows = [
5776
+ ...round.shots.map((shot, at) => ({
5777
+ id: `shot:${at}`,
5778
+ label: `${at + 1} ${printable(shot.name)}${shot.caption ? ` — ${printable(shot.caption)}` : ''}`,
5779
+ ...(isImage(shot) ? {} : { hint: 'html — opens on cawdev' }),
5780
+ })),
5781
+ ...(count ? [{ id: 'all', label: 'Open all', hint: 'images in the viewer, html on cawdev' }] : []),
5782
+ ...(round.confirmed ? [{ id: 'accept', label: 'Accept — open the pull request' }] : []),
5783
+ ...round.rounds.map((each) => ({
5784
+ id: `round:${each.round}`,
5785
+ label: `Round ${each.round} — ${each.shots.length} screenshot${each.shots.length === 1 ? '' : 's'}, comments sent`,
5786
+ })),
5787
+ { id: WRITE_MY_OWN, label: 'Send comments…', hint: '#2 … is a note on screenshot 2; an empty line sends' },
5788
+ ];
5789
+ const select = new Select({
5790
+ kind: 'show',
5791
+ title: `${this.tagOf(this.runs.find((each) => each.id === round.runId))} · round ${round.round} · ${count} screenshot${count === 1 ? '' : 's'}`
5792
+ + (round.confirmed ? '' : ' — this gate names screenshots that were never sealed — /open looks on cawdev'),
5793
+ hint: '/open looks on cawdev',
5794
+ rows,
5795
+ });
5796
+ round.select = select;
5797
+ if (!round.confirmed) {
5798
+ this.say(ink.warn(' this gate names screenshots that were never sealed — /open looks on cawdev'));
5799
+ }
5800
+ return this.openPicker(select);
5801
+ }
5802
+
5803
+ /** One row of the show picker. */
5804
+ choseShow(select, row, typed) {
5805
+ const round = this.showing;
5806
+ if (!round) return this.note('');
5807
+ const back = () => (select.kind === 'show-round' ? select : round.select);
5808
+ const reopenWith = async (work) => {
5809
+ const said = await work;
5810
+ // Not over a gate decided meanwhile, nor over something being typed.
5811
+ if (this.showing?.approvalId !== round.approvalId || this.mode !== 'keys') return;
5812
+ this.reopen(back());
5813
+ this.note(said);
5814
+ };
5815
+ if (select.kind === 'show-round') {
5816
+ const earlier = select.earlier;
5817
+ if (row.id === 'back') return this.reopen(round.select);
5818
+ if (row.id === 'all') return void reopenWith(this.openShots(earlier.shots, round));
5819
+ if (row.id.startsWith('shot:')) {
5820
+ return void reopenWith(this.openShots([earlier.shots[Number(row.id.slice(5))]], round));
5821
+ }
5822
+ return this.note('');
5823
+ }
5824
+ if (row.id.startsWith('shot:')) {
5825
+ return void reopenWith(this.openShots([round.shots[Number(row.id.slice(5))]], round));
5826
+ }
5827
+ if (row.id === 'all') return void reopenWith(this.openShots(round.shots, round));
5828
+ if (row.id === 'accept') return void this.acceptShow(this.runs.find((each) => each.id === round.runId));
5829
+ if (row.id.startsWith('round:')) {
5830
+ return this.showRound(Number(row.id.slice(6)), round, true);
5831
+ }
5832
+ if (row.id === WRITE_MY_OWN) {
5833
+ this.commenting = { approvalId: round.approvalId, lines: [] };
5834
+ if (typed) {
5835
+ return void this.sendComments(this.runs.find((each) => each.id === round.runId), typed);
5836
+ }
5837
+ return this.type('comments', 'comments ▸', '', round.select);
5838
+ }
5839
+ return this.note('');
5840
+ }
5841
+
5842
+ /** An earlier round: what was sent on it printed ONCE, then its screenshots. */
5843
+ showRound(number, round, picker) {
5844
+ const earlier = round.rounds.find((each) => each.round === number);
5845
+ if (!earlier) return this.note(`there is no round ${number} to show — ${round.rounds.map((each) => each.round).join(', ') || 'none earlier'}`);
5846
+ const ink = this.ink;
5847
+ this.say('');
5848
+ this.say(`${ink.bold(ink.warn(` round ${number} `))} ${ink.muted(`${earlier.shots.length} screenshot${earlier.shots.length === 1 ? '' : 's'}${earlier.by ? ` · comments by ${earlier.by}` : ''}`)}`);
5849
+ for (const line of roundListing(earlier.shots, ink)) this.say(line);
5850
+ // The reason alone: it already holds each screenshot's note ("On `x` — caption:
5851
+ // note"), so printing the notes as well would say each one twice.
5852
+ for (const line of printable(earlier.comments, { lines: true }).split('\n')) this.say(` ${ink.text(line)}`);
5853
+ this.say('');
5854
+ if (!picker) return this.note('');
5855
+ const nested = new Select({
5856
+ kind: 'show-round',
5857
+ title: `round ${number}`,
5858
+ rows: [
5859
+ ...earlier.shots.map((shot, at) => ({
5860
+ id: `shot:${at}`,
5861
+ label: `${at + 1} ${printable(shot.name)}${shot.caption ? ` — ${printable(shot.caption)}` : ''}`,
5862
+ })),
5863
+ ...(earlier.shots.length ? [{ id: 'all', label: 'Open all' }] : []),
5864
+ { id: 'back', label: 'Back' },
5865
+ ],
5866
+ });
5867
+ nested.earlier = earlier;
5868
+ return this.openPicker(nested);
5869
+ }
5870
+
5871
+ /**
5872
+ * Hand screenshots to the OS viewer. Images come from the local cache —
5873
+ * downloaded the first time and never again while the bytes match their
5874
+ * checksum; HTML is NEVER written to disk or opened as a file: it opens at
5875
+ * the platform's sandboxed URL, which is the only origin it may run on.
5876
+ *
5877
+ * @returns the sentence for the status line.
5878
+ */
5879
+ async openShots(shots, round) {
5880
+ const run = this.runs.find((each) => each.id === round.runId) ?? { id: round.runId, projectSlug: round.slug };
5881
+ const base = this.attachmentsPath(run);
5882
+ const dir = shotsDir();
5883
+ const paths = [];
5884
+ let downloaded = 0;
5885
+ try {
5886
+ for (const shot of shots.filter(isImage)) {
5887
+ const copy = await localCopy(shot, {
5888
+ dir, fetchBytes: () => this.session.requestBytes(`${base}/${encodeURIComponent(shot.id)}`),
5889
+ });
5890
+ paths.push(copy.path);
5891
+ if (copy.downloaded) downloaded++;
5892
+ }
5893
+ } catch (failure) {
5894
+ return printable(failure.message);
5895
+ }
5896
+ if (paths.length) this.launch(paths);
5897
+ const pages = shots.filter((shot) => !isImage(shot));
5898
+ for (const page of pages) this.openLink(`${this.session.url}${base}/${encodeURIComponent(page.id)}`);
5899
+
5900
+ const home = homedir();
5901
+ const where = (dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir);
5902
+ const said = [];
5903
+ if (paths.length) {
5904
+ said.push(`opened ${paths.length} in the viewer (${downloaded
5905
+ ? `downloaded ${downloaded} into ${where}/${round.runId}/${round.round}` : `from ${where}`})`);
5906
+ }
5907
+ if (pages.length) {
5908
+ said.push(`${pages.length === 1 ? 'the html one opens' : 'the html ones open'} on cawdev — sign in there in your browser if it asks`);
5909
+ }
5910
+ return said.join(' · ');
5911
+ }
5912
+
5913
+ /** The viewer, with files. Injectable, like `openUrl`. */
5914
+ launch(paths) {
5915
+ if (this.options.openFiles) {
5916
+ this.options.openFiles(paths);
5917
+ return;
5918
+ }
5919
+ for (const [command, args] of openerFor(process.platform)(paths)) {
5920
+ spawn(command, args, { stdio: 'ignore', detached: true }).on('error', () => undefined).unref();
5921
+ }
5922
+ }
5923
+
5924
+ /** A line (or several, pasted) written at the comments prompt. An empty one sends. */
5925
+ commentLine(text, back) {
5926
+ const ink = this.ink;
5927
+ if (this.commenting?.approvalId !== this.showing?.approvalId) {
5928
+ this.commenting = { approvalId: this.showing?.approvalId ?? null, lines: [] };
5929
+ }
5930
+ const lines = String(text).split('\n').map((line) => line.trim()).filter(Boolean);
5931
+ if (!lines.length) {
5932
+ if (this.commenting.lines.length) {
5933
+ const run = this.runs.find((each) => each.id === this.showing?.runId);
5934
+ return void this.sendComments(run, this.commenting.lines.join('\n'), back);
5935
+ }
5936
+ return back ? this.reopen(back) : this.note('');
5937
+ }
5938
+ for (const line of lines) {
5939
+ this.commenting.lines.push(line);
5940
+ this.say(ink.muted(` ▸ ${printable(line)}`));
5941
+ }
5942
+ const n = this.commenting.lines.length;
5943
+ return this.type('comments', `comments (${n} line${n === 1 ? '' : 's'} · empty line sends) ▸`, '', back);
5944
+ }
5945
+
5946
+ /** Mark a gate decided, here: the poll must not raise it again (R137). */
5947
+ settleGate(run, approvalId) {
5948
+ this.settled.add(approvalId);
5949
+ this.approvals.delete(run.id);
5950
+ if (this.deciding?.approvalId === approvalId) this.deciding = null;
5951
+ this.showing = null;
5952
+ this.commenting = null;
5953
+ // Only what this gate opened: a prompt or another run's picker is not ours.
5954
+ if (this.select?.kind === 'show' || this.select?.kind === 'show-round'
5955
+ || this.input?.kind === 'comments') {
5956
+ this.mode = 'keys';
5957
+ this.select = null;
5958
+ this.input = null;
5959
+ }
5960
+ this.dirty = true;
5961
+ }
5962
+
5963
+ /** The gate to decide, still the same gate the round was read for. */
5964
+ stillAt(run) {
5965
+ const pending = this.showOn(run);
5966
+ if (!pending || (this.showing && this.showing.approvalId !== pending.approval.id)) {
5967
+ this.note('no longer waiting at SHOW — decided elsewhere, or gone');
5968
+ return null;
5969
+ }
5970
+ return pending;
5971
+ }
5972
+
5973
+ async sendComments(run, text, back = null) {
5974
+ if (!run || !this.requireSignIn('send comments')) return undefined;
5975
+ const pending = this.stillAt(run);
5976
+ if (!pending) return undefined;
5977
+ let round;
5978
+ try {
5979
+ round = await this.roundFor(run, pending);
5980
+ } catch (failure) {
5981
+ return this.note(printable(failure.message));
5982
+ }
5983
+ const parsed = parseComments(text, round.shots);
5984
+ if (parsed.error) {
5985
+ // Nothing is sent. Back on the line, with what was written still held.
5986
+ if (back || this.commenting) {
5987
+ this.type('comments', 'comments ▸', '', back ?? round.select);
5988
+ }
5989
+ return this.note(parsed.error);
5990
+ }
5991
+ try {
5992
+ await this.session.request(
5993
+ `/api/projects/${run.projectSlug}/runs/${run.id}/approvals/${pending.approval.id}/decision`,
5994
+ { method: 'POST', body: { allow: false, scope: 'ONCE', reason: parsed.reason, notes: parsed.notes } },
5995
+ );
5996
+ } catch (failure) {
5997
+ // A 409 for a round that was replaced says so in its own sentence.
5998
+ return this.note(printable(failure.message));
5999
+ }
6000
+ this.settleGate(run, pending.approval.id);
6001
+ // The run is read again rather than assumed: with no stages to walk again
6002
+ // the daemon ends it FAILED, and "goes back to coding" would be untrue.
6003
+ const view = await this.session.request(`/api/projects/${run.projectSlug}/runs/${run.id}`)
6004
+ .catch(() => null);
6005
+ const alive = view && (view.live ?? !['FAILED', 'FINISHED', 'CANCELLED'].includes(view.state));
6006
+ const ref = view?.entryRef ?? this.sessions.get(run.id)?.view?.entryRef ?? null;
6007
+ return this.note(alive
6008
+ ? `sent — ${ref ?? 'the run'} goes back to coding (round ${round.round + 1})`
6009
+ : 'sent');
6010
+ }
6011
+
6012
+ async acceptShow(run) {
6013
+ if (!run || !this.requireSignIn('accept the screenshots')) return undefined;
6014
+ const pending = this.stillAt(run);
6015
+ if (!pending) return undefined;
6016
+ let round;
6017
+ try {
6018
+ round = await this.roundFor(run, pending);
6019
+ } catch (failure) {
6020
+ return this.note(printable(failure.message));
6021
+ }
6022
+ if (!round.confirmed) {
6023
+ return this.note('this gate names screenshots that were never sealed — /open looks on cawdev');
6024
+ }
6025
+ const dropped = this.commenting?.lines.length
6026
+ ? 'the comments you wrote are dropped — accepting · ' : '';
6027
+ try {
6028
+ await this.session.request(
6029
+ `/api/projects/${run.projectSlug}/runs/${run.id}/approvals/${pending.approval.id}/decision`,
6030
+ { method: 'POST', body: { allow: true, scope: 'ONCE' } },
6031
+ );
6032
+ } catch (failure) {
6033
+ return this.note(printable(failure.message));
6034
+ }
6035
+ this.settleGate(run, pending.approval.id);
6036
+ return this.note(`${dropped}accepted — the pull request opens when the run finishes`);
6037
+ }
6038
+
6039
+ /**
6040
+ * `/shots`, `/accept`, `/comments` — and `/view` at a SHOW gate. Each acts on
6041
+ * the focused session, and each says why and does nothing when it is not
6042
+ * stopped at SHOW.
6043
+ */
6044
+ async showCommand(word, rest) {
6045
+ const meta = this.commandTarget();
6046
+ const label = meta ? this.tagOf(this.runOf(meta) ?? meta) || meta.label || 'this session' : null;
6047
+ if (!meta) return this.note(`/${word} needs a session — focus one`);
6048
+ const run = this.runOf(meta);
6049
+ const pending = this.showOn(run);
6050
+ if (!pending) return this.note(`${label} is not waiting at SHOW — nothing to look at`);
6051
+ if (word === 'view') {
6052
+ if (!this.requireSignIn('look and decide')) return undefined;
6053
+ return this.lookAndDecide(run);
6054
+ }
6055
+ if (word === 'accept') return this.acceptShow(run);
6056
+ if (word === 'comments') {
6057
+ // `#2` anywhere starts a note on screenshot 2, which runs to the next
6058
+ // `#n` or the end; what comes before the first is the round's note.
6059
+ const text = wordsText(rest).replace(/[ \t]+(#\d+)(?=[\s:—-]|$)/g, '\n$1');
6060
+ return this.sendComments(run, text);
6061
+ }
6062
+ if (!this.requireSignIn('look at the screenshots')) return undefined;
6063
+ let round;
6064
+ try {
6065
+ round = await this.roundFor(run, pending);
6066
+ } catch (failure) {
6067
+ return this.note(printable(failure.message));
6068
+ }
6069
+ const usage = '/shots · /shots 2 · /shots all · /shots round 1 (all | 2)';
6070
+ let from = round;
6071
+ let shots = round.shots;
6072
+ let words = rest;
6073
+ if (words[0] === 'round') {
6074
+ const wanted = Number(words[1]);
6075
+ const earlier = round.rounds.find((each) => each.round === wanted);
6076
+ if (!earlier) return this.note(Number.isInteger(wanted) ? `there is no round ${wanted} to show — ${usage}` : usage);
6077
+ if (words.length === 2) return this.showRound(wanted, round, false);
6078
+ shots = earlier.shots;
6079
+ words = words.slice(2);
6080
+ from = round;
6081
+ }
6082
+ if (!words.length) {
6083
+ const ink = this.ink;
6084
+ this.say('');
6085
+ this.say(`${ink.bold(ink.warn(' shots '))} ${ink.muted(`round ${round.round} · ${shots.length} screenshot${shots.length === 1 ? '' : 's'}`)}`);
6086
+ for (const line of roundListing(shots, ink)) this.say(line);
6087
+ this.say('');
6088
+ if (!this.split && !this.plain) await this.printThumbnails(shots, run);
6089
+ return this.note(round.confirmed ? '' : 'this gate names screenshots that were never sealed — /open looks on cawdev');
6090
+ }
6091
+ if (words.length === 1 && words[0] === 'all') {
6092
+ return this.note(await this.openShots(shots, from));
6093
+ }
6094
+ if (words.length === 1 && /^\d+$/.test(words[0])) {
6095
+ const at = Number(words[0]);
6096
+ if (at < 1 || at > shots.length) return this.note(`there is no screenshot ${at} — ${shots.length} in this round`);
6097
+ return this.note(await this.openShots([shots[at - 1]], from));
6098
+ }
6099
+ return this.note(usage);
6100
+ }
6101
+
6102
+ /** Inline thumbnails — iTerm2, WezTerm, kitty — into the scrollback, never the live region. */
6103
+ async printThumbnails(shots, run) {
6104
+ const protocol = thumbnailProtocol(process.env);
6105
+ if (!protocol) return;
6106
+ const base = this.attachmentsPath(run);
6107
+ for (const shot of shots.filter(isImage)) {
6108
+ try {
6109
+ const copy = await localCopy(shot, {
6110
+ dir: shotsDir(), fetchBytes: () => this.session.requestBytes(`${base}/${encodeURIComponent(shot.id)}`),
6111
+ });
6112
+ // Pushed as a committed line, not through `say`: `wrap` counts the
6113
+ // base64 as visible text and would cut the escape in half.
6114
+ this.pending.push(thumbnailEscape(await readFile(copy.path), protocol));
6115
+ this.dirty = true;
6116
+ } catch (failure) {
6117
+ this.say(this.ink.muted(` ${printable(shot.name)}: ${printable(failure.message)}`));
6118
+ }
6119
+ }
6120
+ }
6121
+
4551
6122
  async cancel() {
4552
6123
  const run = this.current();
4553
6124
  if (!run || run.state === 'queued') {
@@ -4922,6 +6493,15 @@ export class Attached {
4922
6493
  // it under the first.
4923
6494
  return [];
4924
6495
  }
6496
+ if (this.split?.boardFocus) {
6497
+ return [
6498
+ `${ink.text('←→')} ${ink.muted('column')}`,
6499
+ `${ink.text('↑↓')} ${ink.muted('card')}`,
6500
+ `${ink.text('p')} ${ink.muted(`plan ${this.split.boardFocus.ref}`)}`,
6501
+ `${ink.text('d')} ${ink.muted(`develop ${this.split.boardFocus.ref}`)}`,
6502
+ `${ink.text('esc/b')} ${ink.muted('back to sessions')}`,
6503
+ ];
6504
+ }
4925
6505
  const parts = [
4926
6506
  `${ink.text('enter')} ${ink.muted('prompt')}`,
4927
6507
  `${ink.text('/')} ${ink.muted('commands')}`,
@@ -4938,7 +6518,9 @@ export class Attached {
4938
6518
  + `${ink.muted('permission')}`);
4939
6519
  }
4940
6520
  if (this.split) {
4941
- parts.push(`${ink.text('tab')} ${ink.muted('next window')}`);
6521
+ parts.push(`${ink.text('b')} ${ink.muted('board')}`);
6522
+ parts.push(`${ink.text('tab')} ${ink.muted('next card')}`);
6523
+ parts.push(`${ink.text('v')} ${ink.muted('its log')}`);
4942
6524
  parts.push(`${ink.text('d')} ${ink.muted(this.finishedPlan(this.commandTarget())
4943
6525
  ? `develop ${this.commandTarget().view.entryRef}` : 'develop a planned card')}`);
4944
6526
  }