cawdev-cli 0.9.0

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.
@@ -0,0 +1,2397 @@
1
+ // The cawdev terminal client — R52, rewritten around the scrollback by R81.
2
+ //
3
+ // Two channels, on purpose, because they answer to different authorities:
4
+ //
5
+ // THE SOCKET tells you what this machine is doing. Runs, transcripts, the
6
+ // daemon's log, and the reason each queued run is waiting — which exists
7
+ // nowhere else. Read-only, and available while the platform is down.
8
+ //
9
+ // HTTP does anything that CHANGES something, signed in as you. Prompting,
10
+ // cancelling and deciding a permission request all refuse a token (R51), so
11
+ // they cannot go through the daemon: it would either lend its own credential
12
+ // to a guard built to stop exactly that, or hold yours. Neither.
13
+ //
14
+ // The split has a plain consequence worth knowing: **you can watch without
15
+ // signing in, and you cannot act without it.** Watching is disclosed to whoever
16
+ // can already read the socket in your home directory; acting is you.
17
+ //
18
+ // **R81 took the alternate screen away, and that is the whole change.** This
19
+ // used to own the viewport and repaint a pane into it, which meant the
20
+ // terminal's own scroll, its wheel, its search and its copy all stopped working
21
+ // the moment you attached. Now the transcript is PRINTED — it goes into the
22
+ // scrollback and stays there — and the only thing drawn is a footer at the
23
+ // bottom. See scrollback.mjs for the mechanism; it is less machinery than the
24
+ // pane manager it replaced, not more.
25
+ //
26
+ // Three consequences worth stating, because they are what the rewrite bought:
27
+ //
28
+ // - Scrolling up reaches the start of the session, using the scrollbar you
29
+ // already had.
30
+ // - The rail of runs is gone. It cost width on every line of every transcript
31
+ // to answer a question asked a few times an hour; `L` is an overlay now.
32
+ // - R62's five settings are in the footer, where they never scroll away,
33
+ // rather than in a top bar that did.
34
+ //
35
+ // **R83 made answering a thing you pick rather than a thing you retype.** The
36
+ // agent has usually already worked out the two or three answers it can act on —
37
+ // `ask_user` has carried `options` since R10 — and this was the one surface that
38
+ // threw them away. Now a question with options arrives as a list you move
39
+ // through, a permission request is the same list with R60's three lengths of yes
40
+ // in it, and the last row of a question opens a line editor because the options
41
+ // are the agent's guess and the value of asking a person is that they can say
42
+ // the thing that was not on it. See select.mjs for the widget and input.mjs for
43
+ // the line — both pure, both readable without a terminal.
44
+ //
45
+ // Zero dependencies, so this is ANSI escapes and `setRawMode` rather than a
46
+ // curses library.
47
+
48
+ import { connect } from 'node:net';
49
+ import { listSockets, socketPathFor } from './control.mjs';
50
+ import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
51
+ import { oneLine } from './brand.mjs';
52
+ import { Scrollback } from './scrollback.mjs';
53
+ import { Session, signInThroughBrowser, storedSession } from './sign-in.mjs';
54
+ import { clearSession } from './session-store.mjs';
55
+ import { clearHistory, loadHistory, pushHistory } from './history.mjs';
56
+ import {
57
+ History, KeyStream, Line, Pastes, commonPrefix, completionLines, completions, keysIn,
58
+ } from './input.mjs';
59
+ import { Select, WRITE_MY_OWN, pickFromLine, plainLines } from './select.mjs';
60
+
61
+ // Re-exported because they were this file's before R81 moved them into the
62
+ // shared ANSI helpers — and `keysIn` and `keyList` before R83 moved them beside
63
+ // the rest of the input and the rest of the layout. The tests that pin the
64
+ // arithmetic import them here.
65
+ export { clip, keyList, keysIn, stripAnsi, visibleWidth, wrap };
66
+
67
+ const ESC = '\x1b';
68
+
69
+ /** Colours for a run's state. Muted, because the transcript is the content. */
70
+ const STATE_COLOUR = {
71
+ running: 'success',
72
+ claiming: 'warn',
73
+ queued: 'muted',
74
+ };
75
+
76
+ // --- the rules, which are pure and therefore testable -------------------------
77
+
78
+ /**
79
+ * The question stopping a session, and whether this operator may answer it —
80
+ * R58.
81
+ *
82
+ * Pure, and separate from the drawing, because it is a *rule* rather than a
83
+ * layout: the API refuses anyone else with a message naming the owner, and a
84
+ * terminal that offered the key anyway would turn "this is Alice's question"
85
+ * into "cawdev is broken". The rule is the API's three ways in, by email —
86
+ * this program never learns a user id, and `sharedWithEmail` is on the share
87
+ * for exactly this kind of client.
88
+ *
89
+ * Returns null when nothing is waiting, which is the common case and the one
90
+ * the caller wants to say nothing about.
91
+ */
92
+ export function questionState(questions, email) {
93
+ const open = (questions ?? []).find((question) => !question.answered);
94
+ if (!open) {
95
+ return null;
96
+ }
97
+ const handedToYou = (open.shares ?? []).some(
98
+ (share) => share.open && share.kind === 'DECIDE' && share.sharedWithEmail === email,
99
+ );
100
+ return {
101
+ question: open,
102
+ // No owner means the run has no starter, which leaves the question open to
103
+ // the project — the one case R58 deliberately did not narrow.
104
+ yours: Boolean(email) && (!open.waitingOnEmail || open.waitingOnEmail === email
105
+ || handedToYou),
106
+ waitingOn: open.waitingOnEmail ?? null,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * That question, drawn — R58, and a function rather than a method so it can be
112
+ * rendered and looked at without a terminal.
113
+ *
114
+ * **Two shapes, and the second is why this exists.** When the question is this
115
+ * operator's, `a` answers it here. When it is not, the banner names the person
116
+ * it is waiting on instead of offering a key that would 403 — a terminal that
117
+ * let somebody type an answer and then refused it reads as cawdev being broken
118
+ * rather than as the question belonging to a colleague.
119
+ */
120
+ export function questionBanner(asking, width, ink = painter(3)) {
121
+ if (!asking) return [];
122
+
123
+ const options = asking.question.options?.length
124
+ ? ink.muted(` · ${asking.question.options.join(' / ')}`)
125
+ : '';
126
+ // 12 is the label and its spaces: the same arithmetic the permission banner
127
+ // uses, and for the same reason — clip counts visible characters.
128
+ const head = `${ink.bold(ink.accent(' question '))} ${clip(asking.question.question, width - 12)}`;
129
+
130
+ if (asking.yours) {
131
+ return [head, ` ${ink.success('a')} answer${options}`];
132
+ }
133
+ // The NAME is the last thing to go, the way the session total is on R62's
134
+ // bar: "waiting on alice@…" is the whole answer to "why is that not moving",
135
+ // and the clause after it is a courtesy. Truncating the sentence instead
136
+ // spends the narrow terminal's last columns on the courtesy and cuts the
137
+ // answer — which is what looking at this at 40 columns showed.
138
+ const who = ` waiting on ${asking.waitingOn ?? 'somebody else'}`;
139
+ const whole = `${who} — theirs to answer, or somebody they hand it to`;
140
+ return [head, ink.muted(clip(whole.length <= width ? whole : who, width))];
141
+ }
142
+
143
+ /**
144
+ * What a key means for a permission request — R51, R60, R78, R135.
145
+ *
146
+ * Pure, and returning null for a key that cannot be honoured, because `Y`
147
+ * cannot always be: the caller says so rather than sending a decision with
148
+ * nothing to write, which the server would quietly turn into an allow-once.
149
+ *
150
+ * TWO patterns can be written now, and they are different offers. `suggestion`
151
+ * is the machine's reading of what somebody meant — `mvn --version` offers
152
+ * `Bash(mvn *)`. `exactPattern` is the command itself, verbatim, for the
153
+ * compound and path-shaped commands no wildcard fits; it grants nothing but
154
+ * itself, and before R135 those had no lasting answer at any key.
155
+ */
156
+ export function permissionDecision(approval, key, machine = {}) {
157
+ switch (key) {
158
+ case 'y':
159
+ return { allow: true, scope: 'ONCE' };
160
+ case 's':
161
+ // The whole tool when there is no narrower rule to name — the same two
162
+ // sizes the console offers, chosen for you because a terminal has one
163
+ // key. It dies with the run either way.
164
+ return { allow: true, scope: 'SESSION',
165
+ pattern: approval.suggestion ?? approval.toolName };
166
+ case 'Y': {
167
+ // The exact rule only when this machine said it would apply it: a
168
+ // project rule outside the ceiling is dropped here on every call, so the
169
+ // key would take an answer the next session does not keep. A suggestion
170
+ // is offered as it always was — the console narrows it, and the ceiling
171
+ // filter runs where it is enforced.
172
+ const pattern = approval.suggestion
173
+ ?? (approval.exactWithinCeiling ? approval.exactPattern : null);
174
+ return pattern ? { allow: true, scope: 'PROJECT', pattern } : null;
175
+ }
176
+ case 'M':
177
+ // R126, the widest of the four: every project that ever runs on this
178
+ // machine. Null — and so not offered — for the two reasons it can be:
179
+ // no pattern to write, as with `Y`, and a machine whose own config does
180
+ // not accept rules from the console. A key that takes an answer the
181
+ // platform then refuses reads as cawdev being broken, which is R58's
182
+ // rule about the `a` key applied to this one.
183
+ //
184
+ // NOT filtered by the ceiling, unlike `Y`, and that is the whole of
185
+ // R126: this is the scope that RAISES it.
186
+ return (approval.suggestion ?? approval.exactPattern) && machine.acceptsConsoleRules
187
+ ? { allow: true, scope: 'RUNNER',
188
+ pattern: approval.suggestion ?? approval.exactPattern }
189
+ : null;
190
+ default:
191
+ return null;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * A pending permission request, drawn — R51 and R60's three answers.
197
+ *
198
+ * Two rules met here, from two sessions, and they were nearly contradictory.
199
+ *
200
+ * **Every key has to survive any width, and `n` is the one that would go.**
201
+ * Written at its full length this row is ninety characters; clipped to a
202
+ * forty-column terminal it read `y allow once s allow for th`, which offers
203
+ * two answers and hides *refuse* — the one somebody reaches for when they do
204
+ * not like what they are looking at. Found by rendering it at four widths and
205
+ * reading them, which is the only way this kind of thing is ever found.
206
+ *
207
+ * **And a grant is never cut short** (R78). "allow every Bash this s" describes
208
+ * a promise nobody made, and this is the one banner in the program where the
209
+ * words are a decision about what a machine may do rather than a status.
210
+ *
211
+ * So: the WORDING shortens — a whole phrasing at a time, never mid-clause —
212
+ * and what will not fit on one row WRAPS onto the next. Nothing is dropped and
213
+ * nothing is truncated while a shorter honest wording is still available. The
214
+ * long wording says what `s` covers, because "for this session" and "every
215
+ * Bash for this session" are not the same promise.
216
+ */
217
+ export function permissionBanner(pending, width, ink = painter(3), machine = {}) {
218
+ if (!pending) return [];
219
+ // Called with a pending record by the client, and with the approval itself by
220
+ // the rule's own tests — the banner is about the request either way.
221
+ const approval = pending.approval ?? pending;
222
+ const covers = approval.suggestion ?? `every ${approval.toolName}`;
223
+
224
+ // The two standing-rule keys, asked here exactly as `permissionDecision`
225
+ // asks them, so the banner and the key handler cannot disagree about what is
226
+ // on offer. R126's `M` is offered only when this machine accepts rules from
227
+ // the console; R135's exact rule reaches `Y` only inside the ceiling.
228
+ const here = approval.suggestion
229
+ ?? (approval.exactWithinCeiling ? approval.exactPattern : null);
230
+ const onTheMachine = (approval.suggestion ?? approval.exactPattern)
231
+ && machine.acceptsConsoleRules;
232
+
233
+ // R78, and the reason `Y` is not always labelled with what it writes: an
234
+ // exact rule is as long as the command, which is already on the line above
235
+ // verbatim. Ninety characters inside the key row would either wrap the
236
+ // banner badly or be clipped into a promise nobody made — so the wildcard
237
+ // pattern is named and the exact one is described.
238
+ const always = here
239
+ ? (approval.suggestion
240
+ ? `always allow ${approval.suggestion} here`
241
+ : 'always allow this exact command here')
242
+ : null;
243
+
244
+ const long = [
245
+ `${ink.success('y')} allow once`,
246
+ `${ink.success('s')} allow ${covers} this session`,
247
+ ...(always ? [`${ink.warn('Y')} ${always}`] : []),
248
+ ...(onTheMachine
249
+ ? [`${ink.warn('M')} always, on this machine`]
250
+ : []),
251
+ `${ink.danger('n')} refuse`,
252
+ ];
253
+ const short = [
254
+ `${ink.success('y')} once`,
255
+ `${ink.success('s')} session`,
256
+ ...(always ? [`${ink.warn('Y')} ${always}`] : []),
257
+ ...(onTheMachine ? [`${ink.warn('M')} this machine`] : []),
258
+ `${ink.danger('n')} refuse`,
259
+ ];
260
+
261
+ // Two rows is the most a banner may take before it is the screen rather than
262
+ // a note on it. What gives way, in order: first the wording shortens, and
263
+ // only then the STANDING RULE goes — the widest clause and the least urgent,
264
+ // and the only one of the four that can wait for a wider terminal. `n
265
+ // refuse` never moves, because it is the one somebody reaches for when they
266
+ // do not like what they are looking at.
267
+ // R126's `M` goes with `Y`: both are standing rules, both are the widest and
268
+ // least urgent clauses, and dropping one while keeping the other would leave
269
+ // the banner offering the WIDER of the two on the narrower terminal.
270
+ const without = (choices) => choices.filter((each) => !/\b[YM]\b/.test(stripAnsi(each)));
271
+ const rows = (choices) => wrapChoices(choices, width);
272
+ const keys = [long, without(long), short, without(short)]
273
+ .map(rows)
274
+ .find((lines) => lines.length <= 2)
275
+ ?? rows(without(short));
276
+
277
+ return [
278
+ `${ink.bold(ink.warn(' permission '))} ${clip(approval.summary, Math.max(8, width - 13))}`,
279
+ ink.muted(` ${approval.toolName} · waiting since ${approval.askedAt?.slice(11, 19) ?? ''}`),
280
+ ...keys,
281
+ ];
282
+ }
283
+
284
+ /**
285
+ * The choices across as few rows as fit, wrapping rather than truncating.
286
+ *
287
+ * Only a terminal too narrow for one choice on its own reaches the clip at the
288
+ * end, and there is nothing better than a cut line to give it.
289
+ */
290
+ function wrapChoices(choices, width, gap = ' ') {
291
+ const lines = [];
292
+ let line = '';
293
+ for (const choice of choices) {
294
+ const next = line ? `${line}${gap}${choice}` : ` ${choice}`;
295
+ if (line && visibleWidth(next) > width) {
296
+ lines.push(line);
297
+ line = ` ${choice}`;
298
+ } else {
299
+ line = next;
300
+ }
301
+ }
302
+ if (line) lines.push(line);
303
+ return lines.map((each) => clip(each, width));
304
+ }
305
+
306
+ /**
307
+ * What the footer counts — R62, unchanged by R81 except in where it is drawn.
308
+ *
309
+ * **A queued run is not a session.** It is precisely the run that has NOT
310
+ * taken a slot, and counting it would make the footer say the machine is full
311
+ * at the moment it is not — which is exactly backwards, because "full" is the
312
+ * answer to "why is mine queued".
313
+ *
314
+ * `room` is how many checkouts the project has (R47's per-project gate), and
315
+ * is absent when talking to a daemon older than R62. Then there is nothing to
316
+ * compare against and the count stands alone, rather than being shown over a
317
+ * number that was guessed.
318
+ *
319
+ * **Only coding sessions are counted against `room`** — R70. The checkouts are
320
+ * what that number bounds, and an ASK, a ROADMAP, an AUDIT or a SCOPE takes none.
321
+ */
322
+ export function sessionCounts(runner, runs) {
323
+ const live = (runs ?? []).filter((run) => run.state !== 'queued');
324
+ const here = new Map();
325
+ for (const run of live) {
326
+ // R70: the checkouts bound coding alone. Only a run that SAYS it writes no
327
+ // code is left out — a daemon too old to send the flag is counted as it
328
+ // always was, because inventing "this is a question" from silence would
329
+ // show a busy checkout as free, which is the failure worth avoiding.
330
+ if (run.writesCode === false) continue;
331
+ here.set(run.projectSlug, (here.get(run.projectSlug) ?? 0) + 1);
332
+ }
333
+ const projects = (runner?.projects ?? []).map((slug) => {
334
+ const count = here.get(slug) ?? 0;
335
+ const room = runner?.workspaces?.[slug];
336
+ return { slug, count, room, full: Boolean(room) && count >= room };
337
+ });
338
+ return { total: live.length, cap: runner?.maxSessions, projects };
339
+ }
340
+
341
+ /**
342
+ * The footer's counting row — R62, moved down by R81 and otherwise untouched.
343
+ *
344
+ * The arithmetic is the whole risk here: everything on this row is coloured,
345
+ * and a coloured string is about ten characters longer than it looks. Padding
346
+ * or truncating by `length` is what put `2/2sessions` on screen with no space
347
+ * between them the first time this was rendered and looked at.
348
+ */
349
+ export function settingsBar(runner, runs, width, ink = painter(3)) {
350
+ const url = runner?.url ?? '';
351
+ const { total, cap, projects } = sessionCounts(runner, runs);
352
+
353
+ const served = projects.map(({ slug, count, room, full }) => {
354
+ const of = room ? `${count}/${room}` : String(count);
355
+ // Busy is worth seeing; idle should not shout. Full is worth seeing most,
356
+ // because that is the one answering "why is mine waiting".
357
+ const paint = full ? ink.warn : count ? ink.success : ink.muted;
358
+ return `${ink.muted(slug)} ${paint(of)}`;
359
+ });
360
+
361
+ const tally = cap ? `${total}/${cap}` : String(total);
362
+ const right = `${ink.muted('sessions')} ${
363
+ cap && total >= cap ? ink.warn(tally) : ink.text(tally)} `;
364
+
365
+ // Narrow terminals: drop projects from the END until it fits, rather than
366
+ // dropping the total. Whichever number survives should be the one that
367
+ // answers "why is mine waiting", and on a machine at its cap that is the
368
+ // total — the ellipsis says the list was cut.
369
+ const shown = [...served];
370
+ const build = () => ` ${ink.accent(url)}${shown.length ? ` ${ink.muted('│')} ` : ''}`
371
+ + shown.join(ink.muted(' · '))
372
+ + (shown.length < served.length ? ink.muted(' …') : '');
373
+
374
+ let left = build();
375
+ while (shown.length && visibleWidth(left) + visibleWidth(right) > width - 1) {
376
+ shown.pop();
377
+ left = build();
378
+ }
379
+
380
+ // Right-aligned by what is VISIBLE.
381
+ const room = width - visibleWidth(left) - visibleWidth(right);
382
+ const line = room > 0
383
+ ? `${left}${' '.repeat(room)}${right}`
384
+ : `${clip(left, Math.max(0, width - visibleWidth(right)))}${right}`;
385
+ // No blanket dim over the row: everything in it already carries its own
386
+ // colour, and dimming the lot flattens "full" back into "idle", which is the
387
+ // one distinction this footer exists to make.
388
+ return padVisible(clip(line, width), width);
389
+ }
390
+
391
+ /**
392
+ * A run, as one line of the overlay — R81.
393
+ *
394
+ * The reason a queued run is queued goes on the SAME line as the run, because
395
+ * that reason is the whole argument for this program existing and a list that
396
+ * makes you press a key for it has hidden the answer behind the question.
397
+ */
398
+ export function runLine(run, { chosen, marker, number }, width, ink = painter(3)) {
399
+ const colour = ink[STATE_COLOUR[run.state] ?? 'text'];
400
+ const head = `${chosen ? ink.bold('❯') : ' '}${marker ?? ' '}${ink.muted(number ?? ' ')} `;
401
+ const where = ink.muted(` ${run.projectSlug} · ${run.state}`);
402
+ const why = run.why ? ink.warn(` — ${run.why}`) : '';
403
+
404
+ // **The label is what gets shortened, not the reason.** R58's lesson, in a
405
+ // second place: whichever half survives the narrowing should be the one that
406
+ // answers the question, and here the question is "why is that not moving".
407
+ // A card's title is recognisable from a dozen characters; "no free workspace
408
+ // in cawdev (2 here, all busy" cut mid-parenthesis answers nothing.
409
+ const room = width - visibleWidth(head) - visibleWidth(where) - visibleWidth(why);
410
+ const name = colour(clip(run.label, Math.max(12, room)));
411
+ return clip(`${head}${name}${where}${why}`, width);
412
+ }
413
+
414
+ /**
415
+ * The run being driven right now, as one row — R83.
416
+ *
417
+ * **Which run, for how long, and the key that stops it.** A session that has
418
+ * been going for four minutes and one that has been going for two hours look
419
+ * identical in a transcript, and "is this thing still working" is the question
420
+ * somebody is actually asking when they glance at the bottom of the screen.
421
+ *
422
+ * Absent when nothing is running, which is why this returns null rather than a
423
+ * blank row: an empty line that is always there is a line that says nothing, and
424
+ * the footer is short on rows to spend.
425
+ */
426
+ export function statusLine(run, now, width, ink = painter(3)) {
427
+ if (!run || (run.state !== 'running' && run.state !== 'claiming')) {
428
+ return null;
429
+ }
430
+ const going = run.startedAt ? elapsed(now - Date.parse(run.startedAt)) : null;
431
+ const tail = ink.muted(`${going ? ` · ${going}` : ''} · x stops it`);
432
+ const head = `${ink.success('●')} ${run.state === 'claiming' ? ink.muted('claiming ') : ''}`;
433
+ const room = Math.max(8, width - visibleWidth(head) - visibleWidth(tail) - 1);
434
+ return clip(` ${head}${ink.text(clip(run.label ?? 'a session', room))}${tail}`, width);
435
+ }
436
+
437
+ /** How long, in the shortest form that is still a duration. */
438
+ export function elapsed(ms) {
439
+ if (!Number.isFinite(ms) || ms < 0) return null;
440
+ const seconds = Math.floor(ms / 1000);
441
+ if (seconds < 60) return `${seconds}s`;
442
+ const minutes = Math.floor(seconds / 60);
443
+ if (minutes < 60) return `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`;
444
+ return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
445
+ }
446
+
447
+ /**
448
+ * The footer: everything that never changes, and the few things that do.
449
+ *
450
+ * **Pure, and rendered from a plain object.** R62 learned this the hard way —
451
+ * a bar built inside a draw method can only be checked by looking at it, and
452
+ * the arithmetic in it is exactly the kind that is wrong by ten characters in
453
+ * a way nobody notices until a session goes red.
454
+ *
455
+ * The order is deliberate. What is stopping somebody is at the top, because it
456
+ * is the only thing here that is blocking a person. Then the fixed answers,
457
+ * then who and where, then the keys that work right now.
458
+ */
459
+ export function footerLines(state, width, ink = painter(3)) {
460
+ const {
461
+ runner, runs = [], email, watching, connected = true,
462
+ banner = [], overlay = [], input = null, keys = '', status = '',
463
+ running = null, now = Date.now(), matches = [],
464
+ } = state;
465
+
466
+ const rule = ink.muted('─'.repeat(Math.max(0, width)));
467
+ const lines = [rule];
468
+
469
+ for (const line of overlay) {
470
+ lines.push(line);
471
+ }
472
+ if (overlay.length) {
473
+ lines.push(rule);
474
+ }
475
+ for (const line of banner) {
476
+ lines.push(line);
477
+ }
478
+ if (banner.length) {
479
+ lines.push(rule);
480
+ }
481
+
482
+ lines.push(settingsBar(runner, runs, width, ink));
483
+
484
+ // Who and where. The runner's NAME is what the console shows and what
485
+ // `--runner` takes, so it is the word somebody would type; the email is what
486
+ // every action here is done as, and "watching only" is not a lesser state,
487
+ // it is the honest one.
488
+ const who = email ? ink.text(email) : ink.muted('watching only');
489
+ const link = connected ? '' : ` ${ink.danger('detached')}`;
490
+ const named = `${ink.bold(runner?.name ?? '…')} ${ink.muted('·')} ${who}${link}`;
491
+ // The mark is the only decoration in the program, so it is the first thing
492
+ // to go: at forty columns it was costing eight of them and cutting the email
493
+ // in half, and "who am I acting as" is an answer while a logo is a mood.
494
+ const withMark = ` ${oneLine(ink)} ${ink.muted('·')} ${named}`;
495
+ lines.push(padVisible(clip(
496
+ visibleWidth(withMark) <= width ? withMark : ` ${named}`,
497
+ width,
498
+ ), width));
499
+
500
+ // **A row of its own, and the state is what is reserved for.** Sharing the
501
+ // line above cost the run's state at a hundred columns: a fifty-character
502
+ // card title used every column the label was given and `(cawdev · running)`
503
+ // fell off the end — so the footer said which session was being watched and
504
+ // not whether it was still going, which is the half that changes.
505
+ //
506
+ // The height is affordable in a way it never was before R81: the footer no
507
+ // longer competes with a pane for the screen. Everything it pushes up is in
508
+ // the scrollback and is still there.
509
+ lines.push(padVisible(clip(watching
510
+ ? runLine(watching, { chosen: false, marker: ink.muted('▸'), number: ' ' }, width, ink)
511
+ : ` ${ink.muted('▸ nothing being watched — L lists what this machine has')}`,
512
+ width), width));
513
+
514
+ // R83's status line: what is actually running, and how long it has been. It
515
+ // is absent rather than blank when nothing is — the row above already says
516
+ // which run is printing, and this one is only worth its height while there is
517
+ // a clock ticking behind it.
518
+ const going = statusLine(running, now, width, ink);
519
+ if (going) {
520
+ lines.push(padVisible(going, width));
521
+ }
522
+
523
+ // The commands that still match what is being typed, directly above the line
524
+ // being typed — R83. Next to it rather than in the overlay at the top,
525
+ // because a list of what you are halfway through writing belongs beside it.
526
+ for (const line of completionLines(matches.rows ?? matches, matches.at ?? 0, width, ink)) {
527
+ lines.push(padVisible(line, width));
528
+ }
529
+
530
+ // The last row is either what you are typing or what you can press. Never
531
+ // both: a key list under a half-typed prompt is a list of keys that would
532
+ // land in the prompt.
533
+ if (input) {
534
+ lines.push(padVisible(clip(` ${ink.accent(input.label)} ${input.text}`, width), width));
535
+ } else if (keys.length || status) {
536
+ lines.push(padVisible(clip(` ${keyList(keys, status, width - 1, ink)}`, width), width));
537
+ }
538
+ return lines;
539
+ }
540
+
541
+ // --- the client ---------------------------------------------------------------
542
+
543
+ /**
544
+ * @param argv the command line, for `--runner`, `--url`, `--watch-only`
545
+ * @param options set when the daemon is running IN THIS PROCESS (`--attach`):
546
+ * its socket is already known, quitting means stopping it, and it can say how
547
+ * much work would be lost if you did.
548
+ */
549
+ export async function attach(argv, options = {}) {
550
+ const socket = options.socketPath ?? (await chooseSocket(valueOf(argv, '--runner')));
551
+ const session = await openSession(argv);
552
+
553
+ // Spread second so `--attach`'s own wiring wins: there the daemon IS this
554
+ // process and quitting has always stopped it.
555
+ const ui = new Attached(socket, session,
556
+ { leaveRunning: argv.includes('--leave-running'), ...options });
557
+ await ui.start();
558
+ }
559
+
560
+ export function valueOf(argv, flag) {
561
+ const at = argv.indexOf(flag);
562
+ return at === -1 ? null : argv[at + 1];
563
+ }
564
+
565
+ export function urlFrom(argv) {
566
+ return (valueOf(argv, '--url') ?? process.env.CAWDEV_URL ?? 'http://localhost:4200')
567
+ .replace(/\/+$/, '');
568
+ }
569
+
570
+ /**
571
+ * Which daemon to watch.
572
+ *
573
+ * One is the answer without asking. Several is a machine running more than one
574
+ * daemon, which is unusual enough that naming one is better than guessing.
575
+ */
576
+ async function chooseSocket(wanted) {
577
+ if (wanted) {
578
+ return socketPathFor(wanted);
579
+ }
580
+ const sockets = await listSockets();
581
+ if (!sockets.length) {
582
+ throw new Error(
583
+ 'No runner is offering a socket on this machine. Start one with `cawdev`, which\n' +
584
+ 'launches a daemon when it finds none, or run it yourself:\n\n' +
585
+ ' node runner.mjs --config <your config>.json',
586
+ );
587
+ }
588
+ if (sockets.length === 1) {
589
+ return sockets[0].path;
590
+ }
591
+ const names = sockets.map((each) => each.name).join(', ');
592
+ throw new Error(`More than one runner here (${names}). Choose one: cawdev --runner <name>`);
593
+ }
594
+
595
+ /**
596
+ * The session this launch starts with — R81.
597
+ *
598
+ * Stored first, because the entry's promise is that signing in once is enough.
599
+ * A stored cookie the platform no longer honours is not an error: it is what an
600
+ * expired session looks like, and the answer is the same as having none — the
601
+ * browser, once, and then remembered again.
602
+ */
603
+ async function openSession(argv) {
604
+ const url = urlFrom(argv);
605
+ if (argv.includes('--watch-only')) {
606
+ return new Session(url);
607
+ }
608
+ const session = await storedSession(url);
609
+ if (session.signedIn) {
610
+ return session;
611
+ }
612
+
613
+ const ink = painter();
614
+ console.log(`${ink.muted('Not signed in.')} Opening ${ink.accent(url)} in your browser —`);
615
+ console.log(`${ink.muted('a session is what lets you prompt a run, cancel one, or decide a request.')}\n`);
616
+ const outcome = await runSignIn(session, ink);
617
+ if (!outcome.signedIn) {
618
+ console.log(`${ink.muted('Watching only. Press')} / ${ink.muted('and type')} /login ${ink.muted('to try again.')}\n`);
619
+ }
620
+ return session;
621
+ }
622
+
623
+ /**
624
+ * The browser dance, printed plainly. Shared by the first launch and `/login`.
625
+ *
626
+ * The code is printed as well as opened, because the point of a code is that
627
+ * two screens show the same one and a person compares them. Printing the URL
628
+ * too is not belt and braces: a machine over ssh has no browser to open, and
629
+ * this is the only thing that makes it work at all.
630
+ */
631
+ async function runSignIn(session, ink = painter()) {
632
+ const outcome = await signInThroughBrowser(session, ({ url, code }) => {
633
+ console.log(` ${ink.muted('If your browser did not open, go to')}`);
634
+ console.log(` ${ink.accent(url)}`);
635
+ console.log(` ${ink.muted('and check it is showing')} ${ink.bold(code)}\n`);
636
+ console.log(` ${ink.muted('Waiting…')}`);
637
+ });
638
+ if (outcome.signedIn) {
639
+ console.log(` ${ink.success('Signed in')} as ${ink.bold(outcome.email)}.\n`);
640
+ } else if (outcome.refused) {
641
+ console.log(` ${ink.danger('Refused')} in the browser.\n`);
642
+ } else {
643
+ console.log(` ${ink.warn('The code expired.')}\n`);
644
+ }
645
+ return outcome;
646
+ }
647
+
648
+ /** The slash commands, and the one place they are described. */
649
+ const COMMANDS = [
650
+ ['/help', 'this list'],
651
+ ['/login', 'sign in through the browser'],
652
+ ['/logout', 'forget the stored session on this machine'],
653
+ ['/runs', 'the run list — the same as L'],
654
+ ['/cancel', 'cancel the session you are watching'],
655
+ ['/log', "the daemon's own log, on or off"],
656
+ ['/quit', 'stop the runner and leave (--leave-running keeps it up)'],
657
+ ];
658
+
659
+ /**
660
+ * The client.
661
+ *
662
+ * Exported since R83 so the things it DECIDES can be checked without a
663
+ * terminal: that choosing an option posts the same answer to the same record as
664
+ * typing one, that a watcher who does not own a question is offered no picker,
665
+ * that Esc from free text comes back to the list. Those are rules, and R62's
666
+ * lesson about rules in a renderer is that they can only be tested by looking at
667
+ * them.
668
+ */
669
+ export class Attached {
670
+ /**
671
+ * @param options `out` is where to draw — `process.stdout` in the real thing,
672
+ * anything with `write` in a test, which is the same seam Scrollback takes
673
+ * and for the same reason.
674
+ */
675
+ constructor(socketPath, session, options = {}) {
676
+ this.socketPath = socketPath;
677
+ this.session = session;
678
+ this.options = options;
679
+ this.ink = painter();
680
+ this.screen = new Scrollback(options.out ?? process.stdout);
681
+
682
+ this.runner = null;
683
+ this.runs = [];
684
+ /** The run whose output is being printed. An id, not an index: the list
685
+ * moves under you, and watching your transcript switch because a third
686
+ * session finished is how you send a prompt to the wrong place. */
687
+ this.watching = null;
688
+ /** runId -> transcript lines, for a run that is not the one printing. */
689
+ this.lines = new Map();
690
+ /** Which runs have had their history laid into the scrollback already. */
691
+ this.printed = new Set();
692
+ /** runId -> the pending permission request on it, from the inbox. */
693
+ this.approvals = new Map();
694
+ /** runId -> what it is asking, and whose question that is — R58. */
695
+ this.questions = new Map();
696
+
697
+ /** 'keys', 'select' or 'typing' — and only ever one of them. */
698
+ this.mode = 'keys';
699
+ /** The open picker, whatever it is a picker OF. See select.mjs. */
700
+ this.select = null;
701
+ /** The line being typed, its label, and where Esc goes back to. */
702
+ this.input = null;
703
+ /**
704
+ * The question an open picker or line is answering — R119.
705
+ *
706
+ * Held apart from `mode` because the poll needs to know WHAT is being
707
+ * answered, not merely that something is: the console is the other door
708
+ * onto the same question, and a box waiting for an answer that has already
709
+ * been given is this terminal asking a person to do a thing twice.
710
+ */
711
+ this.answering = null;
712
+ /**
713
+ * The permission request an open picker or refuse-line is deciding — R137.
714
+ *
715
+ * `answering`'s exact counterpart, and here for the same reason: the
716
+ * console is the other door onto the same request, and a picker offering
717
+ * three lengths of yes for something already allowed is this terminal
718
+ * asking a person to decide a thing twice.
719
+ *
720
+ * Carries the APPROVAL id as well as the run because a run can be asked
721
+ * twice: `approvals` is keyed by run, so a second request arriving would
722
+ * otherwise make a picker titled with the first one's command look current.
723
+ */
724
+ this.deciding = null;
725
+ this.status = '';
726
+ this.showLog = false;
727
+ this.asked = new Set();
728
+ this.connected = false;
729
+ this.stopped = false;
730
+ this.dirty = false;
731
+ /** Lines waiting to be committed on the next tick. See {@link say}. */
732
+ this.pending = [];
733
+
734
+ // R83's input line. The history is loaded from disk in `start`, because a
735
+ // constructor that awaits is a constructor nobody can call.
736
+ this.history = new History();
737
+ this.pastes = new Pastes();
738
+ this.stdinKeys = new KeyStream();
739
+ /** Questions and requests already printed, so they are announced once. */
740
+ this.announced = new Set();
741
+ /**
742
+ * Approval ids this client knows are over — R137.
743
+ *
744
+ * The inbox answers the moment anything is pending, so a response built
745
+ * moments before a decision lands can arrive after the poll cleared it and
746
+ * put the banner back for another twenty seconds. Same growth and same
747
+ * lifetime as `announced`: one attached terminal's.
748
+ */
749
+ this.settled = new Set();
750
+ /** Ctrl+C, armed. See {@link onInterrupt}. */
751
+ this.interrupting = false;
752
+ }
753
+
754
+ /**
755
+ * Whether this terminal can be drawn on at all.
756
+ *
757
+ * One flag for two things that are the same question: a stream with no cursor
758
+ * cannot hold a live region, and it cannot hold a picker either. Where this is
759
+ * true a picker is a numbered list read from stdin — a plain prompt, not a
760
+ * broken repaint.
761
+ *
762
+ * **Colour is a different question and is deliberately not this one.** R62's
763
+ * rule and R81's are that `NO_COLOR` is about escape codes, not about the
764
+ * cursor: somebody who has turned colour off in their shell profile forever
765
+ * still has arrow keys, and taking the picker away from them would be a worse
766
+ * terminal for no reason. Under `NO_COLOR` the widget draws in plain text and
767
+ * the `❯`, the numbers and the words carry what the colour did.
768
+ */
769
+ get plain() {
770
+ return !this.screen.tty;
771
+ }
772
+
773
+ async start() {
774
+ this.screen.open();
775
+ this.history = new History(await loadHistory(this.session.url).catch(() => []));
776
+ // Raw mode only where there is a terminal to put in it. Through a pipe
777
+ // there are no keys, the transcript is the whole output, and that is the
778
+ // honest degradation rather than a broken one.
779
+ if (!this.plain && process.stdin.isTTY) {
780
+ process.stdin.setRawMode?.(true);
781
+ process.stdin.resume();
782
+ // Bracketed paste, which is the only way to tell a pasted newline from
783
+ // somebody pressing enter. Without it, pasting a paragraph sends the first
784
+ // line and types the rest into whatever opens next.
785
+ process.stdout.write(`${ESC}[?2004h`);
786
+ // One chunk is however many bytes arrived together, not one keypress —
787
+ // and a paste is not even one chunk. See input.mjs; this line used to be
788
+ // the bug.
789
+ process.stdin.on('data', (chunk) => {
790
+ for (const key of this.stdinKeys.push(chunk.toString('utf8'))) {
791
+ this.onKey(key);
792
+ }
793
+ });
794
+ } else if (process.stdin.readable) {
795
+ // The plain path: no cursor to move, so whole LINES are read. A number
796
+ // picks from whatever list was printed, and anything else is a prompt, an
797
+ // answer, or a command — which is what a plain prompt has always meant.
798
+ process.stdin.setEncoding('utf8');
799
+ process.stdin.resume();
800
+ let buffered = '';
801
+ process.stdin.on('data', (chunk) => {
802
+ buffered += chunk;
803
+ let newline;
804
+ while ((newline = buffered.indexOf('\n')) !== -1) {
805
+ const line = buffered.slice(0, newline);
806
+ buffered = buffered.slice(newline + 1);
807
+ this.onLine(line);
808
+ }
809
+ });
810
+ }
811
+ process.stdout.on('resize', () => {
812
+ this.screen.resize();
813
+ this.dirty = true;
814
+ });
815
+
816
+ this.open();
817
+ if (this.session.signedIn) {
818
+ void this.watchInbox();
819
+ void this.watchQuestions();
820
+ }
821
+
822
+ // One write per tick at most, carrying everything that happened in it. A
823
+ // busy session emits hundreds of lines a second, and a write per line means
824
+ // an erase-and-redraw of the whole footer per line.
825
+ const tick = setInterval(() => {
826
+ if (this.dirty || this.pending.length) {
827
+ this.flush();
828
+ }
829
+ }, 60);
830
+ tick.unref?.();
831
+
832
+ this.flush();
833
+ await new Promise((done) => {
834
+ this.finish = done;
835
+ });
836
+ }
837
+
838
+ open() {
839
+ const client = connect(this.socketPath);
840
+ this.client = client;
841
+ client.setEncoding('utf8');
842
+
843
+ client.on('connect', () => {
844
+ this.connected = true;
845
+ this.note('attached');
846
+ });
847
+
848
+ let buffer = '';
849
+ client.on('data', (chunk) => {
850
+ buffer += chunk;
851
+ let newline;
852
+ while ((newline = buffer.indexOf('\n')) !== -1) {
853
+ const line = buffer.slice(0, newline);
854
+ buffer = buffer.slice(newline + 1);
855
+ if (!line.trim()) continue;
856
+ try {
857
+ this.onEvent(JSON.parse(line));
858
+ } catch {
859
+ // A line we cannot read is not worth tearing the screen down for.
860
+ }
861
+ }
862
+ });
863
+
864
+ client.on('error', (failure) => {
865
+ this.connected = false;
866
+ this.note(`socket: ${failure.message}`);
867
+ });
868
+
869
+ client.on('close', () => {
870
+ this.connected = false;
871
+ this.note('the daemon went away — retrying every two seconds');
872
+ if (!this.stopped) {
873
+ const retry = setTimeout(() => this.open(), 2000);
874
+ retry.unref?.();
875
+ }
876
+ });
877
+ }
878
+
879
+ onEvent(event) {
880
+ if (event.type === 'hello') {
881
+ this.runner = event.runner;
882
+ this.runs = event.runs;
883
+ this.chooseWatched();
884
+ } else if (event.type === 'runs') {
885
+ this.runs = event.runs;
886
+ this.chooseWatched();
887
+ } else if (event.type === 'output') {
888
+ const kept = this.lines.get(event.runId) ?? [];
889
+ kept.push(event.line);
890
+ if (kept.length > 5000) kept.splice(0, kept.length - 5000);
891
+ this.lines.set(event.runId, kept);
892
+ if (event.runId === this.watching && !this.showLog) {
893
+ this.say(this.render(event.line));
894
+ }
895
+ } else if (event.type === 'backlog') {
896
+ this.lines.set(event.runId, event.lines);
897
+ if (event.runId === this.watching && !this.printed.has(event.runId)) {
898
+ this.layHistoryIn(event.runId, event.lines);
899
+ }
900
+ } else if (event.type === 'log') {
901
+ if (this.showLog) {
902
+ this.say(`${this.ink.muted(event.at?.slice(11, 19) ?? '')} ${event.line}`);
903
+ }
904
+ }
905
+ this.dirty = true;
906
+ }
907
+
908
+ /**
909
+ * Queue lines for the scrollback.
910
+ *
911
+ * **Queued rather than written, and flushed with the footer on one tick.** A
912
+ * busy session emits hundreds of lines a second; writing each one on its own
913
+ * means an erase-and-redraw of the whole footer per line, and the footer that
914
+ * gets drawn is the one from before whatever just happened — which showed up
915
+ * in a real terminal as a stale prompt line flashing under `/help`'s output.
916
+ *
917
+ * Wrapped here rather than left to the terminal for one reason: a transcript
918
+ * line carries the agent's own colour and can be a paragraph with newlines in
919
+ * it, and `wrap` is the function that knows the difference between a
920
+ * character and an escape sequence.
921
+ */
922
+ say(text) {
923
+ for (const line of wrap(text, this.screen.width)) {
924
+ this.pending.push(line);
925
+ }
926
+ this.dirty = true;
927
+ }
928
+
929
+ /** Everything queued, plus the footer as it is right now, in one write. */
930
+ flush() {
931
+ const lines = this.pending;
932
+ this.pending = [];
933
+ this.dirty = false;
934
+ this.screen.update(lines, this.footer());
935
+ }
936
+
937
+ /**
938
+ * A run's history, laid into the scrollback when you switch to it — R81.
939
+ *
940
+ * Without this, opening a session that started an hour ago is a blank
941
+ * terminal until the agent next speaks. The daemon has kept the last four
942
+ * thousand lines for exactly this (control.mjs), and putting them in the
943
+ * scrollback rather than in a pane is what makes scrolling up reach the
944
+ * start of the session.
945
+ */
946
+ layHistoryIn(runId, lines) {
947
+ this.printed.add(runId);
948
+ const run = this.runs.find((each) => each.id === runId);
949
+ const ink = this.ink;
950
+ this.say(ink.muted('─'.repeat(Math.max(0, this.screen.width))));
951
+ this.say(`${ink.bold(run?.label ?? 'a session')} ${ink.muted(
952
+ `${run?.projectSlug ?? ''}${run?.branch ? ` · ${run.branch}` : ''}`)}`);
953
+ if (!lines.length) {
954
+ this.say(ink.muted(run?.state === 'queued'
955
+ ? ` waiting — ${run.why ?? 'no reason recorded'}`
956
+ : ' nothing said yet on this session'));
957
+ }
958
+ this.say('');
959
+ for (const line of lines) {
960
+ this.say(this.render(line));
961
+ }
962
+ }
963
+
964
+ /**
965
+ * What is being watched, after the list changed.
966
+ *
967
+ * A run that is gone releases the screen to the newest one, and a first run
968
+ * on an idle machine takes it — the common case is one session, and making
969
+ * somebody press a key to see the only thing happening is a poor greeting.
970
+ */
971
+ chooseWatched() {
972
+ if (this.watching && this.runs.some((run) => run.id === this.watching)) {
973
+ this.requestBacklog();
974
+ return;
975
+ }
976
+ const next = this.runs[0];
977
+ this.watching = next?.id ?? null;
978
+ if (next) {
979
+ this.requestBacklog();
980
+ }
981
+ }
982
+
983
+ /** The backlog of whatever is being watched, once per run. */
984
+ requestBacklog() {
985
+ const run = this.current();
986
+ if (!run || this.asked.has(run.id) || !this.connected) {
987
+ return;
988
+ }
989
+ this.asked.add(run.id);
990
+ try {
991
+ this.client.write(`${JSON.stringify({ type: 'backlog', runId: run.id })}\n`);
992
+ } catch {
993
+ this.asked.delete(run.id);
994
+ }
995
+ }
996
+
997
+ current() {
998
+ return this.runs.find((run) => run.id === this.watching) ?? null;
999
+ }
1000
+
1001
+ /** Switch which session is printing, and lay its history in. */
1002
+ watch(runId) {
1003
+ if (runId === this.watching) {
1004
+ return;
1005
+ }
1006
+ this.watching = runId;
1007
+ this.printed.delete(runId);
1008
+ this.asked.delete(runId);
1009
+ const kept = this.lines.get(runId);
1010
+ if (kept) {
1011
+ this.layHistoryIn(runId, kept);
1012
+ }
1013
+ this.requestBacklog();
1014
+ this.note('');
1015
+ }
1016
+
1017
+ note(text) {
1018
+ this.status = text;
1019
+ this.dirty = true;
1020
+ }
1021
+
1022
+ // --- what is waiting on a person -----------------------------------------
1023
+
1024
+ /**
1025
+ * Permission requests, from the inbox rather than from the daemon.
1026
+ *
1027
+ * The daemon could read them — it has the project's read scope — but it
1028
+ * cannot answer them, and a channel that shows you a decision you must then
1029
+ * make somewhere else is worse than one that does both. The inbox is already
1030
+ * the long poll that answers "what is stopped on me", across every project.
1031
+ */
1032
+ async watchInbox() {
1033
+ while (!this.stopped) {
1034
+ try {
1035
+ const inbox = await this.session.request('/api/inbox?wait=20');
1036
+ this.rememberInbox(inbox);
1037
+ this.announce();
1038
+ this.dirty = true;
1039
+ } catch {
1040
+ await new Promise((done) => setTimeout(done, 5000));
1041
+ }
1042
+ }
1043
+ }
1044
+
1045
+ /**
1046
+ * What the inbox says is waiting, minus what this client has already seen
1047
+ * settled — R137.
1048
+ *
1049
+ * A method rather than three lines in the loop so that the filter can be
1050
+ * tested without an infinite poll. The filter is the point: the inbox returns
1051
+ * as soon as anything is pending, so its answer is frequently older than the
1052
+ * decision that ended the thing it is describing, and a stale answer must not
1053
+ * be able to raise a banner over a request this terminal watched close.
1054
+ */
1055
+ rememberInbox(inbox) {
1056
+ this.approvals = new Map((inbox?.approvals ?? [])
1057
+ .filter((item) => !this.settled.has(item.approval?.id))
1058
+ .map((item) => [item.runId, item]));
1059
+ }
1060
+
1061
+ pendingOn(run) {
1062
+ return run ? this.approvals.get(run.id) ?? null : null;
1063
+ }
1064
+
1065
+ /**
1066
+ * What the watched session is stopped on — R58, and since R137 both shapes.
1067
+ *
1068
+ * The run's own questions rather than the inbox, and that is the point: the
1069
+ * inbox is now only what is *yours*, so a question stopping a session on this
1070
+ * machine that belongs to a colleague would simply not be there — and the one
1071
+ * thing this program exists to answer is "why is that run not moving". Here
1072
+ * the answer is a name.
1073
+ *
1074
+ * <p>A session stops on two things, and this poll now watches both: a
1075
+ * question, and a permission request. The second costs one extra GET, and
1076
+ * only while this client believes a request is pending on the run being
1077
+ * watched — nothing the rest of the time. See {@link decidedElsewhere}.
1078
+ */
1079
+ async watchQuestions() {
1080
+ while (!this.stopped) {
1081
+ const run = this.current();
1082
+ if (run?.projectSlug) {
1083
+ try {
1084
+ const asked = await this.session.request(
1085
+ `/api/projects/${run.projectSlug}/runs/${run.id}/questions`,
1086
+ );
1087
+ const state = questionState(asked, this.session.email);
1088
+ if (state) {
1089
+ this.questions.set(run.id, state);
1090
+ } else {
1091
+ this.questions.delete(run.id);
1092
+ }
1093
+ // R119. The banner going is not enough: a picker or a half-written
1094
+ // line is a MODE, and it sits there over a question that has already
1095
+ // been answered in the console — asking a person a second time for
1096
+ // something they have just done, and posting it into a refusal if
1097
+ // they oblige.
1098
+ this.answeredElsewhere(run.id, asked);
1099
+ // A run entering WAITING_ON_USER is the moment the question becomes
1100
+ // something to look at, and this poll is where that is noticed.
1101
+ this.announce();
1102
+ this.dirty = true;
1103
+ } catch {
1104
+ // A run this operator cannot read is not an error worth a banner.
1105
+ this.questions.delete(run.id);
1106
+ }
1107
+ // Outside the catch: a questions read that failed is no reason to stop
1108
+ // noticing that a permission request was decided somewhere else.
1109
+ await this.decidedElsewhere(run);
1110
+ }
1111
+ await new Promise((done) => setTimeout(done, 2500));
1112
+ }
1113
+ }
1114
+
1115
+ askingOn(run) {
1116
+ return run ? this.questions.get(run.id) ?? null : null;
1117
+ }
1118
+
1119
+ /**
1120
+ * Closes an answer being written to a question somebody has already answered
1121
+ * — R119.
1122
+ *
1123
+ * <p>The console and this terminal are two doors onto one question, and until
1124
+ * now only one of them noticed when it was closed. Answering in the browser
1125
+ * cleared the banner here and left the PICKER up, so the session everybody
1126
+ * could see had moved on was still, on this screen, waiting for a person who
1127
+ * had already answered it. Pressing enter then posted into a refusal.
1128
+ *
1129
+ * <p>Says WHO answered it where it can. "Answered" reads as something this
1130
+ * terminal did; a name is the difference between a thing that happened and a
1131
+ * thing that happened to you.
1132
+ *
1133
+ * <p>Only ever closes an ANSWER. A permission request and a prompt are
1134
+ * different questions with different lives, and a poll that tidied those away
1135
+ * would be closing boxes nobody asked it to touch.
1136
+ *
1137
+ * <p>And what decides that is the box that is OPEN, never the claim held
1138
+ * beside it. The claim says WHICH question is being answered; it does not say
1139
+ * that anything is still on screen, and it outlives its box every time
1140
+ * somebody escapes one.
1141
+ */
1142
+ answeredElsewhere(runId, asked) {
1143
+ const open = this.answering;
1144
+ if (!open || open.runId !== runId) {
1145
+ return false;
1146
+ }
1147
+ const question = (asked ?? []).find((each) => each.id === open.questionId);
1148
+ // Still open, still worth answering. A question that has VANISHED — the run
1149
+ // gone, the list unreadable — is treated as answered rather than left on
1150
+ // screen for ever, because the one thing that cannot be right is a terminal
1151
+ // insisting on an answer to something it can no longer find.
1152
+ if (question && !question.answered) {
1153
+ return false;
1154
+ }
1155
+ this.answering = null;
1156
+ // The claim is stale on every way out that is not an answer — esc from the
1157
+ // picker, a run taken off `L`, a POST that failed — so a poll that trusted
1158
+ // it closed whatever happened to be open INSTEAD: a permission request, a
1159
+ // command half typed, the run list, all of them over the words "answered
1160
+ // elsewhere". That is the one thing the paragraph above promises this
1161
+ // cannot do, which makes it a thing to check rather than to promise.
1162
+ const onScreen = this.select?.kind === 'question' || this.input?.kind === 'answer';
1163
+ if (!onScreen) {
1164
+ return false;
1165
+ }
1166
+ this.mode = 'keys';
1167
+ this.select = null;
1168
+ this.input = null;
1169
+ this.history.reset();
1170
+ this.dirty = true;
1171
+ const by = question?.answeredByEmail;
1172
+ this.note(by && by !== this.session.email
1173
+ ? `answered by ${by} — closing this`
1174
+ : 'answered elsewhere — closing this');
1175
+ return true;
1176
+ }
1177
+
1178
+ /**
1179
+ * Closes a permission request somebody has already decided in the console —
1180
+ * R137. {@link answeredElsewhere}'s other half.
1181
+ *
1182
+ * <p>A session stops on two things and until now only one of them noticed
1183
+ * when it was let go. Deciding in the browser left the picker up here, and
1184
+ * choosing a row under it posted into a 409 — *"that request was already
1185
+ * settled: allowed"* — about the very request drawn on screen. The banner
1186
+ * outlived the decision too: the inbox long-poll returns early when something
1187
+ * <em>is</em> pending and never when something stops being, so the answer
1188
+ * that would have cleared it was asleep for up to twenty seconds.
1189
+ *
1190
+ * <p>Judged by the RECORD, not by absence from the inbox. The inbox is per
1191
+ * person and per visibility, so "not in mine" is not "decided" — it is also
1192
+ * what a colleague being asked instead looks like. A record that has VANISHED
1193
+ * counts as settled, for R119's reason: the one thing that cannot be right is
1194
+ * a terminal insisting on a decision about something it can no longer find.
1195
+ *
1196
+ * <p>And what closes the box is the box that is OPEN, never the claim held
1197
+ * beside it — `deciding` says WHICH request, and it outlives its picker every
1198
+ * time somebody escapes one. Mode-independent, because through a pipe there
1199
+ * is no mode to be in: {@link openPicker} leaves it at `keys`.
1200
+ */
1201
+ async decidedElsewhere(run) {
1202
+ const pending = this.approvals.get(run.id);
1203
+ // Nothing believed pending is not news about anything.
1204
+ if (!pending) {
1205
+ return false;
1206
+ }
1207
+ let seen;
1208
+ try {
1209
+ seen = await this.session.request(
1210
+ `/api/projects/${run.projectSlug}/runs/${run.id}/approvals`,
1211
+ );
1212
+ } catch {
1213
+ // A read that failed says nothing about the request, so it changes
1214
+ // nothing about it: closing a picker on a network blip would be this
1215
+ // terminal inventing a decision nobody made.
1216
+ return false;
1217
+ }
1218
+ const record = (Array.isArray(seen) ? seen : [])
1219
+ .find((each) => each.id === pending.approval.id);
1220
+ // Still stopped on a person, which is exactly what the picker is for.
1221
+ if (record?.pending) {
1222
+ return false;
1223
+ }
1224
+
1225
+ this.settled.add(pending.approval.id);
1226
+ this.approvals.delete(run.id);
1227
+ this.dirty = true;
1228
+
1229
+ const mine = this.deciding?.approvalId === pending.approval.id;
1230
+ const onScreen = this.select?.kind === 'permission' || this.input?.kind === 'reason';
1231
+ if (mine && onScreen) {
1232
+ this.mode = 'keys';
1233
+ this.select = null;
1234
+ this.input = null;
1235
+ this.history.reset();
1236
+ this.deciding = null;
1237
+ }
1238
+
1239
+ const by = record?.decidedByEmail;
1240
+ const verb = record?.state === 'DENIED' ? 'refused'
1241
+ : record?.state === 'ALLOWED' ? 'allowed'
1242
+ : 'decided';
1243
+ // A name where there is one: "allowed" reads as something this terminal
1244
+ // did, and a name is the difference between a thing that happened and a
1245
+ // thing that happened to you. Nobody at all is its own sentence — EXPIRED
1246
+ // is not a tidier DENIED.
1247
+ const message = record?.state === 'EXPIRED'
1248
+ ? 'nobody came — this request expired'
1249
+ : `${verb}${by && by !== this.session.email ? ` by ${by}` : ' elsewhere'}`
1250
+ + ' — closing this';
1251
+ this.note(message);
1252
+ // And into the scrollback as well, under the request `announce` printed
1253
+ // there: through a pipe there is no status line to read this off, so R119's
1254
+ // half of it is invisible today.
1255
+ this.say(this.ink.muted(message));
1256
+ return true;
1257
+ }
1258
+
1259
+ // --- keys ------------------------------------------------------------------
1260
+
1261
+ onKey(key) {
1262
+ // A paste is one key and never a command: `\r` inside it is a newline
1263
+ // somebody copied, not enter. See input.mjs.
1264
+ if (typeof key === 'object' && key.paste !== undefined) {
1265
+ return this.onPaste(key.paste);
1266
+ }
1267
+ // Ctrl+C is the same key everywhere, and it is answered before anything
1268
+ // else has a chance to interpret it.
1269
+ if (key === '\x03') {
1270
+ return this.onInterrupt();
1271
+ }
1272
+ this.interrupting = false;
1273
+
1274
+ if (this.mode === 'typing') {
1275
+ return this.onTyping(key);
1276
+ }
1277
+ if (this.mode === 'select') {
1278
+ return this.onSelectKey(key);
1279
+ }
1280
+ // Any other key means "no". A confirmation that outlives the moment is one
1281
+ // somebody answers by accident three keystrokes later.
1282
+ if (key !== 'x') {
1283
+ this.confirming = null;
1284
+ }
1285
+
1286
+ switch (key) {
1287
+ case 'q':
1288
+ return this.quit();
1289
+ case '\r':
1290
+ case '\n':
1291
+ case 'i': {
1292
+ // R78: a session stopped on a question cannot read a prompt — it is
1293
+ // blocked inside `ask_user`, and the API refuses one. Said here so
1294
+ // nobody types a paragraph first and is told afterwards; `a` is where
1295
+ // those words belong, and the note points at it.
1296
+ const stopped = this.askingOn(this.current());
1297
+ if (stopped) {
1298
+ return this.note(stopped.yours
1299
+ ? 'stopped on a question — press a to answer it, a prompt will not'
1300
+ : `stopped on a question, waiting on ${stopped.waitingOn}`);
1301
+ }
1302
+ return this.type('prompt', 'prompt ▸', '');
1303
+ }
1304
+ case '/':
1305
+ return this.type('command', 'cawdev ▸', '/');
1306
+ case 'l':
1307
+ case 'L':
1308
+ return this.openList();
1309
+ case 'g':
1310
+ this.showLog = !this.showLog;
1311
+ return this.note(this.showLog ? "printing the daemon's log" : 'printing the session');
1312
+ case 'a': {
1313
+ // R58: the refusal is here rather than at the API, so nobody types an
1314
+ // answer into a session that is not going to take it.
1315
+ const asking = this.askingOn(this.current());
1316
+ if (!asking) {
1317
+ return this.note('nothing is waiting for an answer on this one');
1318
+ }
1319
+ if (!asking.yours) {
1320
+ return this.note(`waiting on ${asking.waitingOn} — not yours to answer`);
1321
+ }
1322
+ if (!this.requireSignIn('answer a question')) return undefined;
1323
+ return this.askTheQuestion(asking);
1324
+ }
1325
+ case 'y':
1326
+ case 's':
1327
+ case 'Y':
1328
+ case 'M':
1329
+ return void this.allow(key);
1330
+ case 'n': {
1331
+ if (!this.pendingOn(this.current())) {
1332
+ return this.note('nothing is waiting for permission on this one');
1333
+ }
1334
+ if (!this.requireSignIn('refuse a request')) return undefined;
1335
+ // The other door onto the same claim: `n` opens the line without ever
1336
+ // opening the picker — R137.
1337
+ this.deciding = { runId: this.current()?.id ?? null,
1338
+ approvalId: this.pendingOn(this.current()).approval.id };
1339
+ return this.type('reason', 'refuse, because ▸', '');
1340
+ }
1341
+ case 'x':
1342
+ return void this.cancel();
1343
+ default:
1344
+ if (/^[1-9]$/.test(key)) {
1345
+ const at = Number(key) - 1;
1346
+ if (at < this.runs.length) {
1347
+ this.watch(this.runs[at].id);
1348
+ }
1349
+ return this.note('');
1350
+ }
1351
+ return undefined;
1352
+ }
1353
+ }
1354
+
1355
+ /**
1356
+ * Ctrl+C — once says press again, twice leaves.
1357
+ *
1358
+ * **The first press also closes whatever is open**, which is the difference
1359
+ * between this and Esc: Esc steps back one level, so a free-text answer
1360
+ * returns to the list it came from; Ctrl+C is "stop all of this", and having
1361
+ * pressed it once nobody wants to press it three more times to get out of a
1362
+ * picker they opened by accident.
1363
+ *
1364
+ * The way out is `quit`'s, and since R123 that takes the daemon with it — so
1365
+ * where there is work the second press asks again rather than leaving, and a
1366
+ * third is what actually goes.
1367
+ */
1368
+ onInterrupt() {
1369
+ const closed = this.closeEverything();
1370
+ if (this.interrupting) {
1371
+ return this.quit();
1372
+ }
1373
+ this.interrupting = true;
1374
+ return this.note(closed
1375
+ ? 'cancelled — ctrl+c again to quit'
1376
+ : 'press ctrl+c again to quit, or q');
1377
+ }
1378
+
1379
+ /** Everything open, closed. Returns whether there was anything. */
1380
+ closeEverything() {
1381
+ const open = this.mode !== 'keys';
1382
+ this.mode = 'keys';
1383
+ this.select = null;
1384
+ this.input = null;
1385
+ this.answering = null;
1386
+ this.deciding = null;
1387
+ this.history.reset();
1388
+ this.dirty = true;
1389
+ return open;
1390
+ }
1391
+
1392
+ /**
1393
+ * A pasted blob, which is one thing however many lines it has — R83.
1394
+ *
1395
+ * It goes into the line being typed, and if it has more than one line it goes
1396
+ * in as a placeholder that SAYS what it is. Four hundred lines of somebody's
1397
+ * stack trace scrolling past would bury the transcript this program exists to
1398
+ * keep, and it is sent in full either way.
1399
+ *
1400
+ * A paste with nothing open opens a prompt, because that is plainly what
1401
+ * somebody pasting into this window meant.
1402
+ */
1403
+ onPaste(text) {
1404
+ if (!text) {
1405
+ return undefined;
1406
+ }
1407
+ if (this.mode !== 'typing') {
1408
+ if (this.mode === 'select') {
1409
+ // A paste is not a choice. Leaving the picker open and dropping it
1410
+ // would look like the terminal ignoring a paste.
1411
+ return this.note('a paste is not a choice — esc first, or pick a row');
1412
+ }
1413
+ this.type('prompt', 'prompt ▸', '');
1414
+ if (this.mode !== 'typing') {
1415
+ return undefined; // Not signed in; `type` has already said so.
1416
+ }
1417
+ }
1418
+ this.input.line.insert(text.includes('\n') ? this.pastes.hold(text) : text);
1419
+ this.dirty = true;
1420
+ return undefined;
1421
+ }
1422
+
1423
+ /**
1424
+ * Open the line editor.
1425
+ *
1426
+ * @param back the picker to return to on Esc. R83's rule: Esc from "write my
1427
+ * own answer" goes back to the list rather than abandoning the answer, so
1428
+ * changing your mind about writing prose costs one key and not the question.
1429
+ */
1430
+ type(kind, label, start, back = null) {
1431
+ if (kind === 'prompt' && !this.requireSignIn('prompt a session')) {
1432
+ return undefined;
1433
+ }
1434
+ this.mode = 'typing';
1435
+ this.select = null;
1436
+ this.input = { kind, label, line: new Line(start), back };
1437
+ this.history.reset();
1438
+ return this.note('');
1439
+ }
1440
+
1441
+ /** The commands still matching what is typed, and which one is highlighted. */
1442
+ matching() {
1443
+ if (this.mode !== 'typing' || this.input.kind !== 'command') {
1444
+ return { rows: [], at: 0 };
1445
+ }
1446
+ const rows = completions(this.input.line.text, COMMANDS);
1447
+ return { rows, at: Math.min(this.input.at ?? 0, Math.max(0, rows.length - 1)) };
1448
+ }
1449
+
1450
+ onTyping(key) {
1451
+ const { line } = this.input;
1452
+ const { rows, at } = this.matching();
1453
+
1454
+ if (key === ESC) {
1455
+ // One level at a time. The completion list first, because it is the
1456
+ // thing most recently in the way; then the line, back to whatever opened
1457
+ // it — which for a free-text answer is the list of options.
1458
+ if (rows.length && this.input.kind === 'command' && this.input.showing !== false) {
1459
+ this.input.showing = false;
1460
+ return this.note('');
1461
+ }
1462
+ const back = this.input.back;
1463
+ this.input = null;
1464
+ this.history.reset();
1465
+ if (back) {
1466
+ // Back to the options, still answering them.
1467
+ return this.reopen(back);
1468
+ }
1469
+ this.mode = 'keys';
1470
+ this.answering = null;
1471
+ this.deciding = null;
1472
+ return this.note('cancelled');
1473
+ }
1474
+
1475
+ if (key === '\r' || key === '\n') {
1476
+ // With a completion list open, enter takes the highlighted command —
1477
+ // which is what makes guessing a name stop being a step. With none, it
1478
+ // sends what was typed.
1479
+ if (rows.length && this.input.showing !== false && this.input.kind === 'command') {
1480
+ line.set(rows[at][0]);
1481
+ }
1482
+ return this.submit();
1483
+ }
1484
+
1485
+ if (key === '\t') {
1486
+ if (rows.length) {
1487
+ // As far as they agree, which is what every shell does and nobody has
1488
+ // to be taught. One match completes it whole.
1489
+ line.set(commonPrefix(rows.map(([name]) => name)));
1490
+ this.input.showing = true;
1491
+ this.dirty = true;
1492
+ }
1493
+ return undefined;
1494
+ }
1495
+
1496
+ if (key === `${ESC}[A` || key === `${ESC}OA` || key === '\x10') {
1497
+ if (rows.length && this.input.showing !== false) {
1498
+ this.input.at = (at - 1 + rows.length) % rows.length;
1499
+ this.dirty = true;
1500
+ return undefined;
1501
+ }
1502
+ const older = this.history.back(line.text);
1503
+ if (older !== null) {
1504
+ line.set(older);
1505
+ this.dirty = true;
1506
+ }
1507
+ return undefined;
1508
+ }
1509
+ if (key === `${ESC}[B` || key === `${ESC}OB` || key === '\x0e') {
1510
+ if (rows.length && this.input.showing !== false) {
1511
+ this.input.at = (at + 1) % rows.length;
1512
+ this.dirty = true;
1513
+ return undefined;
1514
+ }
1515
+ const newer = this.history.forward();
1516
+ if (newer !== null) {
1517
+ line.set(newer);
1518
+ this.dirty = true;
1519
+ }
1520
+ return undefined;
1521
+ }
1522
+
1523
+ if (key === '\x7f' || key === '\b') {
1524
+ line.backspace();
1525
+ } else if (key === `${ESC}[3~`) {
1526
+ line.forwardDelete();
1527
+ } else if (key === `${ESC}[D` || key === `${ESC}OD` || key === '\x02') {
1528
+ line.left();
1529
+ } else if (key === `${ESC}[C` || key === `${ESC}OC` || key === '\x06') {
1530
+ line.right();
1531
+ } else if (key === `${ESC}[H` || key === `${ESC}OH` || key === '\x01') {
1532
+ line.home();
1533
+ } else if (key === `${ESC}[F` || key === `${ESC}OF` || key === '\x05') {
1534
+ line.end();
1535
+ } else if (key === '\x15') {
1536
+ line.killToStart();
1537
+ } else if (key === '\x17') {
1538
+ line.killWord();
1539
+ } else if (!key.startsWith(ESC)) {
1540
+ // Printable only: an arrow key inside a prompt should not become "[A".
1541
+ line.insert(key);
1542
+ // Typing again re-opens a completion list Esc closed, because the list is
1543
+ // about what is on the line RIGHT NOW.
1544
+ this.input.showing = true;
1545
+ this.input.at = 0;
1546
+ } else {
1547
+ return undefined;
1548
+ }
1549
+ this.dirty = true;
1550
+ return undefined;
1551
+ }
1552
+
1553
+ /** Enter, on whatever was being typed. */
1554
+ submit() {
1555
+ const typed = this.input.line.text.trim();
1556
+ const kind = this.input.kind;
1557
+ const back = this.input.back;
1558
+ this.input = null;
1559
+ this.mode = 'keys';
1560
+ this.history.reset();
1561
+
1562
+ if (!typed || typed === '/') {
1563
+ // Nothing typed is not an answer, and a question that was open is still
1564
+ // open — so an empty line goes back to it rather than dropping it.
1565
+ return back ? this.reopen(back) : this.note('');
1566
+ }
1567
+ // What was typed, remembered as typed: a placeholder rather than the four
1568
+ // hundred lines behind it.
1569
+ const remembered = this.remember(typed);
1570
+ const text = this.pastes.expand(typed);
1571
+
1572
+ if (kind === 'command' || (kind === 'prompt' && typed.startsWith('/'))) {
1573
+ // After the write, not beside it: `/logout` forgets the history, and a
1574
+ // write still in flight would put the command that cleared it back.
1575
+ return void remembered.then(() => this.runCommand(typed));
1576
+ }
1577
+ if (kind === 'prompt') {
1578
+ return void this.send(text);
1579
+ }
1580
+ return kind === 'answer'
1581
+ ? void this.answer(text)
1582
+ : void this.decide({ allow: false, reason: text }, 'refused');
1583
+ }
1584
+
1585
+ /** Up-arrow's memory, here and next launch — see history.mjs. */
1586
+ async remember(typed) {
1587
+ this.history.add(typed);
1588
+ await pushHistory(this.session.url, typed).catch(() => undefined);
1589
+ }
1590
+
1591
+ // --- the one select widget -------------------------------------------------
1592
+
1593
+ /**
1594
+ * Open a picker, or — where there is no cursor to move — print it and wait
1595
+ * for a line.
1596
+ *
1597
+ * The two paths take the same {@link Select}, which is the point: a numbered
1598
+ * list read from stdin is the same rows in the same order, so there is one
1599
+ * place where "what can be chosen here" is decided.
1600
+ */
1601
+ openPicker(select) {
1602
+ if (this.plain) {
1603
+ this.select = select;
1604
+ for (const line of plainLines(select)) {
1605
+ this.say(line);
1606
+ }
1607
+ return undefined;
1608
+ }
1609
+ this.mode = 'select';
1610
+ this.select = select;
1611
+ this.input = null;
1612
+ return this.note('');
1613
+ }
1614
+
1615
+ /** Back to a picker that was left for the line editor. */
1616
+ reopen(select) {
1617
+ return this.openPicker(select);
1618
+ }
1619
+
1620
+ onSelectKey(key) {
1621
+ const select = this.select;
1622
+ // `q` closes the run list, which is what it did before R83. It is not
1623
+ // offered on a question or a permission request: there `q` could be the
1624
+ // first letter of an answer somebody is about to write.
1625
+ if (key === 'q' && select.kind === 'runs') {
1626
+ this.mode = 'keys';
1627
+ this.select = null;
1628
+ return this.note('');
1629
+ }
1630
+
1631
+ const outcome = select.key(key);
1632
+ if (!outcome) {
1633
+ return undefined;
1634
+ }
1635
+ if (outcome.done === null) {
1636
+ this.dirty = true;
1637
+ return undefined;
1638
+ }
1639
+ if (outcome.done === 'cancelled') {
1640
+ this.mode = 'keys';
1641
+ this.select = null;
1642
+ // With the box goes the claim on the question it was answering — R119.
1643
+ // Nothing reads it while nothing is open, but a field that says an answer
1644
+ // is being written when none is is one the next reader will believe.
1645
+ this.answering = null;
1646
+ this.deciding = null;
1647
+ // Escape leaves without changing anything, which is the promise the key
1648
+ // makes everywhere else.
1649
+ return this.note('');
1650
+ }
1651
+ return this.chose(select, outcome.row);
1652
+ }
1653
+
1654
+ /**
1655
+ * A row was chosen, whichever way it was chosen.
1656
+ *
1657
+ * One place for it, so that a digit, an arrow-and-enter and a number typed at
1658
+ * a plain prompt cannot mean three different things.
1659
+ */
1660
+ chose(select, row, typed = null) {
1661
+ if (!row) {
1662
+ return this.note('');
1663
+ }
1664
+ this.mode = 'keys';
1665
+ this.select = null;
1666
+
1667
+ if (select.kind === 'runs') {
1668
+ this.watch(row.id);
1669
+ return undefined;
1670
+ }
1671
+ if (select.kind === 'question') {
1672
+ if (row.id === WRITE_MY_OWN) {
1673
+ // Free text, with the question still on screen and Esc back to the
1674
+ // list: the options are the agent's guess, and the value of asking a
1675
+ // person is that they can say the thing that was not on it.
1676
+ return typed
1677
+ ? void this.answer(typed)
1678
+ : this.type('answer', 'answer ▸', '', select);
1679
+ }
1680
+ return void this.answer(row.label);
1681
+ }
1682
+ if (select.kind === 'permission') {
1683
+ if (row.id === 'refuse') {
1684
+ return this.type('reason', 'refuse, because ▸', '', select);
1685
+ }
1686
+ return void this.allow(row.id);
1687
+ }
1688
+ return undefined;
1689
+ }
1690
+
1691
+ /**
1692
+ * A line typed where there is no cursor — the plain path's whole input.
1693
+ *
1694
+ * A number picks from whatever was printed; anything else is the free text a
1695
+ * question allows, a command, or a prompt. Same rows, same rules, no repaint.
1696
+ */
1697
+ onLine(text) {
1698
+ const typed = String(text).trim();
1699
+ if (this.select) {
1700
+ const picked = pickFromLine(this.select, typed);
1701
+ if (picked) {
1702
+ return this.chose(this.select, picked.row, picked.text ?? null);
1703
+ }
1704
+ if (!this.select.freeText) {
1705
+ for (const line of plainLines(this.select)) {
1706
+ this.say(line);
1707
+ }
1708
+ return undefined;
1709
+ }
1710
+ }
1711
+ if (!typed) {
1712
+ return undefined;
1713
+ }
1714
+ if (typed.startsWith('/')) {
1715
+ return void this.remember(typed).then(() => this.runCommand(typed));
1716
+ }
1717
+ void this.remember(typed);
1718
+ const asking = this.askingOn(this.current());
1719
+ if (asking?.yours) {
1720
+ // A session stopped on a question cannot read a prompt (R78), and the
1721
+ // words somebody typed here are plainly meant for it.
1722
+ return void this.answer(typed);
1723
+ }
1724
+ return void this.send(typed);
1725
+ }
1726
+
1727
+ // --- the three things there are to pick from -------------------------------
1728
+
1729
+ /**
1730
+ * `L` — every run this machine is driving, claiming or leaving queued.
1731
+ *
1732
+ * An overlay rather than a rail, because that is the trade R81 reversed: the
1733
+ * rail cost thirty columns on every line of every transcript to answer a
1734
+ * question asked a few times an hour. Since R83 it is the same widget as the
1735
+ * other two, which is how it stopped being its own key handler.
1736
+ */
1737
+ openList() {
1738
+ const ink = this.ink;
1739
+ return this.openPicker(new Select({
1740
+ kind: 'runs',
1741
+ title: 'sessions on this machine',
1742
+ empty: 'nothing running, claiming or queued here',
1743
+ at: Math.max(0, this.runs.findIndex((run) => run.id === this.watching)),
1744
+ rows: this.runs.map((run) => ({
1745
+ id: run.id,
1746
+ label: run.label,
1747
+ // `!` is a decision waiting; `?` is a question. Different marks because
1748
+ // they are answered with different keys, and since R58 the second may
1749
+ // not even be yours.
1750
+ marker: this.approvals.has(run.id)
1751
+ ? ink.warn('!')
1752
+ : this.questions.has(run.id) ? ink.accent('?') : ' ',
1753
+ // A run brings its own renderer: `runLine` narrows the LABEL and keeps
1754
+ // the reason a queued run is queued, which is not a rule a generic row
1755
+ // could guess.
1756
+ render: (opts, width, painted) => runLine(run, opts, width, painted),
1757
+ })),
1758
+ }));
1759
+ }
1760
+
1761
+ /**
1762
+ * The question this session stopped on, as something to pick from — R83.
1763
+ *
1764
+ * With no options there is nothing to pick, so it goes straight to the line
1765
+ * editor, which is what it has always done.
1766
+ */
1767
+ askTheQuestion(asking) {
1768
+ // What is being answered, so that the poll can tell whether this is still
1769
+ // worth answering — R119. The RUN as well as the question: a picker left
1770
+ // open while the operator switches to another session is still this
1771
+ // question's, and clearing it on somebody else's news would close a box
1772
+ // with an answer half-written in it.
1773
+ this.answering = { runId: asking.question.runId ?? this.current()?.id ?? null,
1774
+ questionId: asking.question.id };
1775
+ const options = asking.question.options ?? [];
1776
+ if (!options.length) {
1777
+ return this.type('answer', 'answer ▸', '');
1778
+ }
1779
+ return this.openPicker(new Select({
1780
+ kind: 'question',
1781
+ title: clip(asking.question.question, Math.max(20, this.screen.width - 2)),
1782
+ rows: [
1783
+ ...options.map((option) => ({ id: option, label: option })),
1784
+ // Always last, and always there.
1785
+ { id: WRITE_MY_OWN, label: 'Write my own answer', hint: 'opens a line to type on' },
1786
+ ],
1787
+ }));
1788
+ }
1789
+
1790
+ /**
1791
+ * A permission request, as R60's lengths of yes — R83.
1792
+ *
1793
+ * The tool and its arguments are PRINTED above the rows, not clipped into
1794
+ * them: you are deciding about something you can read, which is the whole
1795
+ * reason R51 records the call rather than the tool's name.
1796
+ */
1797
+ askPermission(pending) {
1798
+ const approval = pending.approval;
1799
+ // What is being decided, so that the poll can tell whether this is still
1800
+ // worth deciding — R137, and the RUN as well as the request for the reason
1801
+ // {@link askTheQuestion} carries one.
1802
+ this.deciding = { runId: pending.runId ?? this.current()?.id ?? null,
1803
+ approvalId: approval.id };
1804
+ const covers = approval.suggestion ?? `every ${approval.toolName}`;
1805
+ return this.openPicker(new Select({
1806
+ kind: 'permission',
1807
+ // The call, on the title, as well as printed in full above: the picker
1808
+ // may still be on screen when the transcript under it has moved on.
1809
+ title: `${approval.toolName} · ${approval.summary}`,
1810
+ rows: [
1811
+ { id: 'y', label: 'Allow once', hint: 'this call and no more' },
1812
+ // R78: the middle grant NAMES what it covers, and it says so in the
1813
+ // short wording too — "for this run" and "every Bash for this run" are
1814
+ // not the same promise, and a row that shortened to the first would be
1815
+ // describing one nobody made.
1816
+ {
1817
+ id: 's',
1818
+ label: `Allow ${covers} for the rest of this run`,
1819
+ short: `Allow ${covers} this run`,
1820
+ hint: 'dies with the session',
1821
+ },
1822
+ ...(approval.suggestion
1823
+ ? [{
1824
+ id: 'Y',
1825
+ label: `Always allow ${approval.suggestion} here`,
1826
+ short: `Always ${approval.suggestion}`,
1827
+ hint: 'a project rule',
1828
+ }]
1829
+ : []),
1830
+ { id: 'refuse', label: 'Refuse', hint: 'and say why' },
1831
+ ],
1832
+ }));
1833
+ }
1834
+
1835
+ /**
1836
+ * What a session is stopped on, said once, when it starts being stopped on it.
1837
+ *
1838
+ * **Printed into the transcript and then offered as a list.** The text goes
1839
+ * into the scrollback because it is what happened and it should still be there
1840
+ * when you scroll back to it; the choice goes into the live region because it
1841
+ * is what is happening now. A question that scrolls away leaves a picker
1842
+ * asking about nothing.
1843
+ *
1844
+ * Nothing opens over something somebody is already doing: with a picker or a
1845
+ * half-written prompt on screen the banner and its key are enough, and taking
1846
+ * the keyboard away mid-sentence is how a client loses somebody's paragraph.
1847
+ */
1848
+ announce() {
1849
+ const run = this.current();
1850
+ if (!run) return;
1851
+
1852
+ const pending = this.pendingOn(run);
1853
+ if (pending && !this.announced.has(pending.approval.id)) {
1854
+ this.announced.add(pending.approval.id);
1855
+ const ink = this.ink;
1856
+ this.say('');
1857
+ this.say(`${ink.bold(ink.warn(' permission '))} ${ink.text(pending.approval.toolName)}`);
1858
+ // Not clipped: this is the thing being decided about, and a command cut
1859
+ // at the width is a command you have not read.
1860
+ this.say(` ${ink.text(pending.approval.summary)}`);
1861
+ if (this.mode === 'keys') {
1862
+ this.askPermission(pending);
1863
+ }
1864
+ return;
1865
+ }
1866
+
1867
+ const asking = this.askingOn(run);
1868
+ if (!asking || this.announced.has(asking.question.id)) {
1869
+ return;
1870
+ }
1871
+ this.announced.add(asking.question.id);
1872
+ const ink = this.ink;
1873
+ this.say('');
1874
+ this.say(`${ink.bold(ink.accent(' question '))} ${ink.text(asking.question.question)}`);
1875
+ // R58: a watcher who does not own the question is told whose it is and is
1876
+ // offered nothing. A picker here would take an answer the platform then
1877
+ // refuses, which reads as cawdev being broken.
1878
+ if (!asking.yours) {
1879
+ this.say(` ${ink.muted(`waiting on ${asking.waitingOn ?? 'somebody else'}`)}`);
1880
+ return;
1881
+ }
1882
+ if (this.mode === 'keys' && this.session.signedIn && asking.question.options?.length) {
1883
+ this.askTheQuestion(asking);
1884
+ }
1885
+ }
1886
+
1887
+ overlayLines(width) {
1888
+ if (this.mode !== 'select' || !this.select) return [];
1889
+ // A third of the window, so a long list never becomes the screen. The
1890
+ // transcript underneath is what this program is for.
1891
+ return this.select.lines(width, this.ink, Math.max(3, Math.floor(this.screen.height / 3)));
1892
+ }
1893
+
1894
+ requireSignIn(what) {
1895
+ if (this.session.signedIn) {
1896
+ return true;
1897
+ }
1898
+ // The refusal names the rule rather than the symptom: this is not the
1899
+ // client being awkward, it is the platform refusing anything that is not a
1900
+ // person, and a message that says "403" would send somebody looking in the
1901
+ // wrong place.
1902
+ this.note(`sign in with /login to ${what} — a session may only be changed by a person`);
1903
+ return false;
1904
+ }
1905
+
1906
+ // --- slash commands ---------------------------------------------------------
1907
+
1908
+ async runCommand(text) {
1909
+ const [word, ...rest] = text.slice(1).split(/\s+/);
1910
+ switch (word) {
1911
+ case 'help':
1912
+ this.say('');
1913
+ this.say(this.ink.bold(' commands'));
1914
+ for (const [name, what] of COMMANDS) {
1915
+ this.say(` ${this.ink.accent(name.padEnd(9))} ${this.ink.muted(what)}`);
1916
+ }
1917
+ this.say('');
1918
+ this.say(this.ink.bold(' keys'));
1919
+ this.say(this.ink.muted(' enter prompt · / command · L runs · 1-9 pick a run'));
1920
+ this.say(this.ink.muted(' a answer · y/s/Y/n permission · x cancel · g log · q quit'));
1921
+ this.say(this.ink.muted(' in a list: ↑↓ move · 1-9 pick · enter choose · esc leave'));
1922
+ this.say(this.ink.muted(' while typing: ↑↓ history · tab complete · esc back · ctrl+c twice quits'));
1923
+ this.say('');
1924
+ return this.note('');
1925
+ case 'login':
1926
+ return this.signIn();
1927
+ case 'logout':
1928
+ await this.session.signOut();
1929
+ await clearSession(this.session.url);
1930
+ // The history goes with it. It is what this person typed, and "forget
1931
+ // the stored session on this machine" would be a strange promise to
1932
+ // keep half of.
1933
+ await clearHistory(this.session.url).catch(() => undefined);
1934
+ this.history = new History();
1935
+ return this.note('signed out — /login to sign in again');
1936
+ case 'runs':
1937
+ return this.openList();
1938
+ case 'cancel':
1939
+ return void this.cancel();
1940
+ case 'log':
1941
+ this.showLog = !this.showLog;
1942
+ return this.note(this.showLog ? "printing the daemon's log" : 'printing the session');
1943
+ case 'quit':
1944
+ case 'exit':
1945
+ return this.quit();
1946
+ default:
1947
+ return this.note(`no such command: /${word}${rest.length ? ' …' : ''} — try /help`);
1948
+ }
1949
+ }
1950
+
1951
+ /**
1952
+ * `/login`, from inside the UI.
1953
+ *
1954
+ * The screen is given back for the duration, because this prints a URL and a
1955
+ * code somebody has to read and possibly copy — and a URL that scrolls under
1956
+ * a footer three lines later is a URL nobody can click. The footer comes back
1957
+ * the moment it is decided.
1958
+ */
1959
+ async signIn() {
1960
+ // Everything queued goes out first and the footer comes down, so what
1961
+ // `runSignIn` prints with `console.log` lands under the transcript rather
1962
+ // than through the middle of a live region nothing is going to erase.
1963
+ this.say('');
1964
+ this.screen.update(this.pending, []);
1965
+ this.pending = [];
1966
+ const outcome = await runSignIn(this.session, this.ink)
1967
+ .catch((failure) => ({ signedIn: false, message: failure.message }));
1968
+ if (outcome.message) {
1969
+ this.say(this.ink.danger(` could not sign in: ${outcome.message}`));
1970
+ }
1971
+ if (this.session.signedIn) {
1972
+ void this.watchInbox();
1973
+ void this.watchQuestions();
1974
+ }
1975
+ this.dirty = true;
1976
+ return undefined;
1977
+ }
1978
+
1979
+ // --- the things a person can do ---------------------------------------------
1980
+
1981
+ async send(text) {
1982
+ const run = this.current();
1983
+ if (!run) return;
1984
+ try {
1985
+ await this.session.request(
1986
+ `/api/projects/${run.projectSlug}/runs/${run.id}/prompts`,
1987
+ { method: 'POST', body: { prompt: text } },
1988
+ );
1989
+ this.note('sent');
1990
+ } catch (failure) {
1991
+ this.note(failure.message);
1992
+ }
1993
+ }
1994
+
1995
+ /**
1996
+ * Answering the question this session stopped on — R58.
1997
+ *
1998
+ * The guard was already applied when `a` was pressed; this repeats nothing,
1999
+ * because a second copy of the rule here is a second place for it to drift.
2000
+ * If the API refuses anyway — the question was handed on while this was being
2001
+ * typed — its message names who it is waiting on, which is the right thing to
2002
+ * put on the status line.
2003
+ */
2004
+ async answer(text) {
2005
+ const run = this.current();
2006
+ const asking = this.askingOn(run);
2007
+ if (!asking) {
2008
+ return this.note('nothing is waiting for an answer on this one');
2009
+ }
2010
+ try {
2011
+ await this.session.request(
2012
+ `/api/projects/${run.projectSlug}/runs/${run.id}`
2013
+ + `/questions/${asking.question.id}/answer`,
2014
+ { method: 'POST', body: { answer: text } },
2015
+ );
2016
+ this.questions.delete(run.id);
2017
+ this.answering = null;
2018
+ this.note('answered');
2019
+ } catch (failure) {
2020
+ this.note(failure.message);
2021
+ }
2022
+ return undefined;
2023
+ }
2024
+
2025
+ /**
2026
+ * Allowing it, for how long — R60's three, from a key (R78).
2027
+ *
2028
+ * The scope travels with the decision rather than as a second call, the same
2029
+ * way the console sends it: "allow this and stop asking" is one act, and
2030
+ * splitting it gives you a client that can half-succeed.
2031
+ */
2032
+ async allow(key) {
2033
+ const pending = this.pendingOn(this.current());
2034
+ if (!pending) {
2035
+ return this.note('nothing is waiting for permission on this one');
2036
+ }
2037
+ const decision = permissionDecision(pending.approval, key, this.runner ?? {});
2038
+ if (!decision) {
2039
+ // Two ways to get here and they need different sentences. `Y` or `M` on a
2040
+ // compound command: no rule can be written and a decision with nothing to
2041
+ // remember would silently be an allow-once. `M` on a machine that does
2042
+ // not accept console rules: the rule could be written and would not be
2043
+ // applied, which is worth saying out loud rather than as "no rule".
2044
+ if (key === 'M' && pending.approval.suggestion) {
2045
+ return this.note('this machine does not take rules from the console — add '
2046
+ + '"acceptsRulesFromConsole": true to its config');
2047
+ }
2048
+ return this.note(
2049
+ 'no standing rule can be written for that one — s allows it for this session');
2050
+ }
2051
+ return this.decide(decision, decision.scope === 'PROJECT'
2052
+ ? `allowed, and ${pending.approval.suggestion} is now a project rule`
2053
+ : decision.scope === 'RUNNER'
2054
+ ? `allowed, and ${decision.pattern} is now allowed on this machine`
2055
+ : decision.scope === 'SESSION'
2056
+ ? `allowed ${decision.pattern} for the rest of this run`
2057
+ : 'allowed, once');
2058
+ }
2059
+
2060
+ /**
2061
+ * R51's decision, with R60's three reaches.
2062
+ *
2063
+ * `scope` rather than `remember`: the useful answer was the missing one —
2064
+ * somebody unblocking a session at 2am wants neither "ask me again in ninety
2065
+ * seconds" nor "decide policy for every agent that ever runs here".
2066
+ */
2067
+ async decide(decision, said) {
2068
+ const run = this.current();
2069
+ const pending = this.pendingOn(run);
2070
+ if (!pending) {
2071
+ return this.note('nothing is waiting for permission on this one');
2072
+ }
2073
+ if (!this.requireSignIn('answer a permission request')) {
2074
+ return undefined;
2075
+ }
2076
+ try {
2077
+ await this.session.request(
2078
+ `/api/projects/${pending.projectSlug}/runs/${pending.runId}` +
2079
+ `/approvals/${pending.approval.id}/decision`,
2080
+ { method: 'POST', body: decision },
2081
+ );
2082
+ // Settled, and remembered as settled: the inbox answers as soon as
2083
+ // anything is pending, so its next reply may well have been built before
2084
+ // this POST landed — and without this it would raise the banner again
2085
+ // over a request this terminal has just decided (R137).
2086
+ this.settled.add(pending.approval.id);
2087
+ this.approvals.delete(run.id);
2088
+ this.deciding = null;
2089
+ this.note(said);
2090
+ } catch (failure) {
2091
+ this.note(failure.message);
2092
+ }
2093
+ return undefined;
2094
+ }
2095
+
2096
+ async cancel() {
2097
+ const run = this.current();
2098
+ if (!run || run.state === 'queued') {
2099
+ return this.note('nothing running here to cancel');
2100
+ }
2101
+ if (!this.requireSignIn('cancel a session')) {
2102
+ return undefined;
2103
+ }
2104
+ if (this.confirming !== run.id) {
2105
+ // Two keys, because there is no undo and the transcript of a session you
2106
+ // killed by leaning on the keyboard is not much comfort.
2107
+ this.confirming = run.id;
2108
+ return this.note(`press x again to cancel "${run.label}"`);
2109
+ }
2110
+ this.confirming = null;
2111
+ try {
2112
+ await this.session.request(
2113
+ `/api/projects/${run.projectSlug}/runs/${run.id}/transition`,
2114
+ { method: 'POST', body: { state: 'CANCELLED', summary: 'Cancelled from the terminal.' } },
2115
+ );
2116
+ this.note('cancelling — the daemon will take the process down');
2117
+ } catch (failure) {
2118
+ this.note(failure.message);
2119
+ }
2120
+ return undefined;
2121
+ }
2122
+
2123
+ /**
2124
+ * Leaving, and taking the daemon with it — R123.
2125
+ *
2126
+ * <p>It used to leave one running. The argument was that `cawdev` starts a
2127
+ * daemon when it finds none, and a background process you did not know you
2128
+ * started should be paid for out loud rather than silently — so the goodbye
2129
+ * named the runner and the `kill` that stopped it.
2130
+ *
2131
+ * <p>That is the right sentence for the wrong default. Somebody who typed one
2132
+ * word to look at their machine has one window and one mental model, and what
2133
+ * they are told on the way out is a chore: a process still claiming work,
2134
+ * still holding a checkout, and a command to copy. Two windows later there
2135
+ * are two daemons and the one that answers is whichever started first.
2136
+ *
2137
+ * <p>So quitting stops it, both ways in: `--attach` always did, and now the
2138
+ * detached daemon gets a SIGINT — its own shutdown, which says goodbye to the
2139
+ * platform and takes its children down, rather than a kill that leaves the
2140
+ * platform believing this machine is still there. `--leave-running` is the
2141
+ * old behaviour for anybody who wants it, and the goodbye still names the
2142
+ * runner either way.
2143
+ *
2144
+ * <p>It asks twice while work is live, which is the one thing that has not
2145
+ * changed: the sessions go with it.
2146
+ */
2147
+ quit() {
2148
+ const stopping = this.stopsTheDaemon();
2149
+ const live = this.liveHere();
2150
+ if (stopping && live > 0 && !this.confirmQuit) {
2151
+ this.confirmQuit = true;
2152
+ return this.note(
2153
+ `${live} session${live === 1 ? '' : 's'} running here — press q again to stop the daemon too`,
2154
+ );
2155
+ }
2156
+
2157
+ this.stopped = true;
2158
+ try {
2159
+ this.client?.destroy();
2160
+ } catch {
2161
+ // Going anyway.
2162
+ }
2163
+ // Anything queued is still somebody's transcript. It goes out before the
2164
+ // footer comes down, not after it.
2165
+ if (this.pending.length) {
2166
+ this.screen.update(this.pending, []);
2167
+ this.pending = [];
2168
+ }
2169
+ this.screen.close();
2170
+ if (!this.plain && process.stdin.isTTY) {
2171
+ // Bracketed paste is the terminal's mode, not ours, and leaving it on
2172
+ // would put `ESC[200~` into the shell somebody pastes into next.
2173
+ process.stdout.write(`${ESC}[?2004l`);
2174
+ }
2175
+ process.stdin.setRawMode?.(false);
2176
+
2177
+ if (!this.options.onQuit) {
2178
+ for (const line of this.goodbye(stopping)) {
2179
+ console.log(line);
2180
+ }
2181
+ }
2182
+ this.finish?.();
2183
+
2184
+ if (this.options.onQuit) {
2185
+ // Hand back to the daemon's own shutdown: say goodbye to the platform,
2186
+ // take the children down, then exit. Exiting here would skip all three.
2187
+ return this.options.onQuit();
2188
+ }
2189
+ if (stopping) {
2190
+ // SIGINT rather than SIGKILL, and the daemon's own handler does the rest:
2191
+ // it says goodbye to the platform, so the console does not show a machine
2192
+ // that is still there, and takes its children down with it.
2193
+ this.stopDaemon(this.runner.pid);
2194
+ }
2195
+ return process.exit(0);
2196
+ }
2197
+
2198
+ /**
2199
+ * Whether leaving here ends the daemon — R123.
2200
+ *
2201
+ * <p>Three answers and they are all different questions. `--attach` means the
2202
+ * daemon is this process. `--leave-running` is somebody saying they want it
2203
+ * to outlive the window. Otherwise it is stopped, provided this client knows
2204
+ * WHICH process to stop: a daemon too old to send its pid on `hello` cannot
2205
+ * be signalled, and inventing one to kill is not a thing to guess at.
2206
+ */
2207
+ stopsTheDaemon() {
2208
+ if (this.options.onQuit) {
2209
+ return true;
2210
+ }
2211
+ if (this.options.leaveRunning) {
2212
+ return false;
2213
+ }
2214
+ return Boolean(this.runner?.pid);
2215
+ }
2216
+
2217
+ /** How much would go with it. The daemon's own count where there is one. */
2218
+ liveHere() {
2219
+ return this.options.liveSessions?.()
2220
+ ?? (this.runs ?? []).filter((run) => run.state !== 'queued').length;
2221
+ }
2222
+
2223
+ /** Injected so a test can watch for the signal instead of sending one. */
2224
+ stopDaemon(pid) {
2225
+ if (this.options.stopDaemon) {
2226
+ return this.options.stopDaemon(pid);
2227
+ }
2228
+ try {
2229
+ return process.kill(pid, 'SIGINT');
2230
+ } catch {
2231
+ // Already gone, which is where this was heading.
2232
+ return undefined;
2233
+ }
2234
+ }
2235
+
2236
+ goodbye(stopping = this.stopsTheDaemon()) {
2237
+ return farewell(this.runner, this.runs, this.ink, stopping);
2238
+ }
2239
+
2240
+ render(line) {
2241
+ const kind = line.kind ?? 'SYSTEM';
2242
+ const paint = kind === 'ERROR' ? this.ink.danger : kind === 'USER' ? this.ink.accent : null;
2243
+ // The body already carries the agent's own colour; ours goes in front and
2244
+ // is closed after, so a line that sets a colour and never resets it cannot
2245
+ // paint the rest of the terminal.
2246
+ return paint ? paint(line.body) : `${line.body}${ESC}[0m`;
2247
+ }
2248
+
2249
+ /**
2250
+ * The footer, or what stands in for it through a pipe.
2251
+ *
2252
+ * **A pipe has nothing to pin to**, so the live region stops existing and the
2253
+ * fixed answers are PRINTED instead — once, and again only when they change.
2254
+ * Repeating them per tick would drown the transcript, and dropping them
2255
+ * entirely would make the piped form the one place cawdev refuses to say
2256
+ * which platform it is talking to. The keys and the status line are left out
2257
+ * there on purpose: neither means anything without a keyboard.
2258
+ */
2259
+ footer() {
2260
+ const width = this.screen.width;
2261
+ const run = this.current();
2262
+ const pending = this.pendingOn(run);
2263
+ const state = {
2264
+ runner: this.runner,
2265
+ runs: this.runs,
2266
+ email: this.session.email,
2267
+ watching: run,
2268
+ connected: this.connected,
2269
+ // R83's status line is about what this machine is DRIVING, which is not
2270
+ // always what you are watching: a queued run prints nothing and has no
2271
+ // clock, and the answer to "is anything still going" should not depend on
2272
+ // which row you last opened.
2273
+ running: run?.state === 'running' || run?.state === 'claiming'
2274
+ ? run
2275
+ : this.runs.find((each) => each.state === 'running') ?? null,
2276
+ // A permission request wins when there is one: it is the narrower thing
2277
+ // and the one with three keys behind it. Otherwise a question — and
2278
+ // since R58 that banner has two shapes.
2279
+ //
2280
+ // **Unless its own picker is open**, in which case the banner is the same
2281
+ // choice written twice, one row above itself: three keys under a list of
2282
+ // the same three. At forty columns that was six rows of footer over a
2283
+ // transcript this program exists to show. The picker's title carries the
2284
+ // call, and the whole of it was printed above.
2285
+ banner: this.select && this.select.kind !== 'runs' ? [] : pending
2286
+ ? permissionBanner(pending, width, this.ink, this.runner ?? {})
2287
+ : questionBanner(this.askingOn(run), width, this.ink),
2288
+ };
2289
+
2290
+ if (this.screen.tty) {
2291
+ return footerLines({
2292
+ ...state,
2293
+ overlay: this.overlayLines(width),
2294
+ matches: this.input?.showing === false ? { rows: [], at: 0 } : this.matching(),
2295
+ input: this.mode === 'typing' ? {
2296
+ label: this.input.label,
2297
+ // The width the line has left, so the caret stays on screen when the
2298
+ // text is longer than the terminal — see Line.window.
2299
+ text: this.input.line.render(
2300
+ Math.max(8, width - visibleWidth(this.input.label) - 3), this.ink,
2301
+ ),
2302
+ } : null,
2303
+ keys: this.keys(),
2304
+ status: this.status,
2305
+ }, width, this.ink);
2306
+ }
2307
+
2308
+ // A pipe: nothing is pinned, so the fixed answers are COMMITTED instead —
2309
+ // once, and again only when they change.
2310
+ const lines = footerLines(state, width, this.ink);
2311
+ const said = stripAnsi(lines.join('\n'));
2312
+ if (said !== this.lastSaid) {
2313
+ this.lastSaid = said;
2314
+ for (const line of lines) {
2315
+ this.pending.push(line);
2316
+ }
2317
+ }
2318
+ return [];
2319
+ }
2320
+
2321
+ /**
2322
+ * What you can press right now, which is not the same list at all times.
2323
+ *
2324
+ * Ordered by what survives a narrow terminal: {@link keyList} drops from the
2325
+ * right, so the two that are always true come first and the ones that depend
2326
+ * on what a session is doing come after — those already have a banner above
2327
+ * them saying the same thing.
2328
+ */
2329
+ keys() {
2330
+ const ink = this.ink;
2331
+ if (this.mode === 'select') {
2332
+ // The widget draws its own key row, so this one would be a second copy of
2333
+ // it under the first.
2334
+ return [];
2335
+ }
2336
+ const parts = [
2337
+ `${ink.text('enter')} ${ink.muted('prompt')}`,
2338
+ `${ink.text('/')} ${ink.muted('commands')}`,
2339
+ `${ink.text('L')} ${ink.muted('runs')}`,
2340
+ ];
2341
+ if (this.askingOn(this.current())?.yours) {
2342
+ parts.push(`${ink.success('a')} ${ink.muted('answer')}`);
2343
+ }
2344
+ const pending = this.pendingOn(this.current());
2345
+ if (pending) {
2346
+ // `Y` is offered only when the server has a rule to write; a key that
2347
+ // would be refused is worse than one that is not there.
2348
+ parts.push(`${ink.warn(pending.approval?.suggestion ? 'y/s/Y/n' : 'y/s/n')} `
2349
+ + `${ink.muted('permission')}`);
2350
+ }
2351
+ parts.push(`${ink.text('x')} ${ink.muted('cancel')}`);
2352
+ parts.push(`${ink.text('q')} ${ink.muted(this.stopsTheDaemon() ? 'stop' : 'quit')}`);
2353
+ return parts;
2354
+ }
2355
+ }
2356
+
2357
+ /**
2358
+ * What is said on the way out — R81, and a function so it can be read without
2359
+ * quitting anything.
2360
+ *
2361
+ * Two shapes since R123, because there are two ways to leave. Stopping it says
2362
+ * what went with it and how to have it not, which is where somebody who wanted
2363
+ * the machine left running finds that out. Leaving it running is R81's sentence
2364
+ * unchanged — "a background process you did not know you started is the cost of
2365
+ * this choice and it should be paid out loud" — so it names the runner, what it
2366
+ * is still driving, and the exact command that stops it.
2367
+ */
2368
+ export function farewell(runner, runs, ink = painter(3), stopping = false) {
2369
+ const name = runner?.name ?? 'the runner';
2370
+ const busy = (runs ?? []).filter((run) => run.state !== 'queued').length;
2371
+ if (stopping) {
2372
+ // R123. What went with it, and how to have it not: a person who wanted the
2373
+ // machine left running finds that out here rather than from a run that is
2374
+ // no longer there.
2375
+ const took = busy
2376
+ ? `stopped, and ${busy} session${busy === 1 ? '' : 's'} with it`
2377
+ : 'stopped';
2378
+ return [
2379
+ '',
2380
+ ` ${ink.bold(name)} ${ink.muted(`${took}.`)}`,
2381
+ ` ${ink.muted('Leave it running next time with:')} ${ink.text('cawdev --leave-running')}`,
2382
+ '',
2383
+ ];
2384
+ }
2385
+ const doing = busy
2386
+ ? `still driving ${busy} session${busy === 1 ? '' : 's'}`
2387
+ : 'still claiming work';
2388
+ const stop = runner?.pid
2389
+ ? ` Stop it with: ${ink.text(`kill ${runner.pid}`)}`
2390
+ : ` Stop it by ending the process serving ${name}.`;
2391
+ return [
2392
+ '',
2393
+ ` ${ink.bold(name)} ${ink.muted(`is ${doing} on this machine, and keeps going.`)}`,
2394
+ stop,
2395
+ '',
2396
+ ];
2397
+ }