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