@warren-dean/code-kit 0.12.1 → 0.13.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.
@@ -1257,10 +1257,15 @@ export function codeTab(view, { Box, Text, Button, Svg }, on) {
1257
1257
  return [picker, drawing, legend, detail, keys];
1258
1258
  }
1259
1259
 
1260
+ /** CARD-9: how many decision cards the list shows at once, around the selected one. */
1261
+ const CARDS_SHOWN = 5;
1262
+
1260
1263
  /**
1261
- * DECIDE-3: the Decisions tab: each decision that needs the person, the selected one as a card with its
1262
- * lineage (what they asked for, the story and requirements, what was happening, what's settled, why it
1263
- * needs them) and its options, each saying what it does. `on`: onDecision(i), onNeed(id, line).
1264
+ * DECIDE-3, CARD-9: the Decisions tab: how many decisions need the person, by kind; then each as a short
1265
+ * card (its kind, a one-line headline, and who or what it's for), a few at a time around the selected
1266
+ * one, with j and k to move; then the selected one in full, with its lineage (what they asked for, the
1267
+ * story and requirements, what was happening, what's settled, why it needs them) and its options, each
1268
+ * saying what it does. `on`: onDecision(i), onNeed(id, line).
1264
1269
  */
1265
1270
  export function decisionsTab(cards, ui, { Box, Text, Button }, on) {
1266
1271
  const text = (value, style = {}) => Text({ ...style, children: [value] });
@@ -1277,27 +1282,92 @@ export function decisionsTab(cards, ui, { Box, Text, Button }, on) {
1277
1282
  text(value, { wrap: 'wrap', ...style }),
1278
1283
  ],
1279
1284
  });
1285
+ const kinds = new Map();
1286
+ for (const c of cards) kinds.set(c.tag, (kinds.get(c.tag) ?? 0) + 1);
1287
+ const from = Math.max(0, Math.min(at - Math.floor(CARDS_SHOWN / 2), cards.length - CARDS_SHOWN));
1288
+ const shown = cards.slice(from, from + CARDS_SHOWN);
1280
1289
  const list = Box({
1281
1290
  key: 'decision-list',
1282
1291
  flexDirection: 'column',
1283
- children: cards.map((c, i) =>
1292
+ children: [
1293
+ ...(from > 0 ? [text(` ↑ ${from} more`, { key: 'decision-above', dimColor: true })] : []),
1294
+ ...shown.map((c, n) => {
1295
+ const i = from + n;
1296
+ return Box({
1297
+ key: `decision-item-${i}`,
1298
+ flexDirection: 'column',
1299
+ borderStyle: 'round',
1300
+ borderColor: i === at ? 'yellow' : 'gray',
1301
+ paddingX: 1,
1302
+ children: [
1303
+ Box({
1304
+ flexDirection: 'row',
1305
+ columnGap: 1,
1306
+ children: [
1307
+ text(c.tag.toUpperCase(), { color: i === at ? 'yellow' : 'cyan', bold: true }),
1308
+ Button({
1309
+ key: `decision-pick-${i}`,
1310
+ label: c.headline,
1311
+ plain: true,
1312
+ onPress: () => on.onDecision(i),
1313
+ }),
1314
+ ],
1315
+ }),
1316
+ ...(c.sub ? [text(c.sub, { dimColor: true, wrap: 'truncate-end' })] : []),
1317
+ ],
1318
+ });
1319
+ }),
1320
+ ...(from + CARDS_SHOWN < cards.length
1321
+ ? [
1322
+ text(` ↓ ${cards.length - from - CARDS_SHOWN} more`, {
1323
+ key: 'decision-below',
1324
+ dimColor: true,
1325
+ }),
1326
+ ]
1327
+ : []),
1328
+ ],
1329
+ });
1330
+ const keys = Box({
1331
+ key: 'decision-keys',
1332
+ flexDirection: 'row',
1333
+ columnGap: 2,
1334
+ children: [
1284
1335
  Button({
1285
- key: `decision-pick-${i}`,
1286
- label: `${i === at ? '›' : ' '} ${c.title}`,
1336
+ key: 'decision-next',
1337
+ label: 'Next',
1338
+ hotkey: 'j',
1287
1339
  plain: true,
1288
- onPress: () => on.onDecision(i),
1340
+ onPress: () => on.onDecision(Math.min(at + 1, cards.length - 1)),
1289
1341
  }),
1290
- ),
1342
+ Button({
1343
+ key: 'decision-prev',
1344
+ label: 'Previous',
1345
+ hotkey: 'k',
1346
+ plain: true,
1347
+ onPress: () => on.onDecision(Math.max(at - 1, 0)),
1348
+ }),
1349
+ text(`${at + 1} of ${cards.length}`, { dimColor: true }),
1350
+ ],
1291
1351
  });
