@crouton-kit/crouter 0.3.15 → 0.3.17

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.
Files changed (101) hide show
  1. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  2. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  3. package/dist/builtin-personas/plan/base.md +1 -1
  4. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  5. package/dist/builtin-personas/spec/base.md +1 -1
  6. package/dist/commands/canvas-browse.d.ts +2 -0
  7. package/dist/commands/canvas-browse.js +45 -0
  8. package/dist/commands/canvas-prune.js +11 -2
  9. package/dist/commands/canvas.js +3 -2
  10. package/dist/commands/chord.js +1 -1
  11. package/dist/commands/human/shared.js +1 -1
  12. package/dist/commands/node.js +14 -2
  13. package/dist/commands/skill/author.js +2 -2
  14. package/dist/commands/tmux-spread.js +2 -3
  15. package/dist/core/__tests__/cascade-close.test.js +199 -0
  16. package/dist/core/__tests__/close.test.js +2 -2
  17. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  18. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  19. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  20. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  21. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  22. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  23. package/dist/core/__tests__/focuses.test.js +5 -68
  24. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  25. package/dist/core/__tests__/grace-clock.test.js +115 -0
  26. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  27. package/dist/core/__tests__/helpers/harness.js +406 -0
  28. package/dist/core/__tests__/home-session.test.js +1 -1
  29. package/dist/core/__tests__/lifecycle.test.js +6 -13
  30. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  31. package/dist/core/__tests__/live-mutation.test.js +341 -0
  32. package/dist/core/__tests__/placement-focus.test.js +106 -46
  33. package/dist/core/__tests__/placement-teardown.test.js +4 -9
  34. package/dist/core/__tests__/relaunch.test.js +22 -16
  35. package/dist/core/__tests__/reset.test.js +11 -6
  36. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  37. package/dist/core/__tests__/spike-harness.test.js +241 -0
  38. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  39. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  40. package/dist/core/__tests__/tmux-surface.test.js +8 -9
  41. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  42. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  43. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  44. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  45. package/dist/core/canvas/browse/app.d.ts +4 -0
  46. package/dist/core/canvas/browse/app.js +349 -0
  47. package/dist/core/canvas/browse/model.d.ts +97 -0
  48. package/dist/core/canvas/browse/model.js +258 -0
  49. package/dist/core/canvas/browse/render.d.ts +41 -0
  50. package/dist/core/canvas/browse/render.js +387 -0
  51. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  52. package/dist/core/canvas/browse/terminal.js +100 -0
  53. package/dist/core/canvas/canvas.d.ts +9 -2
  54. package/dist/core/canvas/canvas.js +41 -3
  55. package/dist/core/canvas/db.js +2 -3
  56. package/dist/core/canvas/focuses.d.ts +2 -2
  57. package/dist/core/canvas/focuses.js +4 -3
  58. package/dist/core/canvas/render.d.ts +10 -0
  59. package/dist/core/canvas/render.js +25 -1
  60. package/dist/core/canvas/types.d.ts +1 -1
  61. package/dist/core/feed/inbox.d.ts +0 -3
  62. package/dist/core/feed/inbox.js +1 -5
  63. package/dist/core/runtime/busy.d.ts +8 -0
  64. package/dist/core/runtime/busy.js +46 -0
  65. package/dist/core/runtime/close.js +2 -2
  66. package/dist/core/runtime/demote.js +2 -7
  67. package/dist/core/runtime/launch.d.ts +3 -1
  68. package/dist/core/runtime/launch.js +4 -1
  69. package/dist/core/runtime/lifecycle.d.ts +1 -1
  70. package/dist/core/runtime/lifecycle.js +12 -4
  71. package/dist/core/runtime/naming.d.ts +3 -3
  72. package/dist/core/runtime/naming.js +6 -6
  73. package/dist/core/runtime/nodes.d.ts +7 -0
  74. package/dist/core/runtime/nodes.js +10 -1
  75. package/dist/core/runtime/placement.d.ts +39 -10
  76. package/dist/core/runtime/placement.js +100 -44
  77. package/dist/core/runtime/reset.d.ts +11 -8
  78. package/dist/core/runtime/reset.js +36 -31
  79. package/dist/core/runtime/revive.d.ts +1 -1
  80. package/dist/core/runtime/revive.js +2 -2
  81. package/dist/core/runtime/spawn.js +3 -3
  82. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  83. package/dist/core/runtime/tmux-chrome.js +4 -0
  84. package/dist/core/runtime/tmux.d.ts +13 -6
  85. package/dist/core/runtime/tmux.js +21 -12
  86. package/dist/daemon/crtrd.js +43 -21
  87. package/dist/pi-extensions/canvas-nav.js +40 -28
  88. package/dist/pi-extensions/canvas-resume.d.ts +21 -0
  89. package/dist/pi-extensions/canvas-resume.js +82 -0
  90. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  91. package/dist/pi-extensions/canvas-stophook.js +21 -9
  92. package/dist/prompts/skill.js +6 -1
  93. package/package.json +2 -2
  94. package/dist/commands/__tests__/skill.test.js +0 -290
  95. package/dist/core/__tests__/pkg.test.js +0 -218
  96. package/dist/core/__tests__/sys.test.js +0 -208
  97. package/dist/core/runtime/presence.d.ts +0 -30
  98. package/dist/core/runtime/presence.js +0 -178
  99. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  100. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  101. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -0,0 +1,258 @@
