@warren-dean/code-kit 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1378 @@
1
+ // Panes v2 (docs/specs/11-panes.md): the Lanes pane's frame. A counts header, tabs, what needs the
2
+ // person, the tab's body and the keys, as pure functions of what the mod already holds. The Lanes
3
+ // tab's body is view.mjs's lanesPane; the Queue and Usage tabs reuse its sections.
4
+ import { trailRows as trailRowsDrawn, trailSvg } from './trail.mjs';
5
+ import {
6
+ MAP_LEGEND,
7
+ cardOf,
8
+ mapRows as codeRows,
9
+ mapStoryOf,
10
+ mapSvg,
11
+ neighboursOf,
12
+ } from './codemap.mjs';
13
+ import {
14
+ NOT_CODE_KIT,
15
+ STATE_COLOR,
16
+ ago,
17
+ lanesPane,
18
+ queueSection,
19
+ shownState,
20
+ tokens,
21
+ usageSection,
22
+ } from './view.mjs';
23
+
24
+ export const TABS = [
25
+ { id: 'lanes', label: 'Lanes', hotkey: '1' },
26
+ { id: 'decisions', label: 'Decisions', hotkey: '2' },
27
+ { id: 'journal', label: 'Journal', hotkey: '3' },
28
+ { id: 'code', label: 'Code map', hotkey: '4' },
29
+ { id: 'map', label: 'Trail', hotkey: '5' },
30
+ { id: 'queue', label: 'Queue', hotkey: '6' },
31
+ { id: 'usage', label: 'Usage', hotkey: '7' },
32
+ ];
33
+
34
+ /** The pane's own state, kept by the mod between draws. */
35
+ export const DEFAULT_UI = { tab: 'lanes', selected: null, filter: null, search: '', cell: null };
36
+
37
+ /** The glyph each state carries besides its colour (spec 11's quality target). */
38
+ const GLYPH = {
39
+ building: '●',
40
+ working: '▶',
41
+ 'in review': '◐',
42
+ queued: '◆',
43
+ 'sent back': '↩',
44
+ blocked: '■',
45
+ idle: '·',
46
+ ready: '○',
47
+ merged: '✓',
48
+ };
49
+
50
+ /**
51
+ * VIEW-1: the stories counted by state, in the order a build moves through them, only those there
52
+ * are: [{ state, n, glyph }]. Lanes give building, in review, queued, sent back and blocked; the plan
53
+ * gives ready and merged.
54
+ */
55
+ export function countsOf(state, queue = []) {
56
+ if (state?.kind !== 'ok') return [];
57
+ const lanes = state.lanes.map((l) => shownState(l, queue));
58
+ const n = (s) => lanes.filter((x) => x === s).length;
59
+ const counts = [
60
+ ['building', n('building')],
61
+ ['working', n('working')],
62
+ ['in review', n('in review')],
63
+ ['queued', n('queued')],
64
+ ['sent back', n('sent back')],
65
+ ['blocked', n('blocked')],
66
+ ['ready', state.ready.length],
67
+ ['merged', (state.stories ?? []).filter((s) => s.state === 'done').length],
68
+ ['idle', n('idle')],
69
+ ];
70
+ return counts.filter(([, k]) => k > 0).map(([s, k]) => ({ state: s, n: k, glyph: GLYPH[s] }));
71
+ }
72
+
73
+ /** A lane's stories in plan order. */
74
+ export const laneStories = (lane, state) =>
75
+ (state?.stories ?? []).filter((s) => s.lane === lane.name);
76
+
77
+ /** The story Enter opens on a lane: the one it's on, else its next not done, else its last done. */
78
+ export function storyToOpen(lane, state) {
79
+ if (lane.story) return lane.story;
80
+ const own = laneStories(lane, state);
81
+ return own.find((s) => s.state !== 'done') ?? own.at(-1) ?? null;
82
+ }
83
+
84
+ /** VIEW-1: how much of the plan is done, as words: "3 of 25 stories done". */
85
+ export function planProgress(state) {
86
+ const all = state?.stories ?? [];
87
+ return all.length
88
+ ? `${all.filter((s) => s.state === 'done').length} of ${all.length} stories done`
89
+ : null;
90
+ }
91
+
92
+ /** VIEW-6's letters for the band's actions, as the needs-you strip draws them. */
93
+ const LETTER = {
94
+ approve: 'a',
95
+ approveHeld: 'a',
96
+ review: 'r',
97
+ merge: 'm',
98
+ mergeQueue: 'm',
99
+ go: 'g',
100
+ keepGoing: 'g',
101
+ pause: 'p',
102
+ resume: 'p',
103
+ };
104
+
105
+ /** The selected lane's own acts (VIEW-6), as its state allows them: [{ id, label, hotkey }]. */
106
+ export function laneActions(lane, { queue = [], held = [], requests = [] } = {}) {
107
+ if (!lane) return [];
108
+ const acts = [];
109
+ const asked = [...held, ...requests].some((r) => (r.lane ?? 'lead') === lane.name);
110
+ if (asked) acts.push({ id: 'approve', label: 'Approve', hotkey: 'a' });
111
+ const state = shownState(lane, queue);
112
+ if (state === 'in review') {
113
+ acts.push({ id: 'review', label: 'Review', hotkey: 'r' });
114
+ acts.push({ id: 'sendBack', label: 'Send back', hotkey: 's' });
115
+ }
116
+ const head = queue.find((e) => ['waiting', 'merging'].includes(e.state));
117
+ if (head && head.branch === lane.branch) acts.push({ id: 'merge', label: 'Merge', hotkey: 'm' });
118
+ if (lane.active && lane.agent) {
119
+ acts.push({ id: 'nudge', label: 'Nudge', hotkey: 'n' });
120
+ acts.push({ id: 'stop', label: 'Stop', hotkey: 'x' });
121
+ }
122
+ return acts;
123
+ }
124
+
125
+ /**
126
+ * The Lanes pane (spec 11). `view` holds what to draw: { state, usage, plan, loop, needs, live,
127
+ * held, requests, ui, placement }; `needs` are the band's lines (VIEW-5). `on` acts: onTab(id),
128
+ * onFilter(state), onMove(step), onNeed(id, line), onLane(id, lane), onSearch(text).
129
+ */
130
+ export function lanesFrame(view, els, on) {
131
+ const { Box, Text, Button, Input } = els;
132
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
133
+ const { state, ui = DEFAULT_UI, loop = null, plan = null } = view;
134
+ if (!state || state.kind === 'none') return text(NOT_CODE_KIT, { dimColor: true });
135
+ if (state.kind === 'invalid')
136
+ return text('The config is invalid: run code-kit check.', { color: 'red' });
137
+ const queue = loop?.queue ?? [];
138
+
139
+ // VIEW-1: the counts, each a filter; the loop's state and the plan's use.
140
+ const loopWord =
141
+ loop?.state === 'off'
142
+ ? 'loop off'
143
+ : loop?.state === 'paused'
144
+ ? 'loop paused'
145
+ : loop?.state === 'done'
146
+ ? 'milestone done'
147
+ : 'loop on';
148
+ const header = Box({
149
+ key: 'header',
150
+ flexDirection: 'row',
151
+ columnGap: 2,
152
+ flexWrap: 'wrap',
153
+ children: [
154
+ ...countsOf(state, queue).map((c) =>
155
+ Button({
156
+ key: `count-${c.state}`,
157
+ label: `${c.glyph} ${c.n} ${c.state}`,
158
+ plain: true,
159
+ ...(ui.filter && ui.filter !== c.state ? { dimColor: true } : {}),
160
+ onPress: () => on.onFilter(c.state),
161
+ }),
162
+ ),
163
+ ...(planProgress(state) ? [text(planProgress(state), { dimColor: true })] : []),
164
+ text(loopWord, { color: loop?.state === 'paused' ? 'yellow' : 'green' }),
165
+ ...(plan?.percent != null
166
+ ? [
167
+ text(`plan ${plan.percent}%`, {
168
+ color: plan.paused ? 'red' : undefined,
169
+ dimColor: !plan.paused,
170
+ }),
171
+ ]
172
+ : []),
173
+ ],
174
+ });
175
+
176
+ // VIEW-5: what waits on the person, pinned at the top with VIEW-6's letters.
177
+ const used = new Set();
178
+ const needs = (view.needs ?? []).map((line) => {
179
+ const actions = line.actions.filter((a) => a.id !== 'lanes');
180
+ return Box({
181
+ key: `need-${line.kind}`,
182
+ flexDirection: 'row',
183
+ columnGap: 2,
184
+ children: [
185
+ text('!', { color: 'yellow', bold: true }),
186
+ text(line.text, { bold: true }),
187
+ // DECIDE-2: which story it's for.
188
+ ...(line.context ? [text(line.context, { dimColor: true, wrap: 'truncate-end' })] : []),
189
+ ...actions.map((a) => {
190
+ const letter = LETTER[a.id] && !used.has(LETTER[a.id]) ? LETTER[a.id] : undefined;
191
+ if (letter) used.add(letter);
192
+ return Button({
193
+ key: `need-${a.id}`,
194
+ label: a.label,
195
+ ...(letter ? { hotkey: letter } : {}),
196
+ variant: 'primary',
197
+ onPress: () => on.onNeed(a.id, line),
198
+ });
199
+ }),
200
+ ],
201
+ });
202
+ });
203
+
204
+ // VIEW-8: inline, the header, what needs the person and one line per lane.
205
+ if (view.placement === 'inline')
206
+ return Box({
207
+ flexDirection: 'column',
208
+ children: [
209
+ header,
210
+ ...needs,
211
+ ...state.lanes.map((l) => {
212
+ const live = view.live?.[l.name];
213
+ const s = shownState(l, queue);
214
+ return Box({
215
+ key: `lane-${l.name}`,
216
+ flexDirection: 'row',
217
+ columnGap: 1,
218
+ children: [
219
+ text(l.name, { bold: true }),
220
+ text(s, STATE_COLOR[s] ? { color: STATE_COLOR[s] } : { dimColor: true }),
221
+ ...(l.story ? [text(l.story.id, { dimColor: true })] : []),
222
+ ...(live
223
+ ? [
224
+ text(`${live.tool} · ${live.ago}`, {
225
+ wrap: 'truncate-end',
226
+ ...(live.level === 'stalled'
227
+ ? { color: 'red' }
228
+ : live.level === 'quiet'
229
+ ? { color: 'yellow' }
230
+ : { dimColor: true }),
231
+ }),
232
+ ]
233
+ : []),
234
+ ],
235
+ });
236
+ }),
237
+ ],
238
+ });
239
+
240
+ // VIEW-6: the tabs, on 1 to 4.
241
+ const tabs = Box({
242
+ key: 'tabs',
243
+ flexDirection: 'row',
244
+ columnGap: 1,
245
+ children: TABS.map((t) =>
246
+ Button({
247
+ key: `tab-${t.id}`,
248
+ label: t.label,
249
+ hotkey: t.hotkey,
250
+ ...(ui.tab === t.id ? { variant: 'primary' } : { dimColor: true }),
251
+ onPress: () => on.onTab(t.id),
252
+ }),
253
+ ),
254
+ });
255
+
256
+ // VIEW-7: a story's drill-down takes the tabs' place; Back (b, or Escape) returns.
257
+ if (ui.story && view.story)
258
+ return Box({
259
+ flexDirection: 'column',
260
+ rowGap: 1,
261
+ children: [
262
+ header,
263
+ ...(needs.length ? [Box({ key: 'needs', flexDirection: 'column', children: needs })] : []),
264
+ storyPane(view.story, els, on.story),
265
+ ],
266
+ });
267
+
268
+ const selected = state.lanes.find((l) => l.name === ui.selected) ?? null;
269
+ const body =
270
+ ui.tab === 'decisions'
271
+ ? decisionsTab(view.cards ?? [], ui, els, on)
272
+ : ui.tab === 'journal'
273
+ ? journalTab(view.journal ?? [], els, view.now)
274
+ : ui.tab === 'code'
275
+ ? codeTab(view, els, on)
276
+ : ui.tab === 'map'
277
+ ? mapTab(view.status, ui.cell, els, on, view.trail, {
278
+ surface: view.surface,
279
+ columns: view.columns,
280
+ })
281
+ : ui.tab === 'queue'
282
+ ? queueSection(loop, text, Box)
283
+ : ui.tab === 'usage'
284
+ ? usageSection(view.usage, plan, text, Box)
285
+ : [
286
+ lanesPane(state, els, view.usage, plan, loop, {
287
+ filter: ui.filter,
288
+ search: ui.search,
289
+ selected: ui.selected,
290
+ live: view.live ?? {},
291
+ art: view.art ?? null,
292
+ detail: selected
293
+ ? {
294
+ agent: selected.agent ?? null,
295
+ owns: view.paths?.[selected.name] ?? [],
296
+ stories: laneStories(selected, state),
297
+ }
298
+ : null,
299
+ combined: view.combined ?? null,
300
+ }),
301
+ ];
302
+ const empty =
303
+ ui.tab === 'decisions'
304
+ ? 'Nothing needs you. The lead is handling the build.'
305
+ : ui.tab === 'journal'
306
+ ? 'Nothing recorded yet: the journal starts with the brief.'
307
+ : ui.tab === 'queue'
308
+ ? 'The merge queue is empty.'
309
+ : ui.tab === 'usage'
310
+ ? 'No usage measured yet this session.'
311
+ : null;
312
+
313
+ // VIEW-6: j/k move between lanes; the selected lane's acts; f filters.
314
+ const opening = selected ? storyToOpen(selected, state) : null;
315
+ const keys =
316
+ ui.tab === 'lanes'
317
+ ? [
318
+ Box({
319
+ key: 'keys',
320
+ flexDirection: 'row',
321
+ columnGap: 1,
322
+ flexWrap: 'wrap',
323
+ children: [
324
+ ...(opening
325
+ ? [
326
+ Button({
327
+ key: 'open-story',
328
+ label: `Open ${opening.id}`,
329
+ hotkey: 'o',
330
+ variant: 'primary',
331
+ autoFocus: true,
332
+ onPress: () => on.onOpen({ ...selected, story: opening }),
333
+ }),
334
+ ]
335
+ : []),
336
+ Button({
337
+ key: 'lane-next',
338
+ label: 'Next lane',
339
+ hotkey: 'j',
340
+ plain: true,
341
+ onPress: () => on.onMove(1),
342
+ }),
343
+ Button({
344
+ key: 'lane-prev',
345
+ label: 'Previous',
346
+ hotkey: 'k',
347
+ plain: true,
348
+ onPress: () => on.onMove(-1),
349
+ }),
350
+ ...(selected
351
+ ? laneActions(selected, { queue, held: view.held, requests: view.requests }).map(
352
+ (a) =>
353
+ Button({
354
+ key: `act-${a.id}`,
355
+ label: `${a.label} ${selected.name}`,
356
+ hotkey: used.has(a.hotkey) ? undefined : a.hotkey,
357
+ plain: true,
358
+ onPress: () => on.onLane(a.id, selected),
359
+ }),
360
+ )
361
+ : []),
362
+ ...(!used.has('p') && loop?.state !== 'off' && loop?.state !== 'done'
363
+ ? [
364
+ Button({
365
+ key: 'loop-toggle',
366
+ label: loop?.state === 'paused' ? 'Resume loop' : 'Pause loop',
367
+ hotkey: 'p',
368
+ plain: true,
369
+ onPress: () => on.onNeed(loop?.state === 'paused' ? 'resume' : 'pause', null),
370
+ }),
371
+ ]
372
+ : []),
373
+ ],
374
+ }),
375
+ // One row: f puts the keys in the filter.
376
+ Box({
377
+ key: 'filter-row',
378
+ flexDirection: 'row',
379
+ columnGap: 1,
380
+ children: [
381
+ Button({
382
+ key: 'filter-focus',
383
+ label: 'Filter',
384
+ hotkey: 'f',
385
+ plain: true,
386
+ onPress: () => on.onFocusSearch(),
387
+ }),
388
+ Input({
389
+ key: 'filter',
390
+ label: '',
391
+ value: ui.search,
392
+ placeholder: 'lane, agent or story',
393
+ submitLabel: 'Filter',
394
+ onInput: (v) => on.onSearch(v),
395
+ onSubmit: (v) => on.onSearch(v),
396
+ }),
397
+ ],
398
+ }),
399
+ ]
400
+ : [];
401
+
402
+ return Box({
403
+ flexDirection: 'column',
404
+ rowGap: 1,
405
+ children: [
406
+ header,
407
+ ...(needs.length ? [Box({ key: 'needs', flexDirection: 'column', children: needs })] : []),
408
+ tabs,
409
+ ...(body.length ? body : [text(empty, { dimColor: true })]),
410
+ ...keys,
411
+ ],
412
+ });
413
+ }
414
+
415
+ /** A tool call as the liveness column reads it (VIEW-4): "Bash npm test", "Edit apps/web/a.ts". */
416
+ export function toolLine(call) {
417
+ const tool = call?.tool ?? 'tool';
418
+ const what =
419
+ call?.command ?? call?.file_path ?? call?.path ?? call?.pattern ?? call?.description ?? '';
420
+ const short = String(what).split('\n')[0].replace(/\s+/g, ' ').trim();
421
+ return short ? `${tool} ${short.length > 40 ? `${short.slice(0, 39)}…` : short}` : tool;
422
+ }
423
+
424
+ /** A lane's liveness (VIEW-4): quiet past the nudge time, stalled past the restart time or flagged. */
425
+ export function liveLevel(quietMs, stall, { flagged = false, running = false } = {}) {
426
+ if (flagged) return 'stalled';
427
+ if (running) return null;
428
+ if (quietMs >= stall.restartMinutes * 60000) return 'stalled';
429
+ if (quietMs >= stall.nudgeMinutes * 60000) return 'quiet';
430
+ return null;
431
+ }
432
+
433
+ export const SENDBACK_ID = 'code-kit-sendback';
434
+
435
+ /** VIEW-6's send back: the branch and story, and the person's reason, which the lane's fix reads. */
436
+ export function sendBackPane(
437
+ pending,
438
+ { Box, Text, Input, Button },
439
+ { onInput, onSubmit, onCancel },
440
+ ) {
441
+ if (!pending) return Text({ dimColor: true, children: ['Nothing to send back.'] });
442
+ return Box({
443
+ flexDirection: 'column',
444
+ rowGap: 1,
445
+ children: [
446
+ Text({ bold: true, children: [`Send ${pending.branch} back to the ${pending.lane} lane`] }),
447
+ Text({
448
+ children: [
449
+ `${pending.story} goes back for its fix, with your reason; the loop dispatches the fix.`,
450
+ ],
451
+ }),
452
+ Input({
453
+ key: 'sendback-reason',
454
+ label: 'Reason',
455
+ value: pending.reason,
456
+ submitLabel: 'Send back',
457
+ autoFocus: true,
458
+ onInput,
459
+ onSubmit,
460
+ }),
461
+ ...(pending.error ? [Text({ color: 'red', children: [pending.error] })] : []),
462
+ Box({
463
+ flexDirection: 'row',
464
+ columnGap: 2,
465
+ children: [
466
+ Button({
467
+ key: 'sendback-confirm',
468
+ label: 'Send back',
469
+ onPress: () => onSubmit(pending.reason),
470
+ }),
471
+ Button({ key: 'sendback-cancel', label: 'Cancel', onPress: onCancel }),
472
+ ],
473
+ }),
474
+ ],
475
+ });
476
+ }
477
+
478
+ /**
479
+ * A unified diff split per file, each as the hunks the Code element draws (its `---`/`+++` pair and
480
+ * `@@` hunks, without git's `diff --git` and index lines): [{ path, source }]. Files without hunks
481
+ * (binary, a mode change) are left out.
482
+ */
483
+ export function fileDiffs(text) {
484
+ const out = [];
485
+ for (const part of String(text ?? '')
486
+ .split(/^diff --git /m)
487
+ .slice(1)) {
488
+ const lines = part.split('\n');
489
+ const from = lines.findIndex((l) => l.startsWith('--- '));
490
+ if (from < 0 || !lines.slice(from).some((l) => l.startsWith('@@'))) continue;
491
+ const plus = lines.find((l) => l.startsWith('+++ '))?.slice(4) ?? '';
492
+ const minus = lines[from].slice(4);
493
+ const path = (plus === '/dev/null' ? minus : plus).replace(/^[ab]\//, '');
494
+ out.push({ path, source: lines.slice(from).join('\n').replace(/\n+$/, '\n') });
495
+ }
496
+ return out;
497
+ }
498
+
499
+ const SPEC_CHECK_COLOR = { done: 'green', partial: 'yellow', missing: 'red', conflicts: 'red' };
500
+ const SEVERITY_TITLE = { blocker: 'Blockers', concern: 'Concerns', nit: 'Nits' };
501
+
502
+ /**
503
+ * VIEW-7 and REVW-5: a story's drill-down. `story` is { id, data, error }, data being `code-kit
504
+ * story --json`'s; `review` the reviewer's for its branch; `tokens` its usage; `steps` the loop's for
505
+ * it; `actions` the lane's acts. `on`: onBack(), onFile(path), onAct(id).
506
+ */
507
+ export function storyPane(
508
+ { story, review, tokens: usedTokens, outlier, steps, actions, file, now, understanding = null },
509
+ els,
510
+ on,
511
+ ) {
512
+ const { Box, Text, Button, Code } = els;
513
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
514
+ const section = (key, title, children) =>
515
+ Box({ key, flexDirection: 'column', children: [text(title, { bold: true }), ...children] });
516
+ const d = story.data;
517
+ const head = Box({
518
+ key: 'story-head',
519
+ flexDirection: 'row',
520
+ columnGap: 2,
521
+ children: [
522
+ Button({
523
+ key: 'story-back',
524
+ label: 'Back',
525
+ hotkey: 'b',
526
+ plain: true,
527
+ dimColor: true,
528
+ onPress: on.onBack,
529
+ }),
530
+ text(d ? `${d.id} ${d.title}` : story.id, { bold: true }),
531
+ ...(d
532
+ ? [text(`${d.lane} · ${d.state}${d.branch ? ` · ${d.branch}` : ''}`, { dimColor: true })]
533
+ : []),
534
+ ],
535
+ });
536
+ if (story.error)
537
+ return Box({
538
+ flexDirection: 'column',
539
+ rowGap: 1,
540
+ children: [head, text(story.error, { color: 'red' })],
541
+ });
542
+ if (!d)
543
+ return Box({
544
+ flexDirection: 'column',
545
+ rowGap: 1,
546
+ children: [head, text('Reading the story…', { dimColor: true })],
547
+ });
548
+
549
+ const checked = new Map((d.specCheck?.rows ?? []).map((r) => [r.requirement, r]));
550
+ const requirements = section(
551
+ 'story-requirements',
552
+ `Requirements${d.specCheck ? '' : ' (no spec-check report yet)'}`,
553
+ d.requirements.length
554
+ ? d.requirements.map((r) => {
555
+ const c = checked.get(r.id);
556
+ return Box({
557
+ key: `req-${r.id}`,
558
+ flexDirection: 'row',
559
+ columnGap: 2,
560
+ children: [
561
+ text(r.id),
562
+ text(r.title, { wrap: 'truncate-end' }),
563
+ text(`plan: ${r.state}`, { dimColor: true }),
564
+ ...(c
565
+ ? [text(`spec-check: ${c.status}`, { color: SPEC_CHECK_COLOR[c.status] })]
566
+ : []),
567
+ ...(c?.where ? [text(c.where, { dimColor: true, wrap: 'truncate-end' })] : []),
568
+ ...(r.tests.length ? [text(`${r.tests.length} test(s)`, { dimColor: true })] : []),
569
+ ],
570
+ });
571
+ })
572
+ : [text('It names no requirement.', { dimColor: true })],
573
+ );
574
+
575
+ const findings = review
576
+ ? section(
577
+ 'story-findings',
578
+ review.state === 'done'
579
+ ? `Review of ${review.head.slice(0, 7)}`
580
+ : review.state === 'running'
581
+ ? 'Review running'
582
+ : `Review ${review.state}: ${review.why ?? review.error ?? ''}`,
583
+ ['blocker', 'concern', 'nit'].flatMap((s) => {
584
+ const of = review.findings.filter((f) => f.severity === s);
585
+ if (!of.length) return [];
586
+ return [
587
+ text(SEVERITY_TITLE[s], {
588
+ color: s === 'blocker' ? 'red' : s === 'concern' ? 'yellow' : undefined,
589
+ }),
590
+ ...of.map((f, i) =>
591
+ Button({
592
+ key: `finding-${s}-${i}`,
593
+ label: `${f.where ? `${f.where} ` : ''}${f.text}${f.rule ? ` (${f.rule})` : ''}`,
594
+ plain: true,
595
+ // REVW-5: the finding's place is a way into the diff.
596
+ onPress: () => f.where && on.onFile(f.where.split(':')[0]),
597
+ }),
598
+ ),
599
+ ];
600
+ }),
601
+ )
602
+ : null;
603
+
604
+ const problems = d.verify
605
+ ? Object.entries(d.verify.problems).flatMap(([group, list]) =>
606
+ list.map((p) => `${group}: ${p}`),
607
+ )
608
+ : [];
609
+ const verify = d.verify
610
+ ? section(
611
+ 'story-verify',
612
+ 'verify (without checks)',
613
+ problems.length
614
+ ? problems.map((p, i) =>
615
+ text(p, { key: `problem-${i}`, color: 'red', wrap: 'truncate-end' }),
616
+ )
617
+ : [text('No problems.', { color: 'green' })],
618
+ )
619
+ : null;
620
+
621
+ const diffs = fileDiffs(d.diff?.text);
622
+ const shown = diffs.find((f) => f.path === file) ?? diffs[0] ?? null;
623
+ const diff = d.diff
624
+ ? section(
625
+ 'story-diff',
626
+ `Diff against ${d.base} · ${d.diff.files.length} file(s)${d.diff.truncated ? ' (cut short)' : ''}`,
627
+ [
628
+ Box({
629
+ key: 'diff-files',
630
+ flexDirection: 'row',
631
+ columnGap: 2,
632
+ flexWrap: 'wrap',
633
+ children: d.diff.files.map((f, i) =>
634
+ Button({
635
+ key: `file-${i}`,
636
+ label: `${f.path} +${f.added} -${f.removed}`,
637
+ plain: true,
638
+ ...(shown?.path === f.path ? {} : { dimColor: true }),
639
+ onPress: () => on.onFile(f.path),
640
+ }),
641
+ ),
642
+ }),
643
+ ...(shown
644
+ ? [
645
+ Code({
646
+ key: 'diff-code',
647
+ source: shown.source,
648
+ format: 'diff',
649
+ path: shown.path,
650
+ wrap: 'truncate-end',
651
+ }),
652
+ ]
653
+ : []),
654
+ ],
655
+ )
656
+ : null;
657
+
658
+ const usage = usedTokens
659
+ ? text(
660
+ `Used ${tokens(usedTokens)} tokens this session${outlier ? `, ${outlier}× the usual` : ''}`,
661
+ {
662
+ ...(outlier ? { color: 'yellow' } : { dimColor: true }),
663
+ },
664
+ )
665
+ : null;
666
+ const recent = steps.length
667
+ ? section(
668
+ 'story-steps',
669
+ 'Loop steps',
670
+ steps
671
+ .slice(-6)
672
+ .reverse()
673
+ .map((s, i) =>
674
+ text(`${ago(now - s.at)} ${s.kind} ${s.target}`, {
675
+ key: `sstep-${i}`,
676
+ dimColor: true,
677
+ }),
678
+ ),
679
+ )
680
+ : null;
681
+ const acts = actions.length
682
+ ? Box({
683
+ key: 'story-acts',
684
+ flexDirection: 'row',
685
+ columnGap: 2,
686
+ children: actions.map((a) =>
687
+ Button({
688
+ key: `story-${a.id}`,
689
+ label: a.label,
690
+ hotkey: a.hotkey,
691
+ variant: 'primary',
692
+ onPress: () => on.onAct(a.id),
693
+ }),
694
+ ),
695
+ })
696
+ : null;
697
+
698
+ // JOIN-2 to JOIN-4: what the adapters know: the lane's edits without understanding and the files
699
+ // still unread, its cards owed, and the rules on the story's files; each file opens in the
700
+ // adapter's own view.
701
+ const opener = (d.adapters ?? []).find((a) => a.open) ?? null;
702
+ const openFile = (path, key) =>
703
+ opener
704
+ ? Button({ key, label: path, plain: true, onPress: () => on.onOpenIn(opener, path) })
705
+ : text(path);
706
+ const combined = [];
707
+ if (understanding?.withoutUnderstanding.length)
708
+ combined.push(
709
+ section(
710
+ 'story-understanding',
711
+ `Edited without understanding (${understanding.withoutUnderstanding.length})`,
712
+ understanding.withoutUnderstanding.map((e, i) =>
713
+ Box({
714
+ key: `unread-${i}`,
715
+ flexDirection: 'row',
716
+ columnGap: 2,
717
+ children: [
718
+ openFile(e.path, `unread-file-${i}`),
719
+ text(e.unread.length ? `still unread: ${e.unread.join(', ')}` : '', { color: 'red' }),
720
+ ],
721
+ }),
722
+ ),
723
+ ),
724
+ );
725
+ if (understanding?.cardsOwed.length)
726
+ combined.push(
727
+ section(
728
+ 'story-cards',
729
+ `Cards owed (${understanding.cardsOwed.length})`,
730
+ understanding.cardsOwed.map((c, i) => openFile(c, `owed-${i}`)),
731
+ ),
732
+ );
733
+ const rules = new Map();
734
+ for (const a of d.adapters ?? [])
735
+ for (const [path, f] of Object.entries(a.files ?? {}))
736
+ for (const r of f.rules ?? []) {
737
+ const entry = rules.get(r.id) ?? { ...r, paths: [] };
738
+ entry.paths.push(path);
739
+ rules.set(r.id, entry);
740
+ }
741
+ if (rules.size)
742
+ combined.push(
743
+ section(
744
+ 'story-rules',
745
+ `Rules on its files (${rules.size})`,
746
+ [...rules.values()]
747
+ .sort((a, b) => Number(a.proposed) - Number(b.proposed))
748
+ .map((r) =>
749
+ Box({
750
+ key: `rule-${r.id}`,
751
+ children: [
752
+ text(
753
+ `${r.proposed ? 'proposed' : 'agreed '} ${r.id} ${r.text} (${r.paths.length} file${r.paths.length === 1 ? '' : 's'})`,
754
+ { ...(r.proposed ? { dimColor: true } : {}), wrap: 'truncate-end' },
755
+ ),
756
+ ],
757
+ }),
758
+ ),
759
+ ),
760
+ );
761
+ const viewIn =
762
+ opener && shown
763
+ ? Button({
764
+ key: 'story-open-in',
765
+ label: `View ${shown.path} in ${opener.name}`,
766
+ hotkey: 'v',
767
+ plain: true,
768
+ onPress: () => on.onOpenIn(opener, shown.path),
769
+ })
770
+ : null;
771
+
772
+ return Box({
773
+ flexDirection: 'column',
774
+ rowGap: 1,
775
+ children: [
776
+ head,
777
+ acts,
778
+ requirements,
779
+ findings,
780
+ verify,
781
+ ...combined,
782
+ usage,
783
+ recent,
784
+ diff,
785
+ viewIn,
786
+ ].filter(Boolean),
787
+ });
788
+ }
789
+
790
+ /** VIEW-6: the person's Escape on an open story goes back to the lanes instead of closing the pane. */
791
+ export const closeGoesBack = (e, paneId, ui) =>
792
+ e.id === paneId && e.origin?.kind === 'person' && Boolean(ui.story);
793
+
794
+ // --- the traceability map (spec 10) ------------------------------------------------------------
795
+
796
+ /** Each cell state's glyph, colour and words (TRACE-1, TRACE-4: the glyph reads without colour). */
797
+ export const CELL = {
798
+ tested: { glyph: '■', color: 'green', words: 'done and tested' },
799
+ done: { glyph: '□', color: 'green', words: 'done, untested' },
800
+ 'in progress': { glyph: '◐', color: 'blue', words: 'in progress' },
801
+ todo: { glyph: '○', color: undefined, words: 'todo' },
802
+ 'no story': { glyph: '✗', color: 'red', words: 'without a story' },
803
+ removed: { glyph: '·', color: undefined, words: 'removed' },
804
+ };
805
+
806
+ /** A requirement's cell state: status's state, with done split by whether a test names it. */
807
+ export const cellOf = (r) => (r.state === 'done' ? (r.tests?.length ? 'tested' : 'done') : r.state);
808
+
809
+ /** The requirements by spec, in the specs' order: [{ spec, requirements }]. */
810
+ export function mapRows(requirements = []) {
811
+ const rows = [];
812
+ for (const r of requirements) {
813
+ let row = rows.find((x) => x.spec === r.file);
814
+ if (!row) rows.push((row = { spec: r.file, requirements: [] }));
815
+ row.requirements.push(r);
816
+ }
817
+ return rows;
818
+ }
819
+
820
+ /** TRACE-1's legend: the count of each cell state there is. */
821
+ export function mapLegend(requirements = []) {
822
+ return Object.entries(CELL)
823
+ .map(([state, c]) => ({
824
+ state,
825
+ ...c,
826
+ n: requirements.filter((r) => cellOf(r) === state).length,
827
+ }))
828
+ .filter((c) => c.n > 0);
829
+ }
830
+
831
+ /** The cell `step` places from `id` in reading order, or the first row of the next spec (`rows`). */
832
+ export function moveCell(requirements, id, step, { rows = false } = {}) {
833
+ if (!requirements.length) return null;
834
+ const at = Math.max(
835
+ 0,
836
+ requirements.findIndex((r) => r.id === id),
837
+ );
838
+ if (!rows) return requirements[(at + step + requirements.length) % requirements.length].id;
839
+ const specs = mapRows(requirements);
840
+ const row = specs.findIndex((s) => s.requirements.some((r) => r.id === requirements[at].id));
841
+ return specs[(row + step + specs.length) % specs.length].requirements[0].id;
842
+ }
843
+
844
+ /**
845
+ * The Map tab (TRACE-1 to TRACE-4): a row per spec, a cell per requirement, a legend, and the
846
+ * selected requirement's stories and tests. `status` is `code-kit status --json`'s.
847
+ */
848
+ export function mapTab(
849
+ status,
850
+ selectedId,
851
+ { Box, Text, Button, Select, Svg },
852
+ on,
853
+ trail = null,
854
+ { surface = 'terminal', columns = 100 } = {},
855
+ ) {
856
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
857
+ const requirements = status?.requirements ?? [];
858
+ if (!requirements.length)
859
+ return [
860
+ text('No requirements in the specs yet: the map fills as spec-design writes them.', {
861
+ dimColor: true,
862
+ }),
863
+ ];
864
+ const selected = requirements.find((r) => r.id === selectedId) ?? requirements[0];
865
+ const rows = mapRows(requirements);
866
+ const label = (spec) => spec.split('/').pop().replace(/\.md$/, '');
867
+ const width = Math.max(...rows.map((r) => label(r.spec).length));
868
+ const legend = Box({
869
+ key: 'map-legend',
870
+ flexDirection: 'row',
871
+ columnGap: 2,
872
+ flexWrap: 'wrap',
873
+ children: mapLegend(requirements).map((c) =>
874
+ text(`${c.glyph} ${c.n} ${c.words}`, c.color ? { color: c.color } : { dimColor: true }),
875
+ ),
876
+ });
877
+ const grid = Box({
878
+ key: 'map-grid',
879
+ flexDirection: 'column',
880
+ children: rows.map((row) =>
881
+ Box({
882
+ key: `map-row-${label(row.spec)}`,
883
+ flexDirection: 'row',
884
+ columnGap: 1,
885
+ children: [
886
+ text(label(row.spec).padEnd(width), { dimColor: true }),
887
+ ...row.requirements.map((r) => {
888
+ const c = CELL[cellOf(r)];
889
+ // Keyed by its Box: a Text keeps no key of its own.
890
+ return Box({
891
+ key: `cell-${r.id}`,
892
+ children: [
893
+ text(c.glyph, {
894
+ ...(c.color ? { color: c.color } : { dimColor: cellOf(r) === 'removed' }),
895
+ ...(r.id === selected.id ? { inverse: true, bold: true } : {}),
896
+ }),
897
+ ],
898
+ });
899
+ }),
900
+ ],
901
+ }),
902
+ ),
903
+ });
904
+ const keys = Box({
905
+ key: 'map-keys',
906
+ flexDirection: 'row',
907
+ columnGap: 1,
908
+ children: [
909
+ Button({
910
+ key: 'cell-prev',
911
+ label: 'Previous',
912
+ hotkey: 'h',
913
+ plain: true,
914
+ onPress: () => on.onCell(moveCell(requirements, selected.id, -1)),
915
+ }),
916
+ Button({
917
+ key: 'cell-next',
918
+ label: 'Next',
919
+ hotkey: 'l',
920
+ plain: true,
921
+ onPress: () => on.onCell(moveCell(requirements, selected.id, 1)),
922
+ }),
923
+ Button({
924
+ key: 'row-next',
925
+ label: 'Next spec',
926
+ hotkey: 'j',
927
+ plain: true,
928
+ onPress: () => on.onCell(moveCell(requirements, selected.id, 1, { rows: true })),
929
+ }),
930
+ Button({
931
+ key: 'row-prev',
932
+ label: 'Previous spec',
933
+ hotkey: 'k',
934
+ plain: true,
935
+ onPress: () => on.onCell(moveCell(requirements, selected.id, -1, { rows: true })),
936
+ }),
937
+ ],
938
+ });
939
+ // TRACE-2: the selected requirement, its stories with their states and branches, and its tests.
940
+ const stories = (status.stories ?? []).filter((s) => selected.stories?.includes(s.id));
941
+ const detail = Box({
942
+ key: 'map-detail',
943
+ flexDirection: 'column',
944
+ children: [
945
+ Select({
946
+ key: 'map-select',
947
+ label: 'Requirement',
948
+ value: selected.id,
949
+ options: requirements.map((r) => ({
950
+ value: r.id,
951
+ label: `${CELL[cellOf(r)].glyph} ${r.id} ${r.title}`,
952
+ })),
953
+ onSelect: (id) => on.onCell(id),
954
+ }),
955
+ text(`${selected.id} ${selected.title}`, { bold: true }),
956
+ text(`${selected.file} · ${CELL[cellOf(selected)].words}`, { dimColor: true }),
957
+ ...(stories.length
958
+ ? stories.map((s) =>
959
+ text(
960
+ `${s.id} ${s.title} · ${s.lane} · ${s.state}${s.branchExists ? ` · ${s.branch}` : ''}`,
961
+ { key: `map-story-${s.id}` },
962
+ ),
963
+ )
964
+ : [text('No story delivers it yet: a gap in the plan.', { color: 'red' })]),
965
+ // TRAIL-1: the code its stories changed, once the trail is read.
966
+ ...(() => {
967
+ const row = trail?.requirements?.find((r) => r.id === selected.id);
968
+ if (!row) return [];
969
+ return row.files.length
970
+ ? [
971
+ text(`Code its stories changed (${row.files.length})`, { bold: true }),
972
+ Box({
973
+ key: 'map-files',
974
+ children: [
975
+ text(
976
+ ` ${row.files
977
+ .slice(0, 6)
978
+ .map((f) => f.split('/').pop())
979
+ .join(', ')}${row.files.length > 6 ? ', …' : ''}`,
980
+ { dimColor: true, wrap: 'truncate-end' },
981
+ ),
982
+ ],
983
+ }),
984
+ ]
985
+ : [text('No code changed for it yet.', { dimColor: true })];
986
+ })(),
987
+ ...(selected.tests?.length
988
+ ? [
989
+ text('Tests that name it', { bold: true }),
990
+ ...selected.tests.map((t, i) =>
991
+ text(` ${t}`, { key: `map-test-${i}`, dimColor: true }),
992
+ ),
993
+ ]
994
+ : [text('No test names it.', { dimColor: true })]),
995
+ ],
996
+ });
997
+ // TRAIL-1: the selected requirement's spec as a trail: each requirement to its stories, the code
998
+ // they changed and the tests that name it, a gap where any link is missing.
999
+ const spec = selected.file;
1000
+ const trailRows = (trail?.requirements ?? []).filter(
1001
+ (r) =>
1002
+ r.spec === spec?.split('/').pop() && requirements.find((q) => q.id === r.id)?.file === spec,
1003
+ );
1004
+ const TONE = {
1005
+ req: {},
1006
+ selected: { bold: true, color: 'cyan' },
1007
+ dim: { dimColor: true },
1008
+ done: { color: 'green' },
1009
+ moving: { color: 'blue' },
1010
+ waiting: { color: 'red' },
1011
+ gap: { color: 'red' },
1012
+ test: { color: 'green' },
1013
+ };
1014
+ const flow =
1015
+ surface && surface !== 'terminal' && Svg
1016
+ ? Svg({
1017
+ source: trailSvg(trailRows, { selected: selected.id }),
1018
+ alt: `${label(spec)}'s trail: each requirement to its stories, code and tests`,
1019
+ })
1020
+ : Box({
1021
+ key: 'trail-flow',
1022
+ flexDirection: 'column',
1023
+ children: trailRowsDrawn(trailRows, { selected: selected.id, width: columns }).map(
1024
+ (runs, i) =>
1025
+ Box({
1026
+ key: `trail-${trailRows[i].id}`,
1027
+ flexDirection: 'row',
1028
+ children: runs.map((r) => text(r.text, TONE[r.style] ?? {})),
1029
+ }),
1030
+ ),
1031
+ });
1032
+ const trailBox = trailRows.length
1033
+ ? [
1034
+ Box({
1035
+ key: 'trail',
1036
+ flexDirection: 'column',
1037
+ children: [text(`Trail · ${label(spec)}`, { bold: true }), flow],
1038
+ }),
1039
+ ]
1040
+ : [];
1041
+ return [legend, grid, keys, ...trailBox, detail];
1042
+ }
1043
+
1044
+ /** A tint darkened toward the terminal's ground, for a glowing box's fill: `share` of the tint kept. */
1045
+ const shade = (hex, share) => {
1046
+ const n = Number.parseInt(String(hex).replace('#', ''), 16);
1047
+ if (!Number.isFinite(n)) return undefined;
1048
+ const c = (v) => Math.round(((n >> v) & 0xff) * share + 14 * (1 - share));
1049
+ return `#${[16, 8, 0].map((v) => c(v).toString(16).padStart(2, '0')).join('')}`;
1050
+ };
1051
+
1052
+ /** A code map run's text style: its lane's tint where it glows, a fill inside a glowing box. */
1053
+ function runStyle(r, tints) {
1054
+ const tint = r.lane ? (tints[r.lane] ?? '#5fb3b3') : null;
1055
+ if (tint && (r.style === 'fill-read' || r.style === 'fill-edit'))
1056
+ return {
1057
+ backgroundColor: shade(tint, r.style === 'fill-edit' ? 0.45 : 0.28),
1058
+ color: 'white',
1059
+ ...(r.style === 'fill-edit' ? { bold: true } : {}),
1060
+ };
1061
+ if (tint && (r.style === 'reading' || r.style === 'editing'))
1062
+ return { color: tint, bold: r.style === 'editing' };
1063
+ return RUN_STYLE[r.style] ?? {};
1064
+ }
1065
+
1066
+ /** The colour each style of the code map's runs takes, the lane's tint where it glows. */
1067
+ const RUN_STYLE = {
1068
+ dim: { dimColor: true },
1069
+ node: {},
1070
+ selected: { color: 'cyan', bold: true },
1071
+ ok: { color: 'green' },
1072
+ warn: { color: 'yellow' },
1073
+ };
1074
+
1075
+ /**
1076
+ * MAP-1 to MAP-4: the Code map tab: the stories to choose from, the story's files by layer with their
1077
+ * imports (glowing where a lane reads or edits them), and the selected file's card and neighbours.
1078
+ * `view` holds { state, codeMap, ui, glow, art, surface, columns }; `on` holds onMapStory(id),
1079
+ * onMapFile(path), onOpenFile(path).
1080
+ */
1081
+ export function codeTab(view, { Box, Text, Button, Svg }, on) {
1082
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
1083
+ const { state, ui = DEFAULT_UI, codeMap: map = null, glow = {}, art = null } = view;
1084
+ const storyId = mapStoryOf(state, ui.selected, ui.mapStory);
1085
+ const choices = (state?.stories ?? []).filter(
1086
+ (s) => s.id === storyId || ['in progress', 'review', 'sent back'].includes(s.state),
1087
+ );
1088
+ const picker = Box({
1089
+ key: 'map-stories',
1090
+ flexDirection: 'row',
1091
+ columnGap: 1,
1092
+ flexWrap: 'wrap',
1093
+ children: choices.map((s) =>
1094
+ Button({
1095
+ key: `map-story-${s.id}`,
1096
+ label: `${s.id} ${s.title}`,
1097
+ ...(s.id === storyId ? { variant: 'primary' } : { plain: true }),
1098
+ onPress: () => on.onMapStory(s.id),
1099
+ }),
1100
+ ),
1101
+ });
1102
+ if (!storyId) return [text('No story to map yet: the plan has none open.', { dimColor: true })];
1103
+ if (!map || map.story?.id !== storyId)
1104
+ return [picker, text(`Reading ${storyId}'s code…`, { dimColor: true })];
1105
+ if (!map.files.length)
1106
+ return [picker, text(`${storyId} hasn't changed any code yet.`, { dimColor: true })];
1107
+ const selected = map.files.some((f) => f.path === ui.mapFile) ? ui.mapFile : map.files[0].path;
1108
+ // The lanes' tints are RGB numbers (the characters' colours); text and SVG take them as hex.
1109
+ const tints = Object.fromEntries(
1110
+ Object.entries(art?.tints ?? {}).map(([lane, rgb]) => [
1111
+ lane,
1112
+ typeof rgb === 'number' ? `#${rgb.toString(16).padStart(6, '0')}` : rgb,
1113
+ ]),
1114
+ );
1115
+ const width = Math.max(40, (view.columns ?? 100) - 4);
1116
+ const drawing =
1117
+ view.surface !== 'terminal' && Svg
1118
+ ? Svg({
1119
+ source: mapSvg(map, { glow, selected, tints, width }),
1120
+ alt: `${storyId}'s code: ${map.files.length} files by layer`,
1121
+ isInteractive: true,
1122
+ })
1123
+ : Box({
1124
+ key: 'code-map',
1125
+ flexDirection: 'column',
1126
+ children: codeRows(map, { glow, selected, width }).map((runs, y) =>
1127
+ Box({
1128
+ key: `code-row-${y}`,
1129
+ flexDirection: 'row',
1130
+ children: runs.map((r) => text(r.text, runStyle(r, tints))),
1131
+ }),
1132
+ ),
1133
+ });
1134
+ const at = map.files.findIndex((f) => f.path === selected);
1135
+ const file = map.files[at];
1136
+ const card = cardOf(file);
1137
+ const near = neighboursOf(map, selected);
1138
+ const short = (p) => p.split('/').pop();
1139
+ const detail = Box({
1140
+ key: 'code-file',
1141
+ flexDirection: 'column',
1142
+ borderStyle: 'round',
1143
+ borderColor: 'gray',
1144
+ paddingX: 1,
1145
+ children: [
1146
+ text(selected, { bold: true }),
1147
+ text(`${file.layer}${file.lane ? ` · ${file.lane} lane` : ''}`, { dimColor: true }),
1148
+ ...(card
1149
+ ? [
1150
+ text(
1151
+ card.state === 'missing'
1152
+ ? 'No why written yet: Context Graph keeps one as a card once it is.'
1153
+ : card.text,
1154
+ card.state === 'current' ? {} : { color: 'yellow' },
1155
+ ),
1156
+ ...(card.state === 'stale'
1157
+ ? [text('The file changed since its why was written.', { dimColor: true })]
1158
+ : []),
1159
+ ]
1160
+ : []),
1161
+ ...(near.imports.length
1162
+ ? [text(`imports ${near.imports.map(short).join(', ')}`, { dimColor: true })]
1163
+ : []),
1164
+ ...(near.importers.length
1165
+ ? [text(`imported by ${near.importers.map(short).join(', ')}`, { dimColor: true })]
1166
+ : []),
1167
+ ],
1168
+ });
1169
+ const keys = Box({
1170
+ key: 'code-keys',
1171
+ flexDirection: 'row',
1172
+ columnGap: 1,
1173
+ children: [
1174
+ Button({
1175
+ key: 'code-next',
1176
+ label: 'Next file',
1177
+ hotkey: 'j',
1178
+ plain: true,
1179
+ onPress: () => on.onMapFile(map.files[(at + 1) % map.files.length].path),
1180
+ }),
1181
+ Button({
1182
+ key: 'code-prev',
1183
+ label: 'Previous',
1184
+ hotkey: 'k',
1185
+ plain: true,
1186
+ onPress: () => on.onMapFile(map.files[(at - 1 + map.files.length) % map.files.length].path),
1187
+ }),
1188
+ ...(map.open
1189
+ ? [
1190
+ Button({
1191
+ key: 'code-open',
1192
+ label: 'Open in Context Graph',
1193
+ hotkey: 'o',
1194
+ plain: true,
1195
+ onPress: () => on.onOpenFile(selected),
1196
+ }),
1197
+ ]
1198
+ : []),
1199
+ ],
1200
+ });
1201
+ const legend = Box({
1202
+ key: 'code-legend',
1203
+ flexDirection: 'row',
1204
+ columnGap: 2,
1205
+ flexWrap: 'wrap',
1206
+ children: MAP_LEGEND.map((l) =>
1207
+ text(
1208
+ l.text,
1209
+ l.style === 'glow' ? { color: tints[file.lane] ?? 'cyan' } : (RUN_STYLE[l.style] ?? {}),
1210
+ ),
1211
+ ),
1212
+ });
1213
+ return [picker, drawing, legend, detail, keys];
1214
+ }
1215
+
1216
+ /**
1217
+ * DECIDE-3: the Decisions tab: each decision that needs the person, the selected one as a card with its
1218
+ * lineage (what they asked for, the story and requirements, what was happening, what's settled, why it
1219
+ * needs them) and its options, each saying what it does. `on`: onDecision(i), onNeed(id, line).
1220
+ */
1221
+ export function decisionsTab(cards, ui, { Box, Text, Button }, on) {
1222
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
1223
+ if (!cards.length) return [];
1224
+ const at = Math.min(ui.decision ?? 0, cards.length - 1);
1225
+ const card = cards[at];
1226
+ const row = (label, value, style = {}) =>
1227
+ Box({
1228
+ key: `decision-${label}`,
1229
+ flexDirection: 'row',
1230
+ columnGap: 1,
1231
+ children: [
1232
+ text(label.padEnd(15), { dimColor: true }),
1233
+ text(value, { wrap: 'wrap', ...style }),
1234
+ ],
1235
+ });
1236
+ const list = Box({
1237
+ key: 'decision-list',
1238
+ flexDirection: 'column',
1239
+ children: cards.map((c, i) =>
1240
+ Button({
1241
+ key: `decision-pick-${i}`,
1242
+ label: `${i === at ? '›' : ' '} ${c.title}`,
1243
+ plain: true,
1244
+ onPress: () => on.onDecision(i),
1245
+ }),
1246
+ ),
1247
+ });
1248
+ const detail = Box({
1249
+ key: 'decision-card',
1250
+ flexDirection: 'column',
1251
+ borderStyle: 'round',
1252
+ borderColor: 'yellow',
1253
+ paddingX: 1,
1254
+ rowGap: 0,
1255
+ children: [
1256
+ text(card.title, { bold: true }),
1257
+ ...(card.asked ? [row('You asked for', card.asked)] : []),
1258
+ ...(card.story
1259
+ ? [
1260
+ row(
1261
+ 'Story',
1262
+ `${card.story.id} ${card.story.title}${card.story.branch ? ` · ${card.story.branch}` : ''}`,
1263
+ ),
1264
+ ]
1265
+ : []),
1266
+ ...(card.requirements.length
1267
+ ? [
1268
+ row(
1269
+ 'Requirements',
1270
+ card.requirements.map((r) => `${r.id}${r.title ? ` ${r.title}` : ''}`).join('; '),
1271
+ ),
1272
+ ]
1273
+ : []),
1274
+ ...(card.doing ? [row('Happening', card.doing)] : []),
1275
+ ...(card.settled.length
1276
+ ? [
1277
+ text('Already settled', { dimColor: true }),
1278
+ ...card.settled.map((line, i) =>
1279
+ Box({
1280
+ key: `decision-settled-${i}`,
1281
+ children: [text(` · ${line}`, { wrap: 'wrap' })],
1282
+ }),
1283
+ ),
1284
+ ]
1285
+ : []),
1286
+ ...(card.why ? [row('Why it is yours', card.why, { color: 'yellow' })] : []),
1287
+ text('Your options', { dimColor: true }),
1288
+ ...card.options.map((o) =>
1289
+ Box({
1290
+ key: `decision-option-${o.id}`,
1291
+ flexDirection: 'row',
1292
+ columnGap: 2,
1293
+ children: [
1294
+ Button({
1295
+ key: `decide-${o.id}`,
1296
+ label: o.label,
1297
+ ...(o.hotkey ? { hotkey: o.hotkey } : {}),
1298
+ variant: 'primary',
1299
+ onPress: () => on.onNeed(o.id, card.line),
1300
+ }),
1301
+ ...(o.effect ? [text(o.effect, { dimColor: true, wrap: 'wrap' })] : []),
1302
+ ],
1303
+ }),
1304
+ ),
1305
+ ],
1306
+ });
1307
+ return [
1308
+ text(
1309
+ `${cards.length} decision${cards.length === 1 ? '' : 's'} need${cards.length === 1 ? 's' : ''} you. Everything else is the lead's.`,
1310
+ { bold: true },
1311
+ ),
1312
+ ...(cards.length > 1 ? [list] : []),
1313
+ detail,
1314
+ ];
1315
+ }
1316
+
1317
+ const JOURNAL_GLYPH = {
1318
+ request: '✦',
1319
+ decision: '◆',
1320
+ spec: '▤',
1321
+ setup: '⚙',
1322
+ dispatch: '→',
1323
+ sentBack: '↩',
1324
+ merge: '✓',
1325
+ approval: '✔',
1326
+ milestone: '★',
1327
+ loop: '·',
1328
+ };
1329
+
1330
+ /**
1331
+ * JOURNAL-1: the Journal tab: the build's lineage, newest last, each event with its time and a glyph,
1332
+ * your own approvals and choices marked. The latest 60 are drawn.
1333
+ */
1334
+ export function journalTab(events, { Box, Text }, now = Date.now()) {
1335
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
1336
+ if (!events.length) return [];
1337
+ const shown = events.slice(-60);
1338
+ const day = (at) => new Date(at).toISOString().slice(0, 10);
1339
+ const time = (at) => new Date(at).toISOString().slice(11, 16);
1340
+ const rows = [];
1341
+ let lastDay = null;
1342
+ shown.forEach((e, i) => {
1343
+ if (day(e.at) !== lastDay) {
1344
+ lastDay = day(e.at);
1345
+ rows.push(
1346
+ Box({ key: `journal-day-${i}`, children: [text(lastDay, { bold: true, dimColor: true })] }),
1347
+ );
1348
+ }
1349
+ const yours = e.who === 'person' || e.kind === 'request';
1350
+ rows.push(
1351
+ Box({
1352
+ key: `journal-${i}`,
1353
+ flexDirection: 'row',
1354
+ columnGap: 1,
1355
+ children: [
1356
+ text(time(e.at), { dimColor: true }),
1357
+ text(JOURNAL_GLYPH[e.kind] ?? '·', {
1358
+ color: yours
1359
+ ? 'yellow'
1360
+ : e.kind === 'merge' || e.kind === 'milestone'
1361
+ ? 'green'
1362
+ : e.kind === 'sentBack'
1363
+ ? 'magenta'
1364
+ : undefined,
1365
+ }),
1366
+ text(e.text, { wrap: 'wrap', ...(yours ? { color: 'yellow' } : {}) }),
1367
+ ],
1368
+ }),
1369
+ );
1370
+ });
1371
+ return [
1372
+ text(
1373
+ `From your request to now: ${events.length} event${events.length === 1 ? '' : 's'}${events.length > shown.length ? `, the latest ${shown.length} shown` : ''}. Yours are in yellow.`,
1374
+ { dimColor: true },
1375
+ ),
1376
+ Box({ key: 'journal', flexDirection: 'column', children: rows }),
1377
+ ];
1378
+ }