cawdev-cli 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/runner/attach.mjs CHANGED
@@ -64,10 +64,10 @@ import { restartNeededFor } from '../lib/runner-config.mjs';
64
64
  import {
65
65
  BOARD_COLUMNS, PROMPT_ROWS, SplitScreen, boardPreviewLines, moveInStrip, readerFrame, stripColumns, tabsLine, visibleSessions, actionsFor, composeFrame,
66
66
  batchStartedLines, carriesOnHere, builtNothing, cardLines, columnOf, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, titleFor,
67
- windowLines, showWaitingLine,
67
+ windowLines, showWaitingLine, advancedBlocker,
68
68
  } from './split.mjs';
69
69
  import {
70
- CARD_MIN_HEIGHT, cardGrid, cardStatus, nowLine, seedOf, sessionCardLines, stageWalk,
70
+ CARD_MIN_HEIGHT, CLOSE_AFTER_TICKS, cardGrid, cardStatus, nowLine, seedOf, sessionCardLines, stageWalk,
71
71
  } from './session-cards.mjs';
72
72
  import { KNOWN_AGENTS, agentOfCommand } from '../lib/agents.mjs';
73
73
  import { loadPrefs, savePrefs } from './prefs.mjs';
@@ -94,6 +94,19 @@ import {
94
94
  // arithmetic import them here.
95
95
  export { clip, keyList, keysIn, stripAnsi, visibleWidth, wrap };
96
96
 
97
+ /** Colored key parts packed into rows of `width`, between keys — none is cut off. */
98
+ function packKeys(parts, width, ink) {
99
+ const sep = ink.muted(' · ');
100
+ const rows = [];
101
+ let row = '';
102
+ for (const part of parts) {
103
+ const next = row ? row + sep + part : part;
104
+ if (row && visibleWidth(next) > width) { rows.push(row); row = part; } else row = next;
105
+ }
106
+ if (row) rows.push(row);
107
+ return rows;
108
+ }
109
+
97
110
  const ESC = '\x1b';
98
111
 
99
112
  /** How many times `/review`'s merge is read again (five seconds apart) before it stops following. */
@@ -663,7 +676,7 @@ export async function attach(argv, { url, ...options } = {}) {
663
676
  // Spread second so `--attach`'s own wiring wins: there the daemon IS this
664
677
  // process and quitting has always stopped it.
665
678
  const ui = new Attached(socket, session,
666
- { leaveRunning: argv.includes('--leave-running'), ...options });
679
+ { leaveRunning: argv.includes('--leave-running'), simple: argv.includes('--simple'), ...options });
667
680
  await ui.start();
668
681
  }
669
682
 