1
+ // model.ts — pure, TTY-free logic for the canvas browser.
2
+ //
3
+ // Everything here is a pure function of its inputs (no db, no stdin, no ANSI) so
4
+ // it is exhaustively unit-testable. The app layer (app.ts) wires the canvas data
5
+ // access (dashboardRowsAll / listNodes / subscriptionsOf) into buildTree, then
6
+ // drives flatten() on each keystroke.
7
+ export const TABS = ['All', 'Live', 'Dormant', 'Flagged'];
8
+ export const SORTS = ['tree', 'relevance', 'recency'];
9
+ /** Does a node belong to this tab's slice?
10
+ * All — every node.
11
+ * Live — active | idle.
12
+ * Dormant — done | dead | canceled.
13
+ * Flagged — has > 0 pending human asks. */
14
+ export function tabPredicate(tab, row) {
15
+ switch (tab) {
16
+ case 'All': return true;
17
+ case 'Live': return row.status === 'active' || row.status === 'idle';
18
+ case 'Dormant': return row.status === 'done' || row.status === 'dead' || row.status === 'canceled';
19
+ case 'Flagged': return row.asks > 0;
20
+ }
21
+ }
22
+ /** Sort rank for roots/stragglers — live first (active, then idle), dormant
23
+ * after. Mirrors render.ts / canvas-resume.ts statusRank. */
24
+ export function statusRank(status) {
25
+ switch (status) {
26
+ case 'active': return 0;
27
+ case 'idle': return 1;
28
+ case 'done': return 2;
29
+ case 'canceled': return 3;
30
+ case 'dead': return 4;
31
+ default: return 5;
32
+ }
33
+ }
34
+ /**
35
+ * Build a spanning tree of the whole canvas.
36
+ * - `rows` — one DashboardRow per node (display text + status/asks).
37
+ * - `rootIds` — node ids whose `parent === null` (raw, unsorted).
38
+ * - `childIdsOf` — a node's children = the nodes it subscribes to (its
39
+ * reports), in edge order. (= subscriptionsOf(id) node ids.)
40
+ *
41
+ * Roots are sorted live-first. The graph is walked DFS-preorder; the FIRST
42
+ * parent to reach a node owns it (cycle-/multi-parent-safe via `visited`). Any
43
+ * node never reached from a root (orphaned by a missing subscription edge) is
44
+ * appended as a depth-0 straggler so "All" is genuinely the whole canvas.
45
+ */
46
+ export function buildTree(rows, rootIds, childIdsOf) {
47
+ const rowMap = new Map(rows.map((r) => [r.node_id, r]));
48
+ const nodes = new Map();
49
+ const orderedRoots = [];
50
+ const visited = new Set();
51
+ const rankOf = (id) => {
52
+ const r = rowMap.get(id);
53
+ return r !== undefined ? statusRank(r.status) : 99;
54
+ };
55
+ const walk = (id, depth, parentId) => {
56
+ if (visited.has(id))
57
+ return; // cycle, or already claimed by an earlier parent
58
+ const row = rowMap.get(id);
59
+ if (row === undefined)
60
+ return; // id in the graph but no row (missing meta)
61
+ visited.add(id);
62
+ const childIds = childIdsOf(id).filter((c) => rowMap.has(c) && !visited.has(c));
63
+ nodes.set(id, { row, depth, parentId, childIds });
64
+ for (const c of childIds)
65
+ walk(c, depth + 1, id);
66
+ };
67
+ const sortedRoots = [...rootIds].filter((id) => rowMap.has(id)).sort((a, b) => rankOf(a) - rankOf(b));
68
+ for (const r of sortedRoots) {
69
+ if (visited.has(r))
70
+ continue;
71
+ orderedRoots.push(r);
72
+ walk(r, 0, null);
73
+ }
74
+ // Stragglers: any row never reached from a declared root. Attach live-first as
75
+ // depth-0 pseudo-roots so the whole canvas stays reachable.
76
+ const stragglers = rows
77
+ .map((r) => r.node_id)
78
+ .filter((id) => !visited.has(id))
79
+ .sort((a, b) => rankOf(a) - rankOf(b));
80
+ for (const s of stragglers) {
81
+ if (visited.has(s))
82
+ continue;
83
+ orderedRoots.push(s);
84
+ walk(s, 0, null);
85
+ }
86
+ return { roots: orderedRoots, nodes };
87
+ }
88
+ // ---------------------------------------------------------------------------
89
+ // Fuzzy match
90
+ // ---------------------------------------------------------------------------
91
+ /** Case-insensitive subsequence match: every char of `query` appears in `text`
92
+ * in order (gaps allowed). Empty query matches everything. Substrings are a
93
+ * subsequence, so this subsumes substring matching too. */
94
+ export function fuzzyMatch(query, text) {
95
+ if (query === '')
96
+ return true;
97
+ const q = query.toLowerCase();
98
+ const t = text.toLowerCase();
99
+ let qi = 0;
100
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
101
+ if (t[ti] === q[qi])
102
+ qi++;
103
+ }
104
+ return qi === q.length;
105
+ }
106
+ /** Indices in `text` consumed by a greedy left-to-right subsequence match of
107
+ * `query` — the same walk as `fuzzyMatch`, but returning WHICH chars matched so
108
+ * the renderer can highlight them. Empty set when `query` is empty OR does not
109
+ * fully match (no partial highlights). */
110
+ export function matchIndices(query, text) {
111
+ const out = new Set();
112
+ if (query === '')
113
+ return out;
114
+ const q = query.toLowerCase();
115
+ const t = text.toLowerCase();
116
+ let qi = 0;
117
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
118
+ if (t[ti] === q[qi]) {
119
+ out.add(ti);
120
+ qi++;
121
+ }
122
+ }
123
+ if (qi < q.length)
124
+ out.clear(); // no full match → no highlight
125
+ return out;
126
+ }
127
+ function shortId(id) {
128
+ return id.slice(0, 8);
129
+ }
130
+ /** Does this row match the live query? Super-search spans name (which already
131
+ * folds in the pi-generated description), kind, short-id, AND the spawn prompt
132
+ * (`row.goal`). Empty query matches everything. */
133
+ export function queryMatch(query, row) {
134
+ if (query === '')
135
+ return true;
136
+ return (fuzzyMatch(query, row.name) ||
137
+ fuzzyMatch(query, row.kind) ||
138
+ fuzzyMatch(query, shortId(row.node_id)) ||
139
+ (row.goal !== undefined && fuzzyMatch(query, row.goal)));
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // Relevance scoring (super-search)
143
+ // ---------------------------------------------------------------------------
144
+ /** Score how well `query` matches one field, 0 (no match) → 1 (exact). Tiers:
145
+ * exact > prefix > word-boundary substring > interior substring > subsequence.
146
+ * An interior match decays slightly the later it starts so leading matches win. */
147
+ export function fieldScore(query, text) {
148
+ if (query === '' || text === '')
149
+ return 0;
150
+ const q = query.toLowerCase();
151
+ const t = text.toLowerCase();
152
+ const idx = t.indexOf(q);
153
+ if (idx === 0)
154
+ return t.length === q.length ? 1 : 0.85; // exact | prefix
155
+ if (idx > 0) {
156
+ const prev = t[idx - 1] ?? '';
157
+ const boundary = /[\s_\-/.:]/.test(prev) ? 0.1 : 0; // word-boundary bonus
158
+ return 0.55 + boundary - Math.min(0.2, idx / 400); // interior, decays late
159
+ }
160
+ return fuzzyMatch(q, t) ? 0.2 : 0; // scattered subsequence
161
+ }
162
+ /** Per-field weights — name (handle + description) dominates, the spawn prompt
163
+ * is the long-tail super-search field. */
164
+ const FIELD_WEIGHTS = { name: 4, kind: 2, id: 1, goal: 1.5 };
165
+ /** Weighted relevance of a row to the query across all searched fields. 0 means
166
+ * no field matched (excluded from relevance results, same as `queryMatch`). */
167
+ export function scoreRow(query, row) {
168
+ if (query === '')
169
+ return 0;
170
+ return (FIELD_WEIGHTS.name * fieldScore(query, row.name) +
171
+ FIELD_WEIGHTS.kind * fieldScore(query, row.kind) +
172
+ FIELD_WEIGHTS.id * fieldScore(query, shortId(row.node_id)) +
173
+ FIELD_WEIGHTS.goal * (row.goal !== undefined ? fieldScore(query, row.goal) : 0));
174
+ }
175
+ /** Is this row inside the active cwd scope? No scope (null/undefined) = All dirs. */
176
+ export function cwdMatch(scope, row) {
177
+ return scope === null || scope === undefined || row.cwd === scope;
178
+ }
179
+ /**
180
+ * Flatten the tree to the ordered list of currently-visible rows.
181
+ *
182
+ * Inclusion: a node is shown when it directly matches (tab predicate AND query)
183
+ * — flagged `matched:true` — OR it is an ANCESTOR of a directly-matched node
184
+ * (shown for tree context, `matched:false`, dimmed by the renderer).
185
+ *
186
+ * Collapse: children are emitted only under an EXPANDED node. A node is expanded
187
+ * when it is not in `collapsed` — except under a non-empty query, where every
188
+ * ancestor-of-a-match is force-expanded regardless of `collapsed` so matches are
189
+ * always reachable.
190
+ */
191
+ export function flatten(tree, opts) {
192
+ const { collapsed, tab, query, cwdScope, sort = 'tree' } = opts;
193
+ // 1. Directly-matched nodes: tab predicate AND cwd scope AND query.
194
+ const matched = new Set();
195
+ for (const [id, node] of tree.nodes) {
196
+ if (tabPredicate(tab, node.row) && cwdMatch(cwdScope, node.row) && queryMatch(query, node.row)) {
197
+ matched.add(id);
198
+ }
199
+ }
200
+ // FLAT ranked modes (relevance / recency): no tree, no ancestors — just the
201
+ // directly-matched rows in ranked order. Relevance falls back to recency when
202
+ // the query is empty (every score would be 0).
203
+ if (sort !== 'tree') {
204
+ const ids = [...matched];
205
+ const createdOf = (id) => tree.nodes.get(id)?.row.created ?? '';
206
+ const byRecency = (a, b) => createdOf(b).localeCompare(createdOf(a));
207
+ if (sort === 'recency' || query === '') {
208
+ ids.sort(byRecency);
209
+ }
210
+ else {
211
+ const score = new Map();
212
+ for (const id of ids)
213
+ score.set(id, scoreRow(query, tree.nodes.get(id).row));
214
+ ids.sort((a, b) => (score.get(b) - score.get(a)) || byRecency(a, b));
215
+ }
216
+ return ids.map((id) => ({ id, depth: 0, hasChildren: false, collapsed: false, matched: true }));
217
+ }
218
+ // 2. Ancestors of matches (for tree context + force-expand under query).
219
+ const ancestors = new Set();
220
+ for (const id of matched) {
221
+ let p = tree.nodes.get(id)?.parentId ?? null;
222
+ while (p !== null && !ancestors.has(p)) {
223
+ ancestors.add(p);
224
+ p = tree.nodes.get(p)?.parentId ?? null;
225
+ }
226
+ }
227
+ const included = (id) => matched.has(id) || ancestors.has(id);
228
+ const isExpanded = (id) => {
229
+ if (query !== '' && ancestors.has(id))
230
+ return true; // force-expand to reveal matches
231
+ return !collapsed.has(id);
232
+ };
233
+ // 3. Walk the tree in order, emitting included nodes and descending only into
234
+ // expanded ones.
235
+ const out = [];
236
+ const walk = (id) => {
237
+ const node = tree.nodes.get(id);
238
+ if (node === undefined)
239
+ return;
240
+ if (included(id)) {
241
+ const hasChildren = node.childIds.length > 0;
242
+ out.push({
243
+ id,
244
+ depth: node.depth,
245
+ hasChildren,
246
+ collapsed: hasChildren && !isExpanded(id),
247
+ matched: matched.has(id),
248
+ });
249
+ }
250
+ if (isExpanded(id)) {
251
+ for (const c of node.childIds)
252
+ walk(c);
253
+ }
254
+ };
255
+ for (const r of tree.roots)
256
+ walk(r);
257
+ return out;
258
+ }
@@ -0,0 +1,41 @@
1
+ import type { Tab, Tree, VisibleRow, SortMode } from './model.js';
2
+ export declare const PREVIEW_BODY = 5;
3
+ export declare const PREVIEW_HEIGHT: number;
4
+ export declare function headerHeight(search: boolean): number;
5
+ export interface ColorCaps {
6
+ /** Any hue (fg/bg color) allowed. */
7
+ color: boolean;
8
+ /** 256-color bg allowed — drives the subtle cursor-row background. */
9
+ color256: boolean;
10
+ }
11
+ /** Detect color capability. Honors `NO_COLOR` and `TERM=dumb`, and only emits
12
+ * hue when stdout is a TTY. `color256` additionally requires a 256/truecolor
13
+ * terminal (for the cursor-row background; otherwise we fall back to reverse). */
14
+ export declare function detectColorCaps(stream?: {
15
+ isTTY?: boolean;
16
+ }, env?: NodeJS.ProcessEnv): ColorCaps;
17
+ export interface RenderState {
18
+ tree: Tree;
19
+ visible: VisibleRow[];
20
+ tab: Tab;
21
+ cursor: number;
22
+ scrollOffset: number;
23
+ query: string;
24
+ search: boolean;
25
+ totalNodes: number;
26
+ /** Active cwd-scope filter; null = All dirs. Shown on the status line. */
27
+ cwdScope: string | null;
28
+ /** Active ordering — status line + (flat modes) row presentation. */
29
+ sort: SortMode;
30
+ /** Whether the bottom preview panel is drawn. */
31
+ preview: boolean;
32
+ }
33
+ /**
34
+ * Render the whole frame. Returns a single string that, written as-is, repaints
35
+ * the screen in place. `caps` gates all hue (defaults to a no-color frame so
36
+ * existing callers / non-TTY paths stay color-free).
37
+ */
38
+ export declare function renderFrame(state: RenderState, size: {
39
+ cols: number;
40
+ rows: number;
41
+ }, caps?: ColorCaps): string;
@@ -0,0 +1,387 @@
1
+ // render.ts — pure frame rendering for the canvas browser.
2
+ //
3
+ // renderFrame(state, size, caps) → a full-screen string. The app writes it
4
+ // verbatim on every keystroke. Redraw is flicker-free: home the cursor (\x1b[H),
5
+ // clear each line to EOL (\x1b[K), and clear below the last line (\x1b[J) so a
6
+ // shrunk frame leaves no stale rows. A full frame per keypress is fine for a
7
+ // picker.
8
+ //
9
+ // COLOR is browse-only and *reinforces* the status glyphs (`● ○ ✓ ✗ ⊘`) which
10
+ // stay the primary, color-free encoding (colorblind / light-bg / NO_COLOR safe).
11
+ // Every hue (fg / bg color) is gated on `caps.color`; structural SGR (bold/dim/
12
+ // reverse) is allowed always. See detectColorCaps() for the gate, and the
13
+ // canvas-browse color spec for the rationale + palette.
14
+ import { TABS, matchIndices } from './model.js';
15
+ // Fixed chrome heights, shared with app.ts so its viewport math never drifts
16
+ // from what renderFrame actually draws.
17
+ // header = title + tab bar + status line + separator (+ search input when searching)
18
+ // preview = separator + meta line + PREVIEW_BODY prompt lines
19
+ export const PREVIEW_BODY = 5;
20
+ export const PREVIEW_HEIGHT = PREVIEW_BODY + 2;
21
+ export function headerHeight(search) {
22
+ return 4 + (search ? 1 : 0);
23
+ }
24
+ // ── ANSI ────────────────────────────────────────────────────────────────────
25
+ const ESC = '\x1b[';
26
+ const RESET = `${ESC}0m`;
27
+ const REVERSE = `${ESC}7m`;
28
+ const DIM = `${ESC}2m`;
29
+ const BOLD = `${ESC}1m`;
30
+ const CURSOR_BG = `${ESC}48;5;236m`; // subtle dark-gray cursor-row bg (256-color)
31
+ // Basic-16 ANSI fg codes used by the palette.
32
+ const FG_GREEN = '32';
33
+ const FG_YELLOW = '33';
34
+ const FG_RED = '31';
35
+ const FG_CYAN = '36';
36
+ const FG_GRAY = '90'; // bright-black
37
+ const FG_BRIGHT_YELLOW = '93';
38
+ const FG_BRIGHT_CYAN = '96'; // query-match highlight (ties to the cyan search accent)
39
+ const STATUS_GLYPH = {
40
+ active: '●',
41
+ idle: '○',
42
+ done: '✓',
43
+ dead: '✗',
44
+ canceled: '⊘',
45
+ };
46
+ /** The load-bearing color: glyph hue per status. Single source of truth, mirrors
47
+ * STATUS_GLYPH. Reinforces the glyph everywhere it appears (rows + summary). */
48
+ const STATUS_COLOR = {
49
+ active: FG_GREEN,
50
+ idle: FG_YELLOW,
51
+ done: FG_CYAN,
52
+ dead: FG_RED,
53
+ canceled: FG_GRAY,
54
+ };
55
+ /** Detect color capability. Honors `NO_COLOR` and `TERM=dumb`, and only emits
56
+ * hue when stdout is a TTY. `color256` additionally requires a 256/truecolor
57
+ * terminal (for the cursor-row background; otherwise we fall back to reverse). */
58
+ export function detectColorCaps(stream = process.stdout, env = process.env) {
59
+ const term = env['TERM'] ?? '';
60
+ const color = stream.isTTY === true && !env['NO_COLOR'] && term !== 'dumb';
61
+ const colorTerm = env['COLORTERM'] ?? '';
62
+ const color256 = color && (/256|direct/i.test(term) || /truecolor|24bit/i.test(colorTerm));
63
+ return { color, color256 };
64
+ }
65
+ function fmtCtx(tokens) {
66
+ if (tokens <= 0)
67
+ return '0k';
68
+ return `${Math.floor(tokens / 1000)}k`;
69
+ }
70
+ /** Tiered ctx-budget hue: dim under 50k, yellow 50–100k, red ≥100k. `undefined`
71
+ * → dim (structural), so low budgets recede. */
72
+ function ctxColorCode(tokens) {
73
+ if (tokens >= 100_000)
74
+ return FG_RED;
75
+ if (tokens >= 50_000)
76
+ return FG_YELLOW;
77
+ return undefined;
78
+ }
79
+ /** Truncate to `max` visible cols (plain text, no ANSI). */
80
+ function clip(text, max) {
81
+ if (max <= 0)
82
+ return '';
83
+ return text.length <= max ? text : text.slice(0, Math.max(0, max - 1)) + '…';
84
+ }
85
+ /** Compact relative age, e.g. `45s` `12m` `3h` `5d` `2w` `4mo`. Empty on a bad
86
+ * timestamp. Drives the per-row recency cue + the preview meta line. */
87
+ function relAge(created, now) {
88
+ const t = Date.parse(created);
89
+ if (Number.isNaN(t))
90
+ return '';
91
+ const s = Math.max(0, Math.floor((now - t) / 1000));
92
+ if (s < 60)
93
+ return `${s}s`;
94
+ const m = Math.floor(s / 60);
95
+ if (m < 60)
96
+ return `${m}m`;
97
+ const h = Math.floor(m / 60);
98
+ if (h < 24)
99
+ return `${h}h`;
100
+ const d = Math.floor(h / 24);
101
+ if (d < 7)
102
+ return `${d}d`;
103
+ const w = Math.floor(d / 7);
104
+ if (w < 5)
105
+ return `${w}w`;
106
+ return `${Math.floor(d / 30)}mo`;
107
+ }
108
+ /** Last path segment of a cwd — the project name shown as the All-dirs cue. */
109
+ function baseDir(cwd) {
110
+ const parts = cwd.replace(/\/+$/, '').split('/');
111
+ return parts[parts.length - 1] || cwd;
112
+ }
113
+ /** Greedy word-wrap to `width` cols, capped at `maxLines` (last line clipped). */
114
+ function wrap(text, width, maxLines) {
115
+ if (width <= 0 || maxLines <= 0)
116
+ return [];
117
+ const words = text.replace(/\s+/g, ' ').trim().split(' ');
118
+ const out = [];
119
+ let cur = '';
120
+ for (const w of words) {
121
+ const next = cur === '' ? w : `${cur} ${w}`;
122
+ if (next.length <= width) {
123
+ cur = next;
124
+ continue;
125
+ }
126
+ if (cur !== '')
127
+ out.push(cur);
128
+ cur = w;
129
+ if (out.length >= maxLines)
130
+ break;
131
+ }
132
+ if (out.length < maxLines && cur !== '')
133
+ out.push(cur);
134
+ return out.slice(0, maxLines).map((l) => clip(l, width));
135
+ }
136
+ /** Style one span. Hue is gated on `color`; bold/dim are not. After the span we
137
+ * return to `lineBase` (not a bare reset) so a row-level background/dim persists
138
+ * across the span instead of bleeding or being cleared. */
139
+ function styleSpan(text, span, color, lineBase) {
140
+ if (text === '')
141
+ return '';
142
+ let pre = '';
143
+ if (span.dim)
144
+ pre += DIM;
145
+ if (span.bold)
146
+ pre += BOLD;
147
+ if (color && span.fg)
148
+ pre += `${ESC}${span.fg}m`;
149
+ if (pre === '')
150
+ return text; // inherits lineBase / default
151
+ return `${pre}${text}${RESET}${lineBase}`;
152
+ }
153
+ /** Assemble styled spans into one line clipped to `width` visible cols. When
154
+ * `fill`, pad the remainder with spaces (under `lineBase`) so a cursor-row
155
+ * background spans the full width. Always RESET-terminated so no color bleeds
156
+ * into the next line. */
157
+ function assemble(spans, width, color, lineBase, fill) {
158
+ let used = 0;
159
+ let body = '';
160
+ for (const span of spans) {
161
+ if (used >= width)
162
+ break;
163
+ if (span.text === '')
164
+ continue;
165
+ let t = span.text;
166
+ const remaining = width - used;
167
+ let cut = false;
168
+ if (t.length > remaining) {
169
+ t = t.slice(0, Math.max(0, remaining - 1)) + '…';
170
+ cut = true;
171
+ }
172
+ body += styleSpan(t, span, color, lineBase);
173
+ used += t.length;
174
+ if (cut)
175
+ break;
176
+ }
177
+ if (fill && used < width)
178
+ body += ' '.repeat(width - used);
179
+ return lineBase === '' ? body : `${lineBase}${body}${RESET}`;
180
+ }
181
+ /** Status tallies across the whole canvas, for the right-aligned header. Memoized
182
+ * per tree — the snapshot is immutable for a browse session, so counting is done
183
+ * once, not O(N) on every keystroke (the "massive canvas" target). */
184
+ const summaryCache = new WeakMap();
185
+ function statusCounts(tree) {
186
+ const cached = summaryCache.get(tree);
187
+ if (cached !== undefined)
188
+ return cached;
189
+ const counts = { active: 0, idle: 0, done: 0, dead: 0, canceled: 0 };
190
+ for (const node of tree.nodes.values())
191
+ counts[node.row.status]++;
192
+ const parts = [];
193
+ for (const s of ['active', 'idle', 'done', 'dead', 'canceled']) {
194
+ if (counts[s] > 0)
195
+ parts.push({ status: s, count: counts[s] });
196
+ }
197
+ summaryCache.set(tree, parts);
198
+ return parts;
199
+ }
200
+ function tabBar(active, color) {
201
+ return TABS.map((t) => {
202
+ if (t === active) {
203
+ // Keep the [ ] brackets in BOTH paths so the active tab reads without color.
204
+ return color ? `${BOLD}${ESC}${FG_CYAN}m[ ${t} ]${RESET}` : `${REVERSE}[ ${t} ]${RESET}`;
205
+ }
206
+ return color ? `${DIM} ${t} ${RESET}` : ` ${t} `;
207
+ }).join('');
208
+ }
209
+ const EMPTY_HI = new Set();
210
+ /** Name → spans, splitting out the query-matched chars (bold + bright-cyan) so
211
+ * matches are scannable. Non-matched chars carry the row's name style (dim for
212
+ * terminal status, bold on the cursor row). */
213
+ function nameSpans(name, query, style) {
214
+ const hi = query === '' ? EMPTY_HI : matchIndices(query, name);
215
+ if (hi.size === 0)
216
+ return [{ text: name, dim: style.dim, bold: style.bold }];
217
+ const out = [];
218
+ let buf = '';
219
+ let bufHi = false;
220
+ const flush = () => {
221
+ if (buf === '')
222
+ return;
223
+ if (bufHi)
224
+ out.push({ text: buf, fg: FG_BRIGHT_CYAN, bold: true });
225
+ else
226
+ out.push({ text: buf, dim: style.dim, bold: style.bold });
227
+ buf = '';
228
+ };
229
+ for (let i = 0; i < name.length; i++) {
230
+ const h = hi.has(i);
231
+ if (h !== bufHi) {
232
+ flush();
233
+ bufHi = h;
234
+ }
235
+ buf += name[i];
236
+ }
237
+ flush();
238
+ return out;
239
+ }
240
+ /** One row line: `<indent><collapse> <glyph> <name> [kind/mode] ctx Nk age [~dir] [⚑n]`.
241
+ * `showCwd` adds the project-name cue (All-dirs view); `now` drives the age. */
242
+ function rowLine(row, tree, width, isCursor, query, caps, showCwd, now) {
243
+ const node = tree.nodes.get(row.id);
244
+ if (node === undefined)
245
+ return '';
246
+ const r = node.row;
247
+ const indent = ' '.repeat(row.depth);
248
+ const collapse = !row.hasChildren ? ' ' : row.collapsed ? '▸' : '▾';
249
+ const glyph = STATUS_GLYPH[r.status] ?? '?';
250
+ const terminal = r.status === 'done' || r.status === 'dead' || r.status === 'canceled';
251
+ const ctxStr = fmtCtx(r.ctx_tokens);
252
+ const ctxFg = ctxColorCode(r.ctx_tokens);
253
+ const age = relAge(r.created, now);
254
+ // Name: bold on the cursor row; dim for terminal status (live names stay default).
255
+ const nameStyle = { dim: !isCursor && terminal, bold: isCursor };
256
+ const spans = [
257
+ { text: `${indent}${collapse} ` },
258
+ { text: glyph, fg: STATUS_COLOR[r.status] }, // load-bearing status hue
259
+ { text: ' ' },
260
+ ...nameSpans(r.name, query, nameStyle),
261
+ { text: ` [${r.kind}/${r.mode}]`, fg: FG_GRAY }, // recedes
262
+ { text: ' ctx ', dim: true },
263
+ { text: ctxStr, fg: ctxFg, dim: ctxFg === undefined }, // tiered budget cue
264
+ ];
265
+ if (age !== '')
266
+ spans.push({ text: ` ${age}`, dim: true }); // recency cue
267
+ if (showCwd)
268
+ spans.push({ text: ` ~${baseDir(r.cwd)}`, fg: FG_GRAY }); // project cue (All dirs)
269
+ if (r.asks > 0)
270
+ spans.push({ text: ` ⚑${r.asks}`, fg: FG_BRIGHT_YELLOW, bold: true }); // attention
271
+ // Row base: cursor → subtle bg (256) or reverse fallback (also covers !color);
272
+ // non-matched ancestor → whole-row dim for tree context (keep prior behavior).
273
+ let lineBase = '';
274
+ let fill = false;
275
+ if (isCursor) {
276
+ lineBase = caps.color256 ? CURSOR_BG : REVERSE;
277
+ fill = true;
278
+ }
279
+ else if (!row.matched) {
280
+ lineBase = DIM;
281
+ }
282
+ return assemble(spans, width, caps.color, lineBase, fill);
283
+ }
284
+ /** The status line (always present): active cwd scope · sort mode · committed
285
+ * filter. The committed-filter indicator lives here now (not its own line) so a
286
+ * filtered view always advertises its query without consuming a header row. */
287
+ function statusLine(state) {
288
+ const dim = (s) => `${DIM}${s}${RESET}`;
289
+ const scope = state.cwdScope === null ? 'all dirs' : baseDir(state.cwdScope);
290
+ const segs = [`${dim('scope')} ${scope}`, `${dim('sort')} ${state.sort}`];
291
+ if (!state.search && state.query !== '')
292
+ segs.push(`${dim('filter')} ${state.query}`);
293
+ return segs.join(dim(' · '));
294
+ }
295
+ /** The bottom preview panel — exactly PREVIEW_HEIGHT lines: a separator, a meta
296
+ * line (status · kind/mode · project · age · ctx · asks), then the selected
297
+ * node's spawn prompt wrapped to PREVIEW_BODY lines. The "which one was this?"
298
+ * answer — paired with super-search. Always full height so viewport math holds. */
299
+ function previewPanel(r, width, caps, now) {
300
+ const out = [`${DIM}${'─'.repeat(width)}${RESET}`];
301
+ if (r === undefined) {
302
+ while (out.length < PREVIEW_HEIGHT)
303
+ out.push('');
304
+ return out;
305
+ }
306
+ const glyph = caps.color
307
+ ? `${ESC}${STATUS_COLOR[r.status]}m${STATUS_GLYPH[r.status]}${RESET}`
308
+ : (STATUS_GLYPH[r.status] ?? '?');
309
+ const metaPieces = [`${r.status} ${r.kind}/${r.mode}`, baseDir(r.cwd), relAge(r.created, now), `ctx ${fmtCtx(r.ctx_tokens)}`];
310
+ if (r.asks > 0)
311
+ metaPieces.push(`⚑${r.asks}`);
312
+ const metaText = clip(metaPieces.filter((p) => p !== '').join(' · '), Math.max(0, width - 2));
313
+ out.push(caps.color ? `${glyph} ${DIM}${metaText}${RESET}` : `${glyph} ${metaText}`);
314
+ const goalText = (r.goal ?? '').trim();
315
+ const body = goalText === '' ? [`${DIM}(no spawn prompt)${RESET}`] : wrap(goalText, width, PREVIEW_BODY);
316
+ for (let i = 0; i < PREVIEW_BODY; i++)
317
+ out.push(body[i] ?? '');
318
+ return out;
319
+ }
320
+ /**
321
+ * Render the whole frame. Returns a single string that, written as-is, repaints
322
+ * the screen in place. `caps` gates all hue (defaults to a no-color frame so
323
+ * existing callers / non-TTY paths stay color-free).
324
+ */
325
+ export function renderFrame(state, size, caps = { color: false, color256: false }) {
326
+ const cols = Math.max(20, size.cols);
327
+ const rows = Math.max(8, size.rows);
328
+ const width = cols - 1; // leave the last column for \x1b[K
329
+ const now = Date.now();
330
+ const lines = [];
331
+ // line 1 — title + right-aligned status summary (each glyph in its status hue).
332
+ const title = `${BOLD}Canvas${RESET} — ${state.totalNodes} nodes`;
333
+ const titlePlainLen = `Canvas — ${state.totalNodes} nodes`.length;
334
+ const parts = statusCounts(state.tree);
335
+ const summaryPlain = parts.map((p) => `${STATUS_GLYPH[p.status]}${p.count}`).join(' ');
336
+ const summaryColored = caps.color
337
+ ? parts.map((p) => `${ESC}${STATUS_COLOR[p.status]}m${STATUS_GLYPH[p.status]}${RESET}${DIM}${p.count}${RESET}`).join(' ')
338
+ : `${DIM}${summaryPlain}${RESET}`;
339
+ const gap = width - titlePlainLen - summaryPlain.length;
340
+ lines.push(gap > 1 ? `${title}${' '.repeat(gap)}${summaryColored}` : title);
341
+ // line 2 — tab bar.
342
+ lines.push(tabBar(state.tab, caps.color));
343
+ // line 3 — status line (scope · sort · filter).
344
+ lines.push(statusLine(state));
345
+ // line 4 — search input, only while actively typing a search.
346
+ if (state.search) {
347
+ const slash = caps.color ? `${ESC}${FG_CYAN}m/${RESET}` : '/';
348
+ lines.push(`${slash} ${state.query}▎`);
349
+ }
350
+ // separator.
351
+ lines.push(`${DIM}${'─'.repeat(width)}${RESET}`);
352
+ // body — windowed visible rows, leaving room for the (optional) preview panel.
353
+ const head = lines.length; // === headerHeight(state.search)
354
+ const showCwd = state.cwdScope === null; // project cue only matters across dirs
355
+ const previewOn = state.preview && state.visible.length > 0;
356
+ const previewH = previewOn ? PREVIEW_HEIGHT : 0;
357
+ const viewport = Math.max(1, rows - head - 1 /* footer */ - previewH);
358
+ const start = state.scrollOffset;
359
+ const end = Math.min(state.visible.length, start + viewport);
360
+ if (state.visible.length === 0) {
361
+ lines.push(`${DIM} (no nodes match this view)${RESET}`);
362
+ for (let i = 1; i < viewport; i++)
363
+ lines.push('');
364
+ }
365
+ else {
366
+ for (let i = start; i < end; i++) {
367
+ lines.push(rowLine(state.visible[i], state.tree, width, i === state.cursor, state.query, caps, showCwd, now));
368
+ }
369
+ for (let i = end - start; i < viewport; i++)
370
+ lines.push('');
371
+ }
372
+ // preview panel — the selected row's prompt + meta.
373
+ if (previewOn) {
374
+ const sel = state.visible[state.cursor];
375
+ const r = sel !== undefined ? state.tree.nodes.get(sel.id)?.row : undefined;
376
+ for (const l of previewPanel(r, width, caps, now))
377
+ lines.push(l);
378
+ }
379
+ // footer.
380
+ const footer = state.search
381
+ ? '⏎ commit Esc cancel ⌫ delete'
382
+ : '↑↓ move →/← tree ⏎ resume Tab tabs / search s sort c cwd p preview q quit';
383
+ lines.push(`${DIM}${clip(footer, width)}${RESET}`);
384
+ // Assemble: home, each line cleared to EOL, then clear below.
385
+ const body = lines.map((l) => `${l}${ESC}K`).join('\r\n');
386
+ return `${ESC}H${body}${ESC}J`;
387
+ }