1292
1352
  const detail = Box({
1293
1353
  key: 'decision-card',
1294
1354
  flexDirection: 'column',
1295
- borderStyle: 'round',
1355
+ borderStyle: 'double',
1296
1356
  borderColor: 'yellow',
1297
1357
  paddingX: 1,
1298
1358
  rowGap: 0,
1299
1359
  children: [
1300
- text(card.title, { bold: true }),
1360
+ text(
1361
+ card.kind === 'brief'
1362
+ ? 'An open question in your brief'
1363
+ : card.kind === 'question'
1364
+ ? 'A question from the lead'
1365
+ : card.headline,
1366
+ { bold: true, wrap: 'wrap' },
1367
+ ),
1368
+ ...(card.details ?? []).map((d) =>
1369
+ row(d.label, d.value, d.label === 'Question' ? { bold: true } : {}),
1370
+ ),
1301
1371
  ...(card.asked ? [row('You asked for', card.asked)] : []),
1302
1372
  ...(card.story
1303
1373
  ? [
@@ -1353,7 +1423,18 @@ export function decisionsTab(cards, ui, { Box, Text, Button }, on) {
1353
1423
  `${cards.length} decision${cards.length === 1 ? '' : 's'} need${cards.length === 1 ? 's' : ''} you. Everything else is the lead's.`,
1354
1424
  { bold: true },
1355
1425
  ),
1356
- ...(cards.length > 1 ? [list] : []),
1426
+ Box({
1427
+ key: 'decision-kinds',
1428
+ children: [
1429
+ text(
1430
+ [...kinds]
1431
+ .map(([tag, n]) => `${n} ${tag}${n === 1 || tag.endsWith('brief') ? '' : 's'}`)
1432
+ .join(' · '),
1433
+ { dimColor: true },
1434
+ ),
1435
+ ],
1436
+ }),
1437
+ ...(cards.length > 1 ? [list, keys] : []),
1357
1438
  detail,
1358
1439
  ];
1359
1440
  }
@@ -21,6 +21,7 @@ import {
21
21
  answerPane,
22
22
  approvalAsidePrompt,
23
23
  approvePane,
24
+ questionAsidePrompt,
24
25
  commandItself,
25
26
  band,
26
27
  bandLines,
@@ -564,12 +565,24 @@ export function register(on) {
564
565
  answerWith: (line) => act.sendAnswer({ kind: 'question', ...line.question }, line.choice),
565
566
  // ASK-2, ASK-4: Answer… opens a box for the person's own words.
566
567
  answer: async (line) => {
567
- answering = { kind: 'question', ...line.question, text: '', error: null };
568
+ answering = {
569
+ kind: 'question',
570
+ ...line.question,
571
+ text: '',
572
+ error: null,
573
+ aside: { thread: [], draft: '' },
574
+ };
568
575
  await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
569
576
  $.ui.invalidate('ui.render');
570
577
  },
571
578
  answerBrief: async (line) => {
572
- answering = { kind: 'brief', ...line.brief, text: '', error: null };
579
+ answering = {
580
+ kind: 'brief',
581
+ ...line.brief,
582
+ text: '',
583
+ error: null,
584
+ aside: { thread: [], draft: '' },
585
+ };
573
586
  await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
574
587
  $.ui.invalidate('ui.render');
575
588
  },
@@ -613,17 +626,24 @@ export function register(on) {
613
626
  }
614
627
  }
615
628
  notice = null;
616
- const said = /[.!?]$/.test(text) ? text : `${text}.`;
617
- await $.prompt.submit({
618
- text:
619
- q.kind === 'question'
620
- ? `The person answered ${q.id} (${q.question}): ${said}${q.blocks ? ` ${q.story} no longer waits on them.` : ''} Carry on with it.`
621
- : `The person answered an open question in ${q.path}, "${q.question}": ${said} Record it in the brief's decision log, take it out of the open questions, and carry on with it.`,
622
- });
629
+ // Answered, the decision goes at once; the lead hears it as a turn of its own once it's idle, which
630
+ // can be long after (so the prompt isn't waited on).
623
631
  if (q.kind === 'brief') {
624
632
  briefLater.add(q.question);
625
633
  model = { ...model, brief: (model.brief ?? []).filter((b) => b.question !== q.question) };
626
634
  }
635
+ if (q.kind === 'question')
636
+ model = { ...model, questions: (model.questions ?? []).filter((x) => x.id !== q.id) };
637
+ $.ui.invalidate('ui.render');
638
+ const said = /[.!?]$/.test(text) ? text : `${text}.`;
639
+ $.prompt
640
+ .submit({
641
+ text:
642
+ q.kind === 'question'
643
+ ? `The person answered ${q.id} (${q.question}): ${said}${q.blocks ? ` ${q.story} no longer waits on them.` : ''} Carry on with it.`
644
+ : `The person answered an open question in ${q.path}, "${q.question}": ${said} Record it in the brief's decision log, take it out of the open questions, and carry on with it.`,
645
+ })
646
+ .catch(() => {});
627
647
  await act.reload();
628
648
  return true;
629
649
  },
@@ -634,16 +654,20 @@ export function register(on) {
634
654
  deniedDone: async (line) => {
635
655
  denied.delete(line.denial.id);
636
656
  $.ui.invalidate('ui.render');
637
- await $.prompt.submit({
638
- text: `The person ran it themselves, or allowed it: ${line.denial.command ?? line.denial.what} (Claude Code had refused it for ${line.denial.who}). Check it worked, and carry on.`,
639
- });
657
+ $.prompt
658
+ .submit({
659
+ text: `The person ran it themselves, or allowed it: ${line.denial.command ?? line.denial.what} (Claude Code had refused it for ${line.denial.who}). Check it worked, and carry on.`,
660
+ })
661
+ .catch(() => {});
640
662
  },
641
663
  deniedNo: async (line) => {
642
664
  denied.delete(line.denial.id);
643
665
  $.ui.invalidate('ui.render');
644
- await $.prompt.submit({
645
- text: `The person won't run ${line.denial.command ?? line.denial.what} now (Claude Code refused it for ${line.denial.who}). Don't try to get around it: carry on with other work, and if a story can't go on without it, ask with code-kit ask --blocks.`,
646
- });
666
+ $.prompt
667
+ .submit({
668
+ text: `The person won't run ${line.denial.command ?? line.denial.what} now (Claude Code refused it for ${line.denial.who}). Don't try to get around it: carry on with other work, and if a story can't go on without it, ask with code-kit ask --blocks.`,
669
+ })
670
+ .catch(() => {});
647
671
  },
648
672
  // REPORT-3: the person sends a drafted report to code-kit's GitHub, or discards it.
649
673
  sendReport: async (line) => {
@@ -675,6 +699,40 @@ export function register(on) {
675
699
  heldId: line.held.id,
676
700
  outside: line.held.outside ?? null,
677
701
  }),
702
+ // ACT-5: a side call over the lead's own conversation (it shares the prompt cache, and nothing is
703
+ // added to the conversation); before the lead's first reply, from the prompt's facts alone.
704
+ sideCall: async (prompt) => {
705
+ const failed = (err) => ({ isAnswered: false, reason: String(err?.message ?? err) });
706
+ const reply = await $.model.fork({ prompt }).catch(failed);
707
+ if (reply.isAnswered || reply.reason !== 'nothing-to-fork') return reply;
708
+ return $.model.complete({ model: 'sonnet', prompt, maxTokens: 800 }).catch(failed);
709
+ },
710
+ // ASK-5: a question about a question, asked in the answer box before answering.
711
+ askAnswer: async (question) => {
712
+ const q = question.trim();
713
+ if (!answering || !q) return;
714
+ const asked = answering;
715
+ const turn = { question: q, pending: true };
716
+ const thread = asked.aside.thread;
717
+ answering = { ...asked, aside: { thread: [...thread, turn], draft: '' } };
718
+ $.ui.invalidate('ui.render');
719
+ const reply = await act.sideCall(questionAsidePrompt({ q: asked, thread, question: q }));
720
+ if (!answering || answering.question !== asked.question) return;
721
+ const done = reply.isAnswered
722
+ ? { question: q, answer: reply.text }
723
+ : {
724
+ question: q,
725
+ error: `No answer: ${reply.reason}${reply.status ? ` (${reply.status})` : ''}`,
726
+ };
727
+ answering = {
728
+ ...answering,
729
+ aside: {
730
+ ...answering.aside,
731
+ thread: answering.aside.thread.map((t) => (t === turn ? done : t)),
732
+ },
733
+ };
734
+ $.ui.invalidate('ui.render');
735
+ },
678
736
  // ACT-5: a question about the approval, answered in its pane by a side call over the lead's own
679
737
  // conversation (it shares the prompt cache, and nothing is added to the conversation); before the
680
738
  // lead's first reply, from code-kit's facts alone.
@@ -694,13 +752,7 @@ export function register(on) {
694
752
  thread,
695
753
  question: q,
696
754
  });
697
- let reply = await $.model
698
- .fork({ prompt })
699
- .catch((err) => ({ isAnswered: false, reason: String(err?.message ?? err) }));
700
- if (!reply.isAnswered && reply.reason === 'nothing-to-fork')
701
- reply = await $.model
702
- .complete({ model: 'sonnet', prompt, maxTokens: 800 })
703
- .catch((err) => ({ isAnswered: false, reason: String(err?.message ?? err) }));
755
+ const reply = await act.sideCall(prompt);
704
756
  // Answered for this approval only: a pane closed or moved on meanwhile keeps what it has.
705
757
  if (!approving || approving.request !== asked.request) return;
706
758
  const done = reply.isAnswered
@@ -2004,6 +2056,11 @@ export function register(on) {
2004
2056
  },
2005
2057
  onSubmit: (value) => act.confirmAnswer(value),
2006
2058
  onCancel: () => act.cancelAnswer(),
2059
+ onAskInput: (value) => {
2060
+ answering = { ...answering, aside: { ...answering.aside, draft: value } };
2061
+ $.ui.invalidate('ui.render');
2062
+ },
2063
+ onAsk: (value) => act.askAnswer(value),
2007
2064
  });
2008
2065
  });
2009
2066