@@ -904,6 +917,8 @@ function refOf(word) {
904
917
  const SPLIT_COMPLETIONS = 6;
905
918
  /** How often the cards' rain moves a step (session-cards.mjs `RAIN`): eight a second. */
906
919
  const RAIN_STEP_MS = 120;
920
+ /** The cards' clock, from the wall: a tick stamped while the reader was up is still the right tick. */
921
+ const clockTick = () => Math.floor(Date.now() / RAIN_STEP_MS);
907
922
 
908
923
  /** The slash commands, and the one place they are described. */
909
924
  const COMMANDS = [
@@ -929,7 +944,9 @@ const COMMANDS = [
929
944
  ['/refresh', "read the cards and their statuses from cawdev again — advanced mode's roadmap"],
930
945
  ['/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
946
  ['/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'],
947
+ ['/matrix', 'the rain on advanced mode\'s cards, off by default: /matrix on|off, remembered; the spinner and the robots move either way'],
948
+ ['/simple', 'the scrollback terminal (cawdev --simple starts in it); /advanced-mode is the way back; remembered'],
949
+ ['/export', "lay the focused session's log into the scrollback so the terminal's own search and copy reach it: /export, /export 3, /export R29"],
933
950
  ['/shots', 'the screenshots a session waiting at SHOW took: /shots, /shots 2, /shots all, /shots round 1'],
934
951
  ['/accept', 'accept the screenshots — the pull request opens'],
935
952
  ['/comments', 'send comments on the screenshots: /comments <text>, #2 for one screenshot'],
@@ -1153,7 +1170,12 @@ export class Attached {
1153
1170
  // `/agent`'s choice, from last time — see prefs.mjs.
1154
1171
  const prefs = await loadPrefs(this.session.url).catch(() => ({}));
1155
1172
  this.agent = prefs.agent ?? null;
1156
- this.matrix = prefs.matrix !== false;
1173
+ // The rain is off unless asked for (`/matrix on`); the spinner and the
1174
+ // robots move either way.
1175
+ this.matrix = prefs.matrix === true;
1176
+ // Advanced mode is the default where the terminal can draw it; the choice
1177
+ // `/simple` and `/advanced-mode` made last time is remembered.
1178
+ this.screenMode = this.options.simple ? 'simple' : prefs.mode === 'simple' ? 'simple' : 'advanced';
1157
1179
  // Raw mode only where there is a terminal to put in it. Through a pipe
1158
1180
  // there are no keys, the transcript is the whole output, and that is the
1159
1181
  // honest degradation rather than a broken one.
@@ -1203,7 +1225,13 @@ export class Attached {
1203
1225
  // `/reload`'s other half: the state the terminal was in before it
1204
1226
  // restarted — advanced mode, the focus, the windows of sessions that had
1205
1227
  // ended — so a reload changes the code and nothing else.
1206
- this.restore(this.options.restore ?? readReloadState());
1228
+ const restored = this.options.restore ?? readReloadState();
1229
+ this.restore(restored);
1230
+ // A fresh launch opens in advanced mode wherever the terminal can draw it;
1231
+ // a reload keeps the mode it was in.
1232
+ if (!this.split && !(restored && typeof restored === 'object' && 'advanced' in restored)) {
1233
+ this.openByDefault();
1234
+ }
1207
1235
 
1208
1236
  // One write per tick at most, carrying everything that happened in it. A
1209
1237
  // busy session emits hundreds of lines a second, and a write per line means
@@ -1211,11 +1239,18 @@ export class Attached {
1211
1239
  const tick = setInterval(() => {
1212
1240
  // The cards' rain moves one step every RAIN_STEP_MS, and only while
1213
1241
  // 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);
1242
+ if (this.split && !this.reader) {
1243
+ const step = clockTick();
1216
1244
  if (step !== this.rainTick) {
1217
1245
  this.rainTick = step;
1218
1246
  this.dirty = true;
1247
+ // A landed card closes itself three seconds after the robot waves.
1248
+ for (const meta of this.windows()) {
1249
+ if (meta.merging?.state === 'landed' && step - meta.merging.landedTick >= CLOSE_AFTER_TICKS) {
1250
+ meta.merging = null;
1251
+ this.closeWindowOf(meta);
1252
+ }
1253
+ }
1219
1254
  }
1220
1255
  }
1221
1256
  if (this.dirty || this.pending.length) {
@@ -1756,9 +1791,19 @@ export class Attached {
1756
1791
  }
1757
1792
  this.interrupting = false;
1758
1793
 
1794
+ if (this.help && this.mode === 'keys') {
1795
+ // The key table is one window: any of these closes it, nothing else acts.
1796
+ if (key === ESC || key === '?' || key === 'q' || key === '\r') { this.help = false; this.dirty = true; }
1797
+ return undefined;
1798
+ }
1759
1799
  if (this.reader && this.mode === 'keys') {
1760
1800
  return this.onReaderKey(key);
1761
1801
  }
1802
+ if (this.split && key === '?' && this.mode === 'keys' && !this.select) {
1803
+ this.help = true;
1804
+ this.dirty = true;
1805
+ return undefined;
1806
+ }
1762
1807
  if (this.mode === 'typing') {
1763
1808
  return this.onTyping(key);
1764
1809
  }
@@ -1783,6 +1828,14 @@ export class Attached {
1783
1828
  if (['\r', '\n', 'i', 'd', 'x'].includes(key) && this.refuseWhileMerging()) {
1784
1829
  return undefined;
1785
1830
  }
1831
+ // `n` still refuses a permission request the focused card holds: that act came first.
1832
+ if (this.split && (key === 'n' || key === 'N') && this.mode === 'keys' && !this.split.boardFocus
1833
+ && !this.pendingOn(this.current())) {
1834
+ return this.focusNeedsYou(key === 'n' ? 1 : -1);
1835
+ }
1836
+ if (this.split && key === 'w') {
1837
+ return this.closeWindow();
1838
+ }
1786
1839
  if (this.split && key === 'v') {
1787
1840
  // A card's log — `/view`'s, over the whole screen.
1788
1841
  const meta = this.commandTarget();
@@ -1915,6 +1968,7 @@ export class Attached {
1915
1968
  closeEverything() {
1916
1969
  const open = this.mode !== 'keys' || Boolean(this.reader) || Boolean(this.split?.boardFocus);
1917
1970
  this.reader = null;
1971
+ this.help = false;
1918
1972
  if (this.split?.boardFocus) {
1919
1973
  this.split.lastBoardFocus = this.split.boardFocus;
1920
1974
  this.split.boardFocus = null;
@@ -3054,10 +3108,15 @@ export class Attached {
3054
3108
  case 'log':
3055
3109
  this.showLog = !this.showLog;
3056
3110
  return this.note(this.showLog ? "printing the daemon's log" : 'printing the session');
3111
+ case 'simple':
3112
+ return void this.chooseMode('simple');
3113
+ case 'export':
3114
+ return this.exportLog(rest[0]);
3057
3115
  case 'advanced-mode':
3058
3116
  case 'advanced': {
3059
3117
  const want = rest[0] === 'off' ? false : rest[0] === 'on' ? true : !this.split;
3060
- if (!want) return this.leaveSplit();
3118
+ if (!want) return void this.chooseMode('simple');
3119
+ void savePrefs(this.session.url, { mode: null }).catch(() => undefined);
3061
3120
  // `@slug` names the projects outright; otherwise they are asked.
3062
3121
  const named = rest.filter((word) => word.startsWith('@')).map((word) => word.slice(1));
3063
3122
  return named.length ? this.enterSplit(named) : this.askSplitProjects();
@@ -3109,7 +3168,7 @@ export class Attached {
3109
3168
  case 'drop':
3110
3169
  return void this.dropCheckout();
3111
3170
  case 'close':
3112
- return this.closeWindow();
3171
+ return this.closeWindow(rest[0]);
3113
3172
  case 'reload':
3114
3173
  return void this.reload();
3115
3174
  case 'quit':
@@ -3239,6 +3298,7 @@ export class Attached {
3239
3298
  for (const meta of this.sessions.values()) {
3240
3299
  if (!meta.ended && !live.has(meta.id)) {
3241
3300
  meta.ended = true;
3301
+ meta.changedTick = this.stamp();
3242
3302
  void this.readView(meta, 5);
3243
3303
  }
3244
3304
  }
@@ -3499,12 +3559,13 @@ export class Attached {
3499
3559
  * said meanwhile is laid into it now — or, if the focus moved, that
3500
3560
  * session's history, which is what switching sessions has always done.
3501
3561
  */
3502
- leaveSplit() {
3562
+ leaveSplit({ quiet = false } = {}) {
3503
3563
  if (!this.split) return this.note('advanced mode is not on — /advanced-mode starts it');
3504
3564
  const { screen, enteredWatching, mark, plannedTimer } = this.split;
3505
3565
  clearInterval(plannedTimer);
3506
3566
  this.split = null;
3507
3567
  screen.leave();
3568
+ if (quiet) return undefined;
3508
3569
  const watched = this.watching;
3509
3570
  const lines = this.lines.get(watched) ?? [];
3510
3571
  if (watched && watched === enteredWatching) {
@@ -3515,7 +3576,7 @@ export class Attached {
3515
3576
  this.printed.delete(watched);
3516
3577
  this.layHistoryIn(watched, lines);
3517
3578
  }
3518
- this.note('back to the scrollback');
3579
+ this.note('back to the scrollback · /advanced-mode returns');
3519
3580
  return undefined;
3520
3581
  }
3521
3582
 
@@ -3529,6 +3590,32 @@ export class Attached {
3529
3590
  this.note('');
3530
3591
  }
3531
3592
 
3593
+ /** The tick to stamp a change with: the wall clock's once the cards' clock runs (tests, which never start it, stay at 0). */
3594
+ stamp() {
3595
+ return this.rainTick === undefined ? 0 : clockTick();
3596
+ }
3597
+
3598
+ /** The sessions whose card waits on a person, in card order. */
3599
+ needYou() {
3600
+ return this.windows().filter((meta) => {
3601
+ const run = this.runs.find((each) => each.id === meta.id);
3602
+ return Boolean(run && (this.showOn(run) || this.pendingOn(run) || this.askingOn(run)));
3603
+ });
3604
+ }
3605
+
3606
+ /** `n` / `N`: focus moves to the next / previous card that needs you. */
3607
+ focusNeedsYou(step) {
3608
+ const waiting = this.needYou();
3609
+ if (!waiting.length) return this.note('nothing needs you');
3610
+ const windows = this.windows();
3611
+ const from = windows.findIndex((meta) => meta.id === this.split.focus);
3612
+ for (let i = 1; i <= windows.length; i++) {
3613
+ const meta = windows[(from + step * i + windows.length * i) % windows.length];
3614
+ if (waiting.includes(meta)) return this.focusWindow(meta.id);
3615
+ }
3616
+ return undefined;
3617
+ }
3618
+
3532
3619
  cycleFocus(step) {
3533
3620
  if (this.split?.boardFocus) this.leaveBoard();
3534
3621
  const windows = this.windows();
@@ -3704,8 +3791,13 @@ export class Attached {
3704
3791
  const subtitle = gate
3705
3792
  ? ink.warn(showWaitingLine(gate, gate.ids.length))
3706
3793
  : (who ? `${ink.strong(who)} ` : '') + ink.muted(subtitleFor(meta, room - (who ? who.length + 1 : 0)));
3794
+ const tick = this.rainTick ?? 0;
3795
+ const status = cardStatus(meta, waiting, now, tick);
3796
+ // A status that changes is drawn bright for a few ticks: when its key moves.
3797
+ if (meta.lastKey !== undefined && meta.lastKey !== status.key) meta.changedTick = tick;
3798
+ meta.lastKey = status.key;
3707
3799
  const progress = !meta.ended
3708
- ? stageWalk(meta.stages, meta.stage, room, ink)
3800
+ ? stageWalk(meta.stages, meta.stage, room, ink, tick)
3709
3801
  : meta.commits !== undefined && (view.profile ?? 'CODE') === 'CODE' && view.state === 'FINISHED'
3710
3802
  ? ink.muted(`${meta.commits} commit${meta.commits === 1 ? '' : 's'}${view.branch ? ` on ${view.branch}` : ''}`)
3711
3803
  : '';
@@ -3718,6 +3810,26 @@ export class Attached {
3718
3810
  : asking?.yours ? 'a answers · /view reads it whole'
3719
3811
  : shownGate ? 'a to look and decide' : actionsFor(meta);
3720
3812
  const merging = meta.merging;
3813
+ if (merging?.state === 'landed') {
3814
+ const left = Math.max(0, Math.ceil((CLOSE_AFTER_TICKS - (tick - merging.landedTick)) * RAIN_STEP_MS / 1000));
3815
+ return {
3816
+ n,
3817
+ profile: view.profile ?? meta.profile ?? null,
3818
+ title: `${merging.ref} ${view.entryTitle ?? view.label ?? meta.label ?? ''}`.trim(),
3819
+ status: { key: 'landed', mark: '✓', word: `merged · closes in ${left}s` },
3820
+ lines: [
3821
+ ink.muted(`landed on ${merging.base}${merging.sha ? ` as ${merging.sha}` : ''} · the card is MERGED`),
3822
+ ],
3823
+ footer: '/open the merge · /close now',
3824
+ focused: meta.id === this.split.focus,
3825
+ seed: seedOf(meta.id),
3826
+ tick,
3827
+ since: tick - merging.landedTick,
3828
+ changedAt: merging.landedTick,
3829
+ rain: false,
3830
+ base: merging.base,
3831
+ };
3832
+ }
3721
3833
  if (merging) {
3722
3834
  const failed = merging.state === 'failed';
3723
3835
  const resolving = merging.state === 'resolving';
@@ -3738,8 +3850,8 @@ export class Attached {
3738
3850
  : resolving ? `its development starts again here to resolve it` : '',
3739
3851
  focused: meta.id === this.split.focus,
3740
3852
  seed: seedOf(meta.id),
3741
- tick: this.rainTick ?? 0,
3742
- rain: this.matrix !== false,
3853
+ tick,
3854
+ rain: this.matrix === true,
3743
3855
  base: merging.base,
3744
3856
  };
3745
3857
  }
@@ -3749,20 +3861,59 @@ export class Attached {
3749
3861
  title: view.entryRef
3750
3862
  ? `${view.entryRef} ${view.entryTitle ?? view.label ?? meta.label ?? ''}`.trim()
3751
3863
  : (view.label ?? meta.label ?? 'a session'),
3752
- status: cardStatus(meta, waiting, now),
3864
+ status,
3753
3865
  lines: [subtitle, progress || null, doing ? (pending || asking ? doing : ink.text(doing)) : null],
3754
3866
  footer: keys,
3755
3867
  focused: !this.split.boardFocus && meta.id === this.split.focus,
3756
3868
  seed: seedOf(meta.id),
3757
- tick: this.rainTick ?? 0,
3758
- rain: this.matrix !== false,
3869
+ tick,
3870
+ since: meta.ended && Number.isFinite(meta.changedTick) ? tick - meta.changedTick : undefined,
3871
+ changedAt: meta.changedTick,
3872
+ ref: view.entryRef ?? null,
3873
+ rain: this.matrix === true,
3759
3874
  };
3760
3875
  }
3761
3876
 
3877
+ /** `?` — every key and command in one window, two columns. */
3878
+ helpScreen(width, height) {
3879
+ const ink = this.ink;
3880
+ const keys = [
3881
+ ['enter', 'prompt the focused card'], ['tab ⇧tab 1–9', 'next / previous / pick a card'],
3882
+ ['n N', 'next / previous card needing you'], ['v', 'its log'],
3883
+ ['b', 'the board: ←→ ↑↓ p d esc'], ['↑↓ PgUp PgDn G', 'scroll the log, G follows the end'],
3884
+ ['ctrl+f', 'find in the log'], ['r', 'fold the roadmap'], ['d', 'develop a planned card'],
3885
+ ['a y s Y', 'answer · permission'], ['x x', 'cancel the running session'],
3886
+ ['w', 'close the ended card'], ['c', 'config'], ['L', 'runs'], ['?', 'this window'], ['q', 'quit'],
3887
+ ];
3888
+ const commands = [
3889
+ ['/new', 'plan · dev · ask'], ['/view v', 'read a session'], ['/board b', 'the roadmap'],
3890
+ ['/continue /pr', 'carry on · open the PR'], ['/open', 'on cawdev'], ['/export', 'log to scrollback'],
3891
+ ['/simple', 'the scrollback terminal'], ['/matrix', 'the rain, off by default'],
3892
+ ['/projects /refresh', 'the roadmap'], ['/agent', 'which agent'], ['/quit q', 'leave'],
3893
+ ['/close N|all', 'close ended cards'],
3894
+ ];
3895
+ const half = Math.max(20, Math.floor((width - 4) / 2));
3896
+ const cell = (pair, w) => {
3897
+ if (!pair) return ' '.repeat(w);
3898
+ const key = ink.accent(pair[0]);
3899
+ const gap = Math.max(1, 16 - visibleWidth(pair[0]));
3900
+ return padVisible(clip(`${key}${' '.repeat(gap)}${ink.muted(pair[1])}`, w), w);
3901
+ };
3902
+ const body = [ink.bold(padVisible(' keys', half)) + ink.bold(' commands')];
3903
+ for (let i = 0; i < Math.max(keys.length, commands.length); i++) {
3904
+ body.push(` ${cell(keys[i], half - 1)}${cell(commands[i], half - 1)}`);
3905
+ }
3906
+ body.push('', ink.muted(' the key line at the bottom shows what fits · this is all of it · esc closes'));
3907
+ return windowLines({ width, height }, { title: 'cawdev ▸ keys', body, focused: true }, ink);
3908
+ }
3909
+
3762
3910
  /** The whole alternate screen, as rows — see split.mjs. */
3763
3911
  splitFrame() {
3764
3912
  const { width, height } = this.split.screen;
3765
3913
  const ink = this.ink;
3914
+ if (this.help && this.mode === 'keys') {
3915
+ return composeFrame(width, height, [{ rect: { top: 0, left: 0, width, height }, lines: this.helpScreen(width, height) }]);
3916
+ }
3766
3917
  if (this.reader) {
3767
3918
  return composeFrame(width, height, [{ rect: { top: 0, left: 0, width, height }, lines: this.readerScreen(width, height) }]);
3768
3919
  }
@@ -3781,8 +3932,8 @@ export class Attached {
3781
3932
  const input = this.mode === 'typing'
3782
3933
  ? `${tag}${this.input.label} ${this.input.line.render(
3783
3934
  Math.max(8, inner - visibleWidth(tag) - visibleWidth(this.input.label) - 1), ink)}`
3784
- : tag + ink.muted(`enter prompts ${current ? `“${current.label}”` : 'the focused session'}`
3785
- + ' · / commands · b board · tab next card · v its log');
3935
+ // Only what enter acts on: the keys live once, in the window's footer.
3936
+ : [`${tag}${ink.muted(`enter prompts ${current ? `“${current.label}”` : 'the focused session'}`)}`];
3786
3937
  // A question or a request on screen says which window it came from.
3787
3938
  const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
3788
3939
  // Under the banner and right above the line, so a long one never pushes
@@ -3799,8 +3950,12 @@ export class Attached {
3799
3950
  const completing = completionLines(
3800
3951
  matches.rows.slice(first, first + SPLIT_COMPLETIONS), matches.at - first, inner, ink,
3801
3952
  );
3802
- const fixed = [...from, ...banner, ...whole, ...overlay, ...completing, input];
3803
- const promptRows = Math.max(PROMPT_ROWS, fixed.length + 5);
3953
+ const fixed = [...from, ...banner, ...whole, ...overlay, ...completing, ...[input].flat()];
3954
+ // The window's key footer wraps between keys, so the prompt grows with it.
3955
+ const footerRows = this.status
3956
+ ? [ink.warn(`${this.statusWindow ? `[${this.statusWindow}] ` : ''}${this.status}`)]
3957
+ : packKeys(this.keys(), inner, ink);
3958
+ const promptRows = Math.max(PROMPT_ROWS, fixed.length + 4 + footerRows.length);
3804
3959
  // The roadmap across the top: as tall as its fullest column needs, never
3805
3960
  // more than a third of the screen, and never so tall that no window fits
3806
3961
  // under it — a short terminal drops it before it drops the sessions.
@@ -3893,7 +4048,8 @@ export class Attached {
3893
4048
  });
3894
4049
  const promptTitle = `cawdev ▸ ${this.runner?.name ?? 'runner'}`
3895
4050
  + `${this.session.email ? ` · ${this.session.email}` : ' · not signed in'}`
3896
- + `${this.agent ? ` · ${this.agent}` : ''}${unseen}`;
4051
+ + `${this.agent ? ` · ${this.agent}` : ''}${unseen}`
4052
+ + (this.needYou().length ? ` · ${ink.warn(`${this.needYou().length} need you`)}` : '');
3897
4053
  pieces.push({
3898
4054
  rect: layout.prompt,
3899
4055
  lines: windowLines(layout.prompt, {
@@ -3906,9 +4062,7 @@ export class Attached {
3906
4062
  ...messages,
3907
4063
  ...fixed,
3908
4064
  ],
3909
- footer: this.status
3910
- ? ink.warn(`${this.statusWindow ? `[${this.statusWindow}] ` : ''}${this.status}`)
3911
- : keyList(this.keys(), '', inner, ink),
4065
+ footer: footerRows,
3912
4066
  }, ink),
3913
4067
  });
3914
4068
  if (strip) {
@@ -5139,8 +5293,14 @@ export class Attached {
5139
5293
  const merge = card?.workItem?.merge;
5140
5294
  if (card?.status === 'MERGED' || card?.status === 'SHIPPED' || merge?.state === 'DONE') {
5141
5295
  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.
5296
+ // Landed: the card's work is over, and so are its cards here. In
5297
+ // advanced mode the robot waves first and the card closes itself.
5298
+ const sha = /\b[0-9a-f]{7,40}\b/.exec(String(merge?.result ?? ''))?.[0]?.slice(0, 7) ?? null;
5143
5299
  for (const meta of this.mergingCards(review)) {
5300
+ if (this.split && meta.ended) {
5301
+ meta.merging = { state: 'landed', ref: review.ref, branch: review.branch, base: review.base, sha, landedTick: this.stamp() };
5302
+ continue;
5303
+ }
5144
5304
  meta.merging = null;
5145
5305
  if (meta.ended) this.closeWindowOf(meta);
5146
5306
  }
@@ -5219,12 +5379,12 @@ export class Attached {
5219
5379
  async chooseMatrix(word) {
5220
5380
  const wanted = String(word ?? '').toLowerCase();
5221
5381
  if (!['on', 'off'].includes(wanted)) {
5222
- return this.note(`the rain is ${this.matrix === false ? 'off' : 'on'} — /matrix on|off`);
5382
+ return this.note(`the rain is ${this.matrix ? 'on' : 'off'} — /matrix on|off`);
5223
5383
  }
5224
5384
  this.matrix = wanted === 'on';
5225
5385
  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');
5386
+ await savePrefs(this.session.url, { matrix: this.matrix ? true : null }).catch(() => undefined);
5387
+ return this.note(this.matrix ? 'the cards rain' : 'no rain — the spinners and the robots still move');
5228
5388
  }
5229
5389
 
5230
5390
  openReader() {
@@ -5552,14 +5712,64 @@ export class Attached {
5552
5712
  }
5553
5713
 
5554
5714
  /** `/close` — an ended session's window goes; a live one's stays. */
5555
- closeWindow() {
5556
- const meta = this.commandTarget();
5715
+ closeWindow(which = null) {
5716
+ // `/close now` is the landed card's own footer: the focused card, without the countdown.
5717
+ const word = String(which ?? '').toLowerCase().replace(/^now$/, '');
5718
+ if (word === 'all') {
5719
+ const ended = this.windows().filter((meta) => meta.ended);
5720
+ for (const meta of ended) this.closeWindowOf(meta);
5721
+ return this.note(ended.length ? `closed ${ended.length} ended card${ended.length === 1 ? '' : 's'}` : 'no ended card to close');
5722
+ }
5723
+ const meta = word && this.split ? this.windowNamed([word]) : this.commandTarget();
5724
+ if (word && this.split && !meta) return this.note(`no card ${word} — /close, /close 3 or /close all`);
5557
5725
  if (!meta) return this.note('no window to close');
5558
- if (!meta.ended) return this.note('that session is still running — /cancel stops it');
5726
+ if (!meta.ended) return this.note('that session is still running · x x cancels it');
5559
5727
  this.closeWindowOf(meta);
5560
5728
  return this.note('closed');
5561
5729
  }
5562
5730
 
5731
+ /** `/simple` and `/advanced-mode off`: the scrollback, remembered for next time. */
5732
+ async chooseMode(mode) {
5733
+ this.screenMode = mode;
5734
+ // Left first and remembered after: the next command must find it left.
5735
+ const left = this.split ? this.leaveSplit() : this.note('already the scrollback — /advanced-mode is the way back');
5736
+ await savePrefs(this.session.url, { mode: 'simple' }).catch(() => undefined);
5737
+ return left;
5738
+ }
5739
+
5740
+ /**
5741
+ * A fresh launch: advanced mode where this terminal can draw it, else the
5742
+ * scrollback with one line saying why. `--simple` and a remembered `/simple`
5743
+ * are the person's own choice and need no line.
5744
+ */
5745
+ openByDefault() {
5746
+ if (this.screenMode === 'simple' || this.stopped) return;
5747
+ const out = this.options.out ?? process.stdout;
5748
+ const why = advancedBlocker({
5749
+ tty: !this.plain && Boolean(process.stdin.isTTY), term: process.env.TERM, columns: out.columns, rows: out.rows,
5750
+ });
5751
+ if (why) {
5752
+ this.say(this.ink.muted(` the scrollback, not advanced mode: ${why}`));
5753
+ return;
5754
+ }
5755
+ this.enterSplit(null);
5756
+ }
5757
+
5758
+ /**
5759
+ * `/export`: a session's log laid into the normal screen's scrollback, so
5760
+ * the terminal's own search and copy reach it. Leaves advanced mode's screen
5761
+ * for the scrollback and says how to come back.
5762
+ */
5763
+ exportLog(which = null) {
5764
+ const meta = which && this.split ? this.windowNamed([which]) : this.commandTarget();
5765
+ if (!meta) return this.note(which ? `no session ${which} here` : 'no session to export');
5766
+ const lines = this.lines.get(meta.id) ?? [];
5767
+ if (this.split) this.leaveSplit({ quiet: true });
5768
+ this.printed.delete(meta.id);
5769
+ this.layHistoryIn(meta.id, lines);
5770
+ return this.note('exported — /advanced-mode is the way back');
5771
+ }
5772
+
5563
5773
  /** An ended session's window goes; focus moves to the first one left. */
5564
5774
  closeWindowOf(meta) {
5565
5775
  this.closed.add(meta.id);
@@ -6185,8 +6395,17 @@ export class Attached {
6185
6395
  // lands on a buffer that vanishes the moment the program exits.
6186
6396
  if (this.split) {
6187
6397
  clearInterval(this.split.plannedTimer);
6398
+ const { enteredWatching, mark } = this.split;
6188
6399
  this.split.screen.release();
6189
6400
  this.split = null;
6401
+ // What the watched session said while the screen was up goes into the
6402
+ // scrollback, as `leaveSplit` does: the terminal's own search reaches it.
6403
+ const watched = this.watching;
6404
+ if (watched && watched === enteredWatching) {
6405
+ for (const line of (this.lines.get(watched) ?? []).slice(mark.get(watched) ?? 0)) {
6406
+ this.say(this.render(line));
6407
+ }
6408
+ }
6190
6409
  }
6191
6410
  try {
6192
6411
  this.client?.destroy();
@@ -6505,6 +6724,7 @@ export class Attached {
6505
6724
  const parts = [
6506
6725
  `${ink.text('enter')} ${ink.muted('prompt')}`,
6507
6726
  `${ink.text('/')} ${ink.muted('commands')}`,
6727
+ ...(this.split ? [`${ink.text('?')} ${ink.muted('help')}`] : []),
6508
6728
  `${ink.text('L')} ${ink.muted('runs')}`,
6509
6729
  ];
6510
6730
  if (this.askingOn(this.current())?.yours) {
@@ -6521,6 +6741,8 @@ export class Attached {
6521
6741
  parts.push(`${ink.text('b')} ${ink.muted('board')}`);
6522
6742
  parts.push(`${ink.text('tab')} ${ink.muted('next card')}`);
6523
6743
  parts.push(`${ink.text('v')} ${ink.muted('its log')}`);
6744
+ if (this.needYou().length) parts.push(`${ink.warn('n')} ${ink.muted('next needing you')}`);
6745
+ if (this.commandTarget()?.ended) parts.push(`${ink.text('w')} ${ink.muted('close card')}`);
6524
6746
  parts.push(`${ink.text('d')} ${ink.muted(this.finishedPlan(this.commandTarget())
6525
6747
  ? `develop ${this.commandTarget().view.entryRef}` : 'develop a planned card')}`);
6526
6748
  }