@crouton-kit/humanloop 0.4.8 → 0.4.10

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.
Files changed (117) hide show
  1. package/dist/editor/terminal-review.d.ts +21 -1
  2. package/dist/editor/terminal-review.js +88 -19
  3. package/dist/inbox/controller.d.ts +2 -0
  4. package/dist/inbox/controller.js +75 -23
  5. package/dist/inbox/maintenance.js +1 -10
  6. package/dist/inbox/tui.js +4 -11
  7. package/dist/inbox/visual.d.ts +3 -11
  8. package/dist/inbox/visual.js +12 -56
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +1 -1
  11. package/dist/render/termrender.d.ts +18 -1
  12. package/dist/render/termrender.js +66 -5
  13. package/dist/render/version.d.ts +1 -1
  14. package/dist/render/version.js +1 -1
  15. package/dist/tui/ansi.d.ts +29 -5
  16. package/dist/tui/ansi.js +123 -27
  17. package/dist/tui/app.js +24 -29
  18. package/dist/tui/input.js +13 -0
  19. package/dist/tui/render.d.ts +3 -0
  20. package/dist/tui/render.js +71 -29
  21. package/dist/types.d.ts +9 -0
  22. package/dist/web/assets/{abnfDiagram-VRR7QNED-CeahG6AG.js → abnfDiagram-VRR7QNED-By5eg834.js} +1 -1
  23. package/dist/web/assets/{arc-CDoEbwHt.js → arc-vdkyOWe2.js} +1 -1
  24. package/dist/web/assets/architecture-TIHT7OUA-BjaTawyn.js +1 -0
  25. package/dist/web/assets/{architectureDiagram-ZJ3FMSHR-BPjR0pOT.js → architectureDiagram-ZJ3FMSHR-CKy_ZE5N.js} +1 -1
  26. package/dist/web/assets/{blockDiagram-677ZJIJ3-COPZ3wOy.js → blockDiagram-677ZJIJ3-5LiBO7Io.js} +1 -1
  27. package/dist/web/assets/{c4Diagram-LMCZKHZV-BDlHqm5m.js → c4Diagram-LMCZKHZV-D79itWPb.js} +1 -1
  28. package/dist/web/assets/channel-HnF1q1Mh.js +1 -0
  29. package/dist/web/assets/{chunk-32BRIVSS-ChE1ajNa.js → chunk-32BRIVSS-4LnkfHUo.js} +1 -1
  30. package/dist/web/assets/{chunk-52WLFC77-DN_FDyhc.js → chunk-52WLFC77-2raU4D63.js} +1 -1
  31. package/dist/web/assets/{chunk-C7G6YPKG-B3RJ96Zq.js → chunk-C7G6YPKG-Cq1co5_N.js} +1 -1
  32. package/dist/web/assets/{chunk-EX3LRPZG-DgUeuz17.js → chunk-EX3LRPZG-5pgwNnOC.js} +1 -1
  33. package/dist/web/assets/{chunk-FWX5IMBZ-BLUVTbLv.js → chunk-FWX5IMBZ-DLBDSAUG.js} +2 -2
  34. package/dist/web/assets/{chunk-HOUHSVGY-Be0i0s6_.js → chunk-HOUHSVGY-7q8piy7F.js} +1 -1
  35. package/dist/web/assets/{chunk-ICXQ74PX-3WXYMfu9.js → chunk-ICXQ74PX-B8V7Hf73.js} +1 -1
  36. package/dist/web/assets/{chunk-MOJQB5TN-D-pOUBYm.js → chunk-MOJQB5TN-CvpfWs-s.js} +1 -1
  37. package/dist/web/assets/{chunk-OGEWGWER-B-il3nZ9.js → chunk-OGEWGWER-BIcVy_-w.js} +1 -1
  38. package/dist/web/assets/{chunk-PUDLZKDR-D6N4Hutq.js → chunk-PUDLZKDR-HzobduhR.js} +1 -1
  39. package/dist/web/assets/{chunk-Q4XR5HBZ-9cq52Pkk.js → chunk-Q4XR5HBZ-BVzkZDMe.js} +1 -1
  40. package/dist/web/assets/{chunk-RYQCIY6F-BVYSEout.js → chunk-RYQCIY6F-BXvuzyNS.js} +1 -1
  41. package/dist/web/assets/{chunk-V7JOEXUC-BuJn3hRD.js → chunk-V7JOEXUC-BGmVQ8Gl.js} +1 -1
  42. package/dist/web/assets/{chunk-VAUOI2AC-C6vQq47C.js → chunk-VAUOI2AC-Dr7gTfE6.js} +1 -1
  43. package/dist/web/assets/{chunk-VR4S4FIN-DqY4BkmV.js → chunk-VR4S4FIN-T9cDy9xC.js} +1 -1
  44. package/dist/web/assets/{chunk-WYO6CB5R-DgJ7qTeI.js → chunk-WYO6CB5R-DCmNxnM8.js} +1 -1
  45. package/dist/web/assets/{chunk-XXDRQBXY-BA7v7GnL.js → chunk-XXDRQBXY-D3CBQrix.js} +1 -1
  46. package/dist/web/assets/{chunk-ZGVPDNZ5-BbkNKCvq.js → chunk-ZGVPDNZ5-az9vUYuT.js} +1 -1
  47. package/dist/web/assets/classDiagram-OUVF2IWQ-eVXwjNrO.js +1 -0
  48. package/dist/web/assets/classDiagram-v2-EOCWNBFH-eVXwjNrO.js +1 -0
  49. package/dist/web/assets/{cose-bilkent-JH36ORCC-CXK095PF.js → cose-bilkent-JH36ORCC-BA_JeicW.js} +1 -1
  50. package/dist/web/assets/{cynefin-VYW2F7L2-C_VXlLiV.js → cynefin-VYW2F7L2-CctiGrrW.js} +1 -1
  51. package/dist/web/assets/{cynefinDiagram-TSTJHNR4-C1xlbL_1.js → cynefinDiagram-TSTJHNR4-CUy0zmIT.js} +1 -1
  52. package/dist/web/assets/{dagre-VKFMJZFB-CCTbik65.js → dagre-VKFMJZFB-Ds3jP1MK.js} +1 -1
  53. package/dist/web/assets/{diagram-FQU43EPY-DsT-Ov2v.js → diagram-FQU43EPY-EwA9l88S.js} +1 -1
  54. package/dist/web/assets/{diagram-G47NLZAW-DtoIPU9F.js → diagram-G47NLZAW-DABQWhEl.js} +1 -1
  55. package/dist/web/assets/{diagram-NH7WQ7WH-OFu5gDhD.js → diagram-NH7WQ7WH-ghhVWX8i.js} +1 -1
  56. package/dist/web/assets/{diagram-OA4YK3LP-Dd7dF-gL.js → diagram-OA4YK3LP-DQqZ5_Hn.js} +1 -1
  57. package/dist/web/assets/{diagram-WEI45ONY-Mux4SvIA.js → diagram-WEI45ONY-B7MqXpzX.js} +1 -1
  58. package/dist/web/assets/{dist-9QOA8OIw.js → dist-CccYnoy2.js} +1 -1
  59. package/dist/web/assets/{ebnfDiagram-CCIWWBDH-BrKI9mhn.js → ebnfDiagram-CCIWWBDH-BNbGnz6Y.js} +1 -1
  60. package/dist/web/assets/{erDiagram-Q63AITRT-ByTXMLhH.js → erDiagram-Q63AITRT-C9FlumCQ.js} +1 -1
  61. package/dist/web/assets/eventmodeling-45OFAUF4-BIuBn_KP.js +1 -0
  62. package/dist/web/assets/flowDiagram-23GEKE2U-D38IdS7U.js +1 -0
  63. package/dist/web/assets/{ganttDiagram-NO4QXBWP-DrERNlg-.js → ganttDiagram-NO4QXBWP-CH9MCvIC.js} +1 -1
  64. package/dist/web/assets/{gitGraph-TEB2WS4Q-DD6JbRZi.js → gitGraph-TEB2WS4Q-78WIeQv4.js} +1 -1
  65. package/dist/web/assets/{gitGraphDiagram-IHSO6WYX-Btg9UWYR.js → gitGraphDiagram-IHSO6WYX-DhpBgjPK.js} +1 -1
  66. package/dist/web/assets/{index-CkGfXbfm.js → index-CubZRCZd.js} +3 -3
  67. package/dist/web/assets/index-DOlqkLKF.css +2 -0
  68. package/dist/web/assets/{info-DKCQHKI2-e7fTIPJt.js → info-DKCQHKI2-C92zll-G.js} +1 -1
  69. package/dist/web/assets/{infoDiagram-FWYZ7A6U-D8qwevDR.js → infoDiagram-FWYZ7A6U-SJzTsCEQ.js} +1 -1
  70. package/dist/web/assets/{ishikawaDiagram-FXEZZL3T-DVk__o1X.js → ishikawaDiagram-FXEZZL3T-uPFBPNUE.js} +1 -1
  71. package/dist/web/assets/{journeyDiagram-5HDEW3XC-BAqtMRPh.js → journeyDiagram-5HDEW3XC-Yp2LdPKb.js} +1 -1
  72. package/dist/web/assets/{kanban-definition-HUTT4EX6-BSx4RvJG.js → kanban-definition-HUTT4EX6-CkkJLiTZ.js} +1 -1
  73. package/dist/web/assets/{line-U7gCrM1H.js → line-UR1QpiGL.js} +1 -1
  74. package/dist/web/assets/{linear-LChDqo68.js → linear-Dv1BPDYH.js} +1 -1
  75. package/dist/web/assets/{mermaid-parser.core-DN-im0ZM.js → mermaid-parser.core-CZSrtYeZ.js} +3 -3
  76. package/dist/web/assets/{mermaid.core-BtXRl0Mb.js → mermaid.core-3IhLnwHU.js} +3 -3
  77. package/dist/web/assets/{mindmap-definition-LN4V7U3C-CyKxTVZ0.js → mindmap-definition-LN4V7U3C-D1TtVuKI.js} +1 -1
  78. package/dist/web/assets/{packet-7NZHBO7P-DqwDWu78.js → packet-7NZHBO7P-CJ2IWX3g.js} +1 -1
  79. package/dist/web/assets/{pegDiagram-2B236MQR-BtsNWFkr.js → pegDiagram-2B236MQR-BHoRQXZg.js} +1 -1
  80. package/dist/web/assets/{pie-RZYD4A2V-BHZhIJNY.js → pie-RZYD4A2V-oA5BEo9n.js} +1 -1
  81. package/dist/web/assets/{pieDiagram-ENE6RG2P-Bg2vN06Z.js → pieDiagram-ENE6RG2P-CQHUvgRQ.js} +1 -1
  82. package/dist/web/assets/{quadrantDiagram-ABIIQ3AL-Cy3idFFa.js → quadrantDiagram-ABIIQ3AL-Cns6Us8l.js} +1 -1
  83. package/dist/web/assets/{radar-I7S5WNFK-YJjo6vJv.js → radar-I7S5WNFK-D5BbXMdK.js} +1 -1
  84. package/dist/web/assets/{railroad-3IZDKUUU-CmVtviO7.js → railroad-3IZDKUUU-DFBa68pp.js} +1 -1
  85. package/dist/web/assets/railroad-abnf-AHOZXSZD-BZJp7hTI.js +1 -0
  86. package/dist/web/assets/railroad-ebnf-EBAXGLYW-B7zfPT86.js +1 -0
  87. package/dist/web/assets/railroad-peg-LSFZ7HO6-DGmY03r8.js +1 -0
  88. package/dist/web/assets/{railroadDiagram-RFXS5EU6-H_2GxnX4.js → railroadDiagram-RFXS5EU6-BhjRkGS_.js} +1 -1
  89. package/dist/web/assets/{requirementDiagram-TGXJPOKE-DapbEtLD.js → requirementDiagram-TGXJPOKE-B3Vrz4dg.js} +1 -1
  90. package/dist/web/assets/{sankeyDiagram-HTMAVEWB-CCQHHs1d.js → sankeyDiagram-HTMAVEWB-IguzjAYn.js} +1 -1
  91. package/dist/web/assets/{sequenceDiagram-DBY2YBRQ-CgVVV-1t.js → sequenceDiagram-DBY2YBRQ-uvBF9oJI.js} +1 -1
  92. package/dist/web/assets/{src-BfumX_U8.js → src-C1GaCc7i.js} +1 -1
  93. package/dist/web/assets/{stateDiagram-2N3HPSRC-BBCtLj7x.js → stateDiagram-2N3HPSRC-D55Ia0GX.js} +1 -1
  94. package/dist/web/assets/stateDiagram-v2-6OUMAXLB-D9CZKnmk.js +1 -0
  95. package/dist/web/assets/{swimlanes-5IMT3BWC-DO2t3ahL.js → swimlanes-5IMT3BWC-CxH58sKc.js} +1 -1
  96. package/dist/web/assets/swimlanesDiagram-G3AALYLV-B7yosACx.js +8 -0
  97. package/dist/web/assets/{timeline-definition-FHXFAJF6-lUeG8UJD.js → timeline-definition-FHXFAJF6-BOpGS8lX.js} +1 -1
  98. package/dist/web/assets/{treeView-QDETBFTQ-Btl8LjLd.js → treeView-QDETBFTQ-BLLCYqFV.js} +1 -1
  99. package/dist/web/assets/{treemap-6X3UGDF4-DrCUe046.js → treemap-6X3UGDF4-Cs2rTjAs.js} +1 -1
  100. package/dist/web/assets/{vennDiagram-L72KCM5P-DC1qneaM.js → vennDiagram-L72KCM5P-JEEkwytf.js} +1 -1
  101. package/dist/web/assets/{wardley-OPB4EBWU-BuUed_9_.js → wardley-OPB4EBWU-CCQlq4PR.js} +1 -1
  102. package/dist/web/assets/{wardleyDiagram-EHGQE667-CaHmI1FN.js → wardleyDiagram-EHGQE667-BiZiwurx.js} +1 -1
  103. package/dist/web/assets/{xychartDiagram-FW5EYKEG-ZhKRrmhh.js → xychartDiagram-FW5EYKEG-DKg63gko.js} +1 -1
  104. package/dist/web/index.html +2 -2
  105. package/package.json +1 -1
  106. package/dist/web/assets/architecture-TIHT7OUA-dmDc2lc6.js +0 -1
  107. package/dist/web/assets/channel-Cz6ImOzU.js +0 -1
  108. package/dist/web/assets/classDiagram-OUVF2IWQ-CDIgAUln.js +0 -1
  109. package/dist/web/assets/classDiagram-v2-EOCWNBFH-CDIgAUln.js +0 -1
  110. package/dist/web/assets/eventmodeling-45OFAUF4-DODcRvgw.js +0 -1
  111. package/dist/web/assets/flowDiagram-23GEKE2U-BOjPmrMg.js +0 -1
  112. package/dist/web/assets/index-BT22-qAj.css +0 -2
  113. package/dist/web/assets/railroad-abnf-AHOZXSZD-BwPWzzAs.js +0 -1
  114. package/dist/web/assets/railroad-ebnf-EBAXGLYW-Cc8VfiVd.js +0 -1
  115. package/dist/web/assets/railroad-peg-LSFZ7HO6-DivliSK5.js +0 -1
  116. package/dist/web/assets/stateDiagram-v2-6OUMAXLB-CBzJzUEN.js +0 -1
  117. package/dist/web/assets/swimlanesDiagram-G3AALYLV-CNLJY6SU.js +0 -8
