@warren-dean/code-kit 0.6.0 → 0.7.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +37 -0
- package/README.md +19 -9
- package/bin/code-kit.mjs +353 -39
- package/hooks/guard-bash.mjs +30 -14
- package/hooks/lane-audit.mjs +44 -1
- package/hooks/lib/adapters/context-graph.mjs +75 -6
- package/hooks/lib/adapters/index.mjs +18 -0
- package/hooks/lib/config.mjs +43 -2
- package/hooks/lib/dependencies.mjs +26 -9
- package/hooks/lib/harness.mjs +2 -2
- package/hooks/lib/next.mjs +34 -3
- package/hooks/lib/plan.mjs +29 -0
- package/hooks/lib/queue.mjs +75 -0
- package/hooks/lib/reviews.mjs +18 -0
- package/hooks/lib/rules.mjs +8 -4
- package/hooks/lib/shell.mjs +1 -1
- package/hooks/lib/timeline.mjs +77 -0
- package/hooks/mod/art.mjs +200 -0
- package/hooks/mod/loop.mjs +68 -0
- package/hooks/mod/panes.mjs +943 -0
- package/hooks/mod/register.mjs +978 -15
- package/hooks/mod/register.test.ts +1449 -14
- package/hooks/mod/review.mjs +102 -0
- package/hooks/mod/view.mjs +430 -38
- package/package.json +1 -1
- package/skills/init/SKILL.md +5 -4
- package/skills/review/SKILL.md +8 -1
|
@@ -0,0 +1,943 @@
|
|
|
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 {
|
|
5
|
+
NOT_CODE_KIT,
|
|
6
|
+
STATE_COLOR,
|
|
7
|
+
ago,
|
|
8
|
+
lanesPane,
|
|
9
|
+
loopSection,
|
|
10
|
+
queueSection,
|
|
11
|
+
shownState,
|
|
12
|
+
tokens,
|
|
13
|
+
usageSection,
|
|
14
|
+
} from './view.mjs';
|
|
15
|
+
|
|
16
|
+
export const TABS = [
|
|
17
|
+
{ id: 'lanes', label: 'Lanes', hotkey: '1' },
|
|
18
|
+
{ id: 'queue', label: 'Queue', hotkey: '2' },
|
|
19
|
+
{ id: 'usage', label: 'Usage', hotkey: '3' },
|
|
20
|
+
{ id: 'map', label: 'Map', hotkey: '4' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** The pane's own state, kept by the mod between draws. */
|
|
24
|
+
export const DEFAULT_UI = { tab: 'lanes', selected: null, filter: null, search: '', cell: null };
|
|
25
|
+
|
|
26
|
+
/** The glyph each state carries besides its colour (spec 11's quality target). */
|
|
27
|
+
const GLYPH = {
|
|
28
|
+
building: '●',
|
|
29
|
+
working: '▶',
|
|
30
|
+
'in review': '◐',
|
|
31
|
+
queued: '◆',
|
|
32
|
+
'sent back': '↩',
|
|
33
|
+
blocked: '■',
|
|
34
|
+
idle: '·',
|
|
35
|
+
ready: '○',
|
|
36
|
+
merged: '✓',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* VIEW-1: the stories counted by state, in the order a build moves through them, only those there
|
|
41
|
+
* are: [{ state, n, glyph }]. Lanes give building, in review, queued, sent back and blocked; the plan
|
|
42
|
+
* gives ready and merged.
|
|
43
|
+
*/
|
|
44
|
+
export function countsOf(state, queue = []) {
|
|
45
|
+
if (state?.kind !== 'ok') return [];
|
|
46
|
+
const lanes = state.lanes.map((l) => shownState(l, queue));
|
|
47
|
+
const n = (s) => lanes.filter((x) => x === s).length;
|
|
48
|
+
const counts = [
|
|
49
|
+
['building', n('building')],
|
|
50
|
+
['working', n('working')],
|
|
51
|
+
['in review', n('in review')],
|
|
52
|
+
['queued', n('queued')],
|
|
53
|
+
['sent back', n('sent back')],
|
|
54
|
+
['blocked', n('blocked')],
|
|
55
|
+
['ready', state.ready.length],
|
|
56
|
+
['merged', (state.stories ?? []).filter((s) => s.state === 'done').length],
|
|
57
|
+
['idle', n('idle')],
|
|
58
|
+
];
|
|
59
|
+
return counts.filter(([, k]) => k > 0).map(([s, k]) => ({ state: s, n: k, glyph: GLYPH[s] }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A lane's stories in plan order. */
|
|
63
|
+
export const laneStories = (lane, state) =>
|
|
64
|
+
(state?.stories ?? []).filter((s) => s.lane === lane.name);
|
|
65
|
+
|
|
66
|
+
/** The story Enter opens on a lane: the one it's on, else its next not done, else its last done. */
|
|
67
|
+
export function storyToOpen(lane, state) {
|
|
68
|
+
if (lane.story) return lane.story;
|
|
69
|
+
const own = laneStories(lane, state);
|
|
70
|
+
return own.find((s) => s.state !== 'done') ?? own.at(-1) ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** VIEW-1: how much of the plan is done, as words: "3 of 25 stories done". */
|
|
74
|
+
export function planProgress(state) {
|
|
75
|
+
const all = state?.stories ?? [];
|
|
76
|
+
return all.length
|
|
77
|
+
? `${all.filter((s) => s.state === 'done').length} of ${all.length} stories done`
|
|
78
|
+
: null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** VIEW-6's letters for the band's actions, as the needs-you strip draws them. */
|
|
82
|
+
const LETTER = {
|
|
83
|
+
approve: 'a',
|
|
84
|
+
approveHeld: 'a',
|
|
85
|
+
review: 'r',
|
|
86
|
+
merge: 'm',
|
|
87
|
+
mergeQueue: 'm',
|
|
88
|
+
go: 'g',
|
|
89
|
+
keepGoing: 'g',
|
|
90
|
+
pause: 'p',
|
|
91
|
+
resume: 'p',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The selected lane's own acts (VIEW-6), as its state allows them: [{ id, label, hotkey }]. */
|
|
95
|
+
export function laneActions(lane, { queue = [], held = [], requests = [] } = {}) {
|
|
96
|
+
if (!lane) return [];
|
|
97
|
+
const acts = [];
|
|
98
|
+
const asked = [...held, ...requests].some((r) => (r.lane ?? 'lead') === lane.name);
|
|
99
|
+
if (asked) acts.push({ id: 'approve', label: 'Approve', hotkey: 'a' });
|
|
100
|
+
const state = shownState(lane, queue);
|
|
101
|
+
if (state === 'in review') {
|
|
102
|
+
acts.push({ id: 'review', label: 'Review', hotkey: 'r' });
|
|
103
|
+
acts.push({ id: 'sendBack', label: 'Send back', hotkey: 's' });
|
|
104
|
+
}
|
|
105
|
+
const head = queue.find((e) => ['waiting', 'merging'].includes(e.state));
|
|
106
|
+
if (head && head.branch === lane.branch) acts.push({ id: 'merge', label: 'Merge', hotkey: 'm' });
|
|
107
|
+
if (lane.active && lane.agent) {
|
|
108
|
+
acts.push({ id: 'nudge', label: 'Nudge', hotkey: 'n' });
|
|
109
|
+
acts.push({ id: 'stop', label: 'Stop', hotkey: 'x' });
|
|
110
|
+
}
|
|
111
|
+
return acts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The Lanes pane (spec 11). `view` holds what to draw: { state, usage, plan, loop, needs, live,
|
|
116
|
+
* held, requests, ui, placement }; `needs` are the band's lines (VIEW-5). `on` acts: onTab(id),
|
|
117
|
+
* onFilter(state), onMove(step), onNeed(id, line), onLane(id, lane), onSearch(text).
|
|
118
|
+
*/
|
|
119
|
+
export function lanesFrame(view, els, on) {
|
|
120
|
+
const { Box, Text, Button, Input } = els;
|
|
121
|
+
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
122
|
+
const { state, ui = DEFAULT_UI, loop = null, plan = null } = view;
|
|
123
|
+
if (!state || state.kind === 'none') return text(NOT_CODE_KIT, { dimColor: true });
|
|
124
|
+
if (state.kind === 'invalid')
|
|
125
|
+
return text('The config is invalid: run code-kit check.', { color: 'red' });
|
|
126
|
+
const queue = loop?.queue ?? [];
|
|
127
|
+
|
|
128
|
+
// VIEW-1: the counts, each a filter; the loop's state and the plan's use.
|
|
129
|
+
const loopWord =
|
|
130
|
+
loop?.state === 'off'
|
|
131
|
+
? 'loop off'
|
|
132
|
+
: loop?.state === 'paused'
|
|
133
|
+
? 'loop paused'
|
|
134
|
+
: loop?.state === 'done'
|
|
135
|
+
? 'milestone done'
|
|
136
|
+
: 'loop on';
|
|
137
|
+
const header = Box({
|
|
138
|
+
key: 'header',
|
|
139
|
+
flexDirection: 'row',
|
|
140
|
+
columnGap: 2,
|
|
141
|
+
flexWrap: 'wrap',
|
|
142
|
+
children: [
|
|
143
|
+
...countsOf(state, queue).map((c) =>
|
|
144
|
+
Button({
|
|
145
|
+
key: `count-${c.state}`,
|
|
146
|
+
label: `${c.glyph} ${c.n} ${c.state}`,
|
|
147
|
+
plain: true,
|
|
148
|
+
...(ui.filter && ui.filter !== c.state ? { dimColor: true } : {}),
|
|
149
|
+
onPress: () => on.onFilter(c.state),
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
152
|
+
...(planProgress(state) ? [text(planProgress(state), { dimColor: true })] : []),
|
|
153
|
+
text(loopWord, { color: loop?.state === 'paused' ? 'yellow' : 'green' }),
|
|
154
|
+
...(plan?.percent != null
|
|
155
|
+
? [
|
|
156
|
+
text(`plan ${plan.percent}%`, {
|
|
157
|
+
color: plan.paused ? 'red' : undefined,
|
|
158
|
+
dimColor: !plan.paused,
|
|
159
|
+
}),
|
|
160
|
+
]
|
|
161
|
+
: []),
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// VIEW-5: what waits on the person, pinned at the top with VIEW-6's letters.
|
|
166
|
+
const used = new Set();
|
|
167
|
+
const needs = (view.needs ?? []).map((line) => {
|
|
168
|
+
const actions = line.actions.filter((a) => a.id !== 'lanes');
|
|
169
|
+
return Box({
|
|
170
|
+
key: `need-${line.kind}`,
|
|
171
|
+
flexDirection: 'row',
|
|
172
|
+
columnGap: 2,
|
|
173
|
+
children: [
|
|
174
|
+
text('!', { color: 'yellow', bold: true }),
|
|
175
|
+
text(line.text, { bold: true }),
|
|
176
|
+
...actions.map((a) => {
|
|
177
|
+
const letter = LETTER[a.id] && !used.has(LETTER[a.id]) ? LETTER[a.id] : undefined;
|
|
178
|
+
if (letter) used.add(letter);
|
|
179
|
+
return Button({
|
|
180
|
+
key: `need-${a.id}`,
|
|
181
|
+
label: a.label,
|
|
182
|
+
...(letter ? { hotkey: letter } : {}),
|
|
183
|
+
variant: 'primary',
|
|
184
|
+
onPress: () => on.onNeed(a.id, line),
|
|
185
|
+
});
|
|
186
|
+
}),
|
|
187
|
+
],
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// VIEW-8: inline, the header, what needs the person and one line per lane.
|
|
192
|
+
if (view.placement === 'inline')
|
|
193
|
+
return Box({
|
|
194
|
+
flexDirection: 'column',
|
|
195
|
+
children: [
|
|
196
|
+
header,
|
|
197
|
+
...needs,
|
|
198
|
+
...state.lanes.map((l) => {
|
|
199
|
+
const live = view.live?.[l.name];
|
|
200
|
+
const s = shownState(l, queue);
|
|
201
|
+
return Box({
|
|
202
|
+
key: `lane-${l.name}`,
|
|
203
|
+
flexDirection: 'row',
|
|
204
|
+
columnGap: 1,
|
|
205
|
+
children: [
|
|
206
|
+
text(l.name, { bold: true }),
|
|
207
|
+
text(s, STATE_COLOR[s] ? { color: STATE_COLOR[s] } : { dimColor: true }),
|
|
208
|
+
...(l.story ? [text(l.story.id, { dimColor: true })] : []),
|
|
209
|
+
...(live
|
|
210
|
+
? [
|
|
211
|
+
text(`${live.tool} · ${live.ago}`, {
|
|
212
|
+
wrap: 'truncate-end',
|
|
213
|
+
...(live.level === 'stalled'
|
|
214
|
+
? { color: 'red' }
|
|
215
|
+
: live.level === 'quiet'
|
|
216
|
+
? { color: 'yellow' }
|
|
217
|
+
: { dimColor: true }),
|
|
218
|
+
}),
|
|
219
|
+
]
|
|
220
|
+
: []),
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
}),
|
|
224
|
+
],
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// VIEW-6: the tabs, on 1 to 4.
|
|
228
|
+
const tabs = Box({
|
|
229
|
+
key: 'tabs',
|
|
230
|
+
flexDirection: 'row',
|
|
231
|
+
columnGap: 1,
|
|
232
|
+
children: TABS.map((t) =>
|
|
233
|
+
Button({
|
|
234
|
+
key: `tab-${t.id}`,
|
|
235
|
+
label: t.label,
|
|
236
|
+
hotkey: t.hotkey,
|
|
237
|
+
...(ui.tab === t.id ? { variant: 'primary' } : { dimColor: true }),
|
|
238
|
+
onPress: () => on.onTab(t.id),
|
|
239
|
+
}),
|
|
240
|
+
),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// VIEW-7: a story's drill-down takes the tabs' place; Back (b, or Escape) returns.
|
|
244
|
+
if (ui.story && view.story)
|
|
245
|
+
return Box({
|
|
246
|
+
flexDirection: 'column',
|
|
247
|
+
rowGap: 1,
|
|
248
|
+
children: [
|
|
249
|
+
header,
|
|
250
|
+
...(needs.length ? [Box({ key: 'needs', flexDirection: 'column', children: needs })] : []),
|
|
251
|
+
storyPane(view.story, els, on.story),
|
|
252
|
+
],
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const selected = state.lanes.find((l) => l.name === ui.selected) ?? null;
|
|
256
|
+
const body =
|
|
257
|
+
ui.tab === 'map'
|
|
258
|
+
? mapTab(view.status, ui.cell, els, on)
|
|
259
|
+
: ui.tab === 'queue'
|
|
260
|
+
? queueSection(loop, text, Box)
|
|
261
|
+
: ui.tab === 'usage'
|
|
262
|
+
? usageSection(view.usage, plan, text, Box)
|
|
263
|
+
: [
|
|
264
|
+
lanesPane(state, els, view.usage, plan, loop, {
|
|
265
|
+
filter: ui.filter,
|
|
266
|
+
search: ui.search,
|
|
267
|
+
selected: ui.selected,
|
|
268
|
+
live: view.live ?? {},
|
|
269
|
+
art: view.art ?? null,
|
|
270
|
+
detail: selected
|
|
271
|
+
? {
|
|
272
|
+
owns: view.paths?.[selected.name] ?? [],
|
|
273
|
+
stories: laneStories(selected, state),
|
|
274
|
+
}
|
|
275
|
+
: null,
|
|
276
|
+
combined: view.combined ?? null,
|
|
277
|
+
}),
|
|
278
|
+
...loopSection(loop, text, Box),
|
|
279
|
+
];
|
|
280
|
+
const empty =
|
|
281
|
+
ui.tab === 'queue'
|
|
282
|
+
? 'The merge queue is empty.'
|
|
283
|
+
: ui.tab === 'usage'
|
|
284
|
+
? 'No usage measured yet this session.'
|
|
285
|
+
: null;
|
|
286
|
+
|
|
287
|
+
// VIEW-6: j/k move between lanes; the selected lane's acts; f filters.
|
|
288
|
+
const opening = selected ? storyToOpen(selected, state) : null;
|
|
289
|
+
const keys =
|
|
290
|
+
ui.tab === 'lanes'
|
|
291
|
+
? [
|
|
292
|
+
Box({
|
|
293
|
+
key: 'keys',
|
|
294
|
+
flexDirection: 'row',
|
|
295
|
+
columnGap: 1,
|
|
296
|
+
flexWrap: 'wrap',
|
|
297
|
+
children: [
|
|
298
|
+
...(opening
|
|
299
|
+
? [
|
|
300
|
+
Button({
|
|
301
|
+
key: 'open-story',
|
|
302
|
+
label: `Open ${opening.id}`,
|
|
303
|
+
hotkey: 'o',
|
|
304
|
+
variant: 'primary',
|
|
305
|
+
autoFocus: true,
|
|
306
|
+
onPress: () => on.onOpen({ ...selected, story: opening }),
|
|
307
|
+
}),
|
|
308
|
+
]
|
|
309
|
+
: []),
|
|
310
|
+
Button({
|
|
311
|
+
key: 'lane-next',
|
|
312
|
+
label: 'Next lane',
|
|
313
|
+
hotkey: 'j',
|
|
314
|
+
plain: true,
|
|
315
|
+
onPress: () => on.onMove(1),
|
|
316
|
+
}),
|
|
317
|
+
Button({
|
|
318
|
+
key: 'lane-prev',
|
|
319
|
+
label: 'Previous',
|
|
320
|
+
hotkey: 'k',
|
|
321
|
+
plain: true,
|
|
322
|
+
onPress: () => on.onMove(-1),
|
|
323
|
+
}),
|
|
324
|
+
...(selected
|
|
325
|
+
? laneActions(selected, { queue, held: view.held, requests: view.requests }).map(
|
|
326
|
+
(a) =>
|
|
327
|
+
Button({
|
|
328
|
+
key: `act-${a.id}`,
|
|
329
|
+
label: `${a.label} ${selected.name}`,
|
|
330
|
+
hotkey: used.has(a.hotkey) ? undefined : a.hotkey,
|
|
331
|
+
plain: true,
|
|
332
|
+
onPress: () => on.onLane(a.id, selected),
|
|
333
|
+
}),
|
|
334
|
+
)
|
|
335
|
+
: []),
|
|
336
|
+
...(!used.has('p') && loop?.state !== 'off' && loop?.state !== 'done'
|
|
337
|
+
? [
|
|
338
|
+
Button({
|
|
339
|
+
key: 'loop-toggle',
|
|
340
|
+
label: loop?.state === 'paused' ? 'Resume loop' : 'Pause loop',
|
|
341
|
+
hotkey: 'p',
|
|
342
|
+
plain: true,
|
|
343
|
+
onPress: () => on.onNeed(loop?.state === 'paused' ? 'resume' : 'pause', null),
|
|
344
|
+
}),
|
|
345
|
+
]
|
|
346
|
+
: []),
|
|
347
|
+
],
|
|
348
|
+
}),
|
|
349
|
+
// One row: f puts the keys in the filter.
|
|
350
|
+
Box({
|
|
351
|
+
key: 'filter-row',
|
|
352
|
+
flexDirection: 'row',
|
|
353
|
+
columnGap: 1,
|
|
354
|
+
children: [
|
|
355
|
+
Button({
|
|
356
|
+
key: 'filter-focus',
|
|
357
|
+
label: 'Filter',
|
|
358
|
+
hotkey: 'f',
|
|
359
|
+
plain: true,
|
|
360
|
+
onPress: () => on.onFocusSearch(),
|
|
361
|
+
}),
|
|
362
|
+
Input({
|
|
363
|
+
key: 'filter',
|
|
364
|
+
label: '',
|
|
365
|
+
value: ui.search,
|
|
366
|
+
placeholder: 'lane, agent or story',
|
|
367
|
+
submitLabel: 'Filter',
|
|
368
|
+
onInput: (v) => on.onSearch(v),
|
|
369
|
+
onSubmit: (v) => on.onSearch(v),
|
|
370
|
+
}),
|
|
371
|
+
],
|
|
372
|
+
}),
|
|
373
|
+
]
|
|
374
|
+
: [];
|
|
375
|
+
|
|
376
|
+
return Box({
|
|
377
|
+
flexDirection: 'column',
|
|
378
|
+
rowGap: 1,
|
|
379
|
+
children: [
|
|
380
|
+
header,
|
|
381
|
+
...(needs.length ? [Box({ key: 'needs', flexDirection: 'column', children: needs })] : []),
|
|
382
|
+
tabs,
|
|
383
|
+
...(body.length ? body : [text(empty, { dimColor: true })]),
|
|
384
|
+
...keys,
|
|
385
|
+
],
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** A tool call as the liveness column reads it (VIEW-4): "Bash npm test", "Edit apps/web/a.ts". */
|
|
390
|
+
export function toolLine(call) {
|
|
391
|
+
const tool = call?.tool ?? 'tool';
|
|
392
|
+
const what =
|
|
393
|
+
call?.command ?? call?.file_path ?? call?.path ?? call?.pattern ?? call?.description ?? '';
|
|
394
|
+
const short = String(what).split('\n')[0].replace(/\s+/g, ' ').trim();
|
|
395
|
+
return short ? `${tool} ${short.length > 40 ? `${short.slice(0, 39)}…` : short}` : tool;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** A lane's liveness (VIEW-4): quiet past the nudge time, stalled past the restart time or flagged. */
|
|
399
|
+
export function liveLevel(quietMs, stall, { flagged = false, running = false } = {}) {
|
|
400
|
+
if (flagged) return 'stalled';
|
|
401
|
+
if (running) return null;
|
|
402
|
+
if (quietMs >= stall.restartMinutes * 60000) return 'stalled';
|
|
403
|
+
if (quietMs >= stall.nudgeMinutes * 60000) return 'quiet';
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export const SENDBACK_ID = 'code-kit-sendback';
|
|
408
|
+
|
|
409
|
+
/** VIEW-6's send back: the branch and story, and the person's reason, which the lane's fix reads. */
|
|
410
|
+
export function sendBackPane(
|
|
411
|
+
pending,
|
|
412
|
+
{ Box, Text, Input, Button },
|
|
413
|
+
{ onInput, onSubmit, onCancel },
|
|
414
|
+
) {
|
|
415
|
+
if (!pending) return Text({ dimColor: true, children: ['Nothing to send back.'] });
|
|
416
|
+
return Box({
|
|
417
|
+
flexDirection: 'column',
|
|
418
|
+
rowGap: 1,
|
|
419
|
+
children: [
|
|
420
|
+
Text({ bold: true, children: [`Send ${pending.branch} back to the ${pending.lane} lane`] }),
|
|
421
|
+
Text({
|
|
422
|
+
children: [
|
|
423
|
+
`${pending.story} goes back for its fix, with your reason; the loop dispatches the fix.`,
|
|
424
|
+
],
|
|
425
|
+
}),
|
|
426
|
+
Input({
|
|
427
|
+
key: 'sendback-reason',
|
|
428
|
+
label: 'Reason',
|
|
429
|
+
value: pending.reason,
|
|
430
|
+
submitLabel: 'Send back',
|
|
431
|
+
autoFocus: true,
|
|
432
|
+
onInput,
|
|
433
|
+
onSubmit,
|
|
434
|
+
}),
|
|
435
|
+
...(pending.error ? [Text({ color: 'red', children: [pending.error] })] : []),
|
|
436
|
+
Box({
|
|
437
|
+
flexDirection: 'row',
|
|
438
|
+
columnGap: 2,
|
|
439
|
+
children: [
|
|
440
|
+
Button({
|
|
441
|
+
key: 'sendback-confirm',
|
|
442
|
+
label: 'Send back',
|
|
443
|
+
onPress: () => onSubmit(pending.reason),
|
|
444
|
+
}),
|
|
445
|
+
Button({ key: 'sendback-cancel', label: 'Cancel', onPress: onCancel }),
|
|
446
|
+
],
|
|
447
|
+
}),
|
|
448
|
+
],
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* A unified diff split per file, each as the hunks the Code element draws (its `---`/`+++` pair and
|
|
454
|
+
* `@@` hunks, without git's `diff --git` and index lines): [{ path, source }]. Files without hunks
|
|
455
|
+
* (binary, a mode change) are left out.
|
|
456
|
+
*/
|
|
457
|
+
export function fileDiffs(text) {
|
|
458
|
+
const out = [];
|
|
459
|
+
for (const part of String(text ?? '')
|
|
460
|
+
.split(/^diff --git /m)
|
|
461
|
+
.slice(1)) {
|
|
462
|
+
const lines = part.split('\n');
|
|
463
|
+
const from = lines.findIndex((l) => l.startsWith('--- '));
|
|
464
|
+
if (from < 0 || !lines.slice(from).some((l) => l.startsWith('@@'))) continue;
|
|
465
|
+
const plus = lines.find((l) => l.startsWith('+++ '))?.slice(4) ?? '';
|
|
466
|
+
const minus = lines[from].slice(4);
|
|
467
|
+
const path = (plus === '/dev/null' ? minus : plus).replace(/^[ab]\//, '');
|
|
468
|
+
out.push({ path, source: lines.slice(from).join('\n').replace(/\n+$/, '\n') });
|
|
469
|
+
}
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const SPEC_CHECK_COLOR = { done: 'green', partial: 'yellow', missing: 'red', conflicts: 'red' };
|
|
474
|
+
const SEVERITY_TITLE = { blocker: 'Blockers', concern: 'Concerns', nit: 'Nits' };
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* VIEW-7 and REVW-5: a story's drill-down. `story` is { id, data, error }, data being `code-kit
|
|
478
|
+
* story --json`'s; `review` the reviewer's for its branch; `tokens` its usage; `steps` the loop's for
|
|
479
|
+
* it; `actions` the lane's acts. `on`: onBack(), onFile(path), onAct(id).
|
|
480
|
+
*/
|
|
481
|
+
export function storyPane(
|
|
482
|
+
{ story, review, tokens: usedTokens, outlier, steps, actions, file, now, understanding = null },
|
|
483
|
+
els,
|
|
484
|
+
on,
|
|
485
|
+
) {
|
|
486
|
+
const { Box, Text, Button, Code } = els;
|
|
487
|
+
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
488
|
+
const section = (key, title, children) =>
|
|
489
|
+
Box({ key, flexDirection: 'column', children: [text(title, { bold: true }), ...children] });
|
|
490
|
+
const d = story.data;
|
|
491
|
+
const head = Box({
|
|
492
|
+
key: 'story-head',
|
|
493
|
+
flexDirection: 'row',
|
|
494
|
+
columnGap: 2,
|
|
495
|
+
children: [
|
|
496
|
+
Button({
|
|
497
|
+
key: 'story-back',
|
|
498
|
+
label: 'Back',
|
|
499
|
+
hotkey: 'b',
|
|
500
|
+
plain: true,
|
|
501
|
+
dimColor: true,
|
|
502
|
+
onPress: on.onBack,
|
|
503
|
+
}),
|
|
504
|
+
text(d ? `${d.id} ${d.title}` : story.id, { bold: true }),
|
|
505
|
+
...(d
|
|
506
|
+
? [text(`${d.lane} · ${d.state}${d.branch ? ` · ${d.branch}` : ''}`, { dimColor: true })]
|
|
507
|
+
: []),
|
|
508
|
+
],
|
|
509
|
+
});
|
|
510
|
+
if (story.error)
|
|
511
|
+
return Box({
|
|
512
|
+
flexDirection: 'column',
|
|
513
|
+
rowGap: 1,
|
|
514
|
+
children: [head, text(story.error, { color: 'red' })],
|
|
515
|
+
});
|
|
516
|
+
if (!d)
|
|
517
|
+
return Box({
|
|
518
|
+
flexDirection: 'column',
|
|
519
|
+
rowGap: 1,
|
|
520
|
+
children: [head, text('Reading the story…', { dimColor: true })],
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const checked = new Map((d.specCheck?.rows ?? []).map((r) => [r.requirement, r]));
|
|
524
|
+
const requirements = section(
|
|
525
|
+
'story-requirements',
|
|
526
|
+
`Requirements${d.specCheck ? '' : ' (no spec-check report yet)'}`,
|
|
527
|
+
d.requirements.length
|
|
528
|
+
? d.requirements.map((r) => {
|
|
529
|
+
const c = checked.get(r.id);
|
|
530
|
+
return Box({
|
|
531
|
+
key: `req-${r.id}`,
|
|
532
|
+
flexDirection: 'row',
|
|
533
|
+
columnGap: 2,
|
|
534
|
+
children: [
|
|
535
|
+
text(r.id),
|
|
536
|
+
text(r.title, { wrap: 'truncate-end' }),
|
|
537
|
+
text(`plan: ${r.state}`, { dimColor: true }),
|
|
538
|
+
...(c
|
|
539
|
+
? [text(`spec-check: ${c.status}`, { color: SPEC_CHECK_COLOR[c.status] })]
|
|
540
|
+
: []),
|
|
541
|
+
...(c?.where ? [text(c.where, { dimColor: true, wrap: 'truncate-end' })] : []),
|
|
542
|
+
...(r.tests.length ? [text(`${r.tests.length} test(s)`, { dimColor: true })] : []),
|
|
543
|
+
],
|
|
544
|
+
});
|
|
545
|
+
})
|
|
546
|
+
: [text('It names no requirement.', { dimColor: true })],
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
const findings = review
|
|
550
|
+
? section(
|
|
551
|
+
'story-findings',
|
|
552
|
+
review.state === 'done'
|
|
553
|
+
? `Review of ${review.head.slice(0, 7)}`
|
|
554
|
+
: review.state === 'running'
|
|
555
|
+
? 'Review running'
|
|
556
|
+
: `Review ${review.state}: ${review.why ?? review.error ?? ''}`,
|
|
557
|
+
['blocker', 'concern', 'nit'].flatMap((s) => {
|
|
558
|
+
const of = review.findings.filter((f) => f.severity === s);
|
|
559
|
+
if (!of.length) return [];
|
|
560
|
+
return [
|
|
561
|
+
text(SEVERITY_TITLE[s], {
|
|
562
|
+
color: s === 'blocker' ? 'red' : s === 'concern' ? 'yellow' : undefined,
|
|
563
|
+
}),
|
|
564
|
+
...of.map((f, i) =>
|
|
565
|
+
Button({
|
|
566
|
+
key: `finding-${s}-${i}`,
|
|
567
|
+
label: `${f.where ? `${f.where} ` : ''}${f.text}${f.rule ? ` (${f.rule})` : ''}`,
|
|
568
|
+
plain: true,
|
|
569
|
+
// REVW-5: the finding's place is a way into the diff.
|
|
570
|
+
onPress: () => f.where && on.onFile(f.where.split(':')[0]),
|
|
571
|
+
}),
|
|
572
|
+
),
|
|
573
|
+
];
|
|
574
|
+
}),
|
|
575
|
+
)
|
|
576
|
+
: null;
|
|
577
|
+
|
|
578
|
+
const problems = d.verify
|
|
579
|
+
? Object.entries(d.verify.problems).flatMap(([group, list]) =>
|
|
580
|
+
list.map((p) => `${group}: ${p}`),
|
|
581
|
+
)
|
|
582
|
+
: [];
|
|
583
|
+
const verify = d.verify
|
|
584
|
+
? section(
|
|
585
|
+
'story-verify',
|
|
586
|
+
'verify (without checks)',
|
|
587
|
+
problems.length
|
|
588
|
+
? problems.map((p, i) =>
|
|
589
|
+
text(p, { key: `problem-${i}`, color: 'red', wrap: 'truncate-end' }),
|
|
590
|
+
)
|
|
591
|
+
: [text('No problems.', { color: 'green' })],
|
|
592
|
+
)
|
|
593
|
+
: null;
|
|
594
|
+
|
|
595
|
+
const diffs = fileDiffs(d.diff?.text);
|
|
596
|
+
const shown = diffs.find((f) => f.path === file) ?? diffs[0] ?? null;
|
|
597
|
+
const diff = d.diff
|
|
598
|
+
? section(
|
|
599
|
+
'story-diff',
|
|
600
|
+
`Diff against ${d.base} · ${d.diff.files.length} file(s)${d.diff.truncated ? ' (cut short)' : ''}`,
|
|
601
|
+
[
|
|
602
|
+
Box({
|
|
603
|
+
key: 'diff-files',
|
|
604
|
+
flexDirection: 'row',
|
|
605
|
+
columnGap: 2,
|
|
606
|
+
flexWrap: 'wrap',
|
|
607
|
+
children: d.diff.files.map((f, i) =>
|
|
608
|
+
Button({
|
|
609
|
+
key: `file-${i}`,
|
|
610
|
+
label: `${f.path} +${f.added} -${f.removed}`,
|
|
611
|
+
plain: true,
|
|
612
|
+
...(shown?.path === f.path ? {} : { dimColor: true }),
|
|
613
|
+
onPress: () => on.onFile(f.path),
|
|
614
|
+
}),
|
|
615
|
+
),
|
|
616
|
+
}),
|
|
617
|
+
...(shown
|
|
618
|
+
? [
|
|
619
|
+
Code({
|
|
620
|
+
key: 'diff-code',
|
|
621
|
+
source: shown.source,
|
|
622
|
+
format: 'diff',
|
|
623
|
+
path: shown.path,
|
|
624
|
+
wrap: 'truncate-end',
|
|
625
|
+
}),
|
|
626
|
+
]
|
|
627
|
+
: []),
|
|
628
|
+
],
|
|
629
|
+
)
|
|
630
|
+
: null;
|
|
631
|
+
|
|
632
|
+
const usage = usedTokens
|
|
633
|
+
? text(
|
|
634
|
+
`Used ${tokens(usedTokens)} tokens this session${outlier ? `, ${outlier}× the usual` : ''}`,
|
|
635
|
+
{
|
|
636
|
+
...(outlier ? { color: 'yellow' } : { dimColor: true }),
|
|
637
|
+
},
|
|
638
|
+
)
|
|
639
|
+
: null;
|
|
640
|
+
const recent = steps.length
|
|
641
|
+
? section(
|
|
642
|
+
'story-steps',
|
|
643
|
+
'Loop steps',
|
|
644
|
+
steps
|
|
645
|
+
.slice(-6)
|
|
646
|
+
.reverse()
|
|
647
|
+
.map((s, i) =>
|
|
648
|
+
text(`${ago(now - s.at)} ${s.kind} ${s.target}`, {
|
|
649
|
+
key: `sstep-${i}`,
|
|
650
|
+
dimColor: true,
|
|
651
|
+
}),
|
|
652
|
+
),
|
|
653
|
+
)
|
|
654
|
+
: null;
|
|
655
|
+
const acts = actions.length
|
|
656
|
+
? Box({
|
|
657
|
+
key: 'story-acts',
|
|
658
|
+
flexDirection: 'row',
|
|
659
|
+
columnGap: 2,
|
|
660
|
+
children: actions.map((a) =>
|
|
661
|
+
Button({
|
|
662
|
+
key: `story-${a.id}`,
|
|
663
|
+
label: a.label,
|
|
664
|
+
hotkey: a.hotkey,
|
|
665
|
+
variant: 'primary',
|
|
666
|
+
onPress: () => on.onAct(a.id),
|
|
667
|
+
}),
|
|
668
|
+
),
|
|
669
|
+
})
|
|
670
|
+
: null;
|
|
671
|
+
|
|
672
|
+
// JOIN-2 to JOIN-4: what the adapters know: the lane's edits without understanding and the files
|
|
673
|
+
// still unread, its cards owed, and the rules on the story's files; each file opens in the
|
|
674
|
+
// adapter's own view.
|
|
675
|
+
const opener = (d.adapters ?? []).find((a) => a.open) ?? null;
|
|
676
|
+
const openFile = (path, key) =>
|
|
677
|
+
opener
|
|
678
|
+
? Button({ key, label: path, plain: true, onPress: () => on.onOpenIn(opener, path) })
|
|
679
|
+
: text(path);
|
|
680
|
+
const combined = [];
|
|
681
|
+
if (understanding?.withoutUnderstanding.length)
|
|
682
|
+
combined.push(
|
|
683
|
+
section(
|
|
684
|
+
'story-understanding',
|
|
685
|
+
`Edited without understanding (${understanding.withoutUnderstanding.length})`,
|
|
686
|
+
understanding.withoutUnderstanding.map((e, i) =>
|
|
687
|
+
Box({
|
|
688
|
+
key: `unread-${i}`,
|
|
689
|
+
flexDirection: 'row',
|
|
690
|
+
columnGap: 2,
|
|
691
|
+
children: [
|
|
692
|
+
openFile(e.path, `unread-file-${i}`),
|
|
693
|
+
text(e.unread.length ? `still unread: ${e.unread.join(', ')}` : '', { color: 'red' }),
|
|
694
|
+
],
|
|
695
|
+
}),
|
|
696
|
+
),
|
|
697
|
+
),
|
|
698
|
+
);
|
|
699
|
+
if (understanding?.cardsOwed.length)
|
|
700
|
+
combined.push(
|
|
701
|
+
section(
|
|
702
|
+
'story-cards',
|
|
703
|
+
`Cards owed (${understanding.cardsOwed.length})`,
|
|
704
|
+
understanding.cardsOwed.map((c, i) => openFile(c, `owed-${i}`)),
|
|
705
|
+
),
|
|
706
|
+
);
|
|
707
|
+
const rules = new Map();
|
|
708
|
+
for (const a of d.adapters ?? [])
|
|
709
|
+
for (const [path, f] of Object.entries(a.files ?? {}))
|
|
710
|
+
for (const r of f.rules ?? []) {
|
|
711
|
+
const entry = rules.get(r.id) ?? { ...r, paths: [] };
|
|
712
|
+
entry.paths.push(path);
|
|
713
|
+
rules.set(r.id, entry);
|
|
714
|
+
}
|
|
715
|
+
if (rules.size)
|
|
716
|
+
combined.push(
|
|
717
|
+
section(
|
|
718
|
+
'story-rules',
|
|
719
|
+
`Rules on its files (${rules.size})`,
|
|
720
|
+
[...rules.values()]
|
|
721
|
+
.sort((a, b) => Number(a.proposed) - Number(b.proposed))
|
|
722
|
+
.map((r) =>
|
|
723
|
+
Box({
|
|
724
|
+
key: `rule-${r.id}`,
|
|
725
|
+
children: [
|
|
726
|
+
text(
|
|
727
|
+
`${r.proposed ? 'proposed' : 'agreed '} ${r.id} ${r.text} (${r.paths.length} file${r.paths.length === 1 ? '' : 's'})`,
|
|
728
|
+
{ ...(r.proposed ? { dimColor: true } : {}), wrap: 'truncate-end' },
|
|
729
|
+
),
|
|
730
|
+
],
|
|
731
|
+
}),
|
|
732
|
+
),
|
|
733
|
+
),
|
|
734
|
+
);
|
|
735
|
+
const viewIn =
|
|
736
|
+
opener && shown
|
|
737
|
+
? Button({
|
|
738
|
+
key: 'story-open-in',
|
|
739
|
+
label: `View ${shown.path} in ${opener.name}`,
|
|
740
|
+
hotkey: 'v',
|
|
741
|
+
plain: true,
|
|
742
|
+
onPress: () => on.onOpenIn(opener, shown.path),
|
|
743
|
+
})
|
|
744
|
+
: null;
|
|
745
|
+
|
|
746
|
+
return Box({
|
|
747
|
+
flexDirection: 'column',
|
|
748
|
+
rowGap: 1,
|
|
749
|
+
children: [
|
|
750
|
+
head,
|
|
751
|
+
acts,
|
|
752
|
+
requirements,
|
|
753
|
+
findings,
|
|
754
|
+
verify,
|
|
755
|
+
...combined,
|
|
756
|
+
usage,
|
|
757
|
+
recent,
|
|
758
|
+
diff,
|
|
759
|
+
viewIn,
|
|
760
|
+
].filter(Boolean),
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** VIEW-6: the person's Escape on an open story goes back to the lanes instead of closing the pane. */
|
|
765
|
+
export const closeGoesBack = (e, paneId, ui) =>
|
|
766
|
+
e.id === paneId && e.origin?.kind === 'person' && Boolean(ui.story);
|
|
767
|
+
|
|
768
|
+
// --- the traceability map (spec 10) ------------------------------------------------------------
|
|
769
|
+
|
|
770
|
+
/** Each cell state's glyph, colour and words (TRACE-1, TRACE-4: the glyph reads without colour). */
|
|
771
|
+
export const CELL = {
|
|
772
|
+
tested: { glyph: '■', color: 'green', words: 'done and tested' },
|
|
773
|
+
done: { glyph: '□', color: 'green', words: 'done, untested' },
|
|
774
|
+
'in progress': { glyph: '◐', color: 'blue', words: 'in progress' },
|
|
775
|
+
todo: { glyph: '○', color: undefined, words: 'todo' },
|
|
776
|
+
'no story': { glyph: '✗', color: 'red', words: 'without a story' },
|
|
777
|
+
removed: { glyph: '·', color: undefined, words: 'removed' },
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
/** A requirement's cell state: status's state, with done split by whether a test names it. */
|
|
781
|
+
export const cellOf = (r) => (r.state === 'done' ? (r.tests?.length ? 'tested' : 'done') : r.state);
|
|
782
|
+
|
|
783
|
+
/** The requirements by spec, in the specs' order: [{ spec, requirements }]. */
|
|
784
|
+
export function mapRows(requirements = []) {
|
|
785
|
+
const rows = [];
|
|
786
|
+
for (const r of requirements) {
|
|
787
|
+
let row = rows.find((x) => x.spec === r.file);
|
|
788
|
+
if (!row) rows.push((row = { spec: r.file, requirements: [] }));
|
|
789
|
+
row.requirements.push(r);
|
|
790
|
+
}
|
|
791
|
+
return rows;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** TRACE-1's legend: the count of each cell state there is. */
|
|
795
|
+
export function mapLegend(requirements = []) {
|
|
796
|
+
return Object.entries(CELL)
|
|
797
|
+
.map(([state, c]) => ({
|
|
798
|
+
state,
|
|
799
|
+
...c,
|
|
800
|
+
n: requirements.filter((r) => cellOf(r) === state).length,
|
|
801
|
+
}))
|
|
802
|
+
.filter((c) => c.n > 0);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** The cell `step` places from `id` in reading order, or the first row of the next spec (`rows`). */
|
|
806
|
+
export function moveCell(requirements, id, step, { rows = false } = {}) {
|
|
807
|
+
if (!requirements.length) return null;
|
|
808
|
+
const at = Math.max(
|
|
809
|
+
0,
|
|
810
|
+
requirements.findIndex((r) => r.id === id),
|
|
811
|
+
);
|
|
812
|
+
if (!rows) return requirements[(at + step + requirements.length) % requirements.length].id;
|
|
813
|
+
const specs = mapRows(requirements);
|
|
814
|
+
const row = specs.findIndex((s) => s.requirements.some((r) => r.id === requirements[at].id));
|
|
815
|
+
return specs[(row + step + specs.length) % specs.length].requirements[0].id;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* The Map tab (TRACE-1 to TRACE-4): a row per spec, a cell per requirement, a legend, and the
|
|
820
|
+
* selected requirement's stories and tests. `status` is `code-kit status --json`'s.
|
|
821
|
+
*/
|
|
822
|
+
export function mapTab(status, selectedId, { Box, Text, Button, Select }, on) {
|
|
823
|
+
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
824
|
+
const requirements = status?.requirements ?? [];
|
|
825
|
+
if (!requirements.length)
|
|
826
|
+
return [
|
|
827
|
+
text('No requirements in the specs yet: the map fills as spec-design writes them.', {
|
|
828
|
+
dimColor: true,
|
|
829
|
+
}),
|
|
830
|
+
];
|
|
831
|
+
const selected = requirements.find((r) => r.id === selectedId) ?? requirements[0];
|
|
832
|
+
const rows = mapRows(requirements);
|
|
833
|
+
const label = (spec) => spec.split('/').pop().replace(/\.md$/, '');
|
|
834
|
+
const width = Math.max(...rows.map((r) => label(r.spec).length));
|
|
835
|
+
const legend = Box({
|
|
836
|
+
key: 'map-legend',
|
|
837
|
+
flexDirection: 'row',
|
|
838
|
+
columnGap: 2,
|
|
839
|
+
flexWrap: 'wrap',
|
|
840
|
+
children: mapLegend(requirements).map((c) =>
|
|
841
|
+
text(`${c.glyph} ${c.n} ${c.words}`, c.color ? { color: c.color } : { dimColor: true }),
|
|
842
|
+
),
|
|
843
|
+
});
|
|
844
|
+
const grid = Box({
|
|
845
|
+
key: 'map-grid',
|
|
846
|
+
flexDirection: 'column',
|
|
847
|
+
children: rows.map((row) =>
|
|
848
|
+
Box({
|
|
849
|
+
key: `map-row-${label(row.spec)}`,
|
|
850
|
+
flexDirection: 'row',
|
|
851
|
+
columnGap: 1,
|
|
852
|
+
children: [
|
|
853
|
+
text(label(row.spec).padEnd(width), { dimColor: true }),
|
|
854
|
+
...row.requirements.map((r) => {
|
|
855
|
+
const c = CELL[cellOf(r)];
|
|
856
|
+
// Keyed by its Box: a Text keeps no key of its own.
|
|
857
|
+
return Box({
|
|
858
|
+
key: `cell-${r.id}`,
|
|
859
|
+
children: [
|
|
860
|
+
text(c.glyph, {
|
|
861
|
+
...(c.color ? { color: c.color } : { dimColor: cellOf(r) === 'removed' }),
|
|
862
|
+
...(r.id === selected.id ? { inverse: true, bold: true } : {}),
|
|
863
|
+
}),
|
|
864
|
+
],
|
|
865
|
+
});
|
|
866
|
+
}),
|
|
867
|
+
],
|
|
868
|
+
}),
|
|
869
|
+
),
|
|
870
|
+
});
|
|
871
|
+
const keys = Box({
|
|
872
|
+
key: 'map-keys',
|
|
873
|
+
flexDirection: 'row',
|
|
874
|
+
columnGap: 1,
|
|
875
|
+
children: [
|
|
876
|
+
Button({
|
|
877
|
+
key: 'cell-prev',
|
|
878
|
+
label: 'Previous',
|
|
879
|
+
hotkey: 'h',
|
|
880
|
+
plain: true,
|
|
881
|
+
onPress: () => on.onCell(moveCell(requirements, selected.id, -1)),
|
|
882
|
+
}),
|
|
883
|
+
Button({
|
|
884
|
+
key: 'cell-next',
|
|
885
|
+
label: 'Next',
|
|
886
|
+
hotkey: 'l',
|
|
887
|
+
plain: true,
|
|
888
|
+
onPress: () => on.onCell(moveCell(requirements, selected.id, 1)),
|
|
889
|
+
}),
|
|
890
|
+
Button({
|
|
891
|
+
key: 'row-next',
|
|
892
|
+
label: 'Next spec',
|
|
893
|
+
hotkey: 'j',
|
|
894
|
+
plain: true,
|
|
895
|
+
onPress: () => on.onCell(moveCell(requirements, selected.id, 1, { rows: true })),
|
|
896
|
+
}),
|
|
897
|
+
Button({
|
|
898
|
+
key: 'row-prev',
|
|
899
|
+
label: 'Previous spec',
|
|
900
|
+
hotkey: 'k',
|
|
901
|
+
plain: true,
|
|
902
|
+
onPress: () => on.onCell(moveCell(requirements, selected.id, -1, { rows: true })),
|
|
903
|
+
}),
|
|
904
|
+
],
|
|
905
|
+
});
|
|
906
|
+
// TRACE-2: the selected requirement, its stories with their states and branches, and its tests.
|
|
907
|
+
const stories = (status.stories ?? []).filter((s) => selected.stories?.includes(s.id));
|
|
908
|
+
const detail = Box({
|
|
909
|
+
key: 'map-detail',
|
|
910
|
+
flexDirection: 'column',
|
|
911
|
+
children: [
|
|
912
|
+
Select({
|
|
913
|
+
key: 'map-select',
|
|
914
|
+
label: 'Requirement',
|
|
915
|
+
value: selected.id,
|
|
916
|
+
options: requirements.map((r) => ({
|
|
917
|
+
value: r.id,
|
|
918
|
+
label: `${CELL[cellOf(r)].glyph} ${r.id} ${r.title}`,
|
|
919
|
+
})),
|
|
920
|
+
onSelect: (id) => on.onCell(id),
|
|
921
|
+
}),
|
|
922
|
+
text(`${selected.id} ${selected.title}`, { bold: true }),
|
|
923
|
+
text(`${selected.file} · ${CELL[cellOf(selected)].words}`, { dimColor: true }),
|
|
924
|
+
...(stories.length
|
|
925
|
+
? stories.map((s) =>
|
|
926
|
+
text(
|
|
927
|
+
`${s.id} ${s.title} · ${s.lane} · ${s.state}${s.branchExists ? ` · ${s.branch}` : ''}`,
|
|
928
|
+
{ key: `map-story-${s.id}` },
|
|
929
|
+
),
|
|
930
|
+
)
|
|
931
|
+
: [text('No story delivers it yet: a gap in the plan.', { color: 'red' })]),
|
|
932
|
+
...(selected.tests?.length
|
|
933
|
+
? [
|
|
934
|
+
text('Tests that name it', { bold: true }),
|
|
935
|
+
...selected.tests.map((t, i) =>
|
|
936
|
+
text(` ${t}`, { key: `map-test-${i}`, dimColor: true }),
|
|
937
|
+
),
|
|
938
|
+
]
|
|
939
|
+
: [text('No test names it.', { dimColor: true })]),
|
|
940
|
+
],
|
|
941
|
+
});
|
|
942
|
+
return [legend, grid, keys, detail];
|
|
943
|
+
}
|