cawdev-cli 1.0.9-beta → 1.0.11-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/split.mjs CHANGED
@@ -168,6 +168,38 @@ export function tailOf(lines, width, rows) {
168
168
  return out.reverse();
169
169
  }
170
170
 
171
+ /**
172
+ * The reader — `/view` on a session waiting for an answer or a permission.
173
+ *
174
+ * A window's body is the TAIL of what was said and a question's banner fits
175
+ * the prompt's rows, so a long plan or a long question was cut and could not
176
+ * be scrolled. The reader is one window over the whole screen, showing
177
+ * `lines` from row `top` (wrapped to its width), with where it is in the
178
+ * title. `top` null means "start where the request starts" (`requestAt`);
179
+ * whatever is asked for is clamped, and the answer says what was used so the
180
+ * keys can page from it.
181
+ */
182
+ export function readerFrame(width, height, { title = '', lines = [], requestAt = 0, top = null, footer = '' }, ink) {
183
+ const inner = Math.max(1, width - 2);
184
+ const rows = [];
185
+ let requestRow = 0;
186
+ lines.forEach((line, i) => {
187
+ if (i === requestAt) requestRow = rows.length;
188
+ rows.push(...wrap(String(line ?? ''), inner));
189
+ });
190
+ const room = Math.max(1, height - 2 - (footer ? 1 : 0));
191
+ const max = Math.max(0, rows.length - room);
192
+ const at = Math.min(Math.max(0, top ?? requestRow), max);
193
+ const shown = rows.slice(at, at + room);
194
+ const where = rows.length > room ? ` · ${at + 1}–${at + shown.length} of ${rows.length}` : '';
195
+ return {
196
+ lines: windowLines({ width, height }, { title: `${title}${where}`, body: shown, footer, focused: true }, ink),
197
+ top: at,
198
+ max,
199
+ room,
200
+ };
201
+ }
202
+
171
203
  /**
172
204
  * The whole screen, from placed pieces: `height` rows, each the pieces that
173
205
  * cross it laid left to right, and blank where nothing is.
@@ -629,14 +661,31 @@ export function wordWrap(text, width) {
629
661
  * terminal acts on. MERGED, SHIPPED and DECLINED are not columns: the work
630
662
  * is over, the three only grow, and the console's own board folds the last
631
663
  * two to a count (R68) for the same reason — so here all three are counts
632
- * beside the project's name.
664
+ * beside the project's name. BACKLOG is a count too, at the head of them: it
665
+ * is work not yet taken up rather than work over, but a column of it would be
666
+ * the first to give way, and since R371 it is where a new issue lands — left
667
+ * out, a project's fresh bugs were on no line of the strip at all.
633
668
  */
634
669
  export const BOARD_COLUMNS = [
635
670
  ['CONSIDERING', 'Considering'],
636
671
  ['PLANNED', 'Planned'],
637
672
  ['IN_DEVELOPMENT', 'In development'],
638
673
  ];
639
- const FOLDED = [['MERGED', 'merged'], ['SHIPPED', 'shipped'], ['DECLINED', 'declined']];
674
+ const FOLDED = [['BACKLOG', 'backlog'], ['MERGED', 'merged'], ['SHIPPED', 'shipped'], ['DECLINED', 'declined']];
675
+
676
+ /**
677
+ * The column a card is drawn in. An issue (R85) has statuses of its own for
678
+ * the first two steps — NEW is reported and not yet agreed, CONFIRMED is
679
+ * agreed and waiting for somebody, the platform's `afterPlanning` — and they
680
+ * are Considering's and Planned's counterparts, so they share those columns
681
+ * rather than adding two more to a strip that already gives columns away.
682
+ */
683
+ export function columnOf(status) {
684
+ return { NEW: 'CONSIDERING', CONFIRMED: 'PLANNED' }[status] ?? status;
685
+ }
686
+
687
+ /** Most broken first: the issues board's own order (R85), and what a column cut short keeps. */
688
+ const SEVERITY_ORDER = ['CRITICAL', 'MEDIUM', 'MINOR'];
640
689
 
641
690
  /**
642
691
  * Which column gives way first when the width runs out: the ideas, then what
@@ -669,7 +718,12 @@ const STRIP_HEAD = 2;
669
718
  * column needs, no more — of exactly `width` columns. One line when there is
670
719
  * no project at all.
671
720
  *
672
- * @param cards `[{ ref, title, slug, status, live? }]`
721
+ * ISSUES SHARE THE COLUMNS (`columnOf`) and go first in each, most severe
722
+ * first: a column cut to `+N more` should not hide a critical defect under
723
+ * ideas. Their ref (`i12`) is in the colour of their severity.
724
+ *
725
+ * @param cards `[{ ref, title, slug, status, live?, severity? }]` — a card
726
+ * with a `severity` is an issue
673
727
  * @param projects the slugs to show, in order; defaults to those the cards
674
728
  * come from
675
729
  */
@@ -684,7 +738,7 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
684
738
  slug,
685
739
  folded: FOLDED.map(([status, word]) => [word, mine.filter((c) => c.status === status).length]),
686
740
  columns: BOARD_COLUMNS.map(([status, label]) => ({
687
- status, label, cards: mine.filter((card) => card.status === status),
741
+ status, label, cards: inColumn(mine, status),
688
742
  })),
689
743
  hidden: [],
690
744
  };
