cawdev-cli 1.2.0 → 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/lib/finish-check.mjs +9 -1
- package/lib/gate-condition.mjs +11 -0
- package/lib/sonar-analysis.mjs +55 -9
- package/lib/stage-tools.mjs +33 -2
- package/mcp/server.mjs +44 -12
- package/package.json +1 -1
- package/runner/README.md +294 -33
- package/runner/attach.mjs +1927 -123
- package/runner/card-import.mjs +115 -0
- package/runner/cawdev.mjs +70 -1
- package/runner/diff-view.mjs +252 -0
- package/runner/git-lock.mjs +73 -0
- package/runner/runner.mjs +508 -39
- package/runner/session-cards.mjs +660 -0
- package/runner/shots.mjs +214 -0
- package/runner/sign-in.mjs +29 -2
- package/runner/split.mjs +307 -74
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 {
|
|
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,
|
|
64
|
-
carriesOnHere, builtNothing, cardLines, columnOf, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag,
|
|
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, advancedBlocker,
|
|
66
68
|
} from './split.mjs';
|
|
69
|
+
import {
|
|
70
|
+
CARD_MIN_HEIGHT, CLOSE_AFTER_TICKS, 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
|
|
@@ -82,8 +94,30 @@ import { Select, WRITE_MY_OWN, pickFromLine, plainLines } from './select.mjs';
|
|
|
82
94
|
// arithmetic import them here.
|
|
83
95
|
export { clip, keyList, keysIn, stripAnsi, visibleWidth, wrap };
|
|
84
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
|
+
|
|
85
110
|
const ESC = '\x1b';
|
|
86
111
|
|
|
112
|
+
/** How many times `/review`'s merge is read again (five seconds apart) before it stops following. */
|
|
113
|
+
const REVIEW_FOLLOW_TRIES = 120;
|
|
114
|
+
/**
|
|
115
|
+
* What a session merging its card refuses (`refuseWhileMerging`): every act on
|
|
116
|
+
* its branch or its checkout. `/view`, `/open` and the commands that name
|
|
117
|
+
* another card stay.
|
|
118
|
+
*/
|
|
119
|
+
const DISABLED_WHILE_MERGING = new Set(['pr', 'continue', 'clean', 'drop', 'close', 'cancel', 'develop', 'review']);
|
|
120
|
+
|
|
87
121
|
/** Colours for a run's state. Muted, because the transcript is the content. */
|
|
88
122
|
const STATE_COLOUR = {
|
|
89
123
|
running: 'success',
|
|
@@ -216,6 +250,19 @@ export function permissionDecision(approval, key, machine = {}) {
|
|
|
216
250
|
}
|
|
217
251
|
}
|
|
218
252
|
|
|
253
|
+
/** What the "started" line adds when `--show` or `--no-show` was given. */
|
|
254
|
+
function shownWord(show) {
|
|
255
|
+
return show === true ? ', shown before its PR' : show === false ? ', not shown before its PR' : '';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const A_SHOW_GATE = 'a SHOW gate is decided after looking — a, /accept or /comments';
|
|
259
|
+
|
|
260
|
+
/** What a window's title says a session is stopped on. */
|
|
261
|
+
function waitingWord(kind) {
|
|
262
|
+
return kind === 'show' ? 'waiting for you'
|
|
263
|
+
: kind === 'question' ? 'waiting for an answer' : 'waiting for permission';
|
|
264
|
+
}
|
|
265
|
+
|
|
219
266
|
/**
|
|
220
267
|
* A pending permission request, drawn — R51 and R60's three answers.
|
|
221
268
|
*
|
|
@@ -243,6 +290,22 @@ export function permissionBanner(pending, width, ink = painter(3), machine = {})
|
|
|
243
290
|
// Called with a pending record by the client, and with the approval itself by
|
|
244
291
|
// the rule's own tests — the banner is about the request either way.
|
|
245
292
|
const approval = pending.approval ?? pending;
|
|
293
|
+
// R605. A SHOW gate is not a permission: nobody is asked to allow a call, they
|
|
294
|
+
// are asked to look. None of y / s / Y / M / n does anything on it.
|
|
295
|
+
const gate = showGate(approval);
|
|
296
|
+
if (gate) {
|
|
297
|
+
const count = gate.ids.length;
|
|
298
|
+
return [
|
|
299
|
+
`${ink.bold(ink.warn(' show '))} ${clip(`waiting for you — ${count} screenshot${count === 1 ? '' : 's'}, round ${gate.round}`, Math.max(8, width - 8))}`,
|
|
300
|
+
ink.muted(` ${approval.toolName} · waiting since ${approval.askedAt?.slice(11, 19) ?? ''}`),
|
|
301
|
+
...wrapChoices([
|
|
302
|
+
`${ink.success('a')} look and decide`,
|
|
303
|
+
`${ink.success('/accept')}`,
|
|
304
|
+
`${ink.success('/comments')} …`,
|
|
305
|
+
`${ink.muted('/open on cawdev')}`,
|
|
306
|
+
], width, ' · '),
|
|
307
|
+
];
|
|
308
|
+
}
|
|
246
309
|
const covers = approval.suggestion ?? `every ${approval.toolName}`;
|
|
247
310
|
|
|
248
311
|
// The two standing-rule keys, asked here exactly as `permissionDecision`
|
|
@@ -301,7 +364,7 @@ export function permissionBanner(pending, width, ink = painter(3), machine = {})
|
|
|
301
364
|
// A stage gate's summary is the whole PLAN, many lines long: one row here,
|
|
302
365
|
// and how much more there is — `/view` reads it whole. Clipped as one
|
|
303
366
|
// string, its newlines went through and filled the prompt.
|
|
304
|
-
|
|
367
|
+
let [first = '', ...more] = String(approval.summary ?? '').split('\n');
|
|
305
368
|
const rest = more.filter((line) => line.trim()).length;
|
|
306
369
|
const tail = rest ? ` (+${rest} lines)` : '';
|
|
307
370
|
return [
|
|
@@ -613,7 +676,7 @@ export async function attach(argv, { url, ...options } = {}) {
|
|
|
613
676
|
// Spread second so `--attach`'s own wiring wins: there the daemon IS this
|
|
614
677
|
// process and quitting has always stopped it.
|
|
615
678
|
const ui = new Attached(socket, session,
|
|
616
|
-
{ leaveRunning: argv.includes('--leave-running'), ...options });
|
|
679
|
+
{ leaveRunning: argv.includes('--leave-running'), simple: argv.includes('--simple'), ...options });
|
|
617
680
|
await ui.start();
|
|
618
681
|
}
|
|
619
682
|
|
|
@@ -823,6 +886,20 @@ function withAgent(body, agent) {
|
|
|
823
886
|
return agent ? { ...body, agent } : body;
|
|
824
887
|
}
|
|
825
888
|
|
|
889
|
+
/**
|
|
890
|
+
* The note a branch is sent back with when its merge hit a conflict — the
|
|
891
|
+
* order the next session on the card reads first. What to do, in that order,
|
|
892
|
+
* and what not to touch.
|
|
893
|
+
*/
|
|
894
|
+
export function conflictOrder(review, why) {
|
|
895
|
+
return [
|
|
896
|
+
`The merge of ${review.branch} into ${review.base} stopped on a conflict: ${why}`,
|
|
897
|
+
'',
|
|
898
|
+
`Merge origin/${review.base} into ${review.branch} and resolve every conflict, keeping what both sides meant.`,
|
|
899
|
+
'Change nothing else on this card. Run the tests, then commit the merge and push the branch.',
|
|
900
|
+
].join('\n');
|
|
901
|
+
}
|
|
902
|
+
|
|
826
903
|
/** What was started, saying with which agent when one was named. */
|
|
827
904
|
function withWho(what, agent) {
|
|
828
905
|
return agent ? `${what} with ${agent}` : what;
|
|
@@ -838,6 +915,10 @@ function refOf(word) {
|
|
|
838
915
|
|
|
839
916
|
/** How many matching commands advanced mode's prompt shows at once. */
|
|
840
917
|
const SPLIT_COMPLETIONS = 6;
|
|
918
|
+
/** How often the cards' rain moves a step (session-cards.mjs `RAIN`): eight a second. */
|
|
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);
|
|
841
922
|
|
|
842
923
|
/** The slash commands, and the one place they are described. */
|
|
843
924
|
const COMMANDS = [
|
|
@@ -848,17 +929,27 @@ const COMMANDS = [
|
|
|
848
929
|
['/config', "this machine's config — the same as c"],
|
|
849
930
|
['/cancel', 'cancel the session you are watching'],
|
|
850
931
|
['/log', "the daemon's own log, on or off"],
|
|
851
|
-
['/advanced-mode', '
|
|
852
|
-
['/new', 'start a session here: /new plan|dev R3, /new plan|dev <the task>, /new ask … (@project)'],
|
|
853
|
-
['/
|
|
932
|
+
['/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'],
|
|
933
|
+
['/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)'],
|
|
934
|
+
['/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'],
|
|
935
|
+
['/card', 'add an idea to Considering — no session: /card <the idea> (@project) (--show: shown before its PR)'],
|
|
854
936
|
['/agent', 'which agent /new uses: /agent claude|codex|agy, /agent default, /agent to see it'],
|
|
855
937
|
['/develop', 'start the development of the card a finished plan was about, or of one named: /develop R30 (--codex) (@project)'],
|
|
856
938
|
['/pr', "open the pull request of a finished session's branch"],
|
|
939
|
+
['/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
940
|
['/continue', 'pick a failed or stopped session back up on this machine — the console\'s Carry on'],
|
|
941
|
+
['/clean', 'remove the git lock a crash left in a stopped session\'s checkout (index.lock: File exists), then /continue'],
|
|
858
942
|
['/projects', 'which projects advanced mode shows — tick one or several'],
|
|
943
|
+
['/board', 'focus advanced mode\'s roadmap strip (b too): arrows browse cards and show their full title and description'],
|
|
859
944
|
['/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"],
|
|
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"],
|
|
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"],
|
|
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"],
|
|
950
|
+
['/shots', 'the screenshots a session waiting at SHOW took: /shots, /shots 2, /shots all, /shots round 1'],
|
|
951
|
+
['/accept', 'accept the screenshots — the pull request opens'],
|
|
952
|
+
['/comments', 'send comments on the screenshots: /comments <text>, #2 for one screenshot'],
|
|
862
953
|
['/drop', 'delete the checkout a finished session was made on demand for'],
|
|
863
954
|
['/close', "close an ended session's window"],
|
|
864
955
|
['/reload', 'quit and start again on the code on disk — the runner too, when nothing is running'],
|
|
@@ -977,6 +1068,15 @@ export class Attached {
|
|
|
977
1068
|
* otherwise make a picker titled with the first one's command look current.
|
|
978
1069
|
*/
|
|
979
1070
|
this.deciding = null;
|
|
1071
|
+
/**
|
|
1072
|
+
* The round a SHOW gate names, as the platform's sealed list confirms it —
|
|
1073
|
+
* R605. `{runId, approvalId, slug, round, shots, confirmed, rounds, select}`.
|
|
1074
|
+
* Read when the person asks to look, not before: nobody is asked about
|
|
1075
|
+
* screenshots on a terminal that never opens them.
|
|
1076
|
+
*/
|
|
1077
|
+
this.showing = null;
|
|
1078
|
+
/** The comment lines written so far at a SHOW gate — R605 — `{lines}`. */
|
|
1079
|
+
this.commenting = null;
|
|
980
1080
|
this.status = '';
|
|
981
1081
|
this.showLog = false;
|
|
982
1082
|
this.asked = new Set();
|
|
@@ -1068,7 +1168,14 @@ export class Attached {
|
|
|
1068
1168
|
this.screen.open();
|
|
1069
1169
|
this.history = new History(await loadHistory(this.session.url).catch(() => []));
|
|
1070
1170
|
// `/agent`'s choice, from last time — see prefs.mjs.
|
|
1071
|
-
|
|
1171
|
+
const prefs = await loadPrefs(this.session.url).catch(() => ({}));
|
|
1172
|
+
this.agent = prefs.agent ?? null;
|
|
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';
|
|
1072
1179
|
// Raw mode only where there is a terminal to put in it. Through a pipe
|
|
1073
1180
|
// there are no keys, the transcript is the whole output, and that is the
|
|
1074
1181
|
// honest degradation rather than a broken one.
|
|
@@ -1118,12 +1225,34 @@ export class Attached {
|
|
|
1118
1225
|
// `/reload`'s other half: the state the terminal was in before it
|
|
1119
1226
|
// restarted — advanced mode, the focus, the windows of sessions that had
|
|
1120
1227
|
// ended — so a reload changes the code and nothing else.
|
|
1121
|
-
this.
|
|
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
|
+
}
|
|
1122
1235
|
|
|
1123
1236
|
// One write per tick at most, carrying everything that happened in it. A
|
|
1124
1237
|
// busy session emits hundreds of lines a second, and a write per line means
|
|
1125
1238
|
// an erase-and-redraw of the whole footer per line.
|
|
1126
1239
|
const tick = setInterval(() => {
|
|
1240
|
+
// The cards' rain moves one step every RAIN_STEP_MS, and only while
|
|
1241
|
+
// somebody can see it: not under the reader, not with `/matrix off`.
|
|
1242
|
+
if (this.split && !this.reader) {
|
|
1243
|
+
const step = clockTick();
|
|
1244
|
+
if (step !== this.rainTick) {
|
|
1245
|
+
this.rainTick = step;
|
|
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
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1127
1256
|
if (this.dirty || this.pending.length) {
|
|
1128
1257
|
this.flush();
|
|
1129
1258
|
}
|
|
@@ -1441,6 +1570,12 @@ export class Attached {
|
|
|
1441
1570
|
return run ? this.approvals.get(run.id) ?? null : null;
|
|
1442
1571
|
}
|
|
1443
1572
|
|
|
1573
|
+
/** The pending approval, when it is a SHOW gate — R605 — else null. */
|
|
1574
|
+
showOn(run) {
|
|
1575
|
+
const pending = this.pendingOn(run);
|
|
1576
|
+
return pending && showGate(pending.approval) ? pending : null;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1444
1579
|
/**
|
|
1445
1580
|
* What the watched session is stopped on — R58, and since R137 both shapes.
|
|
1446
1581
|
*
|
|
@@ -1606,7 +1741,9 @@ export class Attached {
|
|
|
1606
1741
|
this.dirty = true;
|
|
1607
1742
|
|
|
1608
1743
|
const mine = this.deciding?.approvalId === pending.approval.id;
|
|
1609
|
-
const onScreen = this.select?.kind === 'permission' || this.input?.kind === 'reason'
|
|
1744
|
+
const onScreen = this.select?.kind === 'permission' || this.input?.kind === 'reason'
|
|
1745
|
+
|| this.select?.kind === 'show' || this.select?.kind === 'show-round'
|
|
1746
|
+
|| this.input?.kind === 'comments';
|
|
1610
1747
|
if (mine && onScreen) {
|
|
1611
1748
|
this.mode = 'keys';
|
|
1612
1749
|
this.select = null;
|
|
@@ -1614,6 +1751,10 @@ export class Attached {
|
|
|
1614
1751
|
this.history.reset();
|
|
1615
1752
|
this.deciding = null;
|
|
1616
1753
|
}
|
|
1754
|
+
if (mine) {
|
|
1755
|
+
this.showing = null;
|
|
1756
|
+
this.commenting = null;
|
|
1757
|
+
}
|
|
1617
1758
|
|
|
1618
1759
|
const by = record?.decidedByEmail;
|
|
1619
1760
|
const verb = record?.state === 'DENIED' ? 'refused'
|
|
@@ -1650,9 +1791,19 @@ export class Attached {
|
|
|
1650
1791
|
}
|
|
1651
1792
|
this.interrupting = false;
|
|
1652
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
|
+
}
|
|
1653
1799
|
if (this.reader && this.mode === 'keys') {
|
|
1654
1800
|
return this.onReaderKey(key);
|
|
1655
1801
|
}
|
|
1802
|
+
if (this.split && key === '?' && this.mode === 'keys' && !this.select) {
|
|
1803
|
+
this.help = true;
|
|
1804
|
+
this.dirty = true;
|
|
1805
|
+
return undefined;
|
|
1806
|
+
}
|
|
1656
1807
|
if (this.mode === 'typing') {
|
|
1657
1808
|
return this.onTyping(key);
|
|
1658
1809
|
}
|
|
@@ -1665,9 +1816,31 @@ export class Attached {
|
|
|
1665
1816
|
this.confirming = null;
|
|
1666
1817
|
}
|
|
1667
1818
|
|
|
1819
|
+
if (this.split?.boardFocus && this.mode === 'keys') {
|
|
1820
|
+
return this.onBoardKey(key);
|
|
1821
|
+
}
|
|
1822
|
+
if (this.split && key === 'b') {
|
|
1823
|
+
return void this.toggleBoard();
|
|
1824
|
+
}
|
|
1668
1825
|
if (this.split && (key === '\t' || key === `${ESC}[Z`)) {
|
|
1669
1826
|
return this.cycleFocus(key === '\t' ? 1 : -1);
|
|
1670
1827
|
}
|
|
1828
|
+
if (['\r', '\n', 'i', 'd', 'x'].includes(key) && this.refuseWhileMerging()) {
|
|
1829
|
+
return undefined;
|
|
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
|
+
}
|
|
1839
|
+
if (this.split && key === 'v') {
|
|
1840
|
+
// A card's log — `/view`'s, over the whole screen.
|
|
1841
|
+
const meta = this.commandTarget();
|
|
1842
|
+
return meta ? this.viewSession(meta) : this.note('no session yet — /new starts one');
|
|
1843
|
+
}
|
|
1671
1844
|
if (this.split && key === 'd') {
|
|
1672
1845
|
// On a finished plan's window, `d` is that plan's `/develop`: the card
|
|
1673
1846
|
// it planned, by the agent that planned it, in the window it leaves.
|
|
@@ -1711,6 +1884,12 @@ export class Attached {
|
|
|
1711
1884
|
this.showLog = !this.showLog;
|
|
1712
1885
|
return this.note(this.showLog ? "printing the daemon's log" : 'printing the session');
|
|
1713
1886
|
case 'a': {
|
|
1887
|
+
// R605: at a SHOW gate `a` is look-and-decide, not an answer.
|
|
1888
|
+
const shown = this.showOn(this.current());
|
|
1889
|
+
if (shown) {
|
|
1890
|
+
if (!this.requireSignIn('look and decide')) return undefined;
|
|
1891
|
+
return void this.lookAndDecide(this.current());
|
|
1892
|
+
}
|
|
1714
1893
|
// R58: the refusal is here rather than at the API, so nobody types an
|
|
1715
1894
|
// answer into a session that is not going to take it.
|
|
1716
1895
|
const asking = this.askingOn(this.current());
|
|
@@ -1727,8 +1906,12 @@ export class Attached {
|
|
|
1727
1906
|
case 's':
|
|
1728
1907
|
case 'Y':
|
|
1729
1908
|
case 'M':
|
|
1909
|
+
// R605: `s` on a SHOW gate used to accept the round and open the pull
|
|
1910
|
+
// request in one key, without anybody having looked.
|
|
1911
|
+
if (this.showOn(this.current())) return this.note(A_SHOW_GATE);
|
|
1730
1912
|
return void this.allow(key);
|
|
1731
1913
|
case 'n': {
|
|
1914
|
+
if (this.showOn(this.current())) return this.note(A_SHOW_GATE);
|
|
1732
1915
|
if (!this.pendingOn(this.current())) {
|
|
1733
1916
|
return this.note('nothing is waiting for permission on this one');
|
|
1734
1917
|
}
|
|
@@ -1783,13 +1966,20 @@ export class Attached {
|
|
|
1783
1966
|
|
|
1784
1967
|
/** Everything open, closed. Returns whether there was anything. */
|
|
1785
1968
|
closeEverything() {
|
|
1786
|
-
const open = this.mode !== 'keys' || Boolean(this.reader);
|
|
1969
|
+
const open = this.mode !== 'keys' || Boolean(this.reader) || Boolean(this.split?.boardFocus);
|
|
1787
1970
|
this.reader = null;
|
|
1971
|
+
this.help = false;
|
|
1972
|
+
if (this.split?.boardFocus) {
|
|
1973
|
+
this.split.lastBoardFocus = this.split.boardFocus;
|
|
1974
|
+
this.split.boardFocus = null;
|
|
1975
|
+
}
|
|
1788
1976
|
this.mode = 'keys';
|
|
1789
1977
|
this.select = null;
|
|
1790
1978
|
this.input = null;
|
|
1791
1979
|
this.answering = null;
|
|
1792
1980
|
this.deciding = null;
|
|
1981
|
+
this.showing = null;
|
|
1982
|
+
this.commenting = null;
|
|
1793
1983
|
this.history.reset();
|
|
1794
1984
|
this.dirty = true;
|
|
1795
1985
|
// A config question closed this way is cancelled, not left waiting — R288.
|
|
@@ -2032,6 +2222,15 @@ export class Attached {
|
|
|
2032
2222
|
this.mode = 'keys';
|
|
2033
2223
|
this.history.reset();
|
|
2034
2224
|
|
|
2225
|
+
if (kind === 'comments') {
|
|
2226
|
+
// R605. Before the empty-line rule, on purpose: here an empty line SENDS.
|
|
2227
|
+
return this.commentLine(this.pastes.expand(typed), back);
|
|
2228
|
+
}
|
|
2229
|
+
if (kind === 'review-note') {
|
|
2230
|
+
// Empty sends nothing and goes back to the diff: a verdict with no note is refused anyway.
|
|
2231
|
+
if (!typed) return this.reopenReview();
|
|
2232
|
+
return void this.reviewChanges(this.pastes.expand(typed));
|
|
2233
|
+
}
|
|
2035
2234
|
if (kind === 'config') {
|
|
2036
2235
|
// Before the empty-line rule, on purpose: every config question shows a
|
|
2037
2236
|
// default in its label, and enter on an empty line TAKES it — R288.
|
|
@@ -2114,7 +2313,8 @@ export class Attached {
|
|
|
2114
2313
|
// A question's or a permission's list holds only the start of what is
|
|
2115
2314
|
// asked; `/` leaves it for a command — `/view` reads it whole, `/open`
|
|
2116
2315
|
// answers it on cawdev — and `a` (or `y`/`n`) brings the choice back.
|
|
2117
|
-
if (key === '/' && (select.kind === 'question' || select.kind === 'permission'
|
|
2316
|
+
if (key === '/' && (select.kind === 'question' || select.kind === 'permission'
|
|
2317
|
+
|| select.kind === 'show' || select.kind === 'show-round')) {
|
|
2118
2318
|
this.mode = 'keys';
|
|
2119
2319
|
this.select = null;
|
|
2120
2320
|
this.answering = null;
|
|
@@ -2138,6 +2338,9 @@ export class Attached {
|
|
|
2138
2338
|
// is being written when none is is one the next reader will believe.
|
|
2139
2339
|
this.answering = null;
|
|
2140
2340
|
this.deciding = null;
|
|
2341
|
+
if (select.kind === 'show' || select.kind === 'show-round') {
|
|
2342
|
+
this.commenting = null;
|
|
2343
|
+
}
|
|
2141
2344
|
if (select.kind === 'config') {
|
|
2142
2345
|
this.configuring?.cancel?.();
|
|
2143
2346
|
}
|
|
@@ -2191,6 +2394,9 @@ export class Attached {
|
|
|
2191
2394
|
}
|
|
2192
2395
|
return void this.answer(row.label);
|
|
2193
2396
|
}
|
|
2397
|
+
if (select.kind === 'show' || select.kind === 'show-round') {
|
|
2398
|
+
return this.choseShow(select, row, typed);
|
|
2399
|
+
}
|
|
2194
2400
|
if (select.kind === 'permission') {
|
|
2195
2401
|
if (row.id === 'refuse') {
|
|
2196
2402
|
return this.type('reason', 'refuse, because ▸', '', select);
|
|
@@ -2206,7 +2412,9 @@ export class Attached {
|
|
|
2206
2412
|
}
|
|
2207
2413
|
if (select.kind === 'planned') {
|
|
2208
2414
|
const [slug, ref] = String(row.id).split(' ');
|
|
2209
|
-
|
|
2415
|
+
const flags = this.plannedFlags ?? [];
|
|
2416
|
+
this.plannedFlags = null;
|
|
2417
|
+
return void this.startNew(['dev', ref, `@${slug}`, ...flags]).then(() => this.refreshPlanned());
|
|
2210
2418
|
}
|
|
2211
2419
|
if (select.kind === 'card-project') {
|
|
2212
2420
|
// `/card` asked which project; the same card, with it named.
|
|
@@ -2244,6 +2452,13 @@ export class Attached {
|
|
|
2244
2452
|
this.configuring?.answer?.(typed);
|
|
2245
2453
|
return undefined;
|
|
2246
2454
|
}
|
|
2455
|
+
if (this.input?.kind === 'comments' && !this.select) {
|
|
2456
|
+
// The plain path's half of R605: lines are held until an empty one.
|
|
2457
|
+
const back = this.input.back;
|
|
2458
|
+
this.input = null;
|
|
2459
|
+
this.mode = 'keys';
|
|
2460
|
+
return this.commentLine(String(text), back);
|
|
2461
|
+
}
|
|
2247
2462
|
if (this.select) {
|
|
2248
2463
|
const picked = pickFromLine(this.select, typed);
|
|
2249
2464
|
if (picked) {
|
|
@@ -2403,6 +2618,22 @@ export class Attached {
|
|
|
2403
2618
|
if (!run) return;
|
|
2404
2619
|
|
|
2405
2620
|
const pending = this.pendingOn(run);
|
|
2621
|
+
if (pending && !this.announced.has(pending.approval.id) && showGate(pending.approval)) {
|
|
2622
|
+
// R605. Not a permission: nobody is shown three lengths of yes. It says
|
|
2623
|
+
// what is waiting and, in a terminal that can draw a picker, opens the
|
|
2624
|
+
// one that looks and decides.
|
|
2625
|
+
this.announced.add(pending.approval.id);
|
|
2626
|
+
const ink = this.ink;
|
|
2627
|
+
const about = pending.approval.id;
|
|
2628
|
+
const gate = showGate(pending.approval);
|
|
2629
|
+
this.sayAbout(about, '');
|
|
2630
|
+
this.sayAbout(about, `${ink.bold(ink.warn(' show '))} ${ink.muted('from')} ${ink.bold(this.tagOf(run))}`);
|
|
2631
|
+
this.sayAbout(about, ` ${ink.text(`${gate.ids.length} screenshot${gate.ids.length === 1 ? '' : 's'}, round ${gate.round} — a looks and decides`)}`);
|
|
2632
|
+
if (this.mode === 'keys' && this.session.signedIn) {
|
|
2633
|
+
void this.lookAndDecide(run);
|
|
2634
|
+
}
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2406
2637
|
if (pending && !this.announced.has(pending.approval.id)) {
|
|
2407
2638
|
this.announced.add(pending.approval.id);
|
|
2408
2639
|
const ink = this.ink;
|
|
@@ -2838,6 +3069,10 @@ export class Attached {
|
|
|
2838
3069
|
|
|
2839
3070
|
async runCommand(text) {
|
|
2840
3071
|
const [word, ...rest] = commandWords(text.slice(1));
|
|
3072
|
+
// A merge asked for once is confirmed by the very next command, or not at all.
|
|
3073
|
+
if (this.reviewing && !(word === 'review' && rest[0] === 'merge')) this.reviewing.confirmingMerge = false;
|
|
3074
|
+
// `/develop R30` and `/review R30` name another card; bare, they are this one's.
|
|
3075
|
+
if (DISABLED_WHILE_MERGING.has(word) && !rest.length && this.refuseWhileMerging()) return undefined;
|
|
2841
3076
|
switch (word) {
|
|
2842
3077
|
case 'help':
|
|
2843
3078
|
this.say('');
|
|
@@ -2848,7 +3083,7 @@ export class Attached {
|
|
|
2848
3083
|
this.say('');
|
|
2849
3084
|
this.say(this.ink.bold(' keys'));
|
|
2850
3085
|
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'));
|
|
3086
|
+
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
3087
|
this.say(this.ink.muted(' in a list: ↑↓ move · 1-9 pick · enter choose · esc leave'));
|
|
2853
3088
|
this.say(this.ink.muted(' while typing: ↑↓ history · tab complete · esc back · ctrl+c twice quits'));
|
|
2854
3089
|
this.say('');
|
|
@@ -2873,39 +3108,67 @@ export class Attached {
|
|
|
2873
3108
|
case 'log':
|
|
2874
3109
|
this.showLog = !this.showLog;
|
|
2875
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]);
|
|
2876
3115
|
case 'advanced-mode':
|
|
2877
3116
|
case 'advanced': {
|
|
2878
3117
|
const want = rest[0] === 'off' ? false : rest[0] === 'on' ? true : !this.split;
|
|
2879
|
-
if (!want) return this.
|
|
3118
|
+
if (!want) return void this.chooseMode('simple');
|
|
3119
|
+
void savePrefs(this.session.url, { mode: null }).catch(() => undefined);
|
|
2880
3120
|
// `@slug` names the projects outright; otherwise they are asked.
|
|
2881
3121
|
const named = rest.filter((word) => word.startsWith('@')).map((word) => word.slice(1));
|
|
2882
3122
|
return named.length ? this.enterSplit(named) : this.askSplitProjects();
|
|
2883
3123
|
}
|
|
2884
3124
|
case 'projects':
|
|
2885
3125
|
return this.askSplitProjects();
|
|
3126
|
+
case 'board': {
|
|
3127
|
+
const want = rest[0] === 'off' ? false : rest[0] === 'on' ? true : undefined;
|
|
3128
|
+
return this.toggleBoard(want);
|
|
3129
|
+
}
|
|
2886
3130
|
case 'refresh':
|
|
2887
3131
|
return void this.refreshCards();
|
|
2888
3132
|
case 'new':
|
|
2889
3133
|
return void this.startNew(rest);
|
|
2890
3134
|
case 'card':
|
|
2891
3135
|
return void this.addCard(rest);
|
|
3136
|
+
case 'import':
|
|
3137
|
+
return void this.importFile(rest);
|
|
2892
3138
|
case 'agent':
|
|
2893
3139
|
return void this.chooseAgent(rest[0]);
|
|
2894
3140
|
case 'develop':
|
|
2895
3141
|
return void this.develop(rest);
|
|
2896
3142
|
case 'pr':
|
|
2897
3143
|
return void this.openPullRequest();
|
|
3144
|
+
case 'review':
|
|
3145
|
+
return void this.review(rest);
|
|
2898
3146
|
case 'continue':
|
|
2899
3147
|
return void this.carryOn();
|
|
3148
|
+
case 'clean':
|
|
3149
|
+
return void this.cleanLock();
|
|
2900
3150
|
case 'open':
|
|
2901
3151
|
return void this.openInBrowser();
|
|
2902
|
-
case '
|
|
3152
|
+
case 'shots':
|
|
3153
|
+
case 'accept':
|
|
3154
|
+
case 'comments':
|
|
3155
|
+
return void this.showCommand(word, rest);
|
|
3156
|
+
case 'view': {
|
|
3157
|
+
// In advanced mode a window's number, or a ref one of them is on, is
|
|
3158
|
+
// that session's log; any other words are a card, as everywhere.
|
|
3159
|
+
const session = this.split ? this.windowNamed(rest) : null;
|
|
3160
|
+
if (session) return this.viewSession(session);
|
|
2903
3161
|
if (rest.length) return void this.viewCard(rest);
|
|
3162
|
+
if (this.showOn(this.runOf(this.commandTarget()))) return this.showCommand('view', []);
|
|
3163
|
+
if (this.split && this.commandTarget()) return this.viewSession(this.commandTarget());
|
|
2904
3164
|
return this.waitingOn(this.commandTarget()) ? this.openReader() : this.viewOnCawdev();
|
|
3165
|
+
}
|
|
3166
|
+
case 'matrix':
|
|
3167
|
+
return void this.chooseMatrix(rest[0]);
|
|
2905
3168
|
case 'drop':
|
|
2906
3169
|
return void this.dropCheckout();
|
|
2907
3170
|
case 'close':
|
|
2908
|
-
return this.closeWindow();
|
|
3171
|
+
return this.closeWindow(rest[0]);
|
|
2909
3172
|
case 'reload':
|
|
2910
3173
|
return void this.reload();
|
|
2911
3174
|
case 'quit':
|
|
@@ -3006,9 +3269,17 @@ export class Attached {
|
|
|
3006
3269
|
// what was known.
|
|
3007
3270
|
agent: run.agent !== undefined ? run.agent : meta.agent ?? null,
|
|
3008
3271
|
model: run.model !== undefined ? run.model : meta.model ?? null,
|
|
3272
|
+
// The stage it is on and the walk it is part of, for its card's
|
|
3273
|
+
// progress line; a daemon older than the field sends neither.
|
|
3274
|
+
stage: run.stage ?? null,
|
|
3275
|
+
stages: run.stages ?? meta.stages ?? null,
|
|
3276
|
+
// A stage is a process with its own start; the card's clock is the
|
|
3277
|
+
// session's, from the first one this terminal saw.
|
|
3278
|
+
startedAt: meta.startedAt ?? run.startedAt ?? null,
|
|
3009
3279
|
ended: false,
|
|
3010
3280
|
// A carried-on run the daemon has taken: the ask is answered.
|
|
3011
3281
|
carryAsked: false,
|
|
3282
|
+
lockCleaned: false,
|
|
3012
3283
|
});
|
|
3013
3284
|
if (!known) {
|
|
3014
3285
|
this.sessions.set(run.id, meta);
|
|
@@ -3027,6 +3298,7 @@ export class Attached {
|
|
|
3027
3298
|
for (const meta of this.sessions.values()) {
|
|
3028
3299
|
if (!meta.ended && !live.has(meta.id)) {
|
|
3029
3300
|
meta.ended = true;
|
|
3301
|
+
meta.changedTick = this.stamp();
|
|
3030
3302
|
void this.readView(meta, 5);
|
|
3031
3303
|
}
|
|
3032
3304
|
}
|
|
@@ -3231,6 +3503,9 @@ export class Attached {
|
|
|
3231
3503
|
if (!this.windows().some((meta) => meta.id === this.split.focus)) {
|
|
3232
3504
|
this.split.focus = this.windows()[0]?.id ?? null;
|
|
3233
3505
|
}
|
|
3506
|
+
if (this.split.boardFocus && !this.split.projects.has(this.split.boardFocus.slug)) {
|
|
3507
|
+
this.leaveBoard();
|
|
3508
|
+
}
|
|
3234
3509
|
this.leaveHiddenWatch();
|
|
3235
3510
|
this.say(this.ink.muted(`showing ${slugs.join(', ')} — /projects changes it`));
|
|
3236
3511
|
this.dirty = true;
|
|
@@ -3251,6 +3526,11 @@ export class Attached {
|
|
|
3251
3526
|
// The projects this mode shows; null is every one this machine serves.
|
|
3252
3527
|
projects: shown,
|
|
3253
3528
|
focus: null,
|
|
3529
|
+
boardFocus: null,
|
|
3530
|
+
lastBoardFocus: null,
|
|
3531
|
+
boardScroll: 0,
|
|
3532
|
+
cardCache: new Map(),
|
|
3533
|
+
cardLoading: new Set(),
|
|
3254
3534
|
messages: [],
|
|
3255
3535
|
enteredWatching: this.watching,
|
|
3256
3536
|
mark: new Map([...this.lines].map(([id, lines]) => [id, lines.length])),
|
|
@@ -3265,8 +3545,8 @@ export class Attached {
|
|
|
3265
3545
|
void this.refreshPlanned();
|
|
3266
3546
|
this.split.plannedTimer = setInterval(() => void this.refreshPlanned(), 30_000);
|
|
3267
3547
|
this.split.plannedTimer.unref?.();
|
|
3268
|
-
this.say(this.ink.muted('advanced mode — tab moves between
|
|
3269
|
-
+ ' · /projects picks what is shown · /advanced-mode leaves'));
|
|
3548
|
+
this.say(this.ink.muted('advanced mode — tab moves between cards · b browses the roadmap · v shows one\'s log'
|
|
3549
|
+
+ ' · /new starts a session · /projects picks what is shown · /advanced-mode leaves'));
|
|
3270
3550
|
if (shown) {
|
|
3271
3551
|
this.say(this.ink.muted(`showing ${[...shown].join(', ')}`));
|
|
3272
3552
|
}
|
|
@@ -3279,12 +3559,13 @@ export class Attached {
|
|
|
3279
3559
|
* said meanwhile is laid into it now — or, if the focus moved, that
|
|
3280
3560
|
* session's history, which is what switching sessions has always done.
|
|
3281
3561
|
*/
|
|
3282
|
-
leaveSplit() {
|
|
3562
|
+
leaveSplit({ quiet = false } = {}) {
|
|
3283
3563
|
if (!this.split) return this.note('advanced mode is not on — /advanced-mode starts it');
|
|
3284
3564
|
const { screen, enteredWatching, mark, plannedTimer } = this.split;
|
|
3285
3565
|
clearInterval(plannedTimer);
|
|
3286
3566
|
this.split = null;
|
|
3287
3567
|
screen.leave();
|
|
3568
|
+
if (quiet) return undefined;
|
|
3288
3569
|
const watched = this.watching;
|
|
3289
3570
|
const lines = this.lines.get(watched) ?? [];
|
|
3290
3571
|
if (watched && watched === enteredWatching) {
|
|
@@ -3295,7 +3576,7 @@ export class Attached {
|
|
|
3295
3576
|
this.printed.delete(watched);
|
|
3296
3577
|
this.layHistoryIn(watched, lines);
|
|
3297
3578
|
}
|
|
3298
|
-
this.note('back to the scrollback');
|
|
3579
|
+
this.note('back to the scrollback · /advanced-mode returns');
|
|
3299
3580
|
return undefined;
|
|
3300
3581
|
}
|
|
3301
3582
|
|
|
@@ -3309,17 +3590,330 @@ export class Attached {
|
|
|
3309
3590
|
this.note('');
|
|
3310
3591
|
}
|
|
3311
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
|
+
|
|
3312
3619
|
cycleFocus(step) {
|
|
3620
|
+
if (this.split?.boardFocus) this.leaveBoard();
|
|
3313
3621
|
const windows = this.windows();
|
|
3314
3622
|
if (!windows.length) return this.note('no session windows yet — /new starts one');
|
|
3315
3623
|
const at = windows.findIndex((meta) => meta.id === this.split.focus);
|
|
3316
3624
|
return this.focusWindow(windows[(at + step + windows.length) % windows.length].id);
|
|
3317
3625
|
}
|
|
3318
3626
|
|
|
3627
|
+
/**
|
|
3628
|
+
* `b` or `/board`: put focus into the roadmap strip at the top so arrows walk
|
|
3629
|
+
* its columns and cards, showing each card's full title and description in a
|
|
3630
|
+
* preview panel directly below the strip.
|
|
3631
|
+
*/
|
|
3632
|
+
async toggleBoard(want = !this.split?.boardFocus) {
|
|
3633
|
+
if (!this.split) {
|
|
3634
|
+
return this.note('the roadmap strip is in advanced mode — /advanced-mode starts it');
|
|
3635
|
+
}
|
|
3636
|
+
if (!want) {
|
|
3637
|
+
return this.leaveBoard();
|
|
3638
|
+
}
|
|
3639
|
+
const width = this.split.screen.width;
|
|
3640
|
+
let columns = stripColumns(width, this.cards, this.shownProjects());
|
|
3641
|
+
if (!columns.length && this.session.signedIn) {
|
|
3642
|
+
await this.refreshPlanned();
|
|
3643
|
+
if (!this.split) return undefined;
|
|
3644
|
+
columns = stripColumns(width, this.cards, this.shownProjects());
|
|
3645
|
+
}
|
|
3646
|
+
if (!columns.length) {
|
|
3647
|
+
return this.note('no cards on the roadmap strip here yet');
|
|
3648
|
+
}
|
|
3649
|
+
const picked = moveInStrip(columns, this.split.lastBoardFocus, 0, 0);
|
|
3650
|
+
this.split.boardFocus = picked;
|
|
3651
|
+
this.split.boardScroll = 0;
|
|
3652
|
+
this.dirty = true;
|
|
3653
|
+
void this.loadBoardCard(picked.slug, picked.ref);
|
|
3654
|
+
return this.note('');
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
/** Return focus from the roadmap strip to the session cards. */
|
|
3658
|
+
leaveBoard() {
|
|
3659
|
+
if (!this.split?.boardFocus) return this.note('');
|
|
3660
|
+
this.split.lastBoardFocus = this.split.boardFocus;
|
|
3661
|
+
this.split.boardFocus = null;
|
|
3662
|
+
this.split.boardScroll = 0;
|
|
3663
|
+
this.dirty = true;
|
|
3664
|
+
return this.note('');
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
/** Step `(dCol, dRow)` in the roadmap strip and fetch the newly focused card's description. */
|
|
3668
|
+
moveBoardFocus(dCol, dRow) {
|
|
3669
|
+
if (!this.split) return undefined;
|
|
3670
|
+
const columns = stripColumns(this.split.screen.width, this.cards, this.shownProjects());
|
|
3671
|
+
const next = moveInStrip(columns, this.split.boardFocus, dCol, dRow);
|
|
3672
|
+
if (!next) {
|
|
3673
|
+
return this.leaveBoard();
|
|
3674
|
+
}
|
|
3675
|
+
this.split.boardFocus = next;
|
|
3676
|
+
this.split.lastBoardFocus = next;
|
|
3677
|
+
this.split.boardScroll = 0;
|
|
3678
|
+
this.dirty = true;
|
|
3679
|
+
void this.loadBoardCard(next.slug, next.ref);
|
|
3680
|
+
return this.note('');
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
/** Scroll the focused card's description inside the preview panel. */
|
|
3684
|
+
scrollBoard(by) {
|
|
3685
|
+
if (!this.split?.boardFocus) return undefined;
|
|
3686
|
+
const max = this.split.boardMax ?? 0;
|
|
3687
|
+
this.split.boardScroll = Math.min(Math.max(0, (this.split.boardScroll ?? 0) + by), max);
|
|
3688
|
+
this.dirty = true;
|
|
3689
|
+
return undefined;
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
/** Read and cache a card's full record (`body`, metadata) for the board preview panel. */
|
|
3693
|
+
async loadBoardCard(slug, ref) {
|
|
3694
|
+
if (!this.split) return;
|
|
3695
|
+
const key = `${slug}:${ref}`;
|
|
3696
|
+
if (this.split.cardCache.has(key) || this.split.cardLoading.has(key)) return;
|
|
3697
|
+
const existing = this.cards.find((card) => card.slug === slug && card.ref === ref);
|
|
3698
|
+
if (existing?.body !== undefined) {
|
|
3699
|
+
this.split.cardCache.set(key, existing);
|
|
3700
|
+
return;
|
|
3701
|
+
}
|
|
3702
|
+
if (!this.session.signedIn) return;
|
|
3703
|
+
this.split.cardLoading.add(key);
|
|
3704
|
+
this.dirty = true;
|
|
3705
|
+
try {
|
|
3706
|
+
const detail = await this.session.request(`/api/projects/${slug}/roadmap/${ref}`);
|
|
3707
|
+
if (detail && this.split) {
|
|
3708
|
+
this.split.cardCache.set(key, detail);
|
|
3709
|
+
}
|
|
3710
|
+
} catch {
|
|
3711
|
+
// The strip's own title and status remain shown even if reading the body fails.
|
|
3712
|
+
} finally {
|
|
3713
|
+
if (this.split) {
|
|
3714
|
+
this.split.cardLoading.delete(key);
|
|
3715
|
+
this.dirty = true;
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
/** The focused roadmap card, merged with its cached full record (`body`, `section`, …). */
|
|
3721
|
+
boardCardOf(focus) {
|
|
3722
|
+
if (!focus) return null;
|
|
3723
|
+
const base = this.cards.find((card) => card.slug === focus.slug && card.ref === focus.ref);
|
|
3724
|
+
const cached = this.split?.cardCache?.get(`${focus.slug}:${focus.ref}`);
|
|
3725
|
+
if (!base && !cached) return null;
|
|
3726
|
+
return { ...base, ...cached, slug: focus.slug, ref: focus.ref };
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
/** Keys while focus is in the top roadmap strip (`this.split.boardFocus`). */
|
|
3730
|
+
onBoardKey(key) {
|
|
3731
|
+
const focus = this.split.boardFocus;
|
|
3732
|
+
switch (key) {
|
|
3733
|
+
case `${ESC}[A`: case `${ESC}OA`: case 'k':
|
|
3734
|
+
return this.moveBoardFocus(0, -1);
|
|
3735
|
+
case `${ESC}[B`: case `${ESC}OB`: case 'j':
|
|
3736
|
+
return this.moveBoardFocus(0, 1);
|
|
3737
|
+
case `${ESC}[D`: case `${ESC}OD`: case 'h':
|
|
3738
|
+
return this.moveBoardFocus(-1, 0);
|
|
3739
|
+
case `${ESC}[C`: case `${ESC}OC`: case 'l':
|
|
3740
|
+
return this.moveBoardFocus(1, 0);
|
|
3741
|
+
case `${ESC}[6~`: case ' ':
|
|
3742
|
+
return this.scrollBoard(this.split.boardRoom ?? 1);
|
|
3743
|
+
case `${ESC}[5~`:
|
|
3744
|
+
return this.scrollBoard(-(this.split.boardRoom ?? 1));
|
|
3745
|
+
case 'p':
|
|
3746
|
+
this.leaveBoard();
|
|
3747
|
+
return void this.startNew(['plan', focus.ref, `@${focus.slug}`]);
|
|
3748
|
+
case 'd':
|
|
3749
|
+
this.leaveBoard();
|
|
3750
|
+
return void this.startNew(['dev', focus.ref, `@${focus.slug}`]);
|
|
3751
|
+
case 'v': case '\r': case '\n':
|
|
3752
|
+
return void this.showCard(focus.slug, focus.ref);
|
|
3753
|
+
case ESC: case 'b':
|
|
3754
|
+
return this.leaveBoard();
|
|
3755
|
+
case '\t': case `${ESC}[Z`:
|
|
3756
|
+
this.leaveBoard();
|
|
3757
|
+
return this.cycleFocus(key === '\t' ? 1 : -1);
|
|
3758
|
+
case '/':
|
|
3759
|
+
return this.type('command', 'cawdev ▸', '/');
|
|
3760
|
+
default:
|
|
3761
|
+
if (/^[1-9]$/.test(key)) {
|
|
3762
|
+
this.leaveBoard();
|
|
3763
|
+
return this.onKey(key);
|
|
3764
|
+
}
|
|
3765
|
+
return undefined;
|
|
3766
|
+
}
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
/**
|
|
3770
|
+
* What a session's card says (session-cards.mjs `cardLines`): its kind, its
|
|
3771
|
+
* card, where it stands, the agent and checkout, the stage it is on, and ONE
|
|
3772
|
+
* line of what it is doing — or, while it waits on you, what it waits on.
|
|
3773
|
+
* Its log is `/view`'s.
|
|
3774
|
+
*/
|
|
3775
|
+
cardOf(meta, n, rect, now = Date.now()) {
|
|
3776
|
+
const ink = this.ink;
|
|
3777
|
+
const run = this.runs.find((each) => each.id === meta.id);
|
|
3778
|
+
const shownGate = this.showOn(run);
|
|
3779
|
+
// A SHOW gate is an approval too, but not one to allow: it is looked at.
|
|
3780
|
+
const pending = shownGate ? null : this.pendingOn(run);
|
|
3781
|
+
const asking = this.askingOn(run);
|
|
3782
|
+
const view = meta.view ?? {};
|
|
3783
|
+
const room = rect.width - 4;
|
|
3784
|
+
const waiting = shownGate ? 'waiting · screenshots'
|
|
3785
|
+
: pending ? 'waiting · permission'
|
|
3786
|
+
: asking ? (asking.yours ? 'waiting on you' : 'waiting · a question') : null;
|
|
3787
|
+
const who = agentModelFor(meta);
|
|
3788
|
+
// R605. At SHOW the card says what it is waiting for, in amber; the agent,
|
|
3789
|
+
// model and path come back once the gate is settled.
|
|
3790
|
+
const gate = shownGate ? showGate(shownGate.approval) : null;
|
|
3791
|
+
const subtitle = gate
|
|
3792
|
+
? ink.warn(showWaitingLine(gate, gate.ids.length))
|
|
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;
|
|
3799
|
+
const progress = !meta.ended
|
|
3800
|
+
? stageWalk(meta.stages, meta.stage, room, ink, tick)
|
|
3801
|
+
: meta.commits !== undefined && (view.profile ?? 'CODE') === 'CODE' && view.state === 'FINISHED'
|
|
3802
|
+
? ink.muted(`${meta.commits} commit${meta.commits === 1 ? '' : 's'}${view.branch ? ` on ${view.branch}` : ''}`)
|
|
3803
|
+
: '';
|
|
3804
|
+
const doing = pending
|
|
3805
|
+
? ink.warn(`◆ ${pending.approval?.toolName ?? 'a tool'}: ${String(pending.approval?.summary ?? '').split('\n')[0]}`)
|
|
3806
|
+
: asking
|
|
3807
|
+
? ink.warn(`◆ ${String(asking.question?.question ?? '').split('\n')[0]}`)
|
|
3808
|
+
: nowLine(meta, this.lines.get(meta.id) ?? []);
|
|
3809
|
+
const keys = pending ? 'y once · s session · n refuse · /view reads it'
|
|
3810
|
+
: asking?.yours ? 'a answers · /view reads it whole'
|
|
3811
|
+
: shownGate ? 'a to look and decide' : actionsFor(meta);
|
|
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
|
+
}
|
|
3833
|
+
if (merging) {
|
|
3834
|
+
const failed = merging.state === 'failed';
|
|
3835
|
+
const resolving = merging.state === 'resolving';
|
|
3836
|
+
return {
|
|
3837
|
+
n,
|
|
3838
|
+
profile: view.profile ?? meta.profile ?? null,
|
|
3839
|
+
title: `${merging.ref} ${view.entryTitle ?? view.label ?? meta.label ?? ''}`.trim(),
|
|
3840
|
+
status: failed ? { key: 'stopped', mark: '✕', word: 'merge failed' }
|
|
3841
|
+
: resolving ? { key: 'waiting', mark: '⟳', word: 'conflict · back to dev' }
|
|
3842
|
+
: { key: 'merging', mark: '⇢', word: 'merging' },
|
|
3843
|
+
lines: [
|
|
3844
|
+
ink.muted(`${merging.branch} → ${merging.base}`),
|
|
3845
|
+
failed ? ink.danger(`why: ${merging.why}`)
|
|
3846
|
+
: resolving ? ink.warn(`why: ${merging.why}`)
|
|
3847
|
+
: ink.muted('nothing to do here: it closes once it lands'),
|
|
3848
|
+
],
|
|
3849
|
+
footer: failed ? '/review to look again'
|
|
3850
|
+
: resolving ? `its development starts again here to resolve it` : '',
|
|
3851
|
+
focused: meta.id === this.split.focus,
|
|
3852
|
+
seed: seedOf(meta.id),
|
|
3853
|
+
tick,
|
|
3854
|
+
rain: this.matrix === true,
|
|
3855
|
+
base: merging.base,
|
|
3856
|
+
};
|
|
3857
|
+
}
|
|
3858
|
+
return {
|
|
3859
|
+
n,
|
|
3860
|
+
profile: view.profile ?? meta.profile ?? null,
|
|
3861
|
+
title: view.entryRef
|
|
3862
|
+
? `${view.entryRef} ${view.entryTitle ?? view.label ?? meta.label ?? ''}`.trim()
|
|
3863
|
+
: (view.label ?? meta.label ?? 'a session'),
|
|
3864
|
+
status,
|
|
3865
|
+
lines: [subtitle, progress || null, doing ? (pending || asking ? doing : ink.text(doing)) : null],
|
|
3866
|
+
footer: keys,
|
|
3867
|
+
focused: !this.split.boardFocus && meta.id === this.split.focus,
|
|
3868
|
+
seed: seedOf(meta.id),
|
|
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,
|
|
3874
|
+
};
|
|
3875
|
+
}
|
|
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
|
+
|
|
3319
3910
|
/** The whole alternate screen, as rows — see split.mjs. */
|
|
3320
3911
|
splitFrame() {
|
|
3321
3912
|
const { width, height } = this.split.screen;
|
|
3322
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
|
+
}
|
|
3323
3917
|
if (this.reader) {
|
|
3324
3918
|
return composeFrame(width, height, [{ rect: { top: 0, left: 0, width, height }, lines: this.readerScreen(width, height) }]);
|
|
3325
3919
|
}
|
|
@@ -3338,8 +3932,8 @@ export class Attached {
|
|
|
3338
3932
|
const input = this.mode === 'typing'
|
|
3339
3933
|
? `${tag}${this.input.label} ${this.input.line.render(
|
|
3340
3934
|
Math.max(8, inner - visibleWidth(tag) - visibleWidth(this.input.label) - 1), ink)}`
|
|
3341
|
-
|
|
3342
|
-
|
|
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'}`)}`];
|
|
3343
3937
|
// A question or a request on screen says which window it came from.
|
|
3344
3938
|
const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
|
|
3345
3939
|
// Under the banner and right above the line, so a long one never pushes
|
|
@@ -3356,61 +3950,85 @@ export class Attached {
|
|
|
3356
3950
|
const completing = completionLines(
|
|
3357
3951
|
matches.rows.slice(first, first + SPLIT_COMPLETIONS), matches.at - first, inner, ink,
|
|
3358
3952
|
);
|
|
3359
|
-
const fixed = [...from, ...banner, ...whole, ...overlay, ...completing, input];
|
|
3360
|
-
|
|
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);
|
|
3361
3959
|
// The roadmap across the top: as tall as its fullest column needs, never
|
|
3362
3960
|
// more than a third of the screen, and never so tall that no window fits
|
|
3363
3961
|
// 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 -
|
|
3962
|
+
const stripRoom = Math.min(Math.max(4, Math.floor(height / 3)), height - promptRows - CARD_MIN_HEIGHT);
|
|
3963
|
+
if (this.split.boardFocus) {
|
|
3964
|
+
this.split.boardFocus = moveInStrip(
|
|
3965
|
+
stripColumns(width, this.cards, this.shownProjects()),
|
|
3966
|
+
this.split.boardFocus,
|
|
3967
|
+
0,
|
|
3968
|
+
0,
|
|
3969
|
+
);
|
|
3970
|
+
}
|
|
3365
3971
|
// A rule under it, from the strip's own room: the roadmap is the board and
|
|
3366
3972
|
// the windows are the sessions, and without it the strip's last card line
|
|
3367
3973
|
// ran straight into the first window's border.
|
|
3368
3974
|
const stripLines = stripRoom >= 4
|
|
3369
|
-
? [...roadmapStrip(width, stripRoom - 1, this.cards, ink, this.shownProjects()),
|
|
3975
|
+
? [...roadmapStrip(width, stripRoom - 1, this.cards, ink, this.shownProjects(), this.split.boardFocus),
|
|
3370
3976
|
ink.muted('─'.repeat(width))]
|
|
3371
3977
|
: [];
|
|
3372
3978
|
const strip = stripLines.length <= Math.max(0, stripRoom) ? stripLines.length : 0;
|
|
3373
|
-
|
|
3374
|
-
//
|
|
3375
|
-
|
|
3979
|
+
|
|
3980
|
+
// When focus is in the roadmap strip (`b` / `/board`), a preview box
|
|
3981
|
+
// directly under it shows the focused card's full title and description.
|
|
3982
|
+
const avail = Math.max(0, height - strip - promptRows);
|
|
3983
|
+
const focusedCard = this.split.boardFocus ? this.boardCardOf(this.split.boardFocus) : null;
|
|
3984
|
+
const previewMax = windows.length && avail >= CARD_MIN_HEIGHT + 5
|
|
3985
|
+
? Math.max(5, avail - CARD_MIN_HEIGHT)
|
|
3986
|
+
: Math.max(5, avail);
|
|
3987
|
+
let previewLines = [];
|
|
3988
|
+
if (focusedCard && avail >= 5) {
|
|
3989
|
+
const loading = Boolean(this.split.cardLoading?.has(`${focusedCard.slug}:${focusedCard.ref}`));
|
|
3990
|
+
const drawn = boardPreviewLines(width, previewMax, focusedCard, ink, {
|
|
3991
|
+
top: this.split.boardScroll ?? 0,
|
|
3992
|
+
loading,
|
|
3993
|
+
});
|
|
3994
|
+
this.split.boardScroll = drawn.top;
|
|
3995
|
+
this.split.boardMax = drawn.max;
|
|
3996
|
+
this.split.boardRoom = drawn.room;
|
|
3997
|
+
previewLines = drawn.lines;
|
|
3998
|
+
}
|
|
3999
|
+
const preview = previewLines.length && previewLines.length <= avail ? previewLines.length : 0;
|
|
4000
|
+
|
|
4001
|
+
// The sessions are CARDS (session-cards.mjs): as many as fit, the rest
|
|
4002
|
+
// named on one line above the prompt, and Tab onto one of them brings it
|
|
4003
|
+
// up in place of the least recent. The prompt has every row they leave.
|
|
4004
|
+
const room = Math.max(0, height - strip - preview - promptRows);
|
|
4005
|
+
let grid = cardGrid(width, room, windows.length);
|
|
4006
|
+
if (grid.hidden) grid = cardGrid(width, room - 1, windows.length);
|
|
4007
|
+
const shown = visibleSessions(windows, this.split.recent ?? [], this.split.focus, grid.zones.length);
|
|
3376
4008
|
const hidden = windows.filter((meta) => !shown.includes(meta));
|
|
3377
4009
|
const tabs = hidden.length ? 1 : 0;
|
|
3378
|
-
const layout =
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
4010
|
+
const layout = {
|
|
4011
|
+
zones: grid.zones.map((rect) => ({ ...rect, top: rect.top + strip + preview })),
|
|
4012
|
+
prompt: {
|
|
4013
|
+
top: strip + preview + grid.height + tabs,
|
|
4014
|
+
left: 0,
|
|
4015
|
+
width,
|
|
4016
|
+
height: height - strip - preview - grid.height - tabs,
|
|
4017
|
+
},
|
|
4018
|
+
hidden: 0,
|
|
4019
|
+
};
|
|
3383
4020
|
|
|
4021
|
+
const now = Date.now();
|
|
3384
4022
|
const pieces = layout.zones.map((rect, i) => {
|
|
3385
4023
|
const meta = shown[i];
|
|
3386
|
-
|
|
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
|
-
};
|
|
4024
|
+
return { rect, lines: sessionCardLines(rect, this.cardOf(meta, windows.indexOf(meta) + 1, rect, now), ink) };
|
|
3413
4025
|
});
|
|
4026
|
+
if (preview) {
|
|
4027
|
+
pieces.push({
|
|
4028
|
+
rect: { top: strip, left: 0, width, height: preview },
|
|
4029
|
+
lines: previewLines,
|
|
4030
|
+
});
|
|
4031
|
+
}
|
|
3414
4032
|
|
|
3415
4033
|
if (tabs) {
|
|
3416
4034
|
pieces.push({
|
|
@@ -3430,7 +4048,8 @@ export class Attached {
|
|
|
3430
4048
|
});
|
|
3431
4049
|
const promptTitle = `cawdev ▸ ${this.runner?.name ?? 'runner'}`
|
|
3432
4050
|
+ `${this.session.email ? ` · ${this.session.email}` : ' · not signed in'}`
|
|
3433
|
-
+ `${this.agent ? ` · ${this.agent}` : ''}${unseen}
|
|
4051
|
+
+ `${this.agent ? ` · ${this.agent}` : ''}${unseen}`
|
|
4052
|
+
+ (this.needYou().length ? ` · ${ink.warn(`${this.needYou().length} need you`)}` : '');
|
|
3434
4053
|
pieces.push({
|
|
3435
4054
|
rect: layout.prompt,
|
|
3436
4055
|
lines: windowLines(layout.prompt, {
|
|
@@ -3443,9 +4062,7 @@ export class Attached {
|
|
|
3443
4062
|
...messages,
|
|
3444
4063
|
...fixed,
|
|
3445
4064
|
],
|
|
3446
|
-
footer:
|
|
3447
|
-
? ink.warn(`${this.statusWindow ? `[${this.statusWindow}] ` : ''}${this.status}`)
|
|
3448
|
-
: keyList(this.keys(), '', inner, ink),
|
|
4065
|
+
footer: footerRows,
|
|
3449
4066
|
}, ink),
|
|
3450
4067
|
});
|
|
3451
4068
|
if (strip) {
|
|
@@ -3479,7 +4096,10 @@ export class Attached {
|
|
|
3479
4096
|
try {
|
|
3480
4097
|
const cards = await this.session.request(`/api/projects/${slug}/roadmap?brief=true`);
|
|
3481
4098
|
for (const card of Array.isArray(cards) ? cards : []) {
|
|
3482
|
-
all.push({
|
|
4099
|
+
all.push({
|
|
4100
|
+
ref: card.ref, title: card.title, slug, status: card.status, live: Boolean(card.activeRun),
|
|
4101
|
+
...(card.body !== undefined ? { body: card.body } : {}),
|
|
4102
|
+
});
|
|
3483
4103
|
}
|
|
3484
4104
|
// The issues are another list on the platform (R85), and were never
|
|
3485
4105
|
// read here: the strip drew a project's ideas and not one of its bugs.
|
|
@@ -3488,6 +4108,7 @@ export class Attached {
|
|
|
3488
4108
|
all.push({
|
|
3489
4109
|
ref: issue.ref, title: issue.title, slug, status: issue.status,
|
|
3490
4110
|
live: Boolean(issue.activeRun), severity: issue.severity ?? 'MINOR',
|
|
4111
|
+
...(issue.body !== undefined ? { body: issue.body } : {}),
|
|
3491
4112
|
});
|
|
3492
4113
|
}
|
|
3493
4114
|
read.push(slug);
|
|
@@ -3588,9 +4209,12 @@ export class Attached {
|
|
|
3588
4209
|
if (shown.length === 1 && !words.some((word) => word.startsWith('@'))) {
|
|
3589
4210
|
words = [...words, `@${shown[0]}`];
|
|
3590
4211
|
}
|
|
3591
|
-
const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway' && word !== '--again'
|
|
4212
|
+
const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway' && word !== '--again'
|
|
4213
|
+
&& word !== '--show' && word !== '--no-show');
|
|
3592
4214
|
if (bare.length === 1 && ['dev', 'code'].includes(bare[0].toLowerCase())) {
|
|
3593
4215
|
const named = words.find((word) => word.startsWith('@'));
|
|
4216
|
+
// R605: the choice of card comes later; the answer about SHOW does not wait for it.
|
|
4217
|
+
this.plannedFlags = words.filter((word) => word === '--show' || word === '--no-show');
|
|
3594
4218
|
await this.pickPlanned(named ? named.slice(1) : null);
|
|
3595
4219
|
return;
|
|
3596
4220
|
}
|
|
@@ -3598,10 +4222,20 @@ export class Attached {
|
|
|
3598
4222
|
// R369: the answer to "a session already finished here" — `--anyway`'s
|
|
3599
4223
|
// shape, for a different question.
|
|
3600
4224
|
const again = words.includes('--again');
|
|
4225
|
+
// R605: this run is shown before its PR, or is not, whatever the card says.
|
|
4226
|
+
// Absent is the card's own switch (R604).
|
|
4227
|
+
const asked = words.includes('--show');
|
|
4228
|
+
const refused = words.includes('--no-show');
|
|
4229
|
+
if (asked && refused) {
|
|
4230
|
+
this.note('--show or --no-show, not both');
|
|
4231
|
+
return;
|
|
4232
|
+
}
|
|
4233
|
+
const show = asked ? true : refused ? false : undefined;
|
|
3601
4234
|
// `--codex`, `--claude`, `--agy`: this session's agent, over `/agent`'s.
|
|
3602
4235
|
const flagged = words.find((word) => AGENT_FLAGS.has(word));
|
|
3603
4236
|
let agent = flagged ? flagged.slice(2) : this.agent;
|
|
3604
|
-
const parsed = parseNew(words.filter((word) => word !== '--anyway' && word !== '--again'
|
|
4237
|
+
const parsed = parseNew(words.filter((word) => word !== '--anyway' && word !== '--again'
|
|
4238
|
+
&& word !== '--show' && word !== '--no-show' && !AGENT_FLAGS.has(word)), {
|
|
3605
4239
|
// In advanced mode, among the projects it shows.
|
|
3606
4240
|
projects: this.shownProjects(),
|
|
3607
4241
|
});
|
|
@@ -3622,13 +4256,21 @@ export class Attached {
|
|
|
3622
4256
|
this.note(parsed.error);
|
|
3623
4257
|
return;
|
|
3624
4258
|
}
|
|
4259
|
+
if (parsed.refs) {
|
|
4260
|
+
await this.startBatch(parsed, { anyway, again, show, agent });
|
|
4261
|
+
return;
|
|
4262
|
+
}
|
|
4263
|
+
if (show !== undefined && parsed.kind !== 'dev') {
|
|
4264
|
+
this.note('--show is for a dev session — a plan or a question shows nothing');
|
|
4265
|
+
return;
|
|
4266
|
+
}
|
|
3625
4267
|
if (parsed.kind === 'dev' && !parsed.ref) {
|
|
3626
4268
|
// A dev from words writes its CARD first, then develops it exactly as
|
|
3627
4269
|
// `/new dev R12` would. It used to be a manual session on a `task-…`
|
|
3628
4270
|
// branch with no card: no number, nothing on the development board,
|
|
3629
4271
|
// and a prompt that never said "commit" — so the work ended
|
|
3630
4272
|
// uncommitted and `/pr` had nothing to propose.
|
|
3631
|
-
await this.developNewCard(parsed.slug, parsed.text, anyway, agent);
|
|
4273
|
+
await this.developNewCard(parsed.slug, parsed.text, anyway, agent, show);
|
|
3632
4274
|
return;
|
|
3633
4275
|
}
|
|
3634
4276
|
// Developing a card whose finished plan has a window here: the agent that
|
|
@@ -3648,7 +4290,8 @@ export class Attached {
|
|
|
3648
4290
|
? ['runs/plan', { entryNumber: parsed.ref }, `a plan of ${parsed.ref}`]
|
|
3649
4291
|
// R150: the session writes the card first, then plans it.
|
|
3650
4292
|
: ['runs/plan', { prompt: parsed.text }, 'a plan — it writes its card first']
|
|
3651
|
-
: ['runs', { entryNumber: parsed.ref
|
|
4293
|
+
: ['runs', { entryNumber: parsed.ref, ...(show === undefined ? {} : { show }) },
|
|
4294
|
+
`the development of ${parsed.ref}${shownWord(show)}`];
|
|
3652
4295
|
const started = await this.startSession(parsed.slug, path, withAgent(body, agent), withWho(what, agent), anyway, again);
|
|
3653
4296
|
if (started && plan) {
|
|
3654
4297
|
this.closeWindowOf(plan);
|
|
@@ -3656,6 +4299,55 @@ export class Attached {
|
|
|
3656
4299
|
}
|
|
3657
4300
|
}
|
|
3658
4301
|
|
|
4302
|
+
/**
|
|
4303
|
+
* `/new plan|dev|plan+dev R3 R5 R7` — several cards as ONE batch, on this
|
|
4304
|
+
* machine (R606). The platform holds the order (the runs' chain), so the
|
|
4305
|
+
* terminal can be closed and the cards still go one after another; posting
|
|
4306
|
+
* N single starts would hold nothing.
|
|
4307
|
+
*
|
|
4308
|
+
* A dev batch is a branch each, queued here, or `--in-order` one branch;
|
|
4309
|
+
* a plan batch is in order; `plan+dev` builds each card after its plan
|
|
4310
|
+
* before the next is planned. `--again` and `--show` are one card's answers,
|
|
4311
|
+
* so a batch refuses them rather than applying them to every card.
|
|
4312
|
+
*/
|
|
4313
|
+
async startBatch(parsed, { anyway, again, show, agent }) {
|
|
4314
|
+
if (again) {
|
|
4315
|
+
this.note('--again is for one card — a batch skips a card whose session already finished, and names it');
|
|
4316
|
+
return;
|
|
4317
|
+
}
|
|
4318
|
+
if (show !== undefined) {
|
|
4319
|
+
this.note("--show is for one card — on several, each card's own switch decides");
|
|
4320
|
+
return;
|
|
4321
|
+
}
|
|
4322
|
+
const body = {
|
|
4323
|
+
strategy: parsed.inOrder ? 'ONE_BRANCH_IN_ORDER' : 'QUEUED_ON_ONE',
|
|
4324
|
+
cards: parsed.refs.map((ref) => ({ entryNumber: ref })),
|
|
4325
|
+
...(parsed.kind === 'dev' ? {} : { profile: 'PLAN' }),
|
|
4326
|
+
...(parsed.kind === 'plan+dev' ? { thenDevelop: true } : {}),
|
|
4327
|
+
...(parsed.branch ? { branch: parsed.branch } : {}),
|
|
4328
|
+
...(agent ? { agent } : {}),
|
|
4329
|
+
...(this.runner?.id ? { targetRunnerId: this.runner.id } : {}),
|
|
4330
|
+
...(anyway ? { acknowledgeUsage: true } : {}),
|
|
4331
|
+
};
|
|
4332
|
+
try {
|
|
4333
|
+
const outcome = await this.session.request(`/api/projects/${parsed.slug}/runs/batch`, {
|
|
4334
|
+
method: 'POST', body,
|
|
4335
|
+
});
|
|
4336
|
+
const [first, ...skipped] = batchStartedLines(parsed, outcome, parsed.slug, agent);
|
|
4337
|
+
this.say(outcome?.started?.length
|
|
4338
|
+
? this.ink.success(` ${first} — each window opens when this machine takes it`)
|
|
4339
|
+
: this.ink.warn(` ${first}`));
|
|
4340
|
+
for (const line of skipped) {
|
|
4341
|
+
this.say(this.ink.warn(` ${line}`));
|
|
4342
|
+
}
|
|
4343
|
+
this.note('');
|
|
4344
|
+
} catch (failure) {
|
|
4345
|
+
this.note(/usage|window/i.test(failure.message)
|
|
4346
|
+
? `${failure.message} — add --anyway to start them regardless`
|
|
4347
|
+
: failure.message);
|
|
4348
|
+
}
|
|
4349
|
+
}
|
|
4350
|
+
|
|
3659
4351
|
/** A FINISHED plan on a card, ended here — what `d` and `/develop` develop. */
|
|
3660
4352
|
finishedPlan(meta) {
|
|
3661
4353
|
return Boolean(meta?.ended && meta.view?.state === 'FINISHED'
|
|
@@ -3711,7 +4403,8 @@ export class Attached {
|
|
|
3711
4403
|
async addCard(words) {
|
|
3712
4404
|
if (!this.requireSignIn('write a card')) return;
|
|
3713
4405
|
const named = words.find((word) => word.startsWith('@'))?.slice(1) ?? null;
|
|
3714
|
-
const
|
|
4406
|
+
const show = words.includes('--show');
|
|
4407
|
+
const text = wordsText(words.filter((word) => word && !word.startsWith('@') && word !== '--show'));
|
|
3715
4408
|
if (!text) {
|
|
3716
4409
|
this.note('/card what? — /card <the idea in words> (@project)');
|
|
3717
4410
|
return;
|
|
@@ -3740,25 +4433,74 @@ export class Attached {
|
|
|
3740
4433
|
try {
|
|
3741
4434
|
card = await this.session.request(`/api/projects/${slug}/roadmap`, {
|
|
3742
4435
|
method: 'POST',
|
|
3743
|
-
body: { title: cardTitleFor(text), body: text, status: 'CONSIDERING' },
|
|
4436
|
+
body: { title: cardTitleFor(text), body: text, status: 'CONSIDERING', ...(show ? { showBeforePr: true } : {}) },
|
|
3744
4437
|
});
|
|
3745
4438
|
} catch (failure) {
|
|
3746
4439
|
this.note(`could not write the card: ${failure.message}`);
|
|
3747
4440
|
return;
|
|
3748
4441
|
}
|
|
3749
4442
|
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}`));
|
|
4443
|
+
this.say(this.ink.success(` wrote ${ref} ${card?.title ?? cardTitleFor(text)} in Considering on ${slug}${show ? ' · shown before its PR' : ''}`));
|
|
3751
4444
|
this.note('');
|
|
3752
4445
|
void this.refreshPlanned();
|
|
3753
4446
|
}
|
|
3754
4447
|
|
|
4448
|
+
/**
|
|
4449
|
+
* `/import <file.json> (@project)` — the cards of a JSON file, filed as you
|
|
4450
|
+
* (`card-import.mjs`). The project is `@slug`, else advanced mode narrowed to
|
|
4451
|
+
* one, else the one project served here; with several it is asked for by
|
|
4452
|
+
* name, never guessed. `--dry-run` says what would be filed and files nothing.
|
|
4453
|
+
*/
|
|
4454
|
+
async importFile(words) {
|
|
4455
|
+
if (!this.requireSignIn('import cards')) return;
|
|
4456
|
+
const named = words.find((word) => word.startsWith('@'))?.slice(1) ?? null;
|
|
4457
|
+
const dryRun = words.includes('--dry-run');
|
|
4458
|
+
const allowDuplicates = words.includes('--allow-duplicates');
|
|
4459
|
+
const statusAt = words.indexOf('--status');
|
|
4460
|
+
const status = statusAt === -1 ? undefined : words[statusAt + 1];
|
|
4461
|
+
const path = words.find((word, at) => word && !word.startsWith('@') && !word.startsWith('--')
|
|
4462
|
+
&& at !== statusAt + 1);
|
|
4463
|
+
if (!path) {
|
|
4464
|
+
this.note('/import what? — /import <file.json> (@project) (--dry-run)');
|
|
4465
|
+
return;
|
|
4466
|
+
}
|
|
4467
|
+
const served = this.servedProjects();
|
|
4468
|
+
const shown = this.shownProjects();
|
|
4469
|
+
const slug = named ?? (shown.length === 1 ? shown[0] : null);
|
|
4470
|
+
if (!slug) {
|
|
4471
|
+
this.note(`which project? — /import ${path} @${shown[0] ?? 'project'}`);
|
|
4472
|
+
return;
|
|
4473
|
+
}
|
|
4474
|
+
if (named && !served.includes(named)) {
|
|
4475
|
+
this.note(`this machine does not serve ${named} — ${served.join(', ') || 'no project'}`);
|
|
4476
|
+
return;
|
|
4477
|
+
}
|
|
4478
|
+
let cards;
|
|
4479
|
+
try {
|
|
4480
|
+
cards = cardsFrom(await readFile(absolute(path), 'utf8'));
|
|
4481
|
+
} catch (failure) {
|
|
4482
|
+
this.note(`could not import ${path}: ${failure.message}`);
|
|
4483
|
+
return;
|
|
4484
|
+
}
|
|
4485
|
+
let result;
|
|
4486
|
+
try {
|
|
4487
|
+
result = await importCards(this.session, slug, cards, { dryRun, status, allowDuplicates });
|
|
4488
|
+
} catch (failure) {
|
|
4489
|
+
this.note(`could not import ${path}: ${failure.message}`);
|
|
4490
|
+
return;
|
|
4491
|
+
}
|
|
4492
|
+
for (const line of resultLines(result, slug, this.ink)) this.say(line);
|
|
4493
|
+
this.note('');
|
|
4494
|
+
if (!dryRun) void this.refreshPlanned();
|
|
4495
|
+
}
|
|
4496
|
+
|
|
3755
4497
|
/**
|
|
3756
4498
|
* `/new dev <the task in words>`: the card, then its development. Two
|
|
3757
4499
|
* requests, and the first is said on its own line — a card that was written
|
|
3758
4500
|
* but whose run was refused (a usage window, say) is still on the board,
|
|
3759
4501
|
* and the person should know which half happened.
|
|
3760
4502
|
*/
|
|
3761
|
-
async developNewCard(slug, text, anyway, agent = this.agent) {
|
|
4503
|
+
async developNewCard(slug, text, anyway, agent = this.agent, show = undefined) {
|
|
3762
4504
|
let card;
|
|
3763
4505
|
try {
|
|
3764
4506
|
card = await this.session.request(`/api/projects/${slug}/roadmap`, {
|
|
@@ -3775,8 +4517,9 @@ export class Attached {
|
|
|
3775
4517
|
return;
|
|
3776
4518
|
}
|
|
3777
4519
|
this.say(this.ink.success(` wrote ${ref} ${card.title ?? ''} on ${slug}`));
|
|
3778
|
-
await this.startSession(slug, 'runs',
|
|
3779
|
-
|
|
4520
|
+
await this.startSession(slug, 'runs',
|
|
4521
|
+
withAgent({ entryNumber: ref, ...(show === undefined ? {} : { show }) }, agent),
|
|
4522
|
+
withWho(`the development of ${ref}${shownWord(show)}`, agent), anyway);
|
|
3780
4523
|
void this.refreshPlanned();
|
|
3781
4524
|
}
|
|
3782
4525
|
|
|
@@ -3831,6 +4574,9 @@ export class Attached {
|
|
|
3831
4574
|
this.say(this.ink.muted(this.split
|
|
3832
4575
|
? ' focus its window and type /continue'
|
|
3833
4576
|
: ' /continue picks up the latest — /advanced-mode gives each one a window'));
|
|
4577
|
+
if (found.some((meta) => staleLockOf(meta.view?.exitSummary, meta.view?.workspace))) {
|
|
4578
|
+
this.say(this.ink.muted(' a git lock a crash left behind stopped it — /clean removes it first'));
|
|
4579
|
+
}
|
|
3834
4580
|
this.dirty = true;
|
|
3835
4581
|
}
|
|
3836
4582
|
|
|
@@ -3872,6 +4618,43 @@ export class Attached {
|
|
|
3872
4618
|
}
|
|
3873
4619
|
}
|
|
3874
4620
|
|
|
4621
|
+
/**
|
|
4622
|
+
* `/clean` — remove the git lock a crash left in the focused (or last ended)
|
|
4623
|
+
* session's checkout, so `/continue` does not fail on it again. The checks
|
|
4624
|
+
* are git-lock.mjs's: the lock must be inside the run's own checkout and old
|
|
4625
|
+
* enough to be nobody's. Done here, not by the daemon, because the control
|
|
4626
|
+
* socket changes nothing (R52); the lock is a file on this machine, and the
|
|
4627
|
+
* run must have been HERE (its runner is this one) for that to be true.
|
|
4628
|
+
* Every session stopped on the same lock is freed by one /clean.
|
|
4629
|
+
*/
|
|
4630
|
+
async cleanLock() {
|
|
4631
|
+
const meta = this.commandTarget();
|
|
4632
|
+
const view = meta?.view;
|
|
4633
|
+
if (!meta || !view || !meta.ended) {
|
|
4634
|
+
this.note('/clean needs a session that stopped — focus its window');
|
|
4635
|
+
return;
|
|
4636
|
+
}
|
|
4637
|
+
const lock = staleLockOf(view.exitSummary, view.workspace ?? meta.workspace);
|
|
4638
|
+
if (!lock) {
|
|
4639
|
+
this.note('that session did not stop on a git lock — nothing to clean');
|
|
4640
|
+
return;
|
|
4641
|
+
}
|
|
4642
|
+
if (view.runnerId && this.runner?.id && view.runnerId !== this.runner.id) {
|
|
4643
|
+
this.note(`that checkout is on ${view.runnerName ?? 'another machine'} — clean it there`);
|
|
4644
|
+
return;
|
|
4645
|
+
}
|
|
4646
|
+
const done = await (this.options.removeLock ?? removeStaleLock)(lock);
|
|
4647
|
+
if (!done.removed) {
|
|
4648
|
+
this.note(done.said);
|
|
4649
|
+
return;
|
|
4650
|
+
}
|
|
4651
|
+
for (const other of this.sessions.values()) {
|
|
4652
|
+
if (staleLockOf(other.view?.exitSummary, other.view?.workspace ?? other.workspace) === lock) other.lockCleaned = true;
|
|
4653
|
+
}
|
|
4654
|
+
this.say(this.ink.success(` ${done.said} — /continue picks it back up`));
|
|
4655
|
+
this.note('');
|
|
4656
|
+
}
|
|
4657
|
+
|
|
3875
4658
|
/**
|
|
3876
4659
|
* `/develop` — start the development of a card on this machine: the card a
|
|
3877
4660
|
* finished plan was about (the focused session's), or the one named —
|
|
@@ -4097,37 +4880,524 @@ export class Attached {
|
|
|
4097
4880
|
opener(url);
|
|
4098
4881
|
}
|
|
4099
4882
|
|
|
4100
|
-
|
|
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
|
-
}
|
|
4883
|
+
// --- /review: the branch's diff, its verdict and its merge, here ---------------------------
|
|
4109
4884
|
|
|
4110
4885
|
/**
|
|
4111
|
-
* `/
|
|
4112
|
-
*
|
|
4113
|
-
*
|
|
4114
|
-
*
|
|
4115
|
-
*
|
|
4116
|
-
*
|
|
4886
|
+
* `/review` — the diff of a card's branch in this terminal, and the three
|
|
4887
|
+
* acts a reviewer takes on it, each the platform's own endpoint: Done
|
|
4888
|
+
* (`work-items/{id}/verdict` APPROVED), changes requested (the same, with the
|
|
4889
|
+
* note the next session is given), and merge (`work-items/{id}/merge`, which a
|
|
4890
|
+
* machine of yours performs). The diff is local git (`diff-view.mjs`), so
|
|
4891
|
+
* nothing about a branch's contents crosses the API.
|
|
4892
|
+
*
|
|
4893
|
+
* The card is the focused session's, or one named (`/review R42 @slug`). In
|
|
4894
|
+
* advanced mode the diff is the reader over the whole screen and the acts are
|
|
4895
|
+
* keys; otherwise it is printed, and the acts are `/review done`,
|
|
4896
|
+
* `/review changes <note>`, `/review merge`. Merge asks twice either way: it
|
|
4897
|
+
* is the one act here nothing undoes.
|
|
4117
4898
|
*/
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4899
|
+
async review(words) {
|
|
4900
|
+
if (!this.requireSignIn('review a branch')) return undefined;
|
|
4901
|
+
const [verb, ...rest] = words;
|
|
4902
|
+
switch (verb) {
|
|
4903
|
+
case 'done':
|
|
4904
|
+
return this.reviewDone();
|
|
4905
|
+
case 'changes':
|
|
4906
|
+
return rest.length ? this.reviewChanges(wordsText(rest)) : this.askReviewNote();
|
|
4907
|
+
case 'merge':
|
|
4908
|
+
return this.reviewMerge();
|
|
4909
|
+
case 'agent-merge':
|
|
4910
|
+
return this.reviewAgentMerge();
|
|
4911
|
+
default:
|
|
4912
|
+
return this.openReview(words);
|
|
4125
4913
|
}
|
|
4126
|
-
|
|
4127
|
-
|
|
4914
|
+
}
|
|
4915
|
+
|
|
4916
|
+
/** The card a review is of: named, else the focused session's. Null after saying why. */
|
|
4917
|
+
async reviewTarget(words) {
|
|
4918
|
+
const named = words.filter((word) => word.startsWith('@')).map((word) => word.slice(1));
|
|
4919
|
+
const bare = words.filter((word) => word && !word.startsWith('@'));
|
|
4920
|
+
if (bare.length) {
|
|
4921
|
+
if (bare.length > 1 || !/^[ri]?\d+$/i.test(bare[0])) {
|
|
4922
|
+
this.note('/review which card? /review R42 (@project), or /review alone for the session\'s');
|
|
4923
|
+
return null;
|
|
4924
|
+
}
|
|
4925
|
+
const ref = refOf(bare[0]);
|
|
4926
|
+
const projects = named.length ? named
|
|
4927
|
+
: this.shownProjects().length ? this.shownProjects() : this.servedProjects();
|
|
4928
|
+
const reads = await Promise.all(projects.map((slug) => this.session
|
|
4929
|
+
.request(`/api/projects/${slug}/roadmap/${ref}`)
|
|
4930
|
+
.then((card) => (card ? { slug, card } : null))
|
|
4931
|
+
.catch(() => null)));
|
|
4932
|
+
const found = reads.filter(Boolean);
|
|
4933
|
+
const building = found.filter((each) => each.card.workItem);
|
|
4934
|
+
const pick = building.length ? building : found;
|
|
4935
|
+
if (pick.length > 1) {
|
|
4936
|
+
this.note(`${ref} is in ${pick.map((each) => each.slug).join(', ')} — name one: /review ${ref} @${pick[0].slug}`);
|
|
4937
|
+
return null;
|
|
4938
|
+
}
|
|
4939
|
+
if (!pick.length) {
|
|
4940
|
+
this.note(`no ${ref} in ${projects.join(', ') || 'any project here'}`);
|
|
4941
|
+
return null;
|
|
4942
|
+
}
|
|
4943
|
+
return { ...pick[0], meta: null };
|
|
4944
|
+
}
|
|
4945
|
+
const meta = this.commandTarget();
|
|
4946
|
+
const ref = meta?.view?.entryRef;
|
|
4947
|
+
const slug = meta?.projectSlug ?? meta?.view?.projectSlug;
|
|
4948
|
+
if (!ref || !slug) {
|
|
4949
|
+
this.note(meta ? 'this session has no card — /review R42 names one' : '/review needs a session — focus one, or name a card: /review R42');
|
|
4950
|
+
return null;
|
|
4951
|
+
}
|
|
4952
|
+
try {
|
|
4953
|
+
return { slug, card: await this.session.request(`/api/projects/${slug}/roadmap/${ref}`), meta };
|
|
4954
|
+
} catch (failure) {
|
|
4955
|
+
this.note(`could not read ${ref}: ${failure.message}`);
|
|
4956
|
+
return null;
|
|
4957
|
+
}
|
|
4958
|
+
}
|
|
4959
|
+
|
|
4960
|
+
/** A checkout of the project on this machine: the session's own, else one the config names. */
|
|
4961
|
+
async checkoutFor(slug, meta) {
|
|
4962
|
+
const candidates = [];
|
|
4963
|
+
if (meta?.view?.workspace) candidates.push(meta.view.workspace);
|
|
4964
|
+
if (this.runner?.configPath) {
|
|
4965
|
+
const file = await readConfigFile(this.runner.configPath).catch(() => null);
|
|
4966
|
+
candidates.push(...pathsOf(file?.projects?.[slug]).map((path) => absolute(path)));
|
|
4967
|
+
}
|
|
4968
|
+
return candidates.find((path) => existsSync(join(path, '.git'))) ?? null;
|
|
4969
|
+
}
|
|
4970
|
+
|
|
4971
|
+
async openReview(words) {
|
|
4972
|
+
const target = await this.reviewTarget(words);
|
|
4973
|
+
if (!target) return undefined;
|
|
4974
|
+
const { slug, card, meta } = target;
|
|
4975
|
+
const ref = card.ref ?? '';
|
|
4976
|
+
const item = card.workItem;
|
|
4977
|
+
if (!item?.id || !item.branch) {
|
|
4978
|
+
return this.note(`${ref} has no branch in development${card.status ? ` — it is ${card.status}` : ''}`);
|
|
4979
|
+
}
|
|
4980
|
+
const cwd = await this.checkoutFor(slug, meta);
|
|
4981
|
+
if (!cwd) {
|
|
4982
|
+
return this.note(`no checkout of ${slug} on this machine to read the diff in — c → Add a workspace`);
|
|
4983
|
+
}
|
|
4984
|
+
const project = await this.session.request(`/api/projects/${slug}`).catch(() => null);
|
|
4985
|
+
const base = item.baseBranch ?? project?.defaultBranch ?? 'main';
|
|
4986
|
+
this.note(`reading ${item.branch} against ${base}…`);
|
|
4987
|
+
let diff;
|
|
4988
|
+
try {
|
|
4989
|
+
diff = await branchDiff(cwd, { branch: item.branch, base });
|
|
4990
|
+
} catch (failure) {
|
|
4991
|
+
return this.note(`could not read the diff: ${failure.message}`);
|
|
4992
|
+
}
|
|
4993
|
+
const files = diff.files;
|
|
4994
|
+
this.reviewing = {
|
|
4995
|
+
slug,
|
|
4996
|
+
ref,
|
|
4997
|
+
title: card.title ?? '',
|
|
4998
|
+
item,
|
|
4999
|
+
branch: item.branch,
|
|
5000
|
+
base,
|
|
5001
|
+
files,
|
|
5002
|
+
cut: diff.cut,
|
|
5003
|
+
stale: diff.stale,
|
|
5004
|
+
folded: new Set(files.filter(foldedByDefault).map((file) => file.path)),
|
|
5005
|
+
// A merge may be pressed only by somebody who holds Merge; absent, the API decides.
|
|
5006
|
+
mayMerge: Array.isArray(project?.yourPrivileges) ? project.yourPrivileges.includes('MERGE') : true,
|
|
5007
|
+
layout: null,
|
|
5008
|
+
confirmingMerge: false,
|
|
5009
|
+
};
|
|
5010
|
+
if (!this.split) {
|
|
5011
|
+
const width = Math.max(20, (this.screen?.width ?? 80) - 2);
|
|
5012
|
+
this.say('');
|
|
5013
|
+
this.say(this.ink.bold(` review ${ref} ${printable(this.reviewing.title)}`));
|
|
5014
|
+
for (const row of layoutDiff(this.reviewing, width, this.ink).rows) this.say(` ${row}`);
|
|
5015
|
+
this.say('');
|
|
5016
|
+
this.say(` ${this.reviewActions()}`);
|
|
5017
|
+
return this.note('');
|
|
5018
|
+
}
|
|
5019
|
+
return this.reopenReview();
|
|
5020
|
+
}
|
|
5021
|
+
|
|
5022
|
+
/** The reader on the review, where it was; also where a cancelled note goes back to. */
|
|
5023
|
+
reopenReview() {
|
|
5024
|
+
if (!this.reviewing) return this.note('');
|
|
5025
|
+
if (!this.split) return this.note('');
|
|
5026
|
+
this.reader = { kind: 'review', top: this.reviewing.top ?? 0, room: 1, max: 0 };
|
|
5027
|
+
this.dirty = true;
|
|
5028
|
+
return this.note('');
|
|
5029
|
+
}
|
|
5030
|
+
|
|
5031
|
+
/** What may be done next, in words — the scrollback's footer. */
|
|
5032
|
+
reviewActions() {
|
|
5033
|
+
const ink = this.ink;
|
|
5034
|
+
const item = this.reviewing?.item;
|
|
5035
|
+
const acts = [`${ink.success('/review done')} ${ink.muted('approves')}`,
|
|
5036
|
+
`${ink.warn('/review changes <note>')} ${ink.muted('sends it back')}`];
|
|
5037
|
+
if (item?.status === 'DONE' && this.reviewing.mayMerge) acts.push(`${ink.accent('/review merge')} ${ink.muted('lands it')}`);
|
|
5038
|
+
return acts.join(ink.muted(' · '));
|
|
5039
|
+
}
|
|
5040
|
+
|
|
5041
|
+
reviewScreen(width, height) {
|
|
5042
|
+
const review = this.reviewing;
|
|
5043
|
+
const ink = this.ink;
|
|
5044
|
+
const inner = Math.max(1, width - 2);
|
|
5045
|
+
if (!review.layout || review.layout.width !== inner) {
|
|
5046
|
+
review.layout = { width: inner, ...layoutDiff(review, inner, ink) };
|
|
5047
|
+
}
|
|
5048
|
+
const item = review.item;
|
|
5049
|
+
const keys = ['↑↓ PgUp/PgDn g/G', 'n/p file', 'f fold', 'd done', 'c changes'];
|
|
5050
|
+
if (item.status === 'DONE' && review.mayMerge) keys.push(review.confirmingMerge ? 'm AGAIN merges' : 'm merge');
|
|
5051
|
+
keys.push('esc close');
|
|
5052
|
+
const drawn = readerFrame(width, height, {
|
|
5053
|
+
title: `review ${review.ref} ${printable(review.title)} · ${item.statusDisplay ?? item.status ?? ''}`,
|
|
5054
|
+
lines: review.layout.rows,
|
|
5055
|
+
requestAt: 0,
|
|
5056
|
+
top: this.reader.top ?? 0,
|
|
5057
|
+
footer: keyList(keys, '', width - 2, ink),
|
|
5058
|
+
}, ink);
|
|
5059
|
+
Object.assign(this.reader, { top: drawn.top, max: drawn.max, room: drawn.room });
|
|
5060
|
+
review.top = drawn.top;
|
|
5061
|
+
return drawn.lines;
|
|
5062
|
+
}
|
|
5063
|
+
|
|
5064
|
+
onReviewKey(key) {
|
|
5065
|
+
const reader = this.reader;
|
|
5066
|
+
const review = this.reviewing;
|
|
5067
|
+
const move = (to) => {
|
|
5068
|
+
reader.top = Math.min(Math.max(0, to), reader.max);
|
|
5069
|
+
this.dirty = true;
|
|
5070
|
+
return undefined;
|
|
5071
|
+
};
|
|
5072
|
+
const at = reader.top ?? 0;
|
|
5073
|
+
const fileRows = review.layout?.fileRows ?? [];
|
|
5074
|
+
if (key !== 'm' && review.confirmingMerge) {
|
|
5075
|
+
review.confirmingMerge = false;
|
|
5076
|
+
this.note('not merged');
|
|
5077
|
+
}
|
|
5078
|
+
switch (key) {
|
|
5079
|
+
case `${ESC}[A`: case `${ESC}OA`: case 'k':
|
|
5080
|
+
return move(at - 1);
|
|
5081
|
+
case `${ESC}[B`: case `${ESC}OB`: case 'j':
|
|
5082
|
+
return move(at + 1);
|
|
5083
|
+
case `${ESC}[5~`: case 'b':
|
|
5084
|
+
return move(at - reader.room);
|
|
5085
|
+
case `${ESC}[6~`: case ' ':
|
|
5086
|
+
return move(at + reader.room);
|
|
5087
|
+
case `${ESC}[H`: case `${ESC}[1~`: case 'g':
|
|
5088
|
+
return move(0);
|
|
5089
|
+
case `${ESC}[F`: case `${ESC}[4~`: case 'G':
|
|
5090
|
+
return move(reader.max);
|
|
5091
|
+
case 'n': {
|
|
5092
|
+
const next = fileRows.find((row) => row > at);
|
|
5093
|
+
return next === undefined ? this.note('the last file') : move(next);
|
|
5094
|
+
}
|
|
5095
|
+
case 'p': {
|
|
5096
|
+
const previous = [...fileRows].reverse().find((row) => row < at);
|
|
5097
|
+
return previous === undefined ? move(0) : move(previous);
|
|
5098
|
+
}
|
|
5099
|
+
case 'f': {
|
|
5100
|
+
const index = Math.max(0, fileAt(fileRows, at));
|
|
5101
|
+
const file = review.files[index];
|
|
5102
|
+
if (!file) return undefined;
|
|
5103
|
+
if (review.folded.has(file.path)) review.folded.delete(file.path);
|
|
5104
|
+
else review.folded.add(file.path);
|
|
5105
|
+
review.layout = null;
|
|
5106
|
+
// Stay on the file: its header row is where it was, whatever folded under it.
|
|
5107
|
+
reader.top = fileRows[index] ?? at;
|
|
5108
|
+
this.dirty = true;
|
|
5109
|
+
return undefined;
|
|
5110
|
+
}
|
|
5111
|
+
case 'd':
|
|
5112
|
+
return void this.reviewDone();
|
|
5113
|
+
case 'c':
|
|
5114
|
+
return this.askReviewNote();
|
|
5115
|
+
case 'm':
|
|
5116
|
+
return void this.reviewMerge();
|
|
5117
|
+
case '/':
|
|
5118
|
+
this.reader = null;
|
|
5119
|
+
this.dirty = true;
|
|
5120
|
+
return this.type('command', 'cawdev ▸', '/');
|
|
5121
|
+
case ESC: case 'q':
|
|
5122
|
+
this.reader = null;
|
|
5123
|
+
this.dirty = true;
|
|
5124
|
+
return this.note('');
|
|
5125
|
+
default:
|
|
5126
|
+
return undefined;
|
|
5127
|
+
}
|
|
5128
|
+
}
|
|
5129
|
+
|
|
5130
|
+
/** The note a changes-requested verdict carries, asked in the prompt. */
|
|
5131
|
+
askReviewNote() {
|
|
5132
|
+
if (!this.reviewing) return this.note('/review a branch first');
|
|
5133
|
+
this.reader = null;
|
|
5134
|
+
this.dirty = true;
|
|
5135
|
+
return this.type('review-note', `changes for ${this.reviewing.ref} — what should the next session do? (empty goes back) ▸`, '');
|
|
5136
|
+
}
|
|
5137
|
+
|
|
5138
|
+
/** The work item as the platform has it now, read again after an act. */
|
|
5139
|
+
async rereadReview() {
|
|
5140
|
+
const review = this.reviewing;
|
|
5141
|
+
const card = await this.session.request(`/api/projects/${review.slug}/roadmap/${review.ref}`).catch(() => null);
|
|
5142
|
+
if (card?.workItem) review.item = card.workItem;
|
|
5143
|
+
this.dirty = true;
|
|
5144
|
+
return card;
|
|
5145
|
+
}
|
|
5146
|
+
|
|
5147
|
+
async reviewVerdict(verdict, note) {
|
|
5148
|
+
const review = this.reviewing;
|
|
5149
|
+
if (!review) return this.note('/review a branch first');
|
|
5150
|
+
try {
|
|
5151
|
+
await this.session.request(`/api/projects/${review.slug}/work-items/${review.item.id}/verdict`, {
|
|
5152
|
+
method: 'POST',
|
|
5153
|
+
body: { verdict, ...(note ? { note } : {}) },
|
|
5154
|
+
});
|
|
5155
|
+
} catch (failure) {
|
|
5156
|
+
return this.note(`could not decide ${review.ref}: ${failure.message}`);
|
|
5157
|
+
}
|
|
5158
|
+
await this.rereadReview();
|
|
5159
|
+
return true;
|
|
5160
|
+
}
|
|
5161
|
+
|
|
5162
|
+
async reviewDone() {
|
|
5163
|
+
if (await this.reviewVerdict('APPROVED') !== true) return undefined;
|
|
5164
|
+
const review = this.reviewing;
|
|
5165
|
+
this.say(this.ink.success(` ${review.ref} ${review.branch}: Done`));
|
|
5166
|
+
return this.note(review.mayMerge ? `Done — m merges it${this.split ? '' : ' (/review merge)'}` : 'Done');
|
|
5167
|
+
}
|
|
5168
|
+
|
|
5169
|
+
async reviewChanges(note) {
|
|
5170
|
+
if (await this.reviewVerdict('CHANGES_REQUESTED', note) !== true) return undefined;
|
|
5171
|
+
const review = this.reviewing;
|
|
5172
|
+
this.say(this.ink.warn(` ${review.ref} ${review.branch}: changes requested`));
|
|
5173
|
+
this.say(this.ink.muted(` ${printable(note)}`));
|
|
5174
|
+
// The verdict is given: the diff is no longer what anybody acts on.
|
|
5175
|
+
this.reader = null;
|
|
5176
|
+
this.dirty = true;
|
|
5177
|
+
return this.note(`sent back — the next session on ${review.ref} is given the note`);
|
|
5178
|
+
}
|
|
5179
|
+
|
|
5180
|
+
/** Twice: the first press says what will land where, the second lands it. */
|
|
5181
|
+
async reviewMerge() {
|
|
5182
|
+
const review = this.reviewing;
|
|
5183
|
+
if (!review) return this.note('/review a branch first');
|
|
5184
|
+
if (review.item.status !== 'DONE') {
|
|
5185
|
+
return this.note(`${review.ref} is ${review.item.statusDisplay ?? review.item.status} — say Done first (d)`);
|
|
5186
|
+
}
|
|
5187
|
+
if (!review.mayMerge) return this.note(`merging needs Merge on ${review.slug}, which you do not hold`);
|
|
5188
|
+
if (!review.confirmingMerge) {
|
|
5189
|
+
review.confirmingMerge = true;
|
|
5190
|
+
this.dirty = true;
|
|
5191
|
+
return this.note(`merge ${review.branch} into ${review.base}? ${this.split ? 'm again' : '/review merge again'} lands it — anything else keeps it`);
|
|
5192
|
+
}
|
|
5193
|
+
review.confirmingMerge = false;
|
|
5194
|
+
try {
|
|
5195
|
+
await this.session.request(`/api/projects/${review.slug}/work-items/${review.item.id}/merge`, { method: 'POST' });
|
|
5196
|
+
} catch (failure) {
|
|
5197
|
+
return this.note(`could not merge ${review.ref}: ${failure.message}`);
|
|
5198
|
+
}
|
|
5199
|
+
this.reader = null;
|
|
5200
|
+
this.markMerging(review, { state: 'merging' });
|
|
5201
|
+
this.say(this.ink.accent(` ${review.ref}: merge of ${review.branch} queued on a machine of yours`));
|
|
5202
|
+
void this.followMerge(review);
|
|
5203
|
+
return this.note('merging — said here when it lands');
|
|
5204
|
+
}
|
|
5205
|
+
|
|
5206
|
+
/**
|
|
5207
|
+
* The sessions on the card being merged: their cards say so, and refuse
|
|
5208
|
+
* every act on the branch until it lands (`refuseWhileMerging`). `state` is
|
|
5209
|
+
* `merging`, or `failed` with `why`, which gives the acts back.
|
|
5210
|
+
*/
|
|
5211
|
+
markMerging(review, { state, why = null }) {
|
|
5212
|
+
for (const meta of this.mergingCards(review)) {
|
|
5213
|
+
meta.merging = { state, why, ref: review.ref, branch: review.branch, base: review.base };
|
|
5214
|
+
}
|
|
5215
|
+
this.dirty = true;
|
|
5216
|
+
}
|
|
5217
|
+
|
|
5218
|
+
/**
|
|
5219
|
+
* A merge that hit a conflict goes back to the card's development: the
|
|
5220
|
+
* branch is sent back (Not done) with the order as its note, which the next
|
|
5221
|
+
* session on the card is handed (`RunReviews.latestRejection`, `task_current`),
|
|
5222
|
+
* and that session is started here, again, with the agent that built it.
|
|
5223
|
+
* The card is IN DEVELOPMENT again, the old session's card gives way to the
|
|
5224
|
+
* new one's, and the merge is asked for once it is Done again. Two endpoints
|
|
5225
|
+
* this terminal already uses; nothing new on the platform.
|
|
5226
|
+
*/
|
|
5227
|
+
async resolveConflict(review, why) {
|
|
5228
|
+
const ink = this.ink;
|
|
5229
|
+
const sessions = this.mergingCards(review);
|
|
5230
|
+
this.markMerging(review, { state: 'resolving', why });
|
|
5231
|
+
this.say(ink.warn(` ${review.ref}: ${review.branch} conflicts with ${review.base} — sent back to its development to resolve it`));
|
|
5232
|
+
try {
|
|
5233
|
+
await this.session.request(`/api/projects/${review.slug}/work-items/${review.item.id}/verdict`, {
|
|
5234
|
+
method: 'POST',
|
|
5235
|
+
body: { verdict: 'CHANGES_REQUESTED', note: conflictOrder(review, why) },
|
|
5236
|
+
});
|
|
5237
|
+
} catch (failure) {
|
|
5238
|
+
this.markMerging(review, { state: 'failed', why: `${why} — and it could not be sent back: ${failure.message}` });
|
|
5239
|
+
return this.note(`${review.ref} is not merged — /review to look again`);
|
|
5240
|
+
}
|
|
5241
|
+
const agent = sessions.map((meta) => meta.agent ?? meta.view?.agent).find(Boolean) ?? null;
|
|
5242
|
+
this.focusOnArrival = review.slug;
|
|
5243
|
+
const started = await this.startSession(review.slug, 'runs', withAgent({ entryNumber: review.ref }, agent),
|
|
5244
|
+
withWho(`the development of ${review.ref}, to resolve the conflict with ${review.base}`, agent), false, true);
|
|
5245
|
+
if (!started) {
|
|
5246
|
+
this.focusOnArrival = null;
|
|
5247
|
+
this.markMerging(review, { state: 'failed', why: `${why} — sent back, but its development did not start: ${this.status}` });
|
|
5248
|
+
return this.note(`/new dev ${review.ref} --again starts it`);
|
|
5249
|
+
}
|
|
5250
|
+
// The development's own card takes over when this machine claims it.
|
|
5251
|
+
for (const meta of sessions) {
|
|
5252
|
+
meta.merging = null;
|
|
5253
|
+
if (meta.ended) this.closeWindowOf(meta);
|
|
5254
|
+
}
|
|
5255
|
+
this.dirty = true;
|
|
5256
|
+
return this.note(`${review.ref} is back in development — /review it again once it is Done`);
|
|
5257
|
+
}
|
|
5258
|
+
|
|
5259
|
+
/** The sessions here whose card is `review`'s. */
|
|
5260
|
+
mergingCards(review) {
|
|
5261
|
+
const ref = String(review.ref).toLowerCase();
|
|
5262
|
+
return [...this.sessions.values()].filter((meta) => meta.projectSlug === review.slug
|
|
5263
|
+
&& String(meta.view?.entryRef ?? '').toLowerCase() === ref);
|
|
5264
|
+
}
|
|
5265
|
+
|
|
5266
|
+
/** True, having said why, when the session acted on is merging its card. */
|
|
5267
|
+
refuseWhileMerging() {
|
|
5268
|
+
const merging = this.commandTarget()?.merging;
|
|
5269
|
+
if (merging?.state !== 'merging' && merging?.state !== 'resolving') return false;
|
|
5270
|
+
this.note(`${merging.ref} is merging into ${merging.base} — its card closes by itself once it lands`);
|
|
5271
|
+
return true;
|
|
5272
|
+
}
|
|
5273
|
+
|
|
5274
|
+
async reviewAgentMerge() {
|
|
5275
|
+
const review = this.reviewing;
|
|
5276
|
+
if (!review) return this.note('/review a branch first');
|
|
5277
|
+
try {
|
|
5278
|
+
await this.session.request(`/api/projects/${review.slug}/work-items/${review.item.id}/agent-merge`, { method: 'POST' });
|
|
5279
|
+
} catch (failure) {
|
|
5280
|
+
return this.note(`could not start the agent merge: ${failure.message}`);
|
|
5281
|
+
}
|
|
5282
|
+
this.say(this.ink.accent(` ${review.ref}: an agent merges ${review.base} into ${review.branch} and resolves the conflict`));
|
|
5283
|
+
return this.note('agent merge started — its session opens like any other');
|
|
5284
|
+
}
|
|
5285
|
+
|
|
5286
|
+
/**
|
|
5287
|
+
* The merge, followed until the machine says: the card MERGED, or the merge
|
|
5288
|
+
* FAILED (a conflict offers the agent). Polls the card, which is where the
|
|
5289
|
+
* outcome is written; gives up saying so after `REVIEW_FOLLOW_TRIES`.
|
|
5290
|
+
*/
|
|
5291
|
+
async followMerge(review, tries = REVIEW_FOLLOW_TRIES) {
|
|
5292
|
+
const card = await this.session.request(`/api/projects/${review.slug}/roadmap/${review.ref}`).catch(() => null);
|
|
5293
|
+
const merge = card?.workItem?.merge;
|
|
5294
|
+
if (card?.status === 'MERGED' || card?.status === 'SHIPPED' || merge?.state === 'DONE') {
|
|
5295
|
+
this.say(this.ink.success(` ${review.ref} landed: ${review.branch} is in ${review.base}${merge?.result ? ` (${printable(merge.result)})` : ''}`));
|
|
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;
|
|
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
|
+
}
|
|
5304
|
+
meta.merging = null;
|
|
5305
|
+
if (meta.ended) this.closeWindowOf(meta);
|
|
5306
|
+
}
|
|
5307
|
+
this.dirty = true;
|
|
5308
|
+
return this.note(`${review.ref} merged`);
|
|
5309
|
+
}
|
|
5310
|
+
if (merge?.state === 'FAILED') {
|
|
5311
|
+
// `failure` is the KIND (R155: CONFLICT, NO_PULL_REQUEST…); `result` the machine's words.
|
|
5312
|
+
const why = printable(merge.result ?? merge.failure ?? 'no reason given');
|
|
5313
|
+
// An older runner names no kind: its words are all there is to go on.
|
|
5314
|
+
if (merge.failure === 'CONFLICT' || (!merge.failure && /conflict/i.test(merge.result ?? ''))) {
|
|
5315
|
+
return this.resolveConflict(review, why);
|
|
5316
|
+
}
|
|
5317
|
+
this.say(this.ink.danger(` ${review.ref}: the merge failed — ${why}`));
|
|
5318
|
+
this.markMerging(review, { state: 'failed', why });
|
|
5319
|
+
return this.note(`${review.ref} is not merged — /review to look again`);
|
|
5320
|
+
}
|
|
5321
|
+
if (tries <= 0) {
|
|
5322
|
+
return this.note(`${review.ref}: still merging — the board says when it lands`);
|
|
5323
|
+
}
|
|
5324
|
+
const again = setTimeout(() => void this.followMerge(review, tries - 1), this.options.reviewFollowMs ?? 5_000);
|
|
5325
|
+
again.unref?.();
|
|
5326
|
+
return undefined;
|
|
5327
|
+
}
|
|
5328
|
+
|
|
5329
|
+
/** What a session is stopped on — a question or a permission request — or null. */
|
|
5330
|
+
waitingOn(meta) {
|
|
5331
|
+
const run = meta ? this.runs.find((each) => each.id === meta.id) : null;
|
|
5332
|
+
if (!run) return null;
|
|
5333
|
+
const shown = this.showOn(run);
|
|
5334
|
+
if (shown) return { kind: 'show', pending: shown };
|
|
5335
|
+
const pending = this.pendingOn(run);
|
|
5336
|
+
if (pending) return { kind: 'permission', pending };
|
|
5337
|
+
const asking = this.askingOn(run);
|
|
5338
|
+
return asking ? { kind: 'question', asking } : null;
|
|
5339
|
+
}
|
|
5340
|
+
|
|
5341
|
+
/**
|
|
5342
|
+
* `/view` on a session waiting for you: what it is waiting on, in FULL. In
|
|
5343
|
+
* advanced mode the reader — the session's output and the request under
|
|
5344
|
+
* it, over the whole screen, scrolled with the arrows (the wheel arrives as
|
|
5345
|
+
* arrows there), PgUp/PgDn, g/G; esc closes it, and any other key closes it
|
|
5346
|
+
* and does what it does (`a` answers, `y`/`n` decide). In the scrollback
|
|
5347
|
+
* the request is printed whole, where the terminal scrolls it.
|
|
5348
|
+
*/
|
|
5349
|
+
/**
|
|
5350
|
+
* The window `/view 2` or `/view R42` names: a number is a card's, a ref
|
|
5351
|
+
* is the session on that card (the focused one first, then the latest). Null
|
|
5352
|
+
* for anything else, which is a card to print.
|
|
5353
|
+
*/
|
|
5354
|
+
windowNamed(words) {
|
|
5355
|
+
if (words.length !== 1) return null;
|
|
5356
|
+
const windows = this.windows();
|
|
5357
|
+
const [word] = words;
|
|
5358
|
+
if (/^\d+$/.test(word)) return windows[Number(word) - 1] ?? null;
|
|
5359
|
+
const ref = word.toLowerCase();
|
|
5360
|
+
const on = windows.filter((meta) => String(meta.view?.entryRef ?? '').toLowerCase() === ref);
|
|
5361
|
+
return on.find((meta) => meta.id === this.split.focus) ?? on.at(-1) ?? null;
|
|
5362
|
+
}
|
|
5363
|
+
|
|
5364
|
+
/**
|
|
5365
|
+
* `/view` in advanced mode: the session takes the screen — its log, live,
|
|
5366
|
+
* following the end until it is scrolled up, and what it waits on under it
|
|
5367
|
+
* when it waits on you. Esc gives the cards back; tab goes to the next
|
|
5368
|
+
* session without leaving.
|
|
5369
|
+
*/
|
|
5370
|
+
viewSession(meta) {
|
|
5371
|
+
this.focusWindow(meta.id);
|
|
5372
|
+
const waiting = Boolean(this.waitingOn(meta));
|
|
5373
|
+
this.reader = { runId: meta.id, top: null, room: 1, max: 0, session: true, follow: !waiting };
|
|
5374
|
+
this.dirty = true;
|
|
4128
5375
|
return this.note('');
|
|
4129
5376
|
}
|
|
4130
5377
|
|
|
5378
|
+
/** `/matrix on|off` — the cards' rain, remembered beside `/agent` (prefs.mjs). */
|
|
5379
|
+
async chooseMatrix(word) {
|
|
5380
|
+
const wanted = String(word ?? '').toLowerCase();
|
|
5381
|
+
if (!['on', 'off'].includes(wanted)) {
|
|
5382
|
+
return this.note(`the rain is ${this.matrix ? 'on' : 'off'} — /matrix on|off`);
|
|
5383
|
+
}
|
|
5384
|
+
this.matrix = wanted === 'on';
|
|
5385
|
+
this.dirty = true;
|
|
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');
|
|
5388
|
+
}
|
|
5389
|
+
|
|
5390
|
+
openReader() {
|
|
5391
|
+
const meta = this.commandTarget();
|
|
5392
|
+
if (!this.split) {
|
|
5393
|
+
this.say('');
|
|
5394
|
+
for (const line of this.requestLines(this.waitingOn(meta), this.screen.width)) this.say(line);
|
|
5395
|
+
this.say('');
|
|
5396
|
+
return this.note('');
|
|
5397
|
+
}
|
|
5398
|
+
return this.viewSession(meta);
|
|
5399
|
+
}
|
|
5400
|
+
|
|
4131
5401
|
/** The request a session waits on, every word of it. */
|
|
4132
5402
|
requestLines(waiting, width) {
|
|
4133
5403
|
const ink = this.ink;
|
|
@@ -4150,6 +5420,14 @@ export class Attached {
|
|
|
4150
5420
|
];
|
|
4151
5421
|
}
|
|
4152
5422
|
const approval = waiting.pending.approval ?? waiting.pending;
|
|
5423
|
+
if (waiting.kind === 'show') {
|
|
5424
|
+
return [
|
|
5425
|
+
...permissionBanner(waiting.pending, width, ink, this.runner ?? {}).slice(0, 2),
|
|
5426
|
+
...String(approval.summary ?? '').split('\n').flatMap((part) => wordWrap(printable(part), room)).map((line) => ` ${line}`),
|
|
5427
|
+
'',
|
|
5428
|
+
...permissionBanner(waiting.pending, width, ink, this.runner ?? {}).slice(2),
|
|
5429
|
+
];
|
|
5430
|
+
}
|
|
4153
5431
|
return [
|
|
4154
5432
|
`${ink.bold(ink.warn(' permission '))} ${ink.muted(`${approval.toolName ?? ''} · waiting since ${approval.askedAt?.slice(11, 19) ?? ''}`)}`,
|
|
4155
5433
|
...String(approval.summary ?? '').split('\n').flatMap((part) => wordWrap(part, room)).map((line) => ` ${line}`),
|
|
@@ -4163,22 +5441,31 @@ export class Attached {
|
|
|
4163
5441
|
|
|
4164
5442
|
/** The reader, drawn over the whole screen — see `readerFrame` in split.mjs. */
|
|
4165
5443
|
readerScreen(width, height) {
|
|
5444
|
+
if (this.reader.kind === 'review') return this.reviewScreen(width, height);
|
|
4166
5445
|
const ink = this.ink;
|
|
4167
5446
|
const meta = this.windows().find((each) => each.id === this.reader.runId)
|
|
4168
5447
|
?? this.sessions.get(this.reader.runId) ?? { id: this.reader.runId };
|
|
4169
5448
|
const output = (this.lines.get(this.reader.runId) ?? []).map((line) => this.render(line));
|
|
4170
5449
|
const waiting = this.waitingOn(meta);
|
|
4171
|
-
|
|
5450
|
+
// What it waits on goes under its log; a session waiting on nobody is its
|
|
5451
|
+
// log alone, followed at the end as it grows.
|
|
5452
|
+
const request = waiting || !this.reader.session ? this.requestLines(waiting, width - 2) : [];
|
|
4172
5453
|
const n = this.windowNumberOf(meta.id);
|
|
4173
|
-
const keys = waiting?.kind === '
|
|
4174
|
-
? '
|
|
4175
|
-
: waiting?.
|
|
5454
|
+
const keys = waiting?.kind === 'show'
|
|
5455
|
+
? 'a look and decide'
|
|
5456
|
+
: waiting?.kind === 'permission'
|
|
5457
|
+
? 'y once · s session · n refuse'
|
|
5458
|
+
: waiting?.asking?.yours ? 'a answer' : '';
|
|
4176
5459
|
const drawn = readerFrame(width, height, {
|
|
4177
|
-
title: `${n ? `${n} ` : ''}${titleFor(meta, waiting ?
|
|
4178
|
-
lines: [
|
|
5460
|
+
title: `${n ? `${n} ` : ''}${titleFor(meta, waiting ? waitingWord(waiting.kind) : null)}`,
|
|
5461
|
+
lines: [
|
|
5462
|
+
...(output.length ? output : [ink.muted(' nothing said on this session here yet')]),
|
|
5463
|
+
...(request.length ? [ink.muted('─'.repeat(Math.min(40, width - 2))), ...request] : []),
|
|
5464
|
+
],
|
|
4179
5465
|
requestAt: output.length,
|
|
4180
|
-
top: this.reader.top,
|
|
4181
|
-
footer: keyList(['↑↓ scroll', 'PgUp/PgDn page', 'g/G top/end',
|
|
5466
|
+
top: this.reader.follow ? Infinity : this.reader.top,
|
|
5467
|
+
footer: keyList(['↑↓ scroll', 'PgUp/PgDn page', 'g/G top/end',
|
|
5468
|
+
...(this.reader.session ? ['tab next session'] : []), 'esc close',
|
|
4182
5469
|
...(keys ? [keys] : []), '/open on cawdev'], '', width - 2, ink),
|
|
4183
5470
|
}, ink);
|
|
4184
5471
|
Object.assign(this.reader, { top: drawn.top, max: drawn.max, room: drawn.room });
|
|
@@ -4187,9 +5474,12 @@ export class Attached {
|
|
|
4187
5474
|
|
|
4188
5475
|
/** Keys while the reader is open. */
|
|
4189
5476
|
onReaderKey(key) {
|
|
5477
|
+
if (this.reader.kind === 'review') return this.onReviewKey(key);
|
|
4190
5478
|
const reader = this.reader;
|
|
4191
5479
|
const move = (to) => {
|
|
4192
5480
|
reader.top = Math.min(Math.max(0, to), reader.max);
|
|
5481
|
+
// At the end a session's log is followed again; above it, it holds still.
|
|
5482
|
+
reader.follow = Boolean(reader.session) && reader.top >= reader.max;
|
|
4193
5483
|
this.dirty = true;
|
|
4194
5484
|
return undefined;
|
|
4195
5485
|
};
|
|
@@ -4212,12 +5502,20 @@ export class Attached {
|
|
|
4212
5502
|
this.dirty = true;
|
|
4213
5503
|
return this.note('');
|
|
4214
5504
|
default:
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
5505
|
+
break;
|
|
5506
|
+
}
|
|
5507
|
+
if (reader.session && (key === '\t' || key === `${ESC}[Z`)) {
|
|
5508
|
+
// The next session's log, without going back to the cards.
|
|
5509
|
+
const windows = this.windows();
|
|
5510
|
+
const here = windows.findIndex((meta) => meta.id === reader.runId);
|
|
5511
|
+
const next = windows[(here + (key === '\t' ? 1 : -1) + windows.length) % windows.length];
|
|
5512
|
+
return next ? this.viewSession(next) : undefined;
|
|
4220
5513
|
}
|
|
5514
|
+
// Anything else is what it always is — `a` answers, `y`/`n` decide,
|
|
5515
|
+
// `/` a command — with the reader closed first.
|
|
5516
|
+
this.reader = null;
|
|
5517
|
+
this.dirty = true;
|
|
5518
|
+
return this.onKey(key);
|
|
4221
5519
|
}
|
|
4222
5520
|
|
|
4223
5521
|
/**
|
|
@@ -4414,14 +5712,64 @@ export class Attached {
|
|
|
4414
5712
|
}
|
|
4415
5713
|
|
|
4416
5714
|
/** `/close` — an ended session's window goes; a live one's stays. */
|
|
4417
|
-
closeWindow() {
|
|
4418
|
-
|
|
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`);
|
|
4419
5725
|
if (!meta) return this.note('no window to close');
|
|
4420
|
-
if (!meta.ended) return this.note('that session is still running
|
|
5726
|
+
if (!meta.ended) return this.note('that session is still running · x x cancels it');
|
|
4421
5727
|
this.closeWindowOf(meta);
|
|
4422
5728
|
return this.note('closed');
|
|
4423
5729
|
}
|
|
4424
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
|
+
|
|
4425
5773
|
/** An ended session's window goes; focus moves to the first one left. */
|
|
4426
5774
|
closeWindowOf(meta) {
|
|
4427
5775
|
this.closed.add(meta.id);
|
|
@@ -4489,6 +5837,7 @@ export class Attached {
|
|
|
4489
5837
|
if (!pending) {
|
|
4490
5838
|
return this.note('nothing is waiting for permission on this one');
|
|
4491
5839
|
}
|
|
5840
|
+
if (showGate(pending.approval)) return this.note(A_SHOW_GATE);
|
|
4492
5841
|
const decision = permissionDecision(pending.approval, key, this.runner ?? {});
|
|
4493
5842
|
if (!decision) {
|
|
4494
5843
|
// Two ways to get here and they need different sentences. `Y` or `M` on a
|
|
@@ -4548,6 +5897,438 @@ export class Attached {
|
|
|
4548
5897
|
return undefined;
|
|
4549
5898
|
}
|
|
4550
5899
|
|
|
5900
|
+
// --- looking and deciding at a SHOW gate — R605 -----------------------------
|
|
5901
|
+
|
|
5902
|
+
/** The daemon's record of a window's session, or null. */
|
|
5903
|
+
runOf(meta) {
|
|
5904
|
+
return meta ? this.runs.find((each) => each.id === meta.id) ?? null : null;
|
|
5905
|
+
}
|
|
5906
|
+
|
|
5907
|
+
/** This round's attachments live under the run; every URL here is built from it. */
|
|
5908
|
+
attachmentsPath(run) {
|
|
5909
|
+
return `/api/projects/${run.projectSlug}/runs/${run.id}/attachments`;
|
|
5910
|
+
}
|
|
5911
|
+
|
|
5912
|
+
/**
|
|
5913
|
+
* The round a gate names, confirmed against what the platform SEALED.
|
|
5914
|
+
*
|
|
5915
|
+
* A gate's `toolInput` is the session's own claim — the run token can raise
|
|
5916
|
+
* a `stage:SHOW` approval naming anything — so a screenshot is shown only
|
|
5917
|
+
* when the sealed list for that round holds its id, and a gate with an id
|
|
5918
|
+
* that does not match offers no Accept.
|
|
5919
|
+
*/
|
|
5920
|
+
async loadRound(run, pending) {
|
|
5921
|
+
const gate = showGate(pending.approval);
|
|
5922
|
+
const base = `/api/projects/${run.projectSlug}/runs/${run.id}`;
|
|
5923
|
+
const listed = await this.session.request(`${base}/attachments`);
|
|
5924
|
+
const all = (Array.isArray(listed) ? listed : []).map((each) => ({ ...each, runId: run.id }));
|
|
5925
|
+
const byId = new Map(all.filter((each) => each.round === gate.round).map((each) => [each.id, each]));
|
|
5926
|
+
const shots = gate.ids.map((id) => byId.get(id)).filter(Boolean);
|
|
5927
|
+
// Confirmed only when the gate names exactly what the platform sealed for
|
|
5928
|
+
// that round: no id that was never sealed, none twice, none left out, and
|
|
5929
|
+
// something to look at. A claim naming some of the shots is a claim.
|
|
5930
|
+
const confirmed = Number.isInteger(gate.round) && gate.round > 0
|
|
5931
|
+
&& gate.ids.length > 0 && new Set(gate.ids).size === gate.ids.length
|
|
5932
|
+
&& shots.length === gate.ids.length && byId.size === gate.ids.length;
|
|
5933
|
+
|
|
5934
|
+
const seen = await this.session.request(`${base}/approvals`).catch(() => []);
|
|
5935
|
+
const rounds = (Array.isArray(seen) ? seen : [])
|
|
5936
|
+
.filter((each) => each.state === 'DENIED' && showGate(each))
|
|
5937
|
+
.map((each) => ({
|
|
5938
|
+
round: showGate(each).round,
|
|
5939
|
+
comments: each.reason ?? '',
|
|
5940
|
+
by: each.decidedByEmail ? printable(each.decidedByEmail) : null,
|
|
5941
|
+
shots: all.filter((shot) => shot.round === showGate(each).round),
|
|
5942
|
+
}))
|
|
5943
|
+
.filter((each) => each.round < gate.round)
|
|
5944
|
+
.sort((a, b) => b.round - a.round);
|
|
5945
|
+
|
|
5946
|
+
return {
|
|
5947
|
+
runId: run.id, approvalId: pending.approval.id, slug: run.projectSlug, round: gate.round,
|
|
5948
|
+
shots, confirmed, rounds, select: null,
|
|
5949
|
+
};
|
|
5950
|
+
}
|
|
5951
|
+
|
|
5952
|
+
/** The loaded round for this gate — read once, kept while it is the same gate. */
|
|
5953
|
+
async roundFor(run, pending) {
|
|
5954
|
+
if (this.showing?.approvalId !== pending.approval.id) {
|
|
5955
|
+
this.showing = await this.loadRound(run, pending);
|
|
5956
|
+
}
|
|
5957
|
+
return this.showing;
|
|
5958
|
+
}
|
|
5959
|
+
|
|
5960
|
+
/** `a`, and `/view`: the picker that looks and decides. */
|
|
5961
|
+
async lookAndDecide(run) {
|
|
5962
|
+
const pending = this.showOn(run);
|
|
5963
|
+
if (!pending) return this.note('nothing is waiting at SHOW on this one');
|
|
5964
|
+
// So R137's `decidedElsewhere` can close this picker when somebody decides
|
|
5965
|
+
// the gate in the console first.
|
|
5966
|
+
this.deciding = { runId: run.id, approvalId: pending.approval.id };
|
|
5967
|
+
try {
|
|
5968
|
+
await this.roundFor(run, pending);
|
|
5969
|
+
} catch (failure) {
|
|
5970
|
+
if (this.deciding?.approvalId === pending.approval.id) this.deciding = null;
|
|
5971
|
+
return this.note(printable(failure.message));
|
|
5972
|
+
}
|
|
5973
|
+
// The gate may have been decided, or the person may have started typing,
|
|
5974
|
+
// while the round loaded: a picker for a settled gate is a lie.
|
|
5975
|
+
if (this.showOn(run)?.approval.id !== pending.approval.id || this.mode !== 'keys') {
|
|
5976
|
+
return undefined;
|
|
5977
|
+
}
|
|
5978
|
+
return this.openShowPicker();
|
|
5979
|
+
}
|
|
5980
|
+
|
|
5981
|
+
openShowPicker() {
|
|
5982
|
+
const round = this.showing;
|
|
5983
|
+
const ink = this.ink;
|
|
5984
|
+
const count = round.shots.length;
|
|
5985
|
+
const rows = [
|
|
5986
|
+
...round.shots.map((shot, at) => ({
|
|
5987
|
+
id: `shot:${at}`,
|
|
5988
|
+
label: `${at + 1} ${printable(shot.name)}${shot.caption ? ` — ${printable(shot.caption)}` : ''}`,
|
|
5989
|
+
...(isImage(shot) ? {} : { hint: 'html — opens on cawdev' }),
|
|
5990
|
+
})),
|
|
5991
|
+
...(count ? [{ id: 'all', label: 'Open all', hint: 'images in the viewer, html on cawdev' }] : []),
|
|
5992
|
+
...(round.confirmed ? [{ id: 'accept', label: 'Accept — open the pull request' }] : []),
|
|
5993
|
+
...round.rounds.map((each) => ({
|
|
5994
|
+
id: `round:${each.round}`,
|
|
5995
|
+
label: `Round ${each.round} — ${each.shots.length} screenshot${each.shots.length === 1 ? '' : 's'}, comments sent`,
|
|
5996
|
+
})),
|
|
5997
|
+
{ id: WRITE_MY_OWN, label: 'Send comments…', hint: '#2 … is a note on screenshot 2; an empty line sends' },
|
|
5998
|
+
];
|
|
5999
|
+
const select = new Select({
|
|
6000
|
+
kind: 'show',
|
|
6001
|
+
title: `${this.tagOf(this.runs.find((each) => each.id === round.runId))} · round ${round.round} · ${count} screenshot${count === 1 ? '' : 's'}`
|
|
6002
|
+
+ (round.confirmed ? '' : ' — this gate names screenshots that were never sealed — /open looks on cawdev'),
|
|
6003
|
+
hint: '/open looks on cawdev',
|
|
6004
|
+
rows,
|
|
6005
|
+
});
|
|
6006
|
+
round.select = select;
|
|
6007
|
+
if (!round.confirmed) {
|
|
6008
|
+
this.say(ink.warn(' this gate names screenshots that were never sealed — /open looks on cawdev'));
|
|
6009
|
+
}
|
|
6010
|
+
return this.openPicker(select);
|
|
6011
|
+
}
|
|
6012
|
+
|
|
6013
|
+
/** One row of the show picker. */
|
|
6014
|
+
choseShow(select, row, typed) {
|
|
6015
|
+
const round = this.showing;
|
|
6016
|
+
if (!round) return this.note('');
|
|
6017
|
+
const back = () => (select.kind === 'show-round' ? select : round.select);
|
|
6018
|
+
const reopenWith = async (work) => {
|
|
6019
|
+
const said = await work;
|
|
6020
|
+
// Not over a gate decided meanwhile, nor over something being typed.
|
|
6021
|
+
if (this.showing?.approvalId !== round.approvalId || this.mode !== 'keys') return;
|
|
6022
|
+
this.reopen(back());
|
|
6023
|
+
this.note(said);
|
|
6024
|
+
};
|
|
6025
|
+
if (select.kind === 'show-round') {
|
|
6026
|
+
const earlier = select.earlier;
|
|
6027
|
+
if (row.id === 'back') return this.reopen(round.select);
|
|
6028
|
+
if (row.id === 'all') return void reopenWith(this.openShots(earlier.shots, round));
|
|
6029
|
+
if (row.id.startsWith('shot:')) {
|
|
6030
|
+
return void reopenWith(this.openShots([earlier.shots[Number(row.id.slice(5))]], round));
|
|
6031
|
+
}
|
|
6032
|
+
return this.note('');
|
|
6033
|
+
}
|
|
6034
|
+
if (row.id.startsWith('shot:')) {
|
|
6035
|
+
return void reopenWith(this.openShots([round.shots[Number(row.id.slice(5))]], round));
|
|
6036
|
+
}
|
|
6037
|
+
if (row.id === 'all') return void reopenWith(this.openShots(round.shots, round));
|
|
6038
|
+
if (row.id === 'accept') return void this.acceptShow(this.runs.find((each) => each.id === round.runId));
|
|
6039
|
+
if (row.id.startsWith('round:')) {
|
|
6040
|
+
return this.showRound(Number(row.id.slice(6)), round, true);
|
|
6041
|
+
}
|
|
6042
|
+
if (row.id === WRITE_MY_OWN) {
|
|
6043
|
+
this.commenting = { approvalId: round.approvalId, lines: [] };
|
|
6044
|
+
if (typed) {
|
|
6045
|
+
return void this.sendComments(this.runs.find((each) => each.id === round.runId), typed);
|
|
6046
|
+
}
|
|
6047
|
+
return this.type('comments', 'comments ▸', '', round.select);
|
|
6048
|
+
}
|
|
6049
|
+
return this.note('');
|
|
6050
|
+
}
|
|
6051
|
+
|
|
6052
|
+
/** An earlier round: what was sent on it printed ONCE, then its screenshots. */
|
|
6053
|
+
showRound(number, round, picker) {
|
|
6054
|
+
const earlier = round.rounds.find((each) => each.round === number);
|
|
6055
|
+
if (!earlier) return this.note(`there is no round ${number} to show — ${round.rounds.map((each) => each.round).join(', ') || 'none earlier'}`);
|
|
6056
|
+
const ink = this.ink;
|
|
6057
|
+
this.say('');
|
|
6058
|
+
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}` : ''}`)}`);
|
|
6059
|
+
for (const line of roundListing(earlier.shots, ink)) this.say(line);
|
|
6060
|
+
// The reason alone: it already holds each screenshot's note ("On `x` — caption:
|
|
6061
|
+
// note"), so printing the notes as well would say each one twice.
|
|
6062
|
+
for (const line of printable(earlier.comments, { lines: true }).split('\n')) this.say(` ${ink.text(line)}`);
|
|
6063
|
+
this.say('');
|
|
6064
|
+
if (!picker) return this.note('');
|
|
6065
|
+
const nested = new Select({
|
|
6066
|
+
kind: 'show-round',
|
|
6067
|
+
title: `round ${number}`,
|
|
6068
|
+
rows: [
|
|
6069
|
+
...earlier.shots.map((shot, at) => ({
|
|
6070
|
+
id: `shot:${at}`,
|
|
6071
|
+
label: `${at + 1} ${printable(shot.name)}${shot.caption ? ` — ${printable(shot.caption)}` : ''}`,
|
|
6072
|
+
})),
|
|
6073
|
+
...(earlier.shots.length ? [{ id: 'all', label: 'Open all' }] : []),
|
|
6074
|
+
{ id: 'back', label: 'Back' },
|
|
6075
|
+
],
|
|
6076
|
+
});
|
|
6077
|
+
nested.earlier = earlier;
|
|
6078
|
+
return this.openPicker(nested);
|
|
6079
|
+
}
|
|
6080
|
+
|
|
6081
|
+
/**
|
|
6082
|
+
* Hand screenshots to the OS viewer. Images come from the local cache —
|
|
6083
|
+
* downloaded the first time and never again while the bytes match their
|
|
6084
|
+
* checksum; HTML is NEVER written to disk or opened as a file: it opens at
|
|
6085
|
+
* the platform's sandboxed URL, which is the only origin it may run on.
|
|
6086
|
+
*
|
|
6087
|
+
* @returns the sentence for the status line.
|
|
6088
|
+
*/
|
|
6089
|
+
async openShots(shots, round) {
|
|
6090
|
+
const run = this.runs.find((each) => each.id === round.runId) ?? { id: round.runId, projectSlug: round.slug };
|
|
6091
|
+
const base = this.attachmentsPath(run);
|
|
6092
|
+
const dir = shotsDir();
|
|
6093
|
+
const paths = [];
|
|
6094
|
+
let downloaded = 0;
|
|
6095
|
+
try {
|
|
6096
|
+
for (const shot of shots.filter(isImage)) {
|
|
6097
|
+
const copy = await localCopy(shot, {
|
|
6098
|
+
dir, fetchBytes: () => this.session.requestBytes(`${base}/${encodeURIComponent(shot.id)}`),
|
|
6099
|
+
});
|
|
6100
|
+
paths.push(copy.path);
|
|
6101
|
+
if (copy.downloaded) downloaded++;
|
|
6102
|
+
}
|
|
6103
|
+
} catch (failure) {
|
|
6104
|
+
return printable(failure.message);
|
|
6105
|
+
}
|
|
6106
|
+
if (paths.length) this.launch(paths);
|
|
6107
|
+
const pages = shots.filter((shot) => !isImage(shot));
|
|
6108
|
+
for (const page of pages) this.openLink(`${this.session.url}${base}/${encodeURIComponent(page.id)}`);
|
|
6109
|
+
|
|
6110
|
+
const home = homedir();
|
|
6111
|
+
const where = (dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir);
|
|
6112
|
+
const said = [];
|
|
6113
|
+
if (paths.length) {
|
|
6114
|
+
said.push(`opened ${paths.length} in the viewer (${downloaded
|
|
6115
|
+
? `downloaded ${downloaded} into ${where}/${round.runId}/${round.round}` : `from ${where}`})`);
|
|
6116
|
+
}
|
|
6117
|
+
if (pages.length) {
|
|
6118
|
+
said.push(`${pages.length === 1 ? 'the html one opens' : 'the html ones open'} on cawdev — sign in there in your browser if it asks`);
|
|
6119
|
+
}
|
|
6120
|
+
return said.join(' · ');
|
|
6121
|
+
}
|
|
6122
|
+
|
|
6123
|
+
/** The viewer, with files. Injectable, like `openUrl`. */
|
|
6124
|
+
launch(paths) {
|
|
6125
|
+
if (this.options.openFiles) {
|
|
6126
|
+
this.options.openFiles(paths);
|
|
6127
|
+
return;
|
|
6128
|
+
}
|
|
6129
|
+
for (const [command, args] of openerFor(process.platform)(paths)) {
|
|
6130
|
+
spawn(command, args, { stdio: 'ignore', detached: true }).on('error', () => undefined).unref();
|
|
6131
|
+
}
|
|
6132
|
+
}
|
|
6133
|
+
|
|
6134
|
+
/** A line (or several, pasted) written at the comments prompt. An empty one sends. */
|
|
6135
|
+
commentLine(text, back) {
|
|
6136
|
+
const ink = this.ink;
|
|
6137
|
+
if (this.commenting?.approvalId !== this.showing?.approvalId) {
|
|
6138
|
+
this.commenting = { approvalId: this.showing?.approvalId ?? null, lines: [] };
|
|
6139
|
+
}
|
|
6140
|
+
const lines = String(text).split('\n').map((line) => line.trim()).filter(Boolean);
|
|
6141
|
+
if (!lines.length) {
|
|
6142
|
+
if (this.commenting.lines.length) {
|
|
6143
|
+
const run = this.runs.find((each) => each.id === this.showing?.runId);
|
|
6144
|
+
return void this.sendComments(run, this.commenting.lines.join('\n'), back);
|
|
6145
|
+
}
|
|
6146
|
+
return back ? this.reopen(back) : this.note('');
|
|
6147
|
+
}
|
|
6148
|
+
for (const line of lines) {
|
|
6149
|
+
this.commenting.lines.push(line);
|
|
6150
|
+
this.say(ink.muted(` ▸ ${printable(line)}`));
|
|
6151
|
+
}
|
|
6152
|
+
const n = this.commenting.lines.length;
|
|
6153
|
+
return this.type('comments', `comments (${n} line${n === 1 ? '' : 's'} · empty line sends) ▸`, '', back);
|
|
6154
|
+
}
|
|
6155
|
+
|
|
6156
|
+
/** Mark a gate decided, here: the poll must not raise it again (R137). */
|
|
6157
|
+
settleGate(run, approvalId) {
|
|
6158
|
+
this.settled.add(approvalId);
|
|
6159
|
+
this.approvals.delete(run.id);
|
|
6160
|
+
if (this.deciding?.approvalId === approvalId) this.deciding = null;
|
|
6161
|
+
this.showing = null;
|
|
6162
|
+
this.commenting = null;
|
|
6163
|
+
// Only what this gate opened: a prompt or another run's picker is not ours.
|
|
6164
|
+
if (this.select?.kind === 'show' || this.select?.kind === 'show-round'
|
|
6165
|
+
|| this.input?.kind === 'comments') {
|
|
6166
|
+
this.mode = 'keys';
|
|
6167
|
+
this.select = null;
|
|
6168
|
+
this.input = null;
|
|
6169
|
+
}
|
|
6170
|
+
this.dirty = true;
|
|
6171
|
+
}
|
|
6172
|
+
|
|
6173
|
+
/** The gate to decide, still the same gate the round was read for. */
|
|
6174
|
+
stillAt(run) {
|
|
6175
|
+
const pending = this.showOn(run);
|
|
6176
|
+
if (!pending || (this.showing && this.showing.approvalId !== pending.approval.id)) {
|
|
6177
|
+
this.note('no longer waiting at SHOW — decided elsewhere, or gone');
|
|
6178
|
+
return null;
|
|
6179
|
+
}
|
|
6180
|
+
return pending;
|
|
6181
|
+
}
|
|
6182
|
+
|
|
6183
|
+
async sendComments(run, text, back = null) {
|
|
6184
|
+
if (!run || !this.requireSignIn('send comments')) return undefined;
|
|
6185
|
+
const pending = this.stillAt(run);
|
|
6186
|
+
if (!pending) return undefined;
|
|
6187
|
+
let round;
|
|
6188
|
+
try {
|
|
6189
|
+
round = await this.roundFor(run, pending);
|
|
6190
|
+
} catch (failure) {
|
|
6191
|
+
return this.note(printable(failure.message));
|
|
6192
|
+
}
|
|
6193
|
+
const parsed = parseComments(text, round.shots);
|
|
6194
|
+
if (parsed.error) {
|
|
6195
|
+
// Nothing is sent. Back on the line, with what was written still held.
|
|
6196
|
+
if (back || this.commenting) {
|
|
6197
|
+
this.type('comments', 'comments ▸', '', back ?? round.select);
|
|
6198
|
+
}
|
|
6199
|
+
return this.note(parsed.error);
|
|
6200
|
+
}
|
|
6201
|
+
try {
|
|
6202
|
+
await this.session.request(
|
|
6203
|
+
`/api/projects/${run.projectSlug}/runs/${run.id}/approvals/${pending.approval.id}/decision`,
|
|
6204
|
+
{ method: 'POST', body: { allow: false, scope: 'ONCE', reason: parsed.reason, notes: parsed.notes } },
|
|
6205
|
+
);
|
|
6206
|
+
} catch (failure) {
|
|
6207
|
+
// A 409 for a round that was replaced says so in its own sentence.
|
|
6208
|
+
return this.note(printable(failure.message));
|
|
6209
|
+
}
|
|
6210
|
+
this.settleGate(run, pending.approval.id);
|
|
6211
|
+
// The run is read again rather than assumed: with no stages to walk again
|
|
6212
|
+
// the daemon ends it FAILED, and "goes back to coding" would be untrue.
|
|
6213
|
+
const view = await this.session.request(`/api/projects/${run.projectSlug}/runs/${run.id}`)
|
|
6214
|
+
.catch(() => null);
|
|
6215
|
+
const alive = view && (view.live ?? !['FAILED', 'FINISHED', 'CANCELLED'].includes(view.state));
|
|
6216
|
+
const ref = view?.entryRef ?? this.sessions.get(run.id)?.view?.entryRef ?? null;
|
|
6217
|
+
return this.note(alive
|
|
6218
|
+
? `sent — ${ref ?? 'the run'} goes back to coding (round ${round.round + 1})`
|
|
6219
|
+
: 'sent');
|
|
6220
|
+
}
|
|
6221
|
+
|
|
6222
|
+
async acceptShow(run) {
|
|
6223
|
+
if (!run || !this.requireSignIn('accept the screenshots')) return undefined;
|
|
6224
|
+
const pending = this.stillAt(run);
|
|
6225
|
+
if (!pending) return undefined;
|
|
6226
|
+
let round;
|
|
6227
|
+
try {
|
|
6228
|
+
round = await this.roundFor(run, pending);
|
|
6229
|
+
} catch (failure) {
|
|
6230
|
+
return this.note(printable(failure.message));
|
|
6231
|
+
}
|
|
6232
|
+
if (!round.confirmed) {
|
|
6233
|
+
return this.note('this gate names screenshots that were never sealed — /open looks on cawdev');
|
|
6234
|
+
}
|
|
6235
|
+
const dropped = this.commenting?.lines.length
|
|
6236
|
+
? 'the comments you wrote are dropped — accepting · ' : '';
|
|
6237
|
+
try {
|
|
6238
|
+
await this.session.request(
|
|
6239
|
+
`/api/projects/${run.projectSlug}/runs/${run.id}/approvals/${pending.approval.id}/decision`,
|
|
6240
|
+
{ method: 'POST', body: { allow: true, scope: 'ONCE' } },
|
|
6241
|
+
);
|
|
6242
|
+
} catch (failure) {
|
|
6243
|
+
return this.note(printable(failure.message));
|
|
6244
|
+
}
|
|
6245
|
+
this.settleGate(run, pending.approval.id);
|
|
6246
|
+
return this.note(`${dropped}accepted — the pull request opens when the run finishes`);
|
|
6247
|
+
}
|
|
6248
|
+
|
|
6249
|
+
/**
|
|
6250
|
+
* `/shots`, `/accept`, `/comments` — and `/view` at a SHOW gate. Each acts on
|
|
6251
|
+
* the focused session, and each says why and does nothing when it is not
|
|
6252
|
+
* stopped at SHOW.
|
|
6253
|
+
*/
|
|
6254
|
+
async showCommand(word, rest) {
|
|
6255
|
+
const meta = this.commandTarget();
|
|
6256
|
+
const label = meta ? this.tagOf(this.runOf(meta) ?? meta) || meta.label || 'this session' : null;
|
|
6257
|
+
if (!meta) return this.note(`/${word} needs a session — focus one`);
|
|
6258
|
+
const run = this.runOf(meta);
|
|
6259
|
+
const pending = this.showOn(run);
|
|
6260
|
+
if (!pending) return this.note(`${label} is not waiting at SHOW — nothing to look at`);
|
|
6261
|
+
if (word === 'view') {
|
|
6262
|
+
if (!this.requireSignIn('look and decide')) return undefined;
|
|
6263
|
+
return this.lookAndDecide(run);
|
|
6264
|
+
}
|
|
6265
|
+
if (word === 'accept') return this.acceptShow(run);
|
|
6266
|
+
if (word === 'comments') {
|
|
6267
|
+
// `#2` anywhere starts a note on screenshot 2, which runs to the next
|
|
6268
|
+
// `#n` or the end; what comes before the first is the round's note.
|
|
6269
|
+
const text = wordsText(rest).replace(/[ \t]+(#\d+)(?=[\s:—-]|$)/g, '\n$1');
|
|
6270
|
+
return this.sendComments(run, text);
|
|
6271
|
+
}
|
|
6272
|
+
if (!this.requireSignIn('look at the screenshots')) return undefined;
|
|
6273
|
+
let round;
|
|
6274
|
+
try {
|
|
6275
|
+
round = await this.roundFor(run, pending);
|
|
6276
|
+
} catch (failure) {
|
|
6277
|
+
return this.note(printable(failure.message));
|
|
6278
|
+
}
|
|
6279
|
+
const usage = '/shots · /shots 2 · /shots all · /shots round 1 (all | 2)';
|
|
6280
|
+
let from = round;
|
|
6281
|
+
let shots = round.shots;
|
|
6282
|
+
let words = rest;
|
|
6283
|
+
if (words[0] === 'round') {
|
|
6284
|
+
const wanted = Number(words[1]);
|
|
6285
|
+
const earlier = round.rounds.find((each) => each.round === wanted);
|
|
6286
|
+
if (!earlier) return this.note(Number.isInteger(wanted) ? `there is no round ${wanted} to show — ${usage}` : usage);
|
|
6287
|
+
if (words.length === 2) return this.showRound(wanted, round, false);
|
|
6288
|
+
shots = earlier.shots;
|
|
6289
|
+
words = words.slice(2);
|
|
6290
|
+
from = round;
|
|
6291
|
+
}
|
|
6292
|
+
if (!words.length) {
|
|
6293
|
+
const ink = this.ink;
|
|
6294
|
+
this.say('');
|
|
6295
|
+
this.say(`${ink.bold(ink.warn(' shots '))} ${ink.muted(`round ${round.round} · ${shots.length} screenshot${shots.length === 1 ? '' : 's'}`)}`);
|
|
6296
|
+
for (const line of roundListing(shots, ink)) this.say(line);
|
|
6297
|
+
this.say('');
|
|
6298
|
+
if (!this.split && !this.plain) await this.printThumbnails(shots, run);
|
|
6299
|
+
return this.note(round.confirmed ? '' : 'this gate names screenshots that were never sealed — /open looks on cawdev');
|
|
6300
|
+
}
|
|
6301
|
+
if (words.length === 1 && words[0] === 'all') {
|
|
6302
|
+
return this.note(await this.openShots(shots, from));
|
|
6303
|
+
}
|
|
6304
|
+
if (words.length === 1 && /^\d+$/.test(words[0])) {
|
|
6305
|
+
const at = Number(words[0]);
|
|
6306
|
+
if (at < 1 || at > shots.length) return this.note(`there is no screenshot ${at} — ${shots.length} in this round`);
|
|
6307
|
+
return this.note(await this.openShots([shots[at - 1]], from));
|
|
6308
|
+
}
|
|
6309
|
+
return this.note(usage);
|
|
6310
|
+
}
|
|
6311
|
+
|
|
6312
|
+
/** Inline thumbnails — iTerm2, WezTerm, kitty — into the scrollback, never the live region. */
|
|
6313
|
+
async printThumbnails(shots, run) {
|
|
6314
|
+
const protocol = thumbnailProtocol(process.env);
|
|
6315
|
+
if (!protocol) return;
|
|
6316
|
+
const base = this.attachmentsPath(run);
|
|
6317
|
+
for (const shot of shots.filter(isImage)) {
|
|
6318
|
+
try {
|
|
6319
|
+
const copy = await localCopy(shot, {
|
|
6320
|
+
dir: shotsDir(), fetchBytes: () => this.session.requestBytes(`${base}/${encodeURIComponent(shot.id)}`),
|
|
6321
|
+
});
|
|
6322
|
+
// Pushed as a committed line, not through `say`: `wrap` counts the
|
|
6323
|
+
// base64 as visible text and would cut the escape in half.
|
|
6324
|
+
this.pending.push(thumbnailEscape(await readFile(copy.path), protocol));
|
|
6325
|
+
this.dirty = true;
|
|
6326
|
+
} catch (failure) {
|
|
6327
|
+
this.say(this.ink.muted(` ${printable(shot.name)}: ${printable(failure.message)}`));
|
|
6328
|
+
}
|
|
6329
|
+
}
|
|
6330
|
+
}
|
|
6331
|
+
|
|
4551
6332
|
async cancel() {
|
|
4552
6333
|
const run = this.current();
|
|
4553
6334
|
if (!run || run.state === 'queued') {
|
|
@@ -4614,8 +6395,17 @@ export class Attached {
|
|
|
4614
6395
|
// lands on a buffer that vanishes the moment the program exits.
|
|
4615
6396
|
if (this.split) {
|
|
4616
6397
|
clearInterval(this.split.plannedTimer);
|
|
6398
|
+
const { enteredWatching, mark } = this.split;
|
|
4617
6399
|
this.split.screen.release();
|
|
4618
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
|
+
}
|
|
4619
6409
|
}
|
|
4620
6410
|
try {
|
|
4621
6411
|
this.client?.destroy();
|
|
@@ -4922,9 +6712,19 @@ export class Attached {
|
|
|
4922
6712
|
// it under the first.
|
|
4923
6713
|
return [];
|
|
4924
6714
|
}
|
|
6715
|
+
if (this.split?.boardFocus) {
|
|
6716
|
+
return [
|
|
6717
|
+
`${ink.text('←→')} ${ink.muted('column')}`,
|
|
6718
|
+
`${ink.text('↑↓')} ${ink.muted('card')}`,
|
|
6719
|
+
`${ink.text('p')} ${ink.muted(`plan ${this.split.boardFocus.ref}`)}`,
|
|
6720
|
+
`${ink.text('d')} ${ink.muted(`develop ${this.split.boardFocus.ref}`)}`,
|
|
6721
|
+
`${ink.text('esc/b')} ${ink.muted('back to sessions')}`,
|
|
6722
|
+
];
|
|
6723
|
+
}
|
|
4925
6724
|
const parts = [
|
|
4926
6725
|
`${ink.text('enter')} ${ink.muted('prompt')}`,
|
|
4927
6726
|
`${ink.text('/')} ${ink.muted('commands')}`,
|
|
6727
|
+
...(this.split ? [`${ink.text('?')} ${ink.muted('help')}`] : []),
|
|
4928
6728
|
`${ink.text('L')} ${ink.muted('runs')}`,
|
|
4929
6729
|
];
|
|
4930
6730
|
if (this.askingOn(this.current())?.yours) {
|
|
@@ -4938,7 +6738,11 @@ export class Attached {
|
|
|
4938
6738
|
+ `${ink.muted('permission')}`);
|
|
4939
6739
|
}
|
|
4940
6740
|
if (this.split) {
|
|
4941
|
-
parts.push(`${ink.text('
|
|
6741
|
+
parts.push(`${ink.text('b')} ${ink.muted('board')}`);
|
|
6742
|
+
parts.push(`${ink.text('tab')} ${ink.muted('next card')}`);
|
|
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')}`);
|
|
4942
6746
|
parts.push(`${ink.text('d')} ${ink.muted(this.finishedPlan(this.commandTarget())
|
|
4943
6747
|
? `develop ${this.commandTarget().view.entryRef}` : 'develop a planned card')}`);
|
|
4944
6748
|
}
|