herdrchat 1.1.0 → 1.1.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.
@@ -44,7 +44,8 @@ const KEY_SETTLE_MS = 700; // a dialog redraws this soon after a key
44
44
  const CLICK_BUDGET_MS = 2000; // a click must be answered within 3 s
45
45
  const OVERVIEW_WAIT_MS = 2000;
46
46
  const UPLOAD_TRIES = 3;
47
- const DRAW_TRIES = 3;
47
+ const DRAW_TRIES = 5;
48
+ const BOX_GAP_MS = 120; // between two boxes pressed for one Next
48
49
  const DRAW_WAIT_MS = 400;
49
50
  const TITLE_MAX = 40;
50
51
  const DIALOG_LINES = 15;
@@ -580,14 +581,18 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
580
581
  // `screen`: the screen read when the event came (a promise), so a dialog answered in
581
582
  // the terminal while earlier work was still being posted still gets its card. `again`:
582
583
  // post it even if it was posted (you typed in the room while it waits). A screen read
583
- // the moment `blocked` arrives may come before the dialog is drawn: with no menu on it,
584
- // the screen is read again a few times.
584
+ // the moment `blocked` arrives may come before the dialog is drawn, and then a numbered
585
+ // list the agent wrote looks like a menu: a dialog is believed once it has its top
586
+ // border and two reads DRAW_WAIT_MS apart show the same one, or after DRAW_TRIES reads.
585
587
  async function postBlocked(session, address, record, { screen: first = null, again = false } = {}) {
586
588
  const firstScreen = await first;
587
589
  let dialog = firstScreen ? dialogOf(record.status, firstScreen) : null;
588
- for (let i = 0; i < DRAW_TRIES && !dialog?.menu; i += 1) {
589
- if (i) await sleep(DRAW_WAIT_MS);
590
- dialog = dialogOf(record.status, await hc.screenTail(session, address, SCREEN_LINES));
590
+ for (let i = 0; i < DRAW_TRIES; i += 1) {
591
+ if (dialog || i) await sleep(DRAW_WAIT_MS);
592
+ const next = dialogOf(record.status, await hc.screenTail(session, address, SCREEN_LINES));
593
+ const steady = dialog?.menu?.framed && next.menu?.key === dialog.menu.key;
594
+ dialog = next;
595
+ if (steady) break;
591
596
  }
592
597
  const k = key(session, address);
593
598
  if (!claimDialog(k, dialog, record.status) && !again) return;
@@ -630,23 +635,48 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
630
635
  async function onAction({ value: v = {}, message, answer = '' }) {
631
636
  const started = Date.now();
632
637
  const shown = dialogCards.get(message) || { title: NEEDS_TITLE, markdown: '', doneTitle: DONE_TITLE };
638
+ // A box in a multi-select is ticked on the card only, at once; Next sends them all.
639
+ const box = shown.menu?.multi && typeof v.pick === 'number' ? shown.menu.options.find((o) => o.n === v.pick && o !== shown.menu.free && o.checked !== null) : null;
640
+ if (box) {
641
+ const menu = { ...shown.menu, options: shown.menu.options.map((o) => (o === box ? { ...o, checked: !o.checked } : o)) };
642
+ const toggled = { ...shown, menu };
643
+ remember(message, toggled);
644
+ return { ...toast('info', `${box.checked ? '☐' : '☑'} ${box.label}`), card: { kind: 'dialog', dialog: toggled, value: v } };
645
+ }
633
646
  const record = views.get(v.s)?.terminals.get(v.a);
634
- if (!record || record.state !== 'live') return answered(shown, 'The terminal has ended.');
647
+ if (!record || record.state !== 'live') {
648
+ log(`-> ${v.s}/${v.a}: click on ${message} not pressed: the terminal has ended`);
649
+ return answered(shown, 'The terminal has ended.');
650
+ }
635
651
  const status = (await hc.getTerminal(v.s, v.a)).status;
636
652
  const menu = status?.state === 'blocked' ? menuOf(await hc.screenTail(v.s, v.a, SCREEN_LINES)) : null;
637
653
  if (!menu || menu.key !== v.k) {
654
+ log(`-> ${v.s}/${v.a}: click on ${message} not pressed: ${menu ? `the dialog changed (card ${v.k}, screen ${menu.key})` : `nothing waits (status ${status?.state || 'none'})`}`);
638
655
  if (menu) later(v.s, v.a, 'dialog', () => postBlocked(v.s, v.a, { ...record, status }));
639
656
  return answered(shown, menu ? 'It now asks something else; see the new card.' : 'It no longer waits on this.');
640
657
  }
641
658
  const k = key(v.s, v.a);
642
659
  if (openCard.get(k)?.message === message) openCard.delete(k);
643
660
  let picked;
644
- if (v.pick === 'esc' || v.pick === 'tab') {
661
+ if (v.pick === 'tab' && shown.menu?.multi) {
662
+ // the boxes the card shows, made so on the screen: each one that differs is pressed
663
+ const changed = shown.menu.options.filter((o) => o.checked !== null && o.checked !== menu.options.find((m) => m.n === o.n)?.checked);
664
+ for (const o of changed) {
665
+ await hc.keys(v.s, v.a, [String(o.n)]);
666
+ await sleep(BOX_GAP_MS);
667
+ }
668
+ await hc.keys(v.s, v.a, ['tab']);
669
+ const ticked = shown.menu.options.filter((o) => o.checked).map((o) => o.label);
670
+ picked = ticked.length ? ticked.join(', ') : 'nothing ticked';
671
+ } else if (v.pick === 'esc' || v.pick === 'tab') {
645
672
  await hc.keys(v.s, v.a, [v.pick]);
646
673
  picked = v.pick === 'esc' ? 'Esc pressed' : 'Next';
647
674
  } else {
648
675
  const option = menu.options.find((o) => o.n === v.pick);
649
- if (!option || option.label !== v.label) return answered(shown, 'The options changed; nothing was pressed.');
676
+ if (!option || option.label !== v.label) {
677
+ log(`-> ${v.s}/${v.a}: click on ${message} not pressed: option ${v.pick} is now ${option ? `"${option.label}"` : 'gone'}, not "${v.label}"`);
678
+ return answered(shown, 'The options changed; nothing was pressed.');
679
+ }
650
680
  if (option === menu.free) {
651
681
  const text = answer.trim();
652
682
  if (!text) return toast('warning', 'Type your answer first.');
package/lib/core/menu.mjs CHANGED
@@ -9,7 +9,8 @@
9
9
  // `text` is what the dialog says above its options: from its top border (a line of ─),
10
10
  // without rules and blank lines; what was on the screen above the dialog is left out.
11
11
  // `key` names the dialog: a hash of it from its top border to its last option, without
12
- // the cursor and the check marks. A card click is pressed only when the
12
+ // the cursor and any check mark (an option's [✔], and the ☐/☒/✔ of the question tabs
13
+ // above a multi-question dialog, which change as you answer: the same dialog keeps its key). A card click is pressed only when the
13
14
  // screen still shows the dialog with that key, so an old card can never answer a newer
14
15
  // dialog that looks alike (two permission prompts both offer "1. Yes").
15
16
  import crypto from 'node:crypto';
@@ -27,8 +28,10 @@ function topOf(lines, first) {
27
28
  return BORDER.test(lines[top]) ? top : Math.max(0, first - TEXT_LINES);
28
29
  }
29
30
 
31
+ const MARKS = /[☐☑☒✔✓✗✘■□●○◉◯]/gu;
32
+
30
33
  function keyOf(lines, top, last) {
31
- const body = lines.slice(top, last + 1).map((l) => l.replace(/❯/g, '').replace(/^(\s*\d{1,2}\.\s+)\[.\]/, '$1').trim()).join('\n');
34
+ const body = lines.slice(top, last + 1).map((l) => l.replace(/❯/g, '').replace(/^(\s*\d{1,2}\.\s+)\[.\]/, '$1').replace(MARKS, '').replace(/\s+/g, ' ').trim()).join('\n');
32
35
  return crypto.createHash('sha1').update(body).digest('hex').slice(0, 12);
33
36
  }
34
37
 
@@ -53,6 +56,7 @@ export function menuOf(screen) {
53
56
  const top = topOf(lines, menu.first);
54
57
  return {
55
58
  key: keyOf(lines, top, menu.last),
59
+ framed: BORDER.test(lines[top]), // a dialog's top border; a numbered list in text has none
56
60
  text: lines.slice(top, menu.first).filter((l) => !RULE.test(l)).map((l) => l.trim()).join('\n'),
57
61
  options: menu.options,
58
62
  multi: menu.options.some((o) => o.checked !== null),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdrchat",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Chat front-ends for hc: every terminal hc runs, in the chat app you use",
5
5
  "repository": {
6
6
  "type": "git",