cawdev-cli 0.9.0 → 1.0.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/runner/attach.mjs CHANGED
@@ -45,8 +45,17 @@
45
45
  // Zero dependencies, so this is ANSI escapes and `setRawMode` rather than a
46
46
  // curses library.
47
47
 
48
+ import { spawn } from 'node:child_process';
48
49
  import { connect } from 'node:net';
50
+ import { basename, dirname } from 'node:path';
49
51
  import { listSockets, socketPathFor } from './control.mjs';
52
+ import {
53
+ addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
54
+ setAcceptsRulesFromConsole, setAgentEnabled, slugsOf,
55
+ } from './configure.mjs';
56
+ import {
57
+ TYPE_A_PATH, absolute, completePath, describe, inspectPath, listDirectories,
58
+ } from './paths.mjs';
50
59
  import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
51
60
  import { oneLine } from './brand.mjs';
52
61
  import { Scrollback } from './scrollback.mjs';
@@ -531,6 +540,10 @@ export function footerLines(state, width, ink = painter(3)) {
531
540
  // both: a key list under a half-typed prompt is a list of keys that would
532
541
  // land in the prompt.
533
542
  if (input) {
543
+ if (input.preview) {
544
+ // R289: what the path on the line IS, above the line being typed.
545
+ lines.push(padVisible(clip(` ${ink.muted(input.preview)}`, width), width));
546
+ }
534
547
  lines.push(padVisible(clip(` ${ink.accent(input.label)} ${input.text}`, width), width));
535
548
  } else if (keys.length || status) {
536
549
  lines.push(padVisible(clip(` ${keyList(keys, status, width - 1, ink)}`, width), width));
@@ -645,12 +658,67 @@ async function runSignIn(session, ink = painter()) {
645
658
  return outcome;
646
659
  }
647
660
 
661
+ /** What Esc throws out of a config step — R288. Compared by identity, never caught by message. */
662
+ export const CANCELLED = Symbol('cancelled');
663
+
664
+ /**
665
+ * A configure.mjs prompt, split into what is PRINTED and what the footer keeps
666
+ * — R289. `confirm` asks "…long sentence… [Y/n] ", and a footer line that
667
+ * clips at the width would cut the sentence and keep the brackets. So anything
668
+ * longer than a label is said in the transcript and the label is the trailing
669
+ * `[default]` with the caret, or just the caret. A short prompt stays whole.
670
+ */
671
+ export function splitPrompt(prompt, limit = 40) {
672
+ const text = String(prompt ?? '').trim();
673
+ const tail = /\s*(\[[^\]]*\])$/.exec(text);
674
+ const bracket = tail ? tail[1] : '';
675
+ const question = tail ? text.slice(0, tail.index).trim() : text;
676
+ if (text.length <= limit) {
677
+ return { said: '', label: `${text} ▸` };
678
+ }
679
+ return { said: question, label: `${bracket ? `${bracket} ` : ''}▸` };
680
+ }
681
+
682
+ /** The agents the config screen offers a switch for, whether or not they are on. */
683
+ const KNOWN_AGENTS = ['claude', 'agy'];
684
+
685
+ /**
686
+ * The config screen's rows, with the state of each thing on the row — R288.
687
+ *
688
+ * Pure, so the wording can be read in a test: "Disable claude" when claude is
689
+ * on and "Enable claude" when it is not, because a row that always said
690
+ * "enable" would ask the person to remember what the file says. The hint is
691
+ * where the current value lives; the label is the change.
692
+ */
693
+ export function configMenu(file, runner = {}) {
694
+ const slugs = slugsOf(file);
695
+ const agents = agentsOf(file);
696
+ const rulesOn = file.acceptsRulesFromConsole === true;
697
+ const checkouts = slugs.map((slug) => `${slug}: ${pathsOf(file.projects[slug]).length}`).join(', ');
698
+ return new Select({
699
+ kind: 'config',
700
+ title: `this machine — ${runner.name ?? file.name ?? 'cawdev'}`,
701
+ rows: [
702
+ { id: 'add-project', label: 'Add a project', hint: slugs.length ? `serves ${slugs.join(', ')}` : 'serves nothing yet' },
703
+ { id: 'add-workspace', label: 'Add a workspace to a project', hint: checkouts || 'no projects to add one to' },
704
+ ...[...new Set([...KNOWN_AGENTS, ...agents])].map((command) => (agents.includes(command)
705
+ ? { id: `agent:${command}`, label: `Disable ${command}`, hint: 'spawned here now' }
706
+ : { id: `agent:${command}`, label: `Enable ${command}`, hint: 'not spawned here' })),
707
+ rulesOn
708
+ ? { id: 'rules', label: 'Stop accepting permission rules from the console', hint: 'on now' }
709
+ : { id: 'rules', label: 'Accept permission rules from the console', hint: 'off now' },
710
+ { id: 'show', label: "Show this machine's config", hint: runner.configPath ?? '' },
711
+ ],
712
+ });
713
+ }
714
+
648
715
  /** The slash commands, and the one place they are described. */
649
716
  const COMMANDS = [
650
717
  ['/help', 'this list'],
651
718
  ['/login', 'sign in through the browser'],
652
719
  ['/logout', 'forget the stored session on this machine'],
653
720
  ['/runs', 'the run list — the same as L'],
721
+ ['/config', "this machine's config — the same as c"],
654
722
  ['/cancel', 'cancel the session you are watching'],
655
723
  ['/log', "the daemon's own log, on or off"],
656
724
  ['/quit', 'stop the runner and leave (--leave-running keeps it up)'],
@@ -749,6 +817,16 @@ export class Attached {
749
817
  this.settled = new Set();
750
818
  /** Ctrl+C, armed. See {@link onInterrupt}. */
751
819
  this.interrupting = false;
820
+ /**
821
+ * The config walk in progress, if one is — R288.
822
+ *
823
+ * `answer` resolves the question it is waiting on — the line editor or
824
+ * the picker hands its result here instead of to a session — and `cancel`
825
+ * rejects it with {@link CANCELLED}, which is what unwinds the whole step
826
+ * on Esc. One walk at a time, because two writing one file is R41 on a
827
+ * laptop.
828
+ */
829
+ this.configuring = null;
752
830
  }
753
831
 
754
832
  /**
@@ -1306,6 +1384,8 @@ export class Attached {
1306
1384
  case 'l':
1307
1385
  case 'L':
1308
1386
  return this.openList();
1387
+ case 'c':
1388
+ return void this.openConfig();
1309
1389
  case 'g':
1310
1390
  this.showLog = !this.showLog;
1311
1391
  return this.note(this.showLog ? "printing the daemon's log" : 'printing the session');
@@ -1386,6 +1466,8 @@ export class Attached {
1386
1466
  this.deciding = null;
1387
1467
  this.history.reset();
1388
1468
  this.dirty = true;
1469
+ // A config question closed this way is cancelled, not left waiting — R288.
1470
+ this.configuring?.cancel?.();
1389
1471
  return open;
1390
1472
  }
1391
1473
 
@@ -1440,6 +1522,11 @@ export class Attached {
1440
1522
 
1441
1523
  /** The commands still matching what is typed, and which one is highlighted. */
1442
1524
  matching() {
1525
+ if (this.mode === 'typing' && this.input.path) {
1526
+ // R289: the directories that would complete the path, drawn where the
1527
+ // commands are drawn for a `/`.
1528
+ return { rows: this.input.path.rows, at: this.input.path.at };
1529
+ }
1443
1530
  if (this.mode !== 'typing' || this.input.kind !== 'command') {
1444
1531
  return { rows: [], at: 0 };
1445
1532
  }
@@ -1460,6 +1547,7 @@ export class Attached {
1460
1547
  return this.note('');
1461
1548
  }
1462
1549
  const back = this.input.back;
1550
+ const kind = this.input.kind;
1463
1551
  this.input = null;
1464
1552
  this.history.reset();
1465
1553
  if (back) {
@@ -1469,6 +1557,13 @@ export class Attached {
1469
1557
  this.mode = 'keys';
1470
1558
  this.answering = null;
1471
1559
  this.deciding = null;
1560
+ if (kind === 'config') {
1561
+ // Esc from a config question cancels the STEP, not just the line —
1562
+ // R288. The walk it was in is waiting on this answer and would
1563
+ // otherwise wait for ever.
1564
+ this.configuring?.cancel?.();
1565
+ return this.note('');
1566
+ }
1472
1567
  return this.note('cancelled');
1473
1568
  }
1474
1569
 
@@ -1483,6 +1578,10 @@ export class Attached {
1483
1578
  }
1484
1579
 
1485
1580
  if (key === '\t') {
1581
+ if (this.input.path) {
1582
+ this.completeThePath();
1583
+ return undefined;
1584
+ }
1486
1585
  if (rows.length) {
1487
1586
  // As far as they agree, which is what every shell does and nobody has
1488
1587
  // to be taught. One match completes it whole.
@@ -1494,6 +1593,11 @@ export class Attached {
1494
1593
  }
1495
1594
 
1496
1595
  if (key === `${ESC}[A` || key === `${ESC}OA` || key === '\x10') {
1596
+ if (this.input.path && rows.length) {
1597
+ this.input.path.at = (at - 1 + rows.length) % rows.length;
1598
+ this.dirty = true;
1599
+ return undefined;
1600
+ }
1497
1601
  if (rows.length && this.input.showing !== false) {
1498
1602
  this.input.at = (at - 1 + rows.length) % rows.length;
1499
1603
  this.dirty = true;
@@ -1507,6 +1611,11 @@ export class Attached {
1507
1611
  return undefined;
1508
1612
  }
1509
1613
  if (key === `${ESC}[B` || key === `${ESC}OB` || key === '\x0e') {
1614
+ if (this.input.path && rows.length) {
1615
+ this.input.path.at = (at + 1) % rows.length;
1616
+ this.dirty = true;
1617
+ return undefined;
1618
+ }
1510
1619
  if (rows.length && this.input.showing !== false) {
1511
1620
  this.input.at = (at + 1) % rows.length;
1512
1621
  this.dirty = true;
@@ -1547,6 +1656,9 @@ export class Attached {
1547
1656
  return undefined;
1548
1657
  }
1549
1658
  this.dirty = true;
1659
+ if (this.input.path) {
1660
+ void this.refreshPath();
1661
+ }
1550
1662
  return undefined;
1551
1663
  }
1552
1664
 
@@ -1559,6 +1671,14 @@ export class Attached {
1559
1671
  this.mode = 'keys';
1560
1672
  this.history.reset();
1561
1673
 
1674
+ if (kind === 'config') {
1675
+ // Before the empty-line rule, on purpose: every config question shows a
1676
+ // default in its label, and enter on an empty line TAKES it — R288.
1677
+ // Not remembered either; a path typed once is not a prompt worth
1678
+ // offering back next launch.
1679
+ this.configuring?.answer?.(this.pastes.expand(typed));
1680
+ return this.note('');
1681
+ }
1562
1682
  if (!typed || typed === '/') {
1563
1683
  // Nothing typed is not an answer, and a question that was open is still
1564
1684
  // open — so an empty line goes back to it rather than dropping it.
@@ -1644,6 +1764,9 @@ export class Attached {
1644
1764
  // is being written when none is is one the next reader will believe.
1645
1765
  this.answering = null;
1646
1766
  this.deciding = null;
1767
+ if (select.kind === 'config') {
1768
+ this.configuring?.cancel?.();
1769
+ }
1647
1770
  // Escape leaves without changing anything, which is the promise the key
1648
1771
  // makes everywhere else.
1649
1772
  return this.note('');
@@ -1685,6 +1808,11 @@ export class Attached {
1685
1808
  }
1686
1809
  return void this.allow(row.id);
1687
1810
  }
1811
+ if (select.kind === 'config') {
1812
+ // R288: the walk asked, the walk gets the row. Nothing is decided here.
1813
+ this.configuring?.answer?.(row);
1814
+ return undefined;
1815
+ }
1688
1816
  return undefined;
1689
1817
  }
1690
1818
 
@@ -1696,6 +1824,14 @@ export class Attached {
1696
1824
  */
1697
1825
  onLine(text) {
1698
1826
  const typed = String(text).trim();
1827
+ if (this.input?.kind === 'config' && !this.select) {
1828
+ // The plain path's half of R288: a config question waiting on a line
1829
+ // takes the whole line, empty included, and nothing else reads it.
1830
+ this.input = null;
1831
+ this.mode = 'keys';
1832
+ this.configuring?.answer?.(typed);
1833
+ return undefined;
1834
+ }
1699
1835
  if (this.select) {
1700
1836
  const picked = pickFromLine(this.select, typed);
1701
1837
  if (picked) {
@@ -1903,6 +2039,307 @@ export class Attached {
1903
2039
  return false;
1904
2040
  }
1905
2041
 
2042
+ // --- this machine's config — R288 ------------------------------------------
2043
+
2044
+ /**
2045
+ * `c` — configure this machine from inside the terminal.
2046
+ *
2047
+ * R283 built `cawdev config …` as a shell subcommand, and the first person to
2048
+ * launch the new CLI could not find it: they were HERE, attached, which is
2049
+ * where cawdev lives on a machine, and nothing here said a word about
2050
+ * configuration. So the same functions get a second door. The rows carry the
2051
+ * state each toggle is in, because a list that says "enable claude" when
2052
+ * claude is already on is a list asking somebody to guess.
2053
+ *
2054
+ * Every change goes through configure.mjs — one implementation, two doors —
2055
+ * and its questions come back through this class's own line editor and its
2056
+ * own picker: {@link askLine} and {@link askPick} are the adapters, and the
2057
+ * walk between them cannot tell readline from raw mode. The daemon is never
2058
+ * told anything over R52's socket; the file is written as the operator and,
2059
+ * for the two settings runner.mjs re-reads, the process is sent a SIGHUP
2060
+ * through the pid the hello already carries.
2061
+ */
2062
+ async openConfig() {
2063
+ if (this.configuring) {
2064
+ return this.note('already configuring — finish that, or esc');
2065
+ }
2066
+ if (!this.runner) {
2067
+ return this.note('not connected to the daemon yet');
2068
+ }
2069
+ if (this.runner.configPath === undefined) {
2070
+ // An older daemon: it does not say which file it booted from, and
2071
+ // guessing at one is how a config screen edits the wrong machine.
2072
+ return this.note('this daemon is older than this client — q, then cawdev again, and c will work');
2073
+ }
2074
+ if (this.runner.configPath === null) {
2075
+ return this.note('this daemon was started from the environment, with no config file — nothing here to edit');
2076
+ }
2077
+ const file = await readConfigFile(this.runner.configPath);
2078
+ if (!file) {
2079
+ return this.note(`could not read ${this.runner.configPath}`);
2080
+ }
2081
+
2082
+ this.configuring = { answer: null, cancel: null };
2083
+ try {
2084
+ await this.configWalk(file);
2085
+ } catch (failure) {
2086
+ if (failure === CANCELLED) {
2087
+ this.note('nothing changed');
2088
+ } else {
2089
+ this.say(` ${this.ink.danger(failure.message)}`);
2090
+ this.note('nothing changed');
2091
+ }
2092
+ } finally {
2093
+ this.configuring = null;
2094
+ this.dirty = true;
2095
+ }
2096
+ return undefined;
2097
+ }
2098
+
2099
+ /** One step of the screen: pick what to change, then change it. */
2100
+ async configWalk(file) {
2101
+ const ink = this.ink;
2102
+ const say = (line) => this.say(line);
2103
+ const configPath = this.runner.configPath;
2104
+ const url = this.session.url;
2105
+ const ask = { line: (prompt) => this.askLine(prompt) };
2106
+ const pick = (_ask, _say, select) => this.askPick(select).then((row) => ({ done: 'chosen', row }));
2107
+
2108
+ const chosen = await this.askPick(configMenu(file, this.runner));
2109
+ this.say('');
2110
+ this.say(`${ink.bold(ink.accent(' config '))} ${ink.text(chosen.label)}`);
2111
+
2112
+ if (chosen.id === 'show') {
2113
+ const shown = { ...file };
2114
+ if (shown.token) shown.token = `${String(shown.token).slice(0, 10)}…`;
2115
+ for (const line of JSON.stringify(shown, null, 2).split('\n')) {
2116
+ this.say(` ${ink.text(line)}`);
2117
+ }
2118
+ this.say(` ${ink.muted(configPath)}`);
2119
+ return this.note('');
2120
+ }
2121
+
2122
+ if (chosen.id === 'add-project') {
2123
+ // Widens the machine's token on the platform, which only a person may do.
2124
+ // `requireSignIn` has already said so; a second note would bury it.
2125
+ if (!this.requireSignIn('add a project to this machine')) {
2126
+ return undefined;
2127
+ }
2128
+ await addProject({
2129
+ configPath, file, url, session: this.session, ask, say, ink, pick,
2130
+ askPath: (spec) => this.askPath(spec),
2131
+ inspect: this.options.inspect ?? inspectPath,
2132
+ clone: (gitUrl, path) => this.cloneInto(gitUrl, path),
2133
+ });
2134
+ return this.afterProjectsChanged();
2135
+ }
2136
+ if (chosen.id === 'add-workspace') {
2137
+ await addWorkspace({
2138
+ configPath, file, url, session: this.session, ask, say, ink, pick,
2139
+ askPath: (spec) => this.askPath(spec),
2140
+ inspect: this.options.inspect ?? inspectPath,
2141
+ clone: (gitUrl, path) => this.cloneInto(gitUrl, path),
2142
+ });
2143
+ return this.afterProjectsChanged();
2144
+ }
2145
+ if (chosen.id.startsWith('agent:')) {
2146
+ const command = chosen.id.slice('agent:'.length);
2147
+ const next = await setAgentEnabled({
2148
+ file, configPath, ask, say, ink, command, enabled: !agentsOf(file).includes(command),
2149
+ });
2150
+ this.runner.agents = agentsOf(next);
2151
+ return this.reloadDaemon(next);
2152
+ }
2153
+ if (chosen.id === 'rules') {
2154
+ const next = await setAcceptsRulesFromConsole({
2155
+ file, configPath, ask, say, ink, enabled: file.acceptsRulesFromConsole !== true,
2156
+ });
2157
+ this.runner.acceptsConsoleRules = next.acceptsRulesFromConsole === true;
2158
+ return this.reloadDaemon(next);
2159
+ }
2160
+ return this.note('');
2161
+ }
2162
+
2163
+ /**
2164
+ * The two settings the daemon re-reads on SIGHUP, applied to the one this
2165
+ * terminal is attached to — R283's `reloadIfRunning` with the pid handed in,
2166
+ * since the hello already says which process this is.
2167
+ */
2168
+ async reloadDaemon(next) {
2169
+ await reloadIfRunning(next.name, (line) => this.say(line), this.ink, {
2170
+ pidOf: async () => this.runner?.pid ?? null,
2171
+ // Injectable so a test can watch the signal without sending one.
2172
+ kill: this.options.kill ?? ((pid, signal) => process.kill(pid, signal)),
2173
+ });
2174
+ return this.note('');
2175
+ }
2176
+
2177
+ /**
2178
+ * Adding a project or a workspace is not hot-swappable — `config.projects`
2179
+ * is read once at boot — so this says what happens next rather than letting
2180
+ * the row above look like it took effect. The same sentence `cawdev --setup`
2181
+ * says, because it is the same fact.
2182
+ */
2183
+ afterProjectsChanged() {
2184
+ const ink = this.ink;
2185
+ this.say(` ${ink.muted('This daemon is still on the config it booted with.')}`);
2186
+ this.say(` ${ink.muted('Press')} ${ink.text('q')} ${ink.muted('to stop it, and the next')} `
2187
+ + `${ink.text('cawdev')} ${ink.muted('starts one on what you just set up.')}`);
2188
+ return this.note('saved — q, then cawdev, to serve it');
2189
+ }
2190
+
2191
+ /**
2192
+ * One line, asked through this class's own editor — the `ask.line` shape
2193
+ * configure.mjs takes. Enter on an empty line resolves '' (the default the
2194
+ * label shows); Esc rejects with {@link CANCELLED}, which unwinds the step.
2195
+ */
2196
+ askLine(prompt, { prefill = '', path = null } = {}) {
2197
+ return new Promise((resolve, reject) => {
2198
+ this.configuring.answer = (text) => {
2199
+ this.configuring.answer = null;
2200
+ this.configuring.cancel = null;
2201
+ resolve(text);
2202
+ };
2203
+ this.configuring.cancel = () => {
2204
+ this.configuring.answer = null;
2205
+ this.configuring.cancel = null;
2206
+ reject(CANCELLED);
2207
+ };
2208
+ const { said, label } = splitPrompt(prompt);
2209
+ if (said) {
2210
+ // R289: a question longer than a label is PRINTED, where it wraps and
2211
+ // stays, and the footer keeps the short part — a line the footer clips
2212
+ // is a question nobody read.
2213
+ this.say(` ${this.ink.text(said)}`);
2214
+ }
2215
+ if (this.plain && !said) {
2216
+ // No footer to carry the label through a pipe, so it is printed.
2217
+ this.say(` ${label}`);
2218
+ }
2219
+ this.type('config', label, prefill);
2220
+ if (path) {
2221
+ this.input.path = { ...path, rows: [], at: 0, preview: '', completion: null };
2222
+ void this.refreshPath();
2223
+ }
2224
+ });
2225
+ }
2226
+
2227
+ /**
2228
+ * Where a checkout goes — R289. Suggestions first, each saying what is there
2229
+ * now; the last row opens a line PRE-FILLED with the first suggestion, with
2230
+ * tab completing directories and a preview under it. `~` expands. The path
2231
+ * comes back absolute; configure.mjs inspects it again before acting.
2232
+ */
2233
+ async askPath({ question, suggestions, slug, gitUrl, inspect = inspectPath }) {
2234
+ this.say(` ${this.ink.text(question)}`);
2235
+ const rows = [];
2236
+ for (const each of suggestions) {
2237
+ const seen = await inspect(each.path, { gitUrl });
2238
+ rows.push({ id: each.path, label: each.path, hint: `${each.why} · ${describe(seen, slug)}` });
2239
+ }
2240
+ rows.push({ id: TYPE_A_PATH, label: 'Type a path', hint: 'pre-filled · tab completes · ~ works' });
2241
+ const chosen = await this.askPick(new Select({ title: question, rows }));
2242
+ if (chosen.id !== TYPE_A_PATH) {
2243
+ return chosen.id;
2244
+ }
2245
+ const typed = await this.askLine('path', {
2246
+ prefill: suggestions[0]?.path ?? '',
2247
+ path: { slug, gitUrl, inspect },
2248
+ });
2249
+ return absolute(typed || suggestions[0]?.path || '');
2250
+ }
2251
+
2252
+ /**
2253
+ * What the path on the line points at, recomputed as it is typed: the
2254
+ * directories that would complete it (drawn above the line, tab takes them)
2255
+ * and one line saying what is there (drawn under it). The inspection is
2256
+ * debounced and checked against the line afterwards, so a slow disk never
2257
+ * paints a verdict about a path that has since changed.
2258
+ */
2259
+ async refreshPath() {
2260
+ const input = this.input;
2261
+ if (!input?.path) return;
2262
+ const text = input.line.text;
2263
+ const expanded = absolute(text);
2264
+ const completion = completePath(text, await listDirectories(
2265
+ text.endsWith('/') ? expanded : dirname(expanded),
2266
+ ));
2267
+ if (this.input !== input || input.line.text !== text) return;
2268
+ input.path.completion = completion;
2269
+ input.path.rows = completion.matches
2270
+ .filter((match) => `${match}/` !== expanded && match !== expanded)
2271
+ .slice(0, 8)
2272
+ .map((match) => [`${basename(match)}/`, '']);
2273
+ input.path.at = Math.min(input.path.at, Math.max(0, input.path.rows.length - 1));
2274
+ this.dirty = true;
2275
+
2276
+ await new Promise((done) => setTimeout(done, 120));
2277
+ if (this.input !== input || input.line.text !== text) return;
2278
+ const seen = await input.path.inspect(expanded, { gitUrl: input.path.gitUrl });
2279
+ if (this.input !== input || input.line.text !== text) return;
2280
+ input.path.preview = `${expanded} — ${describe(seen, input.path.slug)}`;
2281
+ this.dirty = true;
2282
+ }
2283
+
2284
+ /** Tab on a path: as far as the matches agree; cycle them when they do not. */
2285
+ completeThePath() {
2286
+ const input = this.input;
2287
+ const path = input?.path;
2288
+ if (!path?.completion) return;
2289
+ const { completed, matches } = path.completion;
2290
+ if (completed !== input.line.text) {
2291
+ input.line.set(completed);
2292
+ } else if (matches.length > 1) {
2293
+ const pick = matches[path.at % matches.length];
2294
+ input.line.set(`${pick}/`);
2295
+ path.at = (path.at + 1) % matches.length;
2296
+ }
2297
+ this.dirty = true;
2298
+ void this.refreshPath();
2299
+ }
2300
+
2301
+ /** One row, through the one select widget — R83's rule, kept. */
2302
+ askPick(select) {
2303
+ return new Promise((resolve, reject) => {
2304
+ this.configuring.answer = (row) => {
2305
+ this.configuring.answer = null;
2306
+ this.configuring.cancel = null;
2307
+ resolve(row);
2308
+ };
2309
+ this.configuring.cancel = () => {
2310
+ this.configuring.answer = null;
2311
+ this.configuring.cancel = null;
2312
+ reject(CANCELLED);
2313
+ };
2314
+ select.kind = 'config';
2315
+ this.openPicker(select);
2316
+ });
2317
+ }
2318
+
2319
+ /**
2320
+ * `git clone` with its progress printed into the transcript rather than onto
2321
+ * a terminal in raw mode, and `GIT_TERMINAL_PROMPT=0` because a clone that
2322
+ * stops to ask for a password here would hang the screen with no prompt on
2323
+ * it: the credential helper may answer, a person cannot.
2324
+ */
2325
+ cloneInto(gitUrl, path) {
2326
+ return new Promise((done, fail) => {
2327
+ const child = spawn('git', ['clone', '--progress', gitUrl, path], {
2328
+ stdio: ['ignore', 'pipe', 'pipe'],
2329
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
2330
+ });
2331
+ const print = (chunk) => {
2332
+ for (const line of String(chunk).split(/\r?\n|\r/)) {
2333
+ if (line.trim()) this.say(` ${this.ink.muted(line)}`);
2334
+ }
2335
+ };
2336
+ child.stdout.on('data', print);
2337
+ child.stderr.on('data', print);
2338
+ child.on('error', (failure) => fail(new Error(`Could not run git: ${failure.message}`)));
2339
+ child.on('close', (code) => (code === 0 ? done() : fail(new Error(`git clone exited ${code}.`))));
2340
+ });
2341
+ }
2342
+
1906
2343
  // --- slash commands ---------------------------------------------------------
1907
2344
 
1908
2345
  async runCommand(text) {
@@ -1916,7 +2353,7 @@ export class Attached {
1916
2353
  }
1917
2354
  this.say('');
1918
2355
  this.say(this.ink.bold(' keys'));
1919
- this.say(this.ink.muted(' enter prompt · / command · L runs · 1-9 pick a run'));
2356
+ this.say(this.ink.muted(' enter prompt · / command · L runs · 1-9 pick a run · c config'));
1920
2357
  this.say(this.ink.muted(' a answer · y/s/Y/n permission · x cancel · g log · q quit'));
1921
2358
  this.say(this.ink.muted(' in a list: ↑↓ move · 1-9 pick · enter choose · esc leave'));
1922
2359
  this.say(this.ink.muted(' while typing: ↑↓ history · tab complete · esc back · ctrl+c twice quits'));
@@ -1935,6 +2372,8 @@ export class Attached {
1935
2372
  return this.note('signed out — /login to sign in again');
1936
2373
  case 'runs':
1937
2374
  return this.openList();
2375
+ case 'config':
2376
+ return void this.openConfig();
1938
2377
  case 'cancel':
1939
2378
  return void this.cancel();
1940
2379
  case 'log':
@@ -2294,6 +2733,7 @@ export class Attached {
2294
2733
  matches: this.input?.showing === false ? { rows: [], at: 0 } : this.matching(),
2295
2734
  input: this.mode === 'typing' ? {
2296
2735
  label: this.input.label,
2736
+ preview: this.input.path?.preview ?? '',
2297
2737
  // The width the line has left, so the caret stays on screen when the
2298
2738
  // text is longer than the terminal — see Line.window.
2299
2739
  text: this.input.line.render(
@@ -2349,6 +2789,7 @@ export class Attached {
2349
2789
  + `${ink.muted('permission')}`);
2350
2790
  }
2351
2791
  parts.push(`${ink.text('x')} ${ink.muted('cancel')}`);
2792
+ parts.push(`${ink.text('c')} ${ink.muted('config')}`);
2352
2793
  parts.push(`${ink.text('q')} ${ink.muted(this.stopsTheDaemon() ? 'stop' : 'quit')}`);
2353
2794
  return parts;
2354
2795
  }
package/runner/banner.mjs CHANGED
@@ -19,6 +19,12 @@
19
19
  // profile: a question is bounded here and nowhere else
20
20
  // the browser R61, and the one line that says an agent may reach Chrome
21
21
  //
22
+ // R283 adds two more, because both became things a machine can now be
23
+ // RECONFIGURED into without a restart (`cawdev config`, and the daemon's own
24
+ // SIGHUP handler in runner.mjs): which agent(s) this machine spawns, and —
25
+ // when it could be read — the CLI's own version, so a stale one is visible at
26
+ // the moment it would matter most, before anything has failed yet.
27
+ //
22
28
  // It prints once, at boot. Anything that changes afterwards belongs in the
23
29
  // log, not here.
24
30
 
@@ -31,7 +37,7 @@ import { padVisible } from '../lib/ansi.mjs';
31
37
  * Lines rather than output, so the caller decides where it goes — and so this
32
38
  * can be tested by reading it rather than by capturing a stream.
33
39
  */
34
- export function bannerLines(config, ink) {
40
+ export function bannerLines(config, ink, cliVersionValue = null) {
35
41
  const lines = ['', ...mark(ink, { tagline: 'the runner' }), ''];
36
42
 
37
43
  const label = (text) => ink.muted(padVisible(text, 12));
@@ -39,6 +45,10 @@ export function bannerLines(config, ink) {
39
45
 
40
46
  say('platform', ink.accent(config.url));
41
47
  say('runner', ink.text(config.name));
48
+ if (cliVersionValue) {
49
+ say('cli', ink.muted(cliVersionValue));
50
+ }
51
+ say('agents', ink.text((config.agentCommands ?? []).join(', ') || 'claude'));
42
52
 
43
53
  const projects = Object.entries(config.projects);
44
54
  projects.forEach(([slug, project], at) => {