@@ -26,8 +26,10 @@ export interface RenderedDoc {
26
26
  * (a bullet, a table row, a code line…); null for separator rows and rows
27
27
  * of unmapped blocks. Same length as `lines`. */
28
28
  spans: ([number, number] | null)[];
29
- /** Per top-level block: 1-indexed inclusive source-line bounds. Never empty. */
29
+ /** Per top-level block: its termrender block type plus 1-indexed inclusive
30
+ * source-line bounds. Never empty. */
30
31
  blocks: {
32
+ type: string;
31
33
  start: number;
32
34
  end: number;
33
35
  }[];
@@ -35,6 +37,21 @@ export interface RenderedDoc {
35
37
  /** Render markdown with a row→source-line map via the pinned binary
36
38
  * (`doc render --line-map`); plaintext paragraph-block fallback. */
37
39
  export declare function renderMarkdownWithMap(md: string, width: number): RenderedDoc;
40
+ /**
41
+ * The shared block-aware mapped render: prose blocks keep the readability cap
42
+ * (`proseWidth`), while diagram blocks are re-rendered at the pane's full
43
+ * `paneWidth` and spliced back in by block index. Both surfaces (terminal
44
+ * review and the ask deck) go through this, so "which blocks may be wide" is
45
+ * decided once, from the renderer's own block types, never by heuristics on
46
+ * the emitted rows.
47
+ *
48
+ * Row order, block list and source spans are the narrow render's; only the
49
+ * rows OF a wide block are replaced, so anchoring stays source-based and
50
+ * unchanged.
51
+ */
52
+ export declare function renderMarkdownBlockAware(md: string, proseWidth: number, paneWidth: number): RenderedDoc;
53
+ /** Block-aware render as plain rows, for surfaces that do not anchor. */
54
+ export declare function renderMarkdownBlockAwareLines(md: string, proseWidth: number, paneWidth: number): string[];
38
55
  /** Validate markdown via `termrender doc check`. */
39
56
  export declare function checkMarkdown(md: string): {
40
57
  ok: true;
@@ -492,6 +492,10 @@ export function renderMarkdown(md, width) {
492
492
  _bodyCache.set(key, fallback);
493
493
  return fallback;
494
494
  }
495
+ /** Block types allowed to use the full pane width instead of the prose cap:
496
+ * a diagram is a picture, not prose, so a readability column limit only
497
+ * shrinks it below what the pane could show. */
498
+ const WIDE_BLOCK_TYPES = new Set(['mermaid']);
495
499
  /** Validate + normalize `doc render --line-map` JSON into a RenderedDoc.
496
500
  * Null block bounds (a scanner edge case) are filled from neighbors and
497
501
  * clamped into the source range; an empty block list gets one whole-doc
@@ -520,6 +524,10 @@ function parseRenderedDoc(out, source) {
520
524
  for (const raw of p.blocks) {
521
525
  if (typeof raw !== 'object' || raw === null)
522
526
  return null;
527
+ // The block type is what lets callers render diagrams differently from
528
+ // prose; a map without it cannot drive block-aware rendering at all.
529
+ if (typeof raw.type !== 'string' || raw.type === '')
530
+ return null;
523
531
  // Bounds are integers or null — a fractional bound would otherwise flow
524
532
  // into a recorded comment's line/endLine instead of tripping tool-fault.
525
533
  if (raw.start != null && !Number.isInteger(raw.start))
@@ -530,11 +538,11 @@ function parseRenderedDoc(out, source) {
530
538
  const end = typeof raw.end === 'number' ? Math.max(raw.end, start) : start;
531
539
  const s = Math.max(1, Math.min(start, totalSource));
532
540
  const e = Math.max(s, Math.min(end, totalSource));
533
- blocks.push({ start: s, end: e });
541
+ blocks.push({ type: raw.type, start: s, end: e });
534
542
  prevEnd = e;
535
543
  }
536
544
  if (blocks.length === 0)
537
- blocks.push({ start: 1, end: totalSource });
545
+ blocks.push({ type: 'paragraph', start: 1, end: totalSource });
538
546
  const spans = [];
539
547
  for (let r = 0; r < p.spans.length; r++) {
540
548
  const raw = p.spans[r];
@@ -581,14 +589,14 @@ function fallbackDocWithMap(md, width) {
581
589
  openAt = i;
582
590
  }
583
591
  else if (openAt !== null) {
584
- blocks.push({ start: openAt + 1, end: i });
592
+ blocks.push({ type: 'paragraph', start: openAt + 1, end: i });
585
593
  openAt = null;
586
594
  }
587
595
  }
588
596
  if (openAt !== null)
589
- blocks.push({ start: openAt + 1, end: src.length });
597
+ blocks.push({ type: 'paragraph', start: openAt + 1, end: src.length });
590
598
  if (blocks.length === 0)
591
- blocks.push({ start: 1, end: Math.max(1, src.length) });
599
+ blocks.push({ type: 'paragraph', start: 1, end: Math.max(1, src.length) });
592
600
  const lines = [];
593
601
  const rows = [];
594
602
  const spans = [];
@@ -649,6 +657,59 @@ export function renderMarkdownWithMap(md, width) {
649
657
  // for the rest of the session when a later re-render could succeed.
650
658
  return fallbackDocWithMap(md, width);
651
659
  }
660
+ /**
661
+ * The shared block-aware mapped render: prose blocks keep the readability cap
662
+ * (`proseWidth`), while diagram blocks are re-rendered at the pane's full
663
+ * `paneWidth` and spliced back in by block index. Both surfaces (terminal
664
+ * review and the ask deck) go through this, so "which blocks may be wide" is
665
+ * decided once, from the renderer's own block types, never by heuristics on
666
+ * the emitted rows.
667
+ *
668
+ * Row order, block list and source spans are the narrow render's; only the
669
+ * rows OF a wide block are replaced, so anchoring stays source-based and
670
+ * unchanged.
671
+ */
672
+ export function renderMarkdownBlockAware(md, proseWidth, paneWidth) {
673
+ const base = renderMarkdownWithMap(md, proseWidth);
674
+ if (paneWidth <= proseWidth)
675
+ return base;
676
+ if (!base.blocks.some((b) => WIDE_BLOCK_TYPES.has(b.type)))
677
+ return base;
678
+ const wide = renderMarkdownWithMap(md, paneWidth);
679
+ // Same source, so the two renders describe the same top-level blocks. A
680
+ // disagreement means one of the two maps is degraded (renderer failure mid
681
+ // session) — render the narrow one rather than splice mismatched rows.
682
+ if (wide.blocks.length !== base.blocks.length)
683
+ return base;
684
+ const lines = [];
685
+ const rows = [];
686
+ const spans = [];
687
+ for (let r = 0; r < base.lines.length; r++) {
688
+ const bi = base.rows[r] ?? null;
689
+ if (bi === null || !WIDE_BLOCK_TYPES.has(base.blocks[bi].type)) {
690
+ lines.push(base.lines[r]);
691
+ rows.push(bi);
692
+ spans.push(base.spans[r] ?? null);
693
+ continue;
694
+ }
695
+ // First row of this block's run: emit the wide render's rows for the same
696
+ // block, then skip the narrow run (a block's rows are contiguous).
697
+ if (r === 0 || base.rows[r - 1] !== bi) {
698
+ for (let q = 0; q < wide.lines.length; q++) {
699
+ if (wide.rows[q] !== bi)
700
+ continue;
701
+ lines.push(wide.lines[q]);
702
+ rows.push(bi);
703
+ spans.push(wide.spans[q] ?? null);
704
+ }
705
+ }
706
+ }
707
+ return { lines, rows, spans, blocks: base.blocks };
708
+ }
709
+ /** Block-aware render as plain rows, for surfaces that do not anchor. */
710
+ export function renderMarkdownBlockAwareLines(md, proseWidth, paneWidth) {
711
+ return renderMarkdownBlockAware(md, proseWidth, paneWidth).lines;
712
+ }
652
713
  /** Validate markdown via `termrender doc check`. */
653
714
  export function checkMarkdown(md) {
654
715
  ensureRenderer();
@@ -1 +1 @@
1
- export declare const TERMRENDER_VERSION = "4.12.0";
1
+ export declare const TERMRENDER_VERSION = "4.12.1";
@@ -1 +1 @@
1
- export const TERMRENDER_VERSION = '4.12.0';
1
+ export const TERMRENDER_VERSION = '4.12.1';
@@ -33,11 +33,35 @@ export declare function hardWrap(text: string, maxWidth: number): string[];
33
33
  * them as cheap no-ops.
34
34
  */
35
35
  export declare function centerHorizontal(lines: string[], cols: number, contentWidth: number): string[];
36
+ /** Visible width of a line in display cells (string-width ignores ANSI). */
37
+ export declare function visibleWidth(line: string): number;
36
38
  /**
37
- * ANSI-aware clip: truncate a line's *visible* width to `maxWidth`, passing
38
- * escape sequences through untouched. Every line written to the terminal must
39
- * fit within the columns, or it physically wraps onto the next row — which
40
- * breaks diffFrame's one-logical-line-per-row model and strands spillover text
41
- * on rows the differ believes are empty (so it never erases them).
39
+ * The one display-cell containment primitive: the visible cells
40
+ * `[start, start + width)` of `line`, with escape sequences preserved.
41
+ *
42
+ * Every line written to the terminal must fit within its rectangle, or it
43
+ * physically wraps onto the next row which breaks diffFrame's
44
+ * one-logical-line-per-row model and strands spillover text on rows the
45
+ * differ believes are empty (so it never erases them). Styling seen before
46
+ * the window is replayed at its head so a slice keeps the colors it had in
47
+ * place, and a double-width glyph straddling either edge becomes spaces so
48
+ * the slice occupies exactly the cells it claims.
49
+ */
50
+ export declare function sliceCells(line: string, start: number, width: number): string;
51
+ /**
52
+ * ANSI-aware clip: truncate a line's visible width to `maxWidth`. Thin
53
+ * wrapper over `sliceCells` so containment has a single implementation.
42
54
  */
43
55
  export declare function clipLine(line: string, maxWidth: number): string;
56
+ /**
57
+ * Horizontal window onto a row that may be wider than its rectangle (a
58
+ * Mermaid diagram rendered at the pane's full width). A row that fits is
59
+ * returned untouched — panning only ever moves content that overflows, so
60
+ * prose stays put while a diagram slides. Hidden content on either side is
61
+ * marked with a dim ‹/› in the edge cell, so the row always states whether
62
+ * more of the diagram exists left or right, and the marker disappears once
63
+ * that edge is reached (nothing is unreachable).
64
+ */
65
+ export declare function panLine(line: string, offset: number, width: number): string;
66
+ /** Cells of `line` that fall outside a `width`-wide rectangle. */
67
+ export declare function rowOverflow(line: string, width: number): number;
package/dist/tui/ansi.js CHANGED
@@ -172,39 +172,135 @@ export function centerHorizontal(lines, cols, contentWidth) {
172
172
  const pad = ' '.repeat(extraPad);
173
173
  return lines.map((line) => (line === '' ? '' : pad + line));
174
174
  }
175
+ /** Visible width of a line in display cells (string-width ignores ANSI). */
176
+ export function visibleWidth(line) {
177
+ return stringWidth(line);
178
+ }
179
+ const ANSI_TOKEN = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]/g;
180
+ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
181
+ /** ANSI escapes are tokens of their own; all printable text is segmented into
182
+ * extended grapheme clusters before widths or slice boundaries are calculated.
183
+ * `string-width` assigns display width to the complete cluster (not its
184
+ * constituent code points), which keeps ZWJ emoji, flags, skin tones and
185
+ * combining sequences indivisible while panning. */
186
+ function displayTokens(line) {
187
+ const tokens = [];
188
+ let at = 0;
189
+ ANSI_TOKEN.lastIndex = 0;
190
+ for (let match = ANSI_TOKEN.exec(line); match !== null; match = ANSI_TOKEN.exec(line)) {
191
+ for (const segment of GRAPHEMES.segment(line.slice(at, match.index))) {
192
+ tokens.push({ ansi: false, text: segment.segment, width: stringWidth(segment.segment) });
193
+ }
194
+ tokens.push({ ansi: true, text: match[0] });
195
+ at = match.index + match[0].length;
196
+ }
197
+ for (const segment of GRAPHEMES.segment(line.slice(at))) {
198
+ tokens.push({ ansi: false, text: segment.segment, width: stringWidth(segment.segment) });
199
+ }
200
+ return tokens;
201
+ }
175
202
  /**
176
- * ANSI-aware clip: truncate a line's *visible* width to `maxWidth`, passing
177
- * escape sequences through untouched. Every line written to the terminal must
178
- * fit within the columns, or it physically wraps onto the next row — which
179
- * breaks diffFrame's one-logical-line-per-row model and strands spillover text
180
- * on rows the differ believes are empty (so it never erases them).
203
+ * The one display-cell containment primitive: the visible cells
204
+ * `[start, start + width)` of `line`, with escape sequences preserved.
205
+ *
206
+ * Every line written to the terminal must fit within its rectangle, or it
207
+ * physically wraps onto the next row which breaks diffFrame's
208
+ * one-logical-line-per-row model and strands spillover text on rows the
209
+ * differ believes are empty (so it never erases them). Styling seen before
210
+ * the window is replayed at its head so a slice keeps the colors it had in
211
+ * place, and a double-width glyph straddling either edge becomes spaces so
212
+ * the slice occupies exactly the cells it claims.
181
213
  */
182
- export function clipLine(line, maxWidth) {
183
- if (maxWidth < 1)
214
+ export function sliceCells(line, start, width) {
215
+ if (width < 1)
184
216
  return '';
185
- if (stringWidth(line) <= maxWidth)
186
- return line; // string-width ignores ANSI
217
+ const from = Math.max(0, start);
218
+ const end = from + width;
219
+ let carried = ''; // styling established before the window
187
220
  let out = '';
188
- let w = 0;
189
- let i = 0;
190
- let sawAnsi = false;
191
- while (i < line.length) {
192
- if (line[i] === '\x1b') {
193
- const m = /^\x1b\[[0-9;?]*[a-zA-Z]|^\x1b[@-_]/.exec(line.slice(i));
194
- if (m !== null) {
195
- out += m[0];
196
- i += m[0].length;
197
- sawAnsi = true;
198
- continue;
221
+ let emitted = false; // any escape inside the window
222
+ let cell = 0;
223
+ for (const token of displayTokens(line)) {
224
+ if (token.ansi) {
225
+ if (cell < from)
226
+ carried += token.text;
227
+ else {
228
+ out += token.text;
229
+ emitted = true;
199
230
  }
231
+ continue;
200
232
  }
201
- const ch = String.fromCodePoint(line.codePointAt(i));
202
- const cw = stringWidth(ch);
203
- if (w + cw > maxWidth)
233
+ if (cell >= end)
204
234
  break;
205
- out += ch;
206
- w += cw;
207
- i += ch.length;
235
+ const cw = token.width;
236
+ // A standalone combining mark has no cells but is still a complete
237
+ // grapheme cluster; retain it whenever its zero-width position is inside
238
+ // the window rather than dropping it through the usual boundary check.
239
+ if (cw === 0) {
240
+ if (cell >= from)
241
+ out += token.text;
242
+ continue;
243
+ }
244
+ if (cell + cw <= from) {
245
+ cell += cw;
246
+ continue;
247
+ }
248
+ if (cell < from) { // whole grapheme straddles the left edge
249
+ out += ' '.repeat(cell + cw - from);
250
+ cell += cw;
251
+ continue;
252
+ }
253
+ if (cell + cw > end) { // whole grapheme straddles the right edge
254
+ out += ' '.repeat(end - cell);
255
+ cell = end;
256
+ break;
257
+ }
258
+ out += token.text;
259
+ cell += cw;
260
+ }
261
+ if (out === '')
262
+ return '';
263
+ if (carried !== '') {
264
+ out = carried + out;
265
+ emitted = true;
208
266
  }
209
- return sawAnsi ? out + RESET : out;
267
+ return emitted ? out + RESET : out;
268
+ }
269
+ /**
270
+ * ANSI-aware clip: truncate a line's visible width to `maxWidth`. Thin
271
+ * wrapper over `sliceCells` so containment has a single implementation.
272
+ */
273
+ export function clipLine(line, maxWidth) {
274
+ if (maxWidth < 1)
275
+ return '';
276
+ if (stringWidth(line) <= maxWidth)
277
+ return line; // string-width ignores ANSI
278
+ return sliceCells(line, 0, maxWidth);
279
+ }
280
+ /**
281
+ * Horizontal window onto a row that may be wider than its rectangle (a
282
+ * Mermaid diagram rendered at the pane's full width). A row that fits is
283
+ * returned untouched — panning only ever moves content that overflows, so
284
+ * prose stays put while a diagram slides. Hidden content on either side is
285
+ * marked with a dim ‹/› in the edge cell, so the row always states whether
286
+ * more of the diagram exists left or right, and the marker disappears once
287
+ * that edge is reached (nothing is unreachable).
288
+ */
289
+ export function panLine(line, offset, width) {
290
+ if (width < 1)
291
+ return '';
292
+ const w = stringWidth(line);
293
+ if (w <= width)
294
+ return line;
295
+ const max = w - width;
296
+ const off = Math.max(0, Math.min(offset, max));
297
+ const left = off > 0;
298
+ const right = off < max;
299
+ const innerWidth = width - (left ? 1 : 0) - (right ? 1 : 0);
300
+ const body = innerWidth > 0 ? sliceCells(line, off + (left ? 1 : 0), innerWidth) : '';
301
+ return `${left ? `${DIM}‹${RESET}` : ''}${body}${right ? `${DIM}›${RESET}` : ''}`;
302
+ }
303
+ /** Cells of `line` that fall outside a `width`-wide rectangle. */
304
+ export function rowOverflow(line, width) {
305
+ return Math.max(0, stringWidth(line) - width);
210
306
  }
package/dist/tui/app.js CHANGED
@@ -4,7 +4,6 @@ import { dirname, resolve as resolvePath } from 'node:path';
4
4
  import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
5
5
  import { diffFrame, renderOverview, renderItemReview, renderFinal, renderHandoff, clampItemReviewScroll } from './render.js';
6
6
  import { handleKeypress, assignShortcuts } from './input.js';
7
- import { renderMarkdown } from '../render/termrender.js';
8
7
  import { editBufferInEditor } from '../editor/roundtrip.js';
9
8
  import { validateDeck } from '../inbox/deck-schema.js';
10
9
  import { canonicalizeInteraction } from '../inbox/visual.js';
@@ -57,6 +56,8 @@ function buildInitialState(deck, editorAvailable = false, followUpAvailable = fa
57
56
  bodyMode: 'question',
58
57
  scrollOffset: 0,
59
58
  bodyScrollOffsets: { question: 0, visual: 0 },
59
+ hscrollOffset: 0,
60
+ hscrollMax: 0,
60
61
  editorAvailable,
61
62
  followUpAvailable,
62
63
  followUp: undefined,
@@ -107,20 +108,6 @@ function rebindPersist(internals) {
107
108
  internals.callbacks.onProgress?.(responses);
108
109
  };
109
110
  }
110
- function visualRenderWidth(cols) {
111
- return Math.max(1, Math.min(cols - 4, 76));
112
- }
113
- function renderVisualMarkdown(markdown, cols) {
114
- return renderMarkdown(markdown, visualRenderWidth(cols)).join('\n');
115
- }
116
- /** Reflow ready Visual Markdown locally without issuing another request. */
117
- function rerenderVisuals(internals) {
118
- for (const [id, visual] of internals.state.visuals) {
119
- if (visual.status !== 'ready' || visual.markdown === undefined)
120
- continue;
121
- internals.state.visuals.set(id, { ...visual, content: renderVisualMarkdown(visual.markdown, internals.cols) });
122
- }
123
- }
124
111
  function visualRequestInteraction(interaction) {
125
112
  return canonicalizeInteraction(interaction);
126
113
  }
@@ -136,6 +123,17 @@ function retireVisuals(internals) {
136
123
  catch { /* stale UI currency remains authoritative even if a host cancel fails */ }
137
124
  }
138
125
  }
126
+ function detachVisuals(internals) {
127
+ internals.visualGeneration += 1;
128
+ const handles = [...internals.visualHandles.values()];
129
+ internals.visualHandles.clear();
130
+ for (const handle of handles) {
131
+ try {
132
+ handle.detach();
133
+ }
134
+ catch { /* UI teardown remains local even if provider cleanup fails */ }
135
+ }
136
+ }
139
137
  function fireVisuals(internals, interactions) {
140
138
  const provider = internals.visualProvider;
141
139
  if (provider === undefined)
@@ -163,7 +161,7 @@ function fireVisuals(internals, interactions) {
163
161
  if (!internals.state.interactions.some((candidate) => candidate.id === interaction.id))
164
162
  return;
165
163
  internals.state.visuals.set(interaction.id, result.status === 'ready' && typeof result.markdown === 'string'
166
- ? { questionId: interaction.id, content: renderVisualMarkdown(result.markdown, internals.cols), markdown: result.markdown, status: 'ready' }
164
+ ? { questionId: interaction.id, content: result.markdown, markdown: result.markdown, status: 'ready' }
167
165
  : { questionId: interaction.id, content: '', status: 'error' });
168
166
  internals.callbacks.onDirty?.();
169
167
  }).catch(() => {
@@ -208,7 +206,13 @@ export function mountPanel(opts) {
208
206
  const renderLines = () => {
209
207
  switch (internals.state.phase) {
210
208
  case 'overview': return renderOverview(internals.state, internals.cols, internals.rows);
211
- case 'item-review': return renderItemReview(internals.state, internals.cols, internals.rows);
209
+ case 'item-review':
210
+ // The one pre-render clamp: keeps scrollOffset/hscrollOffset inside the
211
+ // current body's bounds (so u/d and h/l stay responsive) and publishes
212
+ // the live horizontal reach the input layer reads. The renderer itself
213
+ // stays pure; running it here covers mount, resize and every keypress.
214
+ clampItemReviewScroll(internals.state, internals.cols, internals.rows);
215
+ return renderItemReview(internals.state, internals.cols, internals.rows);
212
216
  case 'final': return renderFinal(internals.state, internals.cols, internals.rows);
213
217
  }
214
218
  };
@@ -242,11 +246,6 @@ export function mountPanel(opts) {
242
246
  request: (question) => internals.callbacks.onFollowUpRequest?.(question),
243
247
  cancel: () => internals.callbacks.onFollowUpCancel?.(),
244
248
  });
245
- // Pre-render clamp (input layer): keep scrollOffset within the current
246
- // body's bounds so u/d stay responsive. The renderer itself is pure.
247
- if (internals.state.phase === 'item-review') {
248
- clampItemReviewScroll(internals.state, internals.cols, internals.rows);
249
- }
250
249
  },
251
250
  render() {
252
251
  if (!internals.mounted)
@@ -256,19 +255,15 @@ export function mountPanel(opts) {
256
255
  handleResize(cols, rows) {
257
256
  internals.cols = cols;
258
257
  internals.rows = rows;
259
- // Width changes reflow saved visual markdown, so clamp against the new
260
- // content and geometry rather than the stale pre-resize wrapping.
261
- rerenderVisuals(internals);
262
- if (internals.state.phase === 'item-review') {
263
- clampItemReviewScroll(internals.state, cols, rows);
264
- }
258
+ // Visual Markdown is re-rendered by the item layout at this width, so
259
+ // the same block-aware geometry drives both resize and initial paint.
265
260
  return renderLines();
266
261
  },
267
262
  unmount() {
268
263
  if (!internals.mounted)
269
264
  return;
270
265
  internals.mounted = false;
271
- retireVisuals(internals);
266
+ detachVisuals(internals);
272
267
  internals.state.persist = undefined;
273
268
  },
274
269
  loadDeck(deck, loadOpts) {
package/dist/tui/input.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { panItemReview } from './render.js';
1
2
  // 'w' is reserved for the host-level "open in browser" handoff (see
2
3
  // tui/app.ts resolveInteractionDir) — never auto-assignable as an option
3
4
  // shortcut, or pressing it would race the handoff against picking that option.
@@ -180,6 +181,16 @@ function handleItemReview(input, key, state, render, followUp) {
180
181
  render();
181
182
  return;
182
183
  }
184
+ // Body pan: contextual, so h/l only claim the key while the visible body
185
+ // actually overflows its rectangle (a wide diagram) AND no option owns that
186
+ // shortcut. Otherwise they fall through to the option/action handling below.
187
+ if ((input === 'h' || input === 'l' || key.leftArrow || key.rightArrow)
188
+ && (state.hscrollMax ?? 0) > 0
189
+ && !interaction.options.some((o) => o.shortcut === input)) {
190
+ panItemReview(state, input === 'h' || key.leftArrow ? -1 : 1);
191
+ render();
192
+ return;
193
+ }
183
194
  if (input === 'j' || key.downArrow) {
184
195
  const max = actionCount(interaction) - 1;
185
196
  state.selectedAction = Math.min(state.selectedAction + 1, max);
@@ -576,6 +587,7 @@ function advanceItem(state, direction) {
576
587
  state.bodyMode = 'question';
577
588
  state.scrollOffset = 0;
578
589
  state.bodyScrollOffsets = { question: 0, visual: 0 };
590
+ state.hscrollOffset = 0;
579
591
  }
580
592
  /**
581
593
  * Move to the next interaction WITHOUT a response, falling through to the
@@ -598,6 +610,7 @@ function advanceToNextUnanswered(state) {
598
610
  state.bodyMode = 'question';
599
611
  state.scrollOffset = 0;
600
612
  state.bodyScrollOffsets = { question: 0, visual: 0 };
613
+ state.hscrollOffset = 0;
601
614
  }
602
615
  function actionCount(interaction) {
603
616
  return interaction.options.length + (interaction.allowFreetext && interaction.options.length > 0 ? 1 : 0);
@@ -21,6 +21,9 @@ export declare function renderInputBuffer(buffer: string, cursor: number, maxWid
21
21
  * renderer mutating state mid-frame.
22
22
  */
23
23
  export declare function clampItemReviewScroll(state: TuiState, cols: number, rows: number): void;
24
+ /** Pan the card body horizontally. Clamping happens in the pre-render clamp
25
+ * the host already runs, exactly like vertical over-scroll. */
26
+ export declare function panItemReview(state: TuiState, direction: -1 | 1): void;
24
27
  export declare function renderItemReview(state: TuiState, cols: number, rows: number): string[];
25
28
  export declare function renderFinal(state: TuiState, cols: number, rows: number): string[];
26
29
  /**