@warren-dean/code-kit 0.7.0 → 0.8.1

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.
@@ -1,12 +1,20 @@
1
1
  // Panes v2 (docs/specs/11-panes.md): the Lanes pane's frame. A counts header, tabs, what needs the
2
2
  // person, the tab's body and the keys, as pure functions of what the mod already holds. The Lanes
3
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';
4
13
  import {
5
14
  NOT_CODE_KIT,
6
15
  STATE_COLOR,
7
16
  ago,
8
17
  lanesPane,
9
- loopSection,
10
18
  queueSection,
11
19
  shownState,
12
20
  tokens,
@@ -15,9 +23,12 @@ import {
15
23
 
16
24
  export const TABS = [
17
25
  { 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' },
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' },
21
32
  ];
22
33
 
23
34
  /** The pane's own state, kept by the mod between draws. */
@@ -173,6 +184,8 @@ export function lanesFrame(view, els, on) {
173
184
  children: [
174
185
  text('!', { color: 'yellow', bold: true }),
175
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' })] : []),
176
189
  ...actions.map((a) => {
177
190
  const letter = LETTER[a.id] && !used.has(LETTER[a.id]) ? LETTER[a.id] : undefined;
178
191
  if (letter) used.add(letter);
@@ -254,35 +267,48 @@ export function lanesFrame(view, els, on) {
254
267
 
255
268
  const selected = state.lanes.find((l) => l.name === ui.selected) ?? null;
256
269
  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
- ];
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
+ ];
280
302
  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;
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;
286
312
 
287
313
  // VIEW-6: j/k move between lanes; the selected lane's acts; f filters.
288
314
  const opening = selected ? storyToOpen(selected, state) : null;
@@ -819,7 +845,14 @@ export function moveCell(requirements, id, step, { rows = false } = {}) {
819
845
  * The Map tab (TRACE-1 to TRACE-4): a row per spec, a cell per requirement, a legend, and the
820
846
  * selected requirement's stories and tests. `status` is `code-kit status --json`'s.
821
847
  */
822
- export function mapTab(status, selectedId, { Box, Text, Button, Select }, on) {
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
+ ) {
823
856
  const text = (value, style = {}) => Text({ ...style, children: [value] });
824
857
  const requirements = status?.requirements ?? [];
825
858
  if (!requirements.length)
@@ -929,6 +962,28 @@ export function mapTab(status, selectedId, { Box, Text, Button, Select }, on) {
929
962
  ),
930
963
  )
931
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
+ })(),
932
987
  ...(selected.tests?.length
933
988
  ? [
934
989
  text('Tests that name it', { bold: true }),
@@ -939,5 +994,385 @@ export function mapTab(status, selectedId, { Box, Text, Button, Select }, on) {
939
994
  : [text('No test names it.', { dimColor: true })]),
940
995
  ],
941
996
  });
942
- return [legend, grid, keys, detail];
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
+ ];
943
1378
  }