cawdev-cli 1.0.5-beta → 1.0.6-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
@@ -52,7 +52,7 @@ import { basename, dirname } from 'node:path';
52
52
  import { listSockets, socketPathFor } from './control.mjs';
53
53
  import {
54
54
  RESTART_FROM_ATTACH, addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
55
- setAcceptsRulesFromConsole, setAgentEnabled, setQuality, slugsOf,
55
+ removeProject, setAcceptsRulesFromConsole, setAgentEnabled, setQuality, slugsOf,
56
56
  } from './configure.mjs';
57
57
  import {
58
58
  TYPE_A_PATH, absolute, completePath, describe, inspectPath, listDirectories,
@@ -60,11 +60,12 @@ import {
60
60
  import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
61
61
  import { restartNeededFor } from '../lib/runner-config.mjs';
62
62
  import {
63
- MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, tabsLine, visibleSessions, actionsFor, composeFrame,
64
- carriesOnHere, cardTitleFor, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, splitLayout, titleFor, toneFor,
63
+ BOARD_COLUMNS, MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, tabsLine, visibleSessions, actionsFor, composeFrame,
64
+ carriesOnHere, builtNothing, cardLines, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, splitLayout, titleFor, toneFor,
65
65
  windowLines,
66
66
  } from './split.mjs';
67
- import { KNOWN_AGENTS } from '../lib/agents.mjs';
67
+ import { KNOWN_AGENTS, agentOfCommand } from '../lib/agents.mjs';
68
+ import { loadPrefs, savePrefs } from './prefs.mjs';
68
69
  import { oneLine } from './brand.mjs';
69
70
  import { Scrollback } from './scrollback.mjs';
70
71
  import { Session, signInThroughBrowser, storedSession } from './sign-in.mjs';
@@ -730,7 +731,7 @@ function qualityOf(entry) {
730
731
  return entry && typeof entry === 'object' && entry.quality ? entry.quality : null;
731
732
  }
732
733
 
733
- export function configMenu(file, runner = {}) {
734
+ export function configMenu(file, runner = {}, gone = new Set()) {
734
735
  const slugs = slugsOf(file);
735
736
  const agents = agentsOf(file);
736
737
  const rulesOn = file.acceptsRulesFromConsole === true;
@@ -767,11 +768,39 @@ export function configMenu(file, runner = {}) {
767
768
  ...slugs.map((slug) => (qualityOf(file.projects[slug])
768
769
  ? { id: `quality:${slug}`, label: `Stop scanning ${slug} with SonarQube`, hint: `${qualityOf(file.projects[slug]).command ?? 'sonar-scanner'} after TEST now` }
769
770
  : { id: `quality:${slug}`, label: `Scan ${slug} with SonarQube after TEST`, hint: 'not scanned here' })),
771
+ // One row per served project — first the ones cawdev no longer has,
772
+ // which are the reason anybody comes looking for this row.
773
+ ...[...slugs].sort((a, b) => Number(gone.has(b)) - Number(gone.has(a))).map((slug) => ({
774
+ id: `remove:${slug}`,
775
+ label: `Stop serving ${slug}`,
776
+ hint: gone.has(slug) ? 'not on cawdev any more' : 'its checkouts stay on disk',
777
+ })),
770
778
  { id: 'show', label: "Show this machine's config", hint: runner.configPath ?? '' },
771
779
  ],
772
780
  });
773
781
  }
774
782
 
783
+ /** `/new`'s agent flags: `--claude`, `--codex`, `--agy`. */
784
+ const AGENT_FLAGS = new Set(KNOWN_AGENTS.map((agent) => `--${agent}`));
785
+
786
+ /** A start's body, naming the agent when one was chosen — and nothing when not. */
787
+ function withAgent(body, agent) {
788
+ return agent ? { ...body, agent } : body;
789
+ }
790
+
791
+ /** What was started, saying with which agent when one was named. */
792
+ function withWho(what, agent) {
793
+ return agent ? `${what} with ${agent}` : what;
794
+ }
795
+
796
+ /** How many times a finished dev session's zero commits is read again before it is believed — see countCommits. */
797
+ const COMMIT_REREADS = 6;
798
+
799
+ /** `r29` → `R29`, `I12` → `i12`, `29` as it is: how the API spells a card's ref (R221). */
800
+ function refOf(word) {
801
+ return word.replace(/^r/i, 'R').replace(/^i/i, 'i');
802
+ }
803
+
775
804
  /** The slash commands, and the one place they are described. */
776
805
  const COMMANDS = [
777
806
  ['/help', 'this list'],
@@ -783,11 +812,15 @@ const COMMANDS = [
783
812
  ['/log', "the daemon's own log, on or off"],
784
813
  ['/advanced-mode', 'one window per session around the prompt, for the projects you tick (@slug names them), or back'],
785
814
  ['/new', 'start a session here: /new plan|dev R3, /new plan|dev <the task>, /new ask … (@project)'],
786
- ['/develop', 'start the development of the card a finished plan was about'],
815
+ ['/card', 'add an idea to Considering — no session: /card <the idea> (@project)'],
816
+ ['/agent', 'which agent /new uses: /agent claude|codex|agy, /agent default, /agent to see it'],
817
+ ['/develop', 'start the development of the card a finished plan was about, or of one named: /develop R30 (--codex) (@project)'],
787
818
  ['/pr', "open the pull request of a finished session's branch"],
788
819
  ['/continue', 'pick a failed or stopped session back up on this machine — the console\'s Carry on'],
789
820
  ['/projects', 'which projects advanced mode shows — tick one or several'],
790
- ['/open', "open the session's pull request in your browser"],
821
+ ['/refresh', "read the cards and their statuses from cawdev again — advanced mode's roadmap"],
822
+ ['/open', "open the session's pull request in your browser — or, on a finished plan, its card and plan on cawdev"],
823
+ ['/view', "a card here in the terminal: /view R29, /view <words of its title> (@project), /view card for the session's; /view alone opens the session on cawdev"],
791
824
  ['/drop', 'delete the checkout a finished session was made on demand for'],
792
825
  ['/close', "close an ended session's window"],
793
826
  ['/reload', 'quit and start again on the code on disk — the runner too, when nothing is running'],
@@ -952,6 +985,17 @@ export class Attached {
952
985
  this.sessions = new Map();
953
986
  /** Ended sessions whose window somebody closed. */
954
987
  this.closed = new Set();
988
+ /**
989
+ * Projects this machine serves that cawdev answered 404 for — deleted in
990
+ * the console, or the person's access went. The config still names them,
991
+ * so without this the terminal kept offering them everywhere it offers a
992
+ * project. Learnt from `refreshPlanned`, and forgotten when one answers.
993
+ */
994
+ this.gone = new Set();
995
+ /** Of those, the ones not yet said — see `sayGone`. */
996
+ this.goneUnsaid = [];
997
+ /** A project whose next new CODE session takes the focus — see startNew. */
998
+ this.focusOnArrival = null;
955
999
  /** The PLANNED cards of the projects this machine serves — advanced mode's top strip. */
956
1000
  this.planned = [];
957
1001
  // Every card of every served project, whatever its status — the strip.
@@ -980,6 +1024,8 @@ export class Attached {
980
1024
  async start() {
981
1025
  this.screen.open();
982
1026
  this.history = new History(await loadHistory(this.session.url).catch(() => []));
1027
+ // `/agent`'s choice, from last time — see prefs.mjs.
1028
+ this.agent = (await loadPrefs(this.session.url).catch(() => ({}))).agent ?? null;
983
1029
  // Raw mode only where there is a terminal to put in it. Through a pipe
984
1030
  // there are no keys, the transcript is the whole output, and that is the
985
1031
  // honest degradation rather than a broken one.
@@ -1296,9 +1342,19 @@ export class Attached {
1296
1342
 
1297
1343
  note(text) {
1298
1344
  this.status = text;
1345
+ // In advanced mode, which window it was said about — the focused one at
1346
+ // the moment it was said. A note outlives the focus moving, and "a
1347
+ // session is running" read under another window is a note about nobody.
1348
+ this.statusWindow = this.split && text ? this.windowNumberOf(this.split.focus) : null;
1299
1349
  this.dirty = true;
1300
1350
  }
1301
1351
 
1352
+ /** A window's number, as its title shows it; null when it has none. */
1353
+ windowNumberOf(id) {
1354
+ const at = id ? this.windows().findIndex((meta) => meta.id === id) : -1;
1355
+ return at >= 0 ? at + 1 : null;
1356
+ }
1357
+
1302
1358
  // --- what is waiting on a person -----------------------------------------
1303
1359
 
1304
1360
  /**
@@ -1567,7 +1623,17 @@ export class Attached {
1567
1623
  return this.cycleFocus(key === '\t' ? 1 : -1);
1568
1624
  }
1569
1625
  if (this.split && key === 'd') {
1570
- return void this.pickPlanned();
1626
+ // On a finished plan's window, `d` is that plan's `/develop`: the card
1627
+ // it planned, by the agent that planned it, in the window it leaves.
1628
+ // Anywhere else, the list of planned cards.
1629
+ return void (this.finishedPlan(this.commandTarget()) ? this.develop() : this.pickPlanned());
1630
+ }
1631
+ // ↑ with no line open: the last thing typed, on a line ready to send — as
1632
+ // a shell does. Not in advanced mode, where the wheel arrives as arrows
1633
+ // (`?1007h`) and would pull the history up on every scroll; there ↑ works
1634
+ // on the line itself (`/` or enter, then ↑).
1635
+ if (!this.split && (key === `${ESC}[A` || key === `${ESC}OA`)) {
1636
+ return this.recallIntoALine();
1571
1637
  }
1572
1638
 
1573
1639
  switch (key) {
@@ -1784,7 +1850,8 @@ export class Attached {
1784
1850
  // With a completion list open, enter takes the highlighted command —
1785
1851
  // which is what makes guessing a name stop being a step. With none, it
1786
1852
  // sends what was typed.
1787
- if (rows.length && this.input.showing !== false && this.input.kind === 'command') {
1853
+ if (rows.length && this.input.showing !== false && this.input.kind === 'command'
1854
+ && !this.input.recalling) {
1788
1855
  line.set(rows[at][0]);
1789
1856
  }
1790
1857
  return this.submit();
@@ -1805,7 +1872,28 @@ export class Attached {
1805
1872
  return undefined;
1806
1873
  }
1807
1874
 
1808
- if (key === `${ESC}[A` || key === `${ESC}OA` || key === '\x10') {
1875
+ const up = key === `${ESC}[A` || key === `${ESC}OA` || key === '\x10';
1876
+ const down = key === `${ESC}[B` || key === `${ESC}OB` || key === '\x0e';
1877
+ // ↑ on a line nothing has been typed on yet — empty, or the bare `/` a
1878
+ // command line opens with — is HISTORY, as in every shell; so is ↑/↓
1879
+ // while walking it. Only once something is typed do the arrows move in the
1880
+ // list of commands. Before this the `/` line showed every command and ↑
1881
+ // moved in that list, so a command line could never reach its history.
1882
+ const untouched = line.text === '' || line.text === '/';
1883
+ if ((up && (untouched || this.input.recalling)) || (down && this.input.recalling)) {
1884
+ const keep = this.input.kind === 'command' ? (entry) => entry.startsWith('/') : undefined;
1885
+ const recalled = up ? this.history.back(line.text, keep) : this.history.forward(keep);
1886
+ if (recalled !== null) {
1887
+ line.set(recalled);
1888
+ // Walking the history, not the completions: enter sends the line as
1889
+ // recalled rather than swapping it for the highlighted command.
1890
+ this.input.recalling = true;
1891
+ this.input.showing = false;
1892
+ this.dirty = true;
1893
+ }
1894
+ return undefined;
1895
+ }
1896
+ if (up) {
1809
1897
  if (this.input.path && rows.length) {
1810
1898
  this.input.path.at = (at - 1 + rows.length) % rows.length;
1811
1899
  this.dirty = true;
@@ -1819,11 +1907,12 @@ export class Attached {
1819
1907
  const older = this.history.back(line.text);
1820
1908
  if (older !== null) {
1821
1909
  line.set(older);
1910
+ this.input.recalling = true;
1822
1911
  this.dirty = true;
1823
1912
  }
1824
1913
  return undefined;
1825
1914
  }
1826
- if (key === `${ESC}[B` || key === `${ESC}OB` || key === '\x0e') {
1915
+ if (down) {
1827
1916
  if (this.input.path && rows.length) {
1828
1917
  this.input.path.at = (at + 1) % rows.length;
1829
1918
  this.dirty = true;
@@ -1841,6 +1930,10 @@ export class Attached {
1841
1930
  }
1842
1931
  return undefined;
1843
1932
  }
1933
+ // Anything else edits the line: what is on it is no longer a recollection.
1934
+ if (key !== '\r' && key !== '\n') {
1935
+ this.input.recalling = false;
1936
+ }
1844
1937
 
1845
1938
  if (key === '\x7f' || key === '\b') {
1846
1939
  line.backspace();
@@ -1905,7 +1998,10 @@ export class Attached {
1905
1998
  if (kind === 'command' || (kind === 'prompt' && typed.startsWith('/'))) {
1906
1999
  // After the write, not beside it: `/logout` forgets the history, and a
1907
2000
  // write still in flight would put the command that cleared it back.
1908
- return void remembered.then(() => this.runCommand(typed));
2001
+ // The EXPANDED text: a command is as much a place to paste into as a
2002
+ // prompt, and running it on the placeholder sent "[pasted, 12 lines]"
2003
+ // as the task in place of the twelve lines.
2004
+ return void remembered.then(() => this.runCommand(text));
1909
2005
  }
1910
2006
  if (kind === 'prompt') {
1911
2007
  return void this.send(text);
@@ -2039,10 +2135,20 @@ export class Attached {
2039
2135
  if (select.kind === 'drop') {
2040
2136
  return void this.droppedCheckout(row);
2041
2137
  }
2138
+ if (select.kind === 'view-card') {
2139
+ const [slug, ref] = String(row.id).split(' ');
2140
+ return void this.showCard(slug, ref);
2141
+ }
2042
2142
  if (select.kind === 'planned') {
2043
2143
  const [slug, ref] = String(row.id).split(' ');
2044
2144
  return void this.startNew(['dev', ref, `@${slug}`]).then(() => this.refreshPlanned());
2045
2145
  }
2146
+ if (select.kind === 'card-project') {
2147
+ // `/card` asked which project; the same card, with it named.
2148
+ const words = this.cardWaitingForProject ?? [];
2149
+ this.cardWaitingForProject = null;
2150
+ return void this.addCard([...words, `@${row.id}`]);
2151
+ }
2046
2152
  if (select.kind === 'project') {
2047
2153
  // `/new` asked which project; the same command again, with it named.
2048
2154
  const words = this.newWaitingForProject ?? [];
@@ -2356,7 +2462,7 @@ export class Attached {
2356
2462
  const ask = { line: (prompt) => this.askLine(prompt) };
2357
2463
  const pick = (_ask, _say, select) => this.askPick(select).then((row) => ({ done: 'chosen', row }));
2358
2464
 
2359
- const chosen = await this.askPick(configMenu(file, this.runner));
2465
+ const chosen = await this.askPick(configMenu(file, this.runner, this.gone));
2360
2466
  this.say('');
2361
2467
  this.say(`${ink.bold(ink.accent(' config '))} ${ink.text(chosen.label)}`);
2362
2468
 
@@ -2408,6 +2514,15 @@ export class Attached {
2408
2514
  });
2409
2515
  return this.reloadDaemon(file, next);
2410
2516
  }
2517
+ if (chosen.id.startsWith('remove:')) {
2518
+ const slug = chosen.id.slice('remove:'.length);
2519
+ const next = await removeProject({ file, configPath, ask, say, ink, slug });
2520
+ // Off the screen now, though the daemon serves it until a restart.
2521
+ this.gone.add(slug);
2522
+ this.cards = this.cards.filter((card) => card.slug !== slug);
2523
+ this.planned = this.planned.filter((card) => card.slug !== slug);
2524
+ return this.reloadDaemon(file, next);
2525
+ }
2411
2526
  if (chosen.id === 'rules') {
2412
2527
  const next = await setAcceptsRulesFromConsole({
2413
2528
  file, configPath, ask, say, ink, enabled: file.acceptsRulesFromConsole !== true,
@@ -2627,7 +2742,7 @@ export class Attached {
2627
2742
  // --- slash commands ---------------------------------------------------------
2628
2743
 
2629
2744
  async runCommand(text) {
2630
- const [word, ...rest] = text.slice(1).split(/\s+/);
2745
+ const [word, ...rest] = commandWords(text.slice(1));
2631
2746
  switch (word) {
2632
2747
  case 'help':
2633
2748
  this.say('');
@@ -2673,16 +2788,24 @@ export class Attached {
2673
2788
  }
2674
2789
  case 'projects':
2675
2790
  return this.askSplitProjects();
2791
+ case 'refresh':
2792
+ return void this.refreshCards();
2676
2793
  case 'new':
2677
2794
  return void this.startNew(rest);
2795
+ case 'card':
2796
+ return void this.addCard(rest);
2797
+ case 'agent':
2798
+ return void this.chooseAgent(rest[0]);
2678
2799
  case 'develop':
2679
- return void this.develop();
2800
+ return void this.develop(rest);
2680
2801
  case 'pr':
2681
2802
  return void this.openPullRequest();
2682
2803
  case 'continue':
2683
2804
  return void this.carryOn();
2684
2805
  case 'open':
2685
- return this.openInBrowser();
2806
+ return void this.openInBrowser();
2807
+ case 'view':
2808
+ return rest.length ? void this.viewCard(rest) : this.viewOnCawdev();
2686
2809
  case 'drop':
2687
2810
  return void this.dropCheckout();
2688
2811
  case 'close':
@@ -2770,6 +2893,12 @@ export class Attached {
2770
2893
  if (!known) {
2771
2894
  this.sessions.set(run.id, meta);
2772
2895
  void this.readView(meta);
2896
+ // The development `d` started in place of a plan's window takes the
2897
+ // focus that window had, so it reads as the plan's successor.
2898
+ if (this.split && this.focusOnArrival === run.projectSlug && (run.profile ?? 'CODE') === 'CODE') {
2899
+ this.focusOnArrival = null;
2900
+ this.split.focus = run.id;
2901
+ }
2773
2902
  }
2774
2903
  if (this.split) {
2775
2904
  this.requestBacklogFor(run.id);
@@ -2800,6 +2929,8 @@ export class Attached {
2800
2929
  try {
2801
2930
  meta.view = await this.session.request(`/api/projects/${meta.projectSlug}/runs/${meta.id}`);
2802
2931
  this.dirty = true;
2932
+ await this.countCommits(meta);
2933
+ this.sayWhyItFailed(meta);
2803
2934
  if (meta.ended && meta.view?.live && retries > 0) {
2804
2935
  const again = setTimeout(() => void this.readView(meta, retries - 1), 2000);
2805
2936
  again.unref?.();
@@ -2809,6 +2940,70 @@ export class Attached {
2809
2940
  }
2810
2941
  }
2811
2942
 
2943
+ /**
2944
+ * A session that ended FAILED says why, in its own window — once. The
2945
+ * reason is the run's summary on the platform (a refusal before anything
2946
+ * was spawned — Codex on a machine that has not allowed everything — never
2947
+ * reaches the daemon's live transcript), and a window reading "failed" and
2948
+ * nothing else sent people to the console to find out what to do.
2949
+ */
2950
+ sayWhyItFailed(meta) {
2951
+ const view = meta.view;
2952
+ const failed = view?.state === 'FAILED';
2953
+ // A dev session that finished having built nothing says why too — its
2954
+ // summary is where the session explained stopping (R24: "the plan is
2955
+ // incomplete"), and the window otherwise read as work done.
2956
+ if (!meta.ended || meta.failureSaid || !(failed || builtNothing(meta)) || !view.exitSummary) return;
2957
+ meta.failureSaid = true;
2958
+ const kept = this.lines.get(meta.id) ?? [];
2959
+ const said = kept.some((line) => line.body === view.exitSummary.split('\n')[0]);
2960
+ if (said) return;
2961
+ const lines = String(view.exitSummary).split('\n').map((body) => ({ kind: 'ERROR', body }));
2962
+ this.lines.set(meta.id, [...kept, { kind: 'ERROR', body: failed ? 'failed:' : 'nothing built:' }, ...lines]);
2963
+ this.dirty = true;
2964
+ }
2965
+
2966
+ /**
2967
+ * How many commits a finished dev session made — read once it has finished,
2968
+ * so `builtNothing` can tell "finished" from "finished having built nothing".
2969
+ * A read that fails leaves it unknown, and unknown claims nothing: the
2970
+ * window reads "finished" as it always did.
2971
+ *
2972
+ * A ZERO is not believed at once. The runner's last reading of the commits
2973
+ * lands AFTER the run is FINISHED — the agent ends its own run, and the
2974
+ * runner pushes, opens the pull request and only then reports — so the
2975
+ * first read of a run that committed twice and pushed can answer none. That
2976
+ * zero, cached, told a Codex session with two commits on origin that
2977
+ * "nothing was committed". A zero is read again `COMMIT_REREADS` times,
2978
+ * `commitRereadMs` apart, and claimed only if it is still a zero after that.
2979
+ */
2980
+ async countCommits(meta, rereads = COMMIT_REREADS) {
2981
+ const view = meta.view;
2982
+ if (!meta.ended || meta.commits !== undefined || view?.state !== 'FINISHED'
2983
+ || (view.profile ?? 'CODE') !== 'CODE') return;
2984
+ if (rereads === COMMIT_REREADS && meta.countingCommits) return;
2985
+ meta.countingCommits = true;
2986
+ let count;
2987
+ try {
2988
+ const commits = await this.session.request(
2989
+ `/api/projects/${meta.projectSlug}/runs/${meta.id}/commits`);
2990
+ count = Array.isArray(commits) ? commits.length : undefined;
2991
+ } catch {
2992
+ // Unknown: nothing claimed.
2993
+ }
2994
+ if (count === 0 && rereads > 0) {
2995
+ const again = setTimeout(async () => {
2996
+ await this.countCommits(meta, rereads - 1);
2997
+ this.sayWhyItFailed(meta);
2998
+ }, this.options.commitRereadMs ?? 5_000);
2999
+ again.unref?.();
3000
+ return;
3001
+ }
3002
+ meta.countingCommits = false;
3003
+ meta.commits = count;
3004
+ this.dirty = true;
3005
+ }
3006
+
2812
3007
  /**
2813
3008
  * Which session a question or a request in the prompt comes from, by the
2814
3009
  * number its window carries (the position in the run list outside
@@ -2830,9 +3025,14 @@ export class Attached {
2830
3025
  && (!only || only.has(meta.projectSlug)));
2831
3026
  }
2832
3027
 
3028
+ /** The projects this machine serves that cawdev still has — see `gone`. */
3029
+ servedProjects() {
3030
+ return (this.runner?.projects ?? []).filter((slug) => !this.gone.has(slug));
3031
+ }
3032
+
2833
3033
  /** The projects on screen: advanced mode's pick, else every one served here. */
2834
3034
  shownProjects() {
2835
- const served = this.runner?.projects ?? [];
3035
+ const served = this.servedProjects();
2836
3036
  return this.split?.projects ? served.filter((slug) => this.split.projects.has(slug)) : served;
2837
3037
  }
2838
3038
 
@@ -2865,12 +3065,19 @@ export class Attached {
2865
3065
  * today's behaviour). One project served: nothing to ask. `/projects` asks
2866
3066
  * again from inside the mode.
2867
3067
  */
2868
- askSplitProjects() {
3068
+ async askSplitProjects() {
2869
3069
  if (this.plain) return this.note('advanced mode needs a terminal it can draw on');
2870
- const served = this.runner?.projects ?? [];
3070
+ // Read first, so a project deleted in the console is not offered — and
3071
+ // say so AFTER the mode is entered, or the sentence lands on the normal
3072
+ // screen the alternate one is about to cover.
3073
+ await this.refreshPlanned({ hold: true });
3074
+ const served = this.servedProjects();
2871
3075
  if (served.length <= 1) {
2872
- return this.split ? this.note('this machine serves one project') : this.enterSplit(served);
3076
+ const entered = this.split ? this.note('this machine serves one project') : this.enterSplit(served);
3077
+ this.sayGone();
3078
+ return entered;
2873
3079
  }
3080
+ this.sayGone();
2874
3081
  const current = this.split?.projects ? [...this.split.projects] : served;
2875
3082
  return this.openPicker(new Select({
2876
3083
  kind: 'split-projects',
@@ -3002,10 +3209,13 @@ export class Attached {
3002
3209
  const banner = this.select && this.select.kind !== 'runs' ? [] : pending
3003
3210
  ? permissionBanner(pending, inner, ink, this.runner ?? {})
3004
3211
  : questionBanner(this.askingOn(current), inner, ink, { entire: true });
3212
+ // Which window a command or a prompt will act on, before it is sent.
3213
+ const target = this.windowNumberOf(this.split.focus);
3214
+ const tag = target ? ink.bold(ink.accent(`[${target}] `)) : '';
3005
3215
  const input = this.mode === 'typing'
3006
- ? `${this.input.label} ${this.input.line.render(
3007
- Math.max(8, inner - visibleWidth(this.input.label) - 1), ink)}`
3008
- : ink.muted(`enter prompts ${current ? `“${current.label}”` : 'the focused session'}`
3216
+ ? `${tag}${this.input.label} ${this.input.line.render(
3217
+ Math.max(8, inner - visibleWidth(tag) - visibleWidth(this.input.label) - 1), ink)}`
3218
+ : tag + ink.muted(`enter prompts ${current ? `“${current.label}”` : 'the focused session'}`
3009
3219
  + ' · / commands · tab next window');
3010
3220
  // A question or a request on screen says which window it came from.
3011
3221
  const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
@@ -3015,8 +3225,12 @@ export class Attached {
3015
3225
  // more than a third of the screen, and never so tall that no window fits
3016
3226
  // under it — a short terminal drops it before it drops the sessions.
3017
3227
  const stripRoom = Math.min(Math.max(4, Math.floor(height / 3)), height - promptRows - MIN_ZONE_HEIGHT);
3018
- const stripLines = stripRoom >= 3
3019
- ? roadmapStrip(width, stripRoom, this.cards, ink, this.shownProjects())
3228
+ // A rule under it, from the strip's own room: the roadmap is the board and
3229
+ // the windows are the sessions, and without it the strip's last card line
3230
+ // ran straight into the first window's border.
3231
+ const stripLines = stripRoom >= 4
3232
+ ? [...roadmapStrip(width, stripRoom - 1, this.cards, ink, this.shownProjects()),
3233
+ ink.muted('─'.repeat(width))]
3020
3234
  : [];
3021
3235
  const strip = stripLines.length <= Math.max(0, stripRoom) ? stripLines.length : 0;
3022
3236
  // Four windows at most; the rest are named on one line above the prompt,
@@ -3078,7 +3292,8 @@ export class Attached {
3078
3292
  return wordWrap(line.body, inner - 2 - line.prefix.length).map((part) => `${line.prefix}${line.paint(part)}`);
3079
3293
  });
3080
3294
  const promptTitle = `cawdev ▸ ${this.runner?.name ?? 'runner'}`
3081
- + `${this.session.email ? ` · ${this.session.email}` : ' · not signed in'}${unseen}`;
3295
+ + `${this.session.email ? ` · ${this.session.email}` : ' · not signed in'}`
3296
+ + `${this.agent ? ` · ${this.agent}` : ''}${unseen}`;
3082
3297
  pieces.push({
3083
3298
  rect: layout.prompt,
3084
3299
  lines: windowLines(layout.prompt, {
@@ -3091,7 +3306,9 @@ export class Attached {
3091
3306
  ...messages,
3092
3307
  ...fixed,
3093
3308
  ],
3094
- footer: this.status ? ink.warn(this.status) : keyList(this.keys(), '', inner, ink),
3309
+ footer: this.status
3310
+ ? ink.warn(`${this.statusWindow ? `[${this.statusWindow}] ` : ''}${this.status}`)
3311
+ : keyList(this.keys(), '', inner, ink),
3095
3312
  }, ink),
3096
3313
  });
3097
3314
  if (strip) {
@@ -3107,11 +3324,18 @@ export class Attached {
3107
3324
  * Every card of every project this machine serves, whatever its status —
3108
3325
  * the strip draws them all, and `d` offers the PLANNED ones. One request
3109
3326
  * per project, the board's own (`brief`, so no bodies). Needs a person's
3110
- * session, like everything read off the roadmap here; a failure keeps the
3111
- * last list rather than blanking the strip.
3327
+ * session, like everything read off the roadmap here.
3328
+ *
3329
+ * A project that cannot be read keeps ITS last cards and no other's: this
3330
+ * used to give up on the first failure and keep the whole last list, so one
3331
+ * served slug the session could not reach left every column at 0 — a strip
3332
+ * saying "nothing considered" about a board full of ideas, and saying it
3333
+ * silently. What failed is returned so `/refresh` can say it.
3112
3334
  */
3113
- async refreshPlanned() {
3114
- if (!this.session.signedIn) return;
3335
+ async refreshPlanned({ hold = false } = {}) {
3336
+ if (!this.session.signedIn) return { signedIn: false, read: [], failed: [] };
3337
+ const read = [];
3338
+ const failed = [];
3115
3339
  const all = [];
3116
3340
  for (const slug of this.runner?.projects ?? []) {
3117
3341
  try {
@@ -3119,13 +3343,66 @@ export class Attached {
3119
3343
  for (const card of Array.isArray(cards) ? cards : []) {
3120
3344
  all.push({ ref: card.ref, title: card.title, slug, status: card.status, live: Boolean(card.activeRun) });
3121
3345
  }
3122
- } catch {
3123
- return;
3346
+ read.push(slug);
3347
+ this.gone.delete(slug);
3348
+ } catch (failure) {
3349
+ if (failure?.status === 404) {
3350
+ this.forgetGone(slug);
3351
+ continue;
3352
+ }
3353
+ failed.push({ slug, message: failure?.message ?? String(failure) });
3354
+ all.push(...this.cards.filter((card) => card.slug === slug));
3124
3355
  }
3125
3356
  }
3126
3357
  this.cards = all;
3127
3358
  this.planned = all.filter((card) => card.status === 'PLANNED');
3128
3359
  this.dirty = true;
3360
+ if (!hold) this.sayGone();
3361
+ return { signedIn: true, read, failed };
3362
+ }
3363
+
3364
+ /** A served project cawdev answered 404 for: hidden, and said once. */
3365
+ forgetGone(slug) {
3366
+ if (this.gone.has(slug)) return;
3367
+ this.gone.add(slug);
3368
+ this.goneUnsaid.push(slug);
3369
+ }
3370
+
3371
+ /** What `forgetGone` learnt, said where it will be read. */
3372
+ sayGone() {
3373
+ for (const slug of this.goneUnsaid.splice(0)) {
3374
+ this.say(this.ink.warn(` this machine serves ${slug}, but cawdev has no such project for you any more`
3375
+ + ' — deleted, or your access went. It is hidden here;'
3376
+ + ` c → Stop serving ${slug} takes it off this machine.`));
3377
+ }
3378
+ }
3379
+
3380
+ /**
3381
+ * `/refresh`: the cards and their statuses read again now, rather than on
3382
+ * the strip's thirty-second timer — somebody who just moved a card in the
3383
+ * console should not have to wait for the terminal to believe it. Says what
3384
+ * it read per project shown, and which could not be read and why.
3385
+ */
3386
+ async refreshCards() {
3387
+ if (!this.requireSignIn('read the roadmap')) return;
3388
+ const { read, failed } = await this.refreshPlanned();
3389
+ const shown = new Set(this.shownProjects());
3390
+ const counts = read.filter((slug) => shown.has(slug)).map((slug) => {
3391
+ const mine = this.cards.filter((card) => card.slug === slug);
3392
+ const tally = BOARD_COLUMNS
3393
+ .map(([status, label]) => `${mine.filter((card) => card.status === status).length} ${label.toLowerCase()}`)
3394
+ .join(' · ');
3395
+ return `${slug}: ${tally}`;
3396
+ });
3397
+ for (const { slug, message } of failed) {
3398
+ this.say(this.ink.danger(` could not read the roadmap of ${slug}: ${message}`));
3399
+ }
3400
+ if (!read.length && !failed.length) {
3401
+ return this.note('refreshed — this machine serves no project yet');
3402
+ }
3403
+ this.note(failed.length && !counts.length
3404
+ ? `refresh failed for ${failed.map((each) => each.slug).join(', ')}`
3405
+ : `refreshed — ${counts.join('; ')}${failed.length ? ` (${failed.length} could not be read)` : ''}`);
3129
3406
  }
3130
3407
 
3131
3408
  /**
@@ -3170,7 +3447,10 @@ export class Attached {
3170
3447
  return;
3171
3448
  }
3172
3449
  const anyway = words.includes('--anyway');
3173
- const parsed = parseNew(words.filter((word) => word !== '--anyway'), {
3450
+ // `--codex`, `--claude`, `--agy`: this session's agent, over `/agent`'s.
3451
+ const flagged = words.find((word) => AGENT_FLAGS.has(word));
3452
+ let agent = flagged ? flagged.slice(2) : this.agent;
3453
+ const parsed = parseNew(words.filter((word) => word !== '--anyway' && !AGENT_FLAGS.has(word)), {
3174
3454
  // In advanced mode, among the projects it shows.
3175
3455
  projects: this.shownProjects(),
3176
3456
  });
@@ -3197,9 +3477,19 @@ export class Attached {
3197
3477
  // branch with no card: no number, nothing on the development board,
3198
3478
  // and a prompt that never said "commit" — so the work ended
3199
3479
  // uncommitted and `/pr` had nothing to propose.
3200
- await this.developNewCard(parsed.slug, parsed.text, anyway);
3480
+ await this.developNewCard(parsed.slug, parsed.text, anyway, agent);
3201
3481
  return;
3202
3482
  }
3483
+ // Developing a card whose finished plan has a window here: the agent that
3484
+ // planned it develops it unless one was named on the line — it has read
3485
+ // the code once already, and swapping CLIs between the two halves of one
3486
+ // card was a choice nobody made — and the plan's window gives way to the
3487
+ // development's once it has started.
3488
+ const plan = parsed.kind === 'dev' && parsed.ref ? this.planWindowFor(parsed.slug, parsed.ref) : null;
3489
+ const planAgent = plan ? plan.agent ?? plan.view?.agent ?? null : null;
3490
+ if (!flagged && planAgent) {
3491
+ agent = planAgent;
3492
+ }
3203
3493
  const [path, body, what] = parsed.kind === 'ask'
3204
3494
  ? ['runs/ask', { prompt: parsed.text }, 'a question']
3205
3495
  : parsed.kind === 'plan'
@@ -3208,7 +3498,107 @@ export class Attached {
3208
3498
  // R150: the session writes the card first, then plans it.
3209
3499
  : ['runs/plan', { prompt: parsed.text }, 'a plan — it writes its card first']
3210
3500
  : ['runs', { entryNumber: parsed.ref }, `the development of ${parsed.ref}`];
3211
- await this.startSession(parsed.slug, path, body, what, anyway);
3501
+ const started = await this.startSession(parsed.slug, path, withAgent(body, agent), withWho(what, agent), anyway);
3502
+ if (started && plan) {
3503
+ this.closeWindowOf(plan);
3504
+ this.focusOnArrival = plan.projectSlug;
3505
+ }
3506
+ }
3507
+
3508
+ /** A FINISHED plan on a card, ended here — what `d` and `/develop` develop. */
3509
+ finishedPlan(meta) {
3510
+ return Boolean(meta?.ended && meta.view?.state === 'FINISHED'
3511
+ && meta.view.profile === 'PLAN' && meta.view.entryRef);
3512
+ }
3513
+
3514
+ /** The open window of a finished plan of `ref` in `slug`, if there is one. */
3515
+ planWindowFor(slug, ref) {
3516
+ return [...this.sessions.values()].reverse().find((meta) => !this.closed.has(meta.id)
3517
+ && meta.projectSlug === slug && this.finishedPlan(meta)
3518
+ && meta.view.entryRef.toLowerCase() === String(ref).toLowerCase()) ?? null;
3519
+ }
3520
+
3521
+ /**
3522
+ * `/agent` — which CLI `/new` asks for when a command names none. Kept
3523
+ * across launches (prefs.mjs). `default` goes back to the project's own
3524
+ * choice. A `--codex` on one `/new` still wins over it.
3525
+ */
3526
+ async chooseAgent(name) {
3527
+ if (!name) {
3528
+ return this.note(this.agent
3529
+ ? `/new uses ${this.agent} — /agent default goes back to the project's choice`
3530
+ : "/new uses the project's default agent — /agent claude|codex|agy picks one here");
3531
+ }
3532
+ const wanted = name.toLowerCase();
3533
+ if (['default', 'auto', 'none', 'project'].includes(wanted)) {
3534
+ this.agent = null;
3535
+ await savePrefs(this.session.url, { agent: null }).catch(() => undefined);
3536
+ this.dirty = true;
3537
+ return this.note("/new uses the project's default agent again");
3538
+ }
3539
+ if (!KNOWN_AGENTS.includes(wanted)) {
3540
+ return this.note(`no agent called ${name} — ${KNOWN_AGENTS.join(', ')}`);
3541
+ }
3542
+ this.agent = wanted;
3543
+ await savePrefs(this.session.url, { agent: wanted }).catch(() => undefined);
3544
+ this.dirty = true;
3545
+ // Said, not refused: the platform may send the run to another machine,
3546
+ // and this one can be told to spawn it (`c` → Enable) in a second.
3547
+ const here = (this.runner?.agents ?? []).map((command) => agentOfCommand(command));
3548
+ return this.note(this.runner?.agents && !here.includes(wanted)
3549
+ ? `/new uses ${wanted} — but this machine does not spawn it: c → Enable ${wanted}`
3550
+ : `/new uses ${wanted}`);
3551
+ }
3552
+
3553
+ /**
3554
+ * `/card <the idea in words>` — a card in CONSIDERING, and nothing else: no
3555
+ * session, no branch, nothing spent. The roadmap's first column is where an
3556
+ * idea waits to be decided on, and writing one down should not need a model
3557
+ * to do it. The project is chosen as `/new` chooses it: `@slug`, else
3558
+ * advanced mode narrowed to one, else asked — never guessed.
3559
+ */
3560
+ async addCard(words) {
3561
+ if (!this.requireSignIn('write a card')) return;
3562
+ const named = words.find((word) => word.startsWith('@'))?.slice(1) ?? null;
3563
+ const text = wordsText(words.filter((word) => word && !word.startsWith('@')));
3564
+ if (!text) {
3565
+ this.note('/card what? — /card <the idea in words> (@project)');
3566
+ return;
3567
+ }
3568
+ const served = this.servedProjects();
3569
+ const shown = this.shownProjects();
3570
+ const slug = named ?? (this.split?.projects && shown.length === 1 ? shown[0] : null);
3571
+ if (named && !served.includes(named)) {
3572
+ this.note(`this machine does not serve ${named} — ${served.join(', ') || 'no project'}`);
3573
+ return;
3574
+ }
3575
+ if (!slug) {
3576
+ if (!shown.length) {
3577
+ this.note('this machine serves no project to write a card in');
3578
+ return;
3579
+ }
3580
+ this.openPicker(new Select({
3581
+ kind: 'card-project',
3582
+ title: 'which project is this card for?',
3583
+ rows: shown.map((each) => ({ id: each, label: each })),
3584
+ }));
3585
+ this.cardWaitingForProject = words;
3586
+ return;
3587
+ }
3588
+ let card;
3589
+ try {
3590
+ card = await this.session.request(`/api/projects/${slug}/roadmap`, {
3591
+ method: 'POST',
3592
+ body: { title: cardTitleFor(text), body: text, status: 'CONSIDERING' },
3593
+ });
3594
+ } catch (failure) {
3595
+ this.note(`could not write the card: ${failure.message}`);
3596
+ return;
3597
+ }
3598
+ const ref = card?.ref ?? (card?.number ? `R${card.number}` : 'the card');
3599
+ this.say(this.ink.success(` wrote ${ref} ${card?.title ?? cardTitleFor(text)} in Considering on ${slug}`));
3600
+ this.note('');
3601
+ void this.refreshPlanned();
3212
3602
  }
3213
3603
 
3214
3604
  /**
@@ -3217,7 +3607,7 @@ export class Attached {
3217
3607
  * but whose run was refused (a usage window, say) is still on the board,
3218
3608
  * and the person should know which half happened.
3219
3609
  */
3220
- async developNewCard(slug, text, anyway) {
3610
+ async developNewCard(slug, text, anyway, agent = this.agent) {
3221
3611
  let card;
3222
3612
  try {
3223
3613
  card = await this.session.request(`/api/projects/${slug}/roadmap`, {
@@ -3234,7 +3624,8 @@ export class Attached {
3234
3624
  return;
3235
3625
  }
3236
3626
  this.say(this.ink.success(` wrote ${ref} ${card.title ?? ''} on ${slug}`));
3237
- await this.startSession(slug, 'runs', { entryNumber: ref }, `the development of ${ref}`, anyway);
3627
+ await this.startSession(slug, 'runs', withAgent({ entryNumber: ref }, agent),
3628
+ withWho(`the development of ${ref}`, agent), anyway);
3238
3629
  void this.refreshPlanned();
3239
3630
  }
3240
3631
 
@@ -3330,20 +3721,39 @@ export class Attached {
3330
3721
  }
3331
3722
  }
3332
3723
 
3333
- /** `/develop` — the card a finished plan was about, on this machine. */
3334
- async develop() {
3724
+ /**
3725
+ * `/develop` — start the development of a card on this machine: the card a
3726
+ * finished plan was about (the focused session's), or the one named —
3727
+ * `/develop R30`, `@slug` for its project. It is `/new dev R30` said from a
3728
+ * plan's window, and it goes THROUGH `startNew` so the agent is chosen the
3729
+ * same way: `--codex`/`--claude`/`--agy`, else `/agent`'s. It used to ignore
3730
+ * every word after it, so `/develop R30 --codex` started the focused plan's
3731
+ * card with the runner's default agent — Claude — and said nothing.
3732
+ * Words that are none of those (`starts`, from the window's own hint) are
3733
+ * passed over.
3734
+ */
3735
+ async develop(words = []) {
3335
3736
  if (!this.requireSignIn('start a session')) return;
3737
+ const flags = words.filter((word) => word.startsWith('--'));
3738
+ const named = words.find((word) => word.startsWith('@'));
3739
+ const typed = words.find((word) => /^r?\d+$/i.test(word));
3740
+ if (typed) {
3741
+ const ref = typed.replace(/^r/i, 'R').replace(/^(\d)/, 'R$1');
3742
+ const slug = named?.slice(1) ?? this.commandTarget()?.projectSlug;
3743
+ await this.startNew(['dev', ref, ...(slug ? [`@${slug}`] : []), ...flags]);
3744
+ return;
3745
+ }
3336
3746
  const meta = this.commandTarget();
3337
3747
  const ref = meta?.view?.entryRef;
3338
3748
  if (!meta || !ref) {
3339
- this.note('/develop needs a session on a card — focus a finished plan');
3749
+ this.note('/develop needs a session on a card — focus a finished plan, or name one: /develop R30');
3340
3750
  return;
3341
3751
  }
3342
3752
  if (!meta.ended) {
3343
3753
  this.note(`${ref} is still being worked on — wait for it to end`);
3344
3754
  return;
3345
3755
  }
3346
- await this.startSession(meta.projectSlug, 'runs', { entryNumber: ref }, `the development of ${ref}`, false);
3756
+ await this.startNew(['dev', ref, `@${meta.projectSlug}`, ...flags]);
3347
3757
  }
3348
3758
 
3349
3759
  /**
@@ -3364,10 +3774,12 @@ export class Attached {
3364
3774
  });
3365
3775
  this.say(this.ink.success(` started ${what} on ${slug} — its window opens when this machine takes it`));
3366
3776
  this.note('');
3777
+ return true;
3367
3778
  } catch (failure) {
3368
3779
  this.note(/usage|window/i.test(failure.message)
3369
3780
  ? `${failure.message} — add --anyway to start it regardless`
3370
3781
  : failure.message);
3782
+ return false;
3371
3783
  }
3372
3784
  }
3373
3785
 
@@ -3467,21 +3879,181 @@ export class Attached {
3467
3879
  again.unref?.();
3468
3880
  }
3469
3881
 
3470
- /** `/open` — the session's pull request, in the browser. */
3471
- openInBrowser() {
3472
- const url = this.commandTarget()?.view?.prUrl;
3882
+ /**
3883
+ * `/open` — the session's pull request, in the browser. A finished plan has
3884
+ * none: what it produced is the card's plan of record, so `/open` there is
3885
+ * the card on cawdev, opened on its plan (`?show=plan`).
3886
+ */
3887
+ async openInBrowser() {
3888
+ const meta = this.commandTarget();
3889
+ if (!meta) {
3890
+ return this.note('/open needs a session — focus one');
3891
+ }
3892
+ if (this.finishedPlan(meta)) {
3893
+ const ref = meta.view.entryRef;
3894
+ const board = /^i/i.test(ref) ? 'issues' : 'roadmap';
3895
+ const url = this.consoleUrl(meta.projectSlug ?? meta.view?.projectSlug, `${board}/${ref}?show=plan`);
3896
+ if (!url) {
3897
+ return this.note('cannot tell where this card is on cawdev');
3898
+ }
3899
+ this.openLink(url);
3900
+ return this.note(`opened ${url}`);
3901
+ }
3902
+ // Read again, not the view taken when the session ended: the runner opens
3903
+ // the pull request and records its link AFTER the run is FINISHED, so the
3904
+ // copy read at that moment had none — and /open said "no pull request"
3905
+ // about a branch with one open on GitHub.
3906
+ await this.readView(meta);
3907
+ let url = meta.view?.prUrl;
3908
+ // A staged run's pull request can be the card's, through its work item
3909
+ // (i168) — the branch's, whichever run opened it.
3910
+ const ref = meta.view?.entryRef;
3911
+ if (!isPullRequest(url) && ref && this.session.signedIn) {
3912
+ const card = await this.session.request(`/api/projects/${meta.projectSlug}/roadmap/${ref}`)
3913
+ .catch(() => null);
3914
+ url = card?.workItem?.prUrl ?? card?.review?.prUrl ?? url;
3915
+ }
3473
3916
  if (!isPullRequest(url)) {
3474
3917
  return this.note('no pull request on this session yet — /pr opens one');
3475
3918
  }
3919
+ this.openLink(url);
3920
+ return this.note(`opened ${url}`);
3921
+ }
3922
+
3923
+ /** A link, in this machine's browser. */
3924
+ openLink(url) {
3476
3925
  const opener = this.options.openUrl ?? ((link) => {
3477
3926
  const command = process.platform === 'darwin' ? 'open'
3478
3927
  : process.platform === 'win32' ? 'explorer' : 'xdg-open';
3479
3928
  spawn(command, [link], { stdio: 'ignore', detached: true }).on('error', () => undefined).unref();
3480
3929
  });
3481
3930
  opener(url);
3931
+ }
3932
+
3933
+ /**
3934
+ * `/view` alone — the focused session's page on cawdev (its transcript,
3935
+ * stages, card and checkout). On the console this terminal signed in to:
3936
+ * the sign-in URL is the console's, where the daemon's may be the API
3937
+ * behind it.
3938
+ */
3939
+ viewOnCawdev() {
3940
+ const meta = this.commandTarget();
3941
+ if (!meta) {
3942
+ return this.note('/view needs a session — focus one, or name a card: /view R29');
3943
+ }
3944
+ const url = this.consoleUrl(meta.projectSlug ?? meta.view?.projectSlug, `runs/${meta.id}`);
3945
+ if (!url) {
3946
+ return this.note('cannot tell where this session is on cawdev');
3947
+ }
3948
+ this.openLink(url);
3482
3949
  return this.note(`opened ${url}`);
3483
3950
  }
3484
3951
 
3952
+ /** A page of a project on the console this terminal signed in to, or null. */
3953
+ consoleUrl(slug, path) {
3954
+ const base = String(this.session.url ?? '').replace(/\/+$/, '');
3955
+ return base && slug ? `${base}/projects/${slug}/${path}` : null;
3956
+ }
3957
+
3958
+ /**
3959
+ * `/view R29` · `/view i12` · `/view <words of its title>` · `/view card`
3960
+ * — a card printed here, rather than a browser opened on it: somebody at
3961
+ * this terminal wants to read what a card says without leaving it. `card`
3962
+ * is the focused session's own. `@slug` names the project; otherwise every
3963
+ * project on screen is looked in, and a ref or words matching cards in
3964
+ * several are offered as a list rather than guessed between.
3965
+ */
3966
+ async viewCard(words) {
3967
+ if (!this.requireSignIn('read a card')) return;
3968
+ const named = words.filter((word) => word.startsWith('@')).map((word) => word.slice(1));
3969
+ const bare = words.filter((word) => word && !word.startsWith('@'));
3970
+ if (bare.length === 1 && bare[0].toLowerCase() === 'card') {
3971
+ const meta = this.commandTarget();
3972
+ const ref = meta?.view?.entryRef;
3973
+ if (!ref) {
3974
+ return this.note(meta ? 'this session has no card — /view R29 names one' : '/view card needs a session — focus one');
3975
+ }
3976
+ return this.showCard(meta.projectSlug, ref);
3977
+ }
3978
+ const projects = named.length ? named
3979
+ : this.shownProjects().length ? this.shownProjects() : this.servedProjects();
3980
+ if (!projects.length) {
3981
+ return this.note('no project here to look in — name one: /view R29 @slug');
3982
+ }
3983
+ const asRef = bare.length === 1 && /^[ri]?\d+$/i.test(bare[0]) ? refOf(bare[0]) : null;
3984
+ let found;
3985
+ if (asRef) {
3986
+ const reads = await Promise.all(projects.map((slug) => this.session
3987
+ .request(`/api/projects/${slug}/roadmap/${asRef}`)
3988
+ .then((card) => (card ? { slug, ref: card.ref ?? asRef, title: card.title } : null))
3989
+ .catch(() => null)));
3990
+ found = reads.filter(Boolean);
3991
+ if (!found.length) {
3992
+ return this.note(`no ${asRef} in ${projects.join(', ')}`);
3993
+ }
3994
+ } else {
3995
+ const wanted = bare.map((word) => word.toLowerCase());
3996
+ if (!wanted.length) {
3997
+ return this.note('/view which card? /view R29, or words of its title');
3998
+ }
3999
+ await this.refreshPlanned();
4000
+ found = this.cards.filter((card) => projects.includes(card.slug)
4001
+ && wanted.every((word) => String(card.title ?? '').toLowerCase().includes(word)));
4002
+ if (!found.length) {
4003
+ return this.note(`no card titled like "${bare.join(' ')}" in ${projects.join(', ')}`);
4004
+ }
4005
+ }
4006
+ if (found.length === 1) {
4007
+ return this.showCard(found[0].slug, found[0].ref);
4008
+ }
4009
+ const several = new Set(found.map((card) => card.slug)).size > 1;
4010
+ this.openPicker(new Select({
4011
+ kind: 'view-card',
4012
+ title: 'view which card?',
4013
+ rows: found.map((card) => ({
4014
+ id: `${card.slug} ${card.ref}`,
4015
+ label: `${card.ref} ${card.title ?? ''}`.trim(),
4016
+ ...(several ? { hint: card.slug } : {}),
4017
+ })),
4018
+ }));
4019
+ return undefined;
4020
+ }
4021
+
4022
+ /** One card, read fresh off the platform and printed into the transcript. */
4023
+ async showCard(slug, ref) {
4024
+ let card;
4025
+ try {
4026
+ card = await this.session.request(`/api/projects/${slug}/roadmap/${ref}`);
4027
+ } catch (failure) {
4028
+ return this.note(`could not read ${ref} on ${slug}: ${failure.message}`);
4029
+ }
4030
+ if (!card) {
4031
+ return this.note(`no ${ref} on ${slug}`);
4032
+ }
4033
+ const url = this.consoleUrl(slug, `roadmap/${card.ref ?? ref}`);
4034
+ this.say('');
4035
+ for (const line of cardLines(card, this.ink, url)) this.say(line);
4036
+ this.say('');
4037
+ return this.note(`${card.ref ?? ref} on ${slug}`);
4038
+ }
4039
+
4040
+ /** ↑ with no line open: the most recent entry, on the line it belongs to. */
4041
+ recallIntoALine() {
4042
+ const last = this.history.entries.at(-1);
4043
+ if (last === undefined) {
4044
+ return this.note('nothing typed yet to go back to');
4045
+ }
4046
+ const command = last.startsWith('/');
4047
+ // The line first — opening one resets the history — then one step back,
4048
+ // so the next ↑ goes further rather than to the same entry again.
4049
+ this.type(command ? 'command' : 'prompt', command ? 'cawdev ▸' : 'prompt ▸', '');
4050
+ this.input.line.set(this.history.back(''));
4051
+ this.input.recalling = true;
4052
+ this.input.showing = false;
4053
+ this.dirty = true;
4054
+ return undefined;
4055
+ }
4056
+
3485
4057
  /**
3486
4058
  * `/drop` — delete the checkout a finished session was cloned into.
3487
4059
  *
@@ -3556,11 +4128,17 @@ export class Attached {
3556
4128
  const meta = this.commandTarget();
3557
4129
  if (!meta) return this.note('no window to close');
3558
4130
  if (!meta.ended) return this.note('that session is still running — /cancel stops it');
4131
+ this.closeWindowOf(meta);
4132
+ return this.note('closed');
4133
+ }
4134
+
4135
+ /** An ended session's window goes; focus moves to the first one left. */
4136
+ closeWindowOf(meta) {
3559
4137
  this.closed.add(meta.id);
3560
- if (this.split) {
4138
+ if (this.split && (this.split.focus === meta.id || !this.sessions.has(this.split.focus))) {
3561
4139
  this.split.focus = this.windows()[0]?.id ?? null;
3562
4140
  }
3563
- return this.note('closed');
4141
+ this.dirty = true;
3564
4142
  }
3565
4143
 
3566
4144
  // --- the things a person can do ---------------------------------------------
@@ -4070,7 +4648,8 @@ export class Attached {
4070
4648
  }
4071
4649
  if (this.split) {
4072
4650
  parts.push(`${ink.text('tab')} ${ink.muted('next window')}`);
4073
- parts.push(`${ink.text('d')} ${ink.muted('develop a planned card')}`);
4651
+ parts.push(`${ink.text('d')} ${ink.muted(this.finishedPlan(this.commandTarget())
4652
+ ? `develop ${this.commandTarget().view.entryRef}` : 'develop a planned card')}`);
4074
4653
  }
4075
4654
  parts.push(`${ink.text('x')} ${ink.muted('cancel')}`);
4076
4655
  parts.push(`${ink.text('c')} ${ink.muted('config')}`);