@@ -764,7 +818,7 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
764
818
  } else if (column.cards[row]) {
765
819
  const card = column.cards[row];
766
820
  const mark = card.live ? ink.accent('●') : ' ';
767
- cell = `${mark}${ink.bold(card.ref)} ${card.title ?? ''}`;
821
+ cell = `${mark}${refInk(card, ink)(card.ref)} ${card.title ?? ''}`;
768
822
  } else if (row === 0 && !column.cards.length) {
769
823
  cell = ink.muted(' —');
770
824
  }
@@ -774,6 +828,24 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
774
828
  return lines.map((line) => padVisible(clip(line, width), width));
775
829
  }
776
830
 
831
+ /** A column's cards: its issues first, most severe first, then its roadmap cards, each in the order given. */
832
+ function inColumn(cards, status) {
833
+ const here = cards.filter((card) => columnOf(card.status) === status);
834
+ const rank = (card) => {
835
+ const at = SEVERITY_ORDER.indexOf(card.severity);
836
+ return at === -1 ? SEVERITY_ORDER.length : at;
837
+ };
838
+ const issues = here.filter((card) => card.severity).sort((a, b) => rank(a) - rank(b));
839
+ return [...issues, ...here.filter((card) => !card.severity)];
840
+ }
841
+
842
+ /** A card's ref in bold; an issue's in the colour of how broken it is. */
843
+ function refInk(card, ink) {
844
+ if (!card.severity) return ink.bold;
845
+ const hue = card.severity === 'CRITICAL' ? ink.danger : card.severity === 'MEDIUM' ? ink.warn : ink.muted;
846
+ return (text) => ink.bold(hue(text));
847
+ }
848
+
777
849
  /** Clipped to `width`, with `…` where it had to stop — a title cut dead reads as the whole title. */
778
850
  function ellipsed(text, width) {
779
851
  return visibleWidth(text) <= width ? text : `${clip(text, width - 1)}…`;
@@ -0,0 +1,78 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { access, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * Whether a workspace folder can be used at all, and how to say so — R377.
7
+ *
8
+ * Node reports a missing `cwd` as `spawn claude ENOENT`, which reads as "the
9
+ * agent CLI is not installed" and sends a person looking at their PATH. The
10
+ * folder is checked before it gets that far, and said in its own words.
11
+ *
12
+ * `.git` is checked with `access`, never `isDirectory()`: in a worktree —
13
+ * including the `-read` copy — it is a file.
14
+ */
15
+ export async function workspaceProblem(path) {
16
+ try {
17
+ const found = await stat(path);
18
+ if (!found.isDirectory()) return { kind: 'not-a-directory', path };
19
+ } catch (failure) {
20
+ // ENOTDIR: a parent of it is a file, so it is no more there than if absent.
21
+ if (failure.code === 'ENOENT' || failure.code === 'ENOTDIR') return { kind: 'missing', path };
22
+ return { kind: 'unreadable', path, code: failure.code };
23
+ }
24
+ try {
25
+ await access(join(path, '.git'));
26
+ } catch {
27
+ return { kind: 'not-git', path };
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * The sentence for a problem. `first` is the project's `workspaces[0]`.
34
+ * The wording must not change from one poll to the next: `noteQueued` only
35
+ * logs again when the text does.
36
+ */
37
+ export function workspaceProblemSaid(slug, problem, { first = null } = {}) {
38
+ const { kind, path } = problem;
39
+ if (kind === 'missing') {
40
+ const base = `${slug}'s workspace ${path} does not exist on this machine. `
41
+ + 'Clone the repository there, or correct "workspaces" with `cawdev config`.';
42
+ // `createAutoWorkspace` clones FROM the first workspace, so the advice
43
+ // cannot help when that is the one that is missing.
44
+ return path !== first
45
+ ? `${base} Or remove it and set "autoWorkspaces" so the runner clones checkouts beside ${first} itself.`
46
+ : base;
47
+ }
48
+ if (kind === 'not-a-directory') {
49
+ return `${slug}'s workspace ${path} is a file, not a folder. `
50
+ + 'Correct "workspaces" with `cawdev config`.';
51
+ }
52
+ if (kind === 'unreadable') {
53
+ return `${slug}'s workspace ${path} cannot be read (${problem.code ?? 'unknown error'}). `
54
+ + 'Fix its permissions, or correct "workspaces" with `cawdev config`.';
55
+ }
56
+ return `${slug}'s workspace ${path} exists but is not a git checkout (no .git in it). `
57
+ + 'Clone the repository there, or correct "workspaces" with `cawdev config`.';
58
+ }
59
+
60
+ function problemSync(cwd) {
61
+ if (!cwd || !existsSync(cwd)) return { kind: 'missing', path: cwd };
62
+ if (!statSync(cwd).isDirectory()) return { kind: 'not-a-directory', path: cwd };
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * What to tell a person when `spawn` fails. Runs inside an `'error'` handler,
68
+ * hence the synchronous checks. `label` is "the agent", "Antigravity" or "Codex".
69
+ */
70
+ export function spawnFailureSaid({ failure, command, cwd, slug, label, first = null }) {
71
+ if (failure?.code === 'ENOENT') {
72
+ const problem = problemSync(cwd);
73
+ if (problem) return workspaceProblemSaid(slug, problem, { first });
74
+ return `\`${command}\` not found on this runner's PATH — set "agentCommands" to its full path `
75
+ + `(run \`which ${command}\` where it works).`;
76
+ }
77
+ return `Could not spawn ${label}: ${failure?.message}`;
78
+ }