@zenera/cli 1.1.19 → 1.1.21

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/dist/tui/wrap.js CHANGED
@@ -93,8 +93,8 @@ export const CHROME_ROWS = 7;
93
93
  */
94
94
  export const THINKING_ROWS = 1;
95
95
  /** How much of the frame the work in flight may take. A branch is a box now,
96
- * not a row, so a fan-out of two costs fourteen of these. */
97
- export const ACTIVITY_ROWS = 16;
96
+ * not a row, so a fan-out of two costs twenty-two of these. */
97
+ export const ACTIVITY_ROWS = 24;
98
98
  /** The width of the elapsed-time column every call row is drawn behind. */
99
99
  export const TIME_COL = 5;
100
100
  /**
@@ -114,8 +114,18 @@ export function budgetOf(rows, activity, thinking) {
114
114
  const tail = Math.min(Math.max(0, thinking), THINKING_ROWS, Math.max(0, rest - 1));
115
115
  return { activity: shown, thinking: tail, live: rest - tail, total };
116
116
  }
117
- /** The rows a branch box may spend on its own calls and its reasoning. */
118
- export const BRANCH_ROWS = 5;
117
+ /** The rows a branch box may spend on its own calls, prose and reasoning. */
118
+ export const BRANCH_ROWS = 9;
119
+ /**
120
+ * The fewest a box is worth drawing at.
121
+ *
122
+ * One row per branch fit more of them on screen and said nothing about any of
123
+ * them: a box holding a single call is a label, and the reason it is a box at
124
+ * all is that what a branch just did and said is why it is doing this. Below
125
+ * six rows there is no *what it just did* — so a fork too wide to give every
126
+ * branch six is cut to the ones that fit and counted, which is `fitActivity`.
127
+ */
128
+ export const BRANCH_MIN = 6;
119
129
  /** Rows a branch box spends on chrome: the title rule and the closing one. */
120
130
  export const BOX_CHROME = 2;
121
131
  /**
@@ -123,26 +133,51 @@ export const BOX_CHROME = 2;
123
133
  * activity region has to divide between them.
124
134
  *
125
135
  * Every branch gets the same number, because they are the same kind of thing
126
- * and a fan-out is read across, not down. A wide fork spends its rows on being
127
- * complete rather than on being detailed: eight branches showing one call each
128
- * is a picture of the fork, eight rows of one branch is not.
136
+ * and a fan-out is read across, not down.
129
137
  */
130
138
  export function branchRows(count, allowance) {
131
139
  if (count <= 0) {
132
140
  return 0;
133
141
  }
134
142
  const each = Math.floor(Math.max(0, allowance) / count) - BOX_CHROME;
135
- return Math.max(1, Math.min(BRANCH_ROWS, each));
143
+ return Math.max(BRANCH_MIN, Math.min(BRANCH_ROWS, each));
136
144
  }
137
145
  /**
138
- * How wide the answer is drawn. A line of prose spanning a 200-column terminal
139
- * is measurably harder to read than one that stops, which is why every demo in
146
+ * How wide *prose* is drawn. A line spanning a 200-column terminal is
147
+ * measurably harder to read than one that stops, which is why every demo in
140
148
  * `examples/sdk/` puts its answer in a box of bounded width — the terminal is the
141
149
  * page, not the paragraph.
150
+ *
151
+ * `boxWidth` is what a whole answer is drawn at; this is the floor it starts from.
142
152
  */
143
153
  export function answerWidth(columns) {
144
154
  return Math.max(24, Math.min(columns - 4, 96));
145
155
  }
156
+ /**
157
+ * How wide the box holding `text` has to be.
158
+ *
159
+ * Prose stops at the comfort width, but a fence and a table cannot be
160
+ * reflowed — fold a table row and the columns stop lining up, which is the one
161
+ * thing the table was for — so the box grows to hold the widest of them, as far
162
+ * as the terminal allows and no further.
163
+ */
164
+ export function boxWidth(text, columns) {
165
+ let rigid = 0;
166
+ for (const block of blocksOf(text)) {
167
+ // A fence is drawn behind a `│ ` gutter; a table row is not.
168
+ const lines = block.kind === 'code'
169
+ ? (block.lines ?? []).map((l) => l.length + 6)
170
+ : block.kind === 'table'
171
+ ? textOf(block)
172
+ .split('\n')
173
+ .map((l) => l.length + 4)
174
+ : [];
175
+ for (const width of lines) {
176
+ rigid = Math.max(rigid, width);
177
+ }
178
+ }
179
+ return Math.max(answerWidth(columns), Math.min(Math.max(24, columns - 4), rigid));
180
+ }
146
181
  // ---------------------------------------------------------------------------
147
182
  // Reading a payload
148
183
  // ---------------------------------------------------------------------------
@@ -349,32 +384,470 @@ export function gistOf(text, width) {
349
384
  const last = heads.at(-1)?.[1];
350
385
  return clip(last ?? tail, width);
351
386
  }
387
+ // ---------------------------------------------------------------------------
388
+ // Inline
389
+ // ---------------------------------------------------------------------------
390
+ /** What a backslash may turn back into ordinary text. */
391
+ const ESCAPABLE = /[\\`*_~[\]()#+\-.!>|]/;
392
+ /** Emphasis has to hug its own text, or `2 * 3 * 4` reads as arithmetic in italics. */
393
+ const spacey = (ch) => ch === undefined || /\s/.test(ch);
394
+ /** An underscore between word characters is part of the word: `snake_case_name`. */
395
+ const wordy = (ch) => ch !== undefined && /[\p{L}\p{N}]/u.test(ch);
396
+ /** Far past anything a model writes, and the thing that stops a pathological nest. */
397
+ const NESTING = 4;
352
398
  /**
353
- * Splits an answer on ``` fences. Prose is left exactly as it was — the common
354
- * answer has no fence in it and comes back in one piece — but a fenced block is
355
- * the one thing a terminal must not reflow: its indentation is its meaning, and
356
- * wrapping it as prose destroys it.
399
+ * The inline markup of one block, resolved.
400
+ *
401
+ * Order is the correctness argument. Code is settled first and never entered
402
+ * again, which is why this is a scanner and not a chain of replaces: in
403
+ * "use `[a](b)` syntax" the brackets are prose, and no regex over the whole
404
+ * string can know that. Everything else recurses on the text between its own
405
+ * markers, so `**bold with `code` in it**` keeps both.
357
406
  */
358
- export function segmentsOf(text) {
407
+ export function inlineOf(text, style = {}, depth = 0) {
359
408
  const out = [];
360
- let current = { code: false, lines: [] };
361
- const flush = () => {
362
- if (current.lines.length) {
363
- out.push(current);
409
+ let buffer = '';
410
+ const keep = () => {
411
+ if (buffer) {
412
+ out.push({ text: buffer, ...style });
413
+ buffer = '';
414
+ }
415
+ };
416
+ let i = 0;
417
+ while (i < text.length) {
418
+ const ch = text[i];
419
+ if (ch === '\\' && ESCAPABLE.test(text[i + 1] ?? '')) {
420
+ buffer += text[i + 1];
421
+ i += 2;
422
+ continue;
423
+ }
424
+ if (ch === '`') {
425
+ const span = codeAt(text, i);
426
+ if (span) {
427
+ keep();
428
+ out.push({ text: span.text, ...style, code: true });
429
+ i = span.end;
430
+ continue;
431
+ }
432
+ }
433
+ if (depth < NESTING && (ch === '[' || (ch === '!' && text[i + 1] === '['))) {
434
+ const link = linkAt(text, i);
435
+ if (link) {
436
+ keep();
437
+ const inner = { ...style, ...(link.href ? { href: link.href } : {}) };
438
+ out.push(...inlineOf(link.label, inner, depth + 1));
439
+ i = link.end;
440
+ continue;
441
+ }
442
+ }
443
+ if (depth < NESTING && (ch === '*' || ch === '_' || ch === '~')) {
444
+ const marks = ch === '~' ? 2 : Math.min(runOf(text, i, ch), 2);
445
+ const close = marks === 2 || ch !== '~' ? closingAt(text, i, ch, marks) : undefined;
446
+ if (close !== undefined) {
447
+ keep();
448
+ // Strikethrough has no weight of its own here, so it resolves
449
+ // to its text: the markers were never meant to be read.
450
+ const added = ch === '~' ? {} : marks === 2 ? { bold: true } : { italic: true };
451
+ out.push(...inlineOf(text.slice(i + marks, close), { ...style, ...added }, depth + 1));
452
+ i = close + marks;
453
+ continue;
454
+ }
455
+ }
456
+ buffer += ch;
457
+ i += 1;
458
+ }
459
+ keep();
460
+ return out;
461
+ }
462
+ /** How many of `ch` run on from `at`. */
463
+ function runOf(text, at, ch) {
464
+ let n = 0;
465
+ while (text[at + n] === ch) {
466
+ n += 1;
467
+ }
468
+ return n;
469
+ }
470
+ /**
471
+ * The code span opening at `at`, or nothing when its backticks never close.
472
+ *
473
+ * A run of n backticks closes on a run of exactly n, which is what lets a code
474
+ * span contain a backtick. One space either side of the content is padding for
475
+ * that case rather than text, so it comes off.
476
+ */
477
+ function codeAt(text, at) {
478
+ const run = runOf(text, at, '`');
479
+ const fence = '`'.repeat(run);
480
+ let from = at + run;
481
+ for (;;) {
482
+ const found = text.indexOf(fence, from);
483
+ if (found < 0) {
484
+ return undefined;
364
485
  }
486
+ if (text[found + run] === '`') {
487
+ from = found + runOf(text, found, '`');
488
+ continue;
489
+ }
490
+ const body = text.slice(at + run, found);
491
+ const padded = body.length > 2 && body.startsWith(' ') && body.endsWith(' ');
492
+ return { text: padded ? body.slice(1, -1) : body, end: found + run };
493
+ }
494
+ }
495
+ /**
496
+ * Where the emphasis opened at `at` closes, or nothing.
497
+ *
498
+ * Two of CommonMark's flanking rules earn their keep and the rest do not: a
499
+ * marker that opens is followed by something other than a space, one that
500
+ * closes is preceded by something other than a space. Without them `a * b * c`
501
+ * is italic and every second multiplication in an answer goes missing.
502
+ */
503
+ function closingAt(text, at, ch, marks) {
504
+ if (spacey(text[at + marks]) || (ch === '_' && wordy(text[at - 1]))) {
505
+ return undefined;
506
+ }
507
+ const mark = ch.repeat(marks);
508
+ let from = at + marks + 1;
509
+ while (from < text.length) {
510
+ const found = text.indexOf(mark, from);
511
+ if (found < 0) {
512
+ return undefined;
513
+ }
514
+ if (!spacey(text[found - 1]) && !(ch === '_' && wordy(text[found + marks]))) {
515
+ return found;
516
+ }
517
+ from = found + 1;
518
+ }
519
+ return undefined;
520
+ }
521
+ /** `[label](href)` or `![alt](href)` at `at`, or nothing when it is only a bracket. */
522
+ function linkAt(text, at) {
523
+ const open = text[at] === '!' ? at + 1 : at;
524
+ let depth = 0;
525
+ let close = -1;
526
+ for (let i = open; i < text.length; i++) {
527
+ const ch = text[i];
528
+ if (ch === '\\') {
529
+ i += 1;
530
+ }
531
+ else if (ch === '[') {
532
+ depth += 1;
533
+ }
534
+ else if (ch === ']') {
535
+ depth -= 1;
536
+ if (depth === 0) {
537
+ close = i;
538
+ break;
539
+ }
540
+ }
541
+ }
542
+ if (close < 0 || text[close + 1] !== '(') {
543
+ return undefined;
544
+ }
545
+ const end = text.indexOf(')', close + 2);
546
+ if (end < 0) {
547
+ return undefined;
548
+ }
549
+ // `[label](url "title")` — the title is for a tooltip nothing here has.
550
+ const target = text
551
+ .slice(close + 2, end)
552
+ .trim()
553
+ .split(/\s+/)[0] ?? '';
554
+ return {
555
+ label: text.slice(open + 1, close),
556
+ href: target.replace(/^<|>$/g, ''),
557
+ end: end + 1,
558
+ };
559
+ }
560
+ // ---------------------------------------------------------------------------
561
+ // Blocks
562
+ // ---------------------------------------------------------------------------
563
+ const FENCE = /^(\s*)(```+|~~~+)\s*(\S*)/;
564
+ const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
565
+ const RULE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/;
566
+ const QUOTE = /^ {0,3}>\s?(.*)$/;
567
+ const ITEM = /^(\s*)(?:([-*+])|(\d{1,9})[.)])\s+(.*)$/;
568
+ /** A markdown table row. Both pipes, so a sentence with one in it is still prose. */
569
+ const TABLE_ROW = /^\s*\|.*\|\s*$/;
570
+ /** A cell of the rule row under a header: dashes, and where the column is set. */
571
+ const RULE_CELL = /^:?-+:?$/;
572
+ /** What a bullet is drawn as, by how deep it is nested. */
573
+ const BULLETS = ['\u2022', '\u25e6', '\u25aa'];
574
+ /** Splits a row on the pipes that divide it, dropping the two that bound it. */
575
+ function cellsOf(row) {
576
+ const out = [];
577
+ let cell = '';
578
+ for (let i = 0; i < row.length; i += 1) {
579
+ const ch = row[i];
580
+ // An escaped pipe is text, and stays escaped for the inline scan.
581
+ if (ch === '\\' && row[i + 1] === '|') {
582
+ cell += '\\|';
583
+ i += 1;
584
+ }
585
+ else if (ch === '|') {
586
+ out.push(cell);
587
+ cell = '';
588
+ }
589
+ else {
590
+ cell += ch;
591
+ }
592
+ }
593
+ out.push(cell);
594
+ return out.slice(1, -1).map((c) => c.trim());
595
+ }
596
+ /**
597
+ * The rows a table was written as, read into a grid.
598
+ *
599
+ * The rule row is not kept: what it said about alignment is kept instead, and
600
+ * `gridOf` draws a rule of the width the columns turn out to be. A table
601
+ * without one is still a table — models write them — it just has no header.
602
+ */
603
+ function gatherTable(rows) {
604
+ const grid = rows.map(cellsOf);
605
+ const rule = grid[1];
606
+ const ruled = rule !== undefined && rule.length > 0 && rule.every((c) => RULE_CELL.test(c));
607
+ const body = ruled ? [grid[0], ...grid.slice(2)] : grid;
608
+ const columns = Math.max(0, ...body.map((row) => row.length));
609
+ return {
610
+ kind: 'table',
611
+ cells: body.map((row) => Array.from({ length: columns }, (_, i) => inlineOf(row[i] ?? ''))),
612
+ ...(ruled
613
+ ? {
614
+ align: Array.from({ length: columns }, (_, i) => (rule[i] ?? '').endsWith(':')
615
+ ? rule[i].startsWith(':')
616
+ ? 'center'
617
+ : 'right'
618
+ : 'left'),
619
+ }
620
+ : {}),
621
+ };
622
+ }
623
+ /** Takes back up to `by` columns of indent, and no more than the line has. */
624
+ function dedent(line, by) {
625
+ let n = 0;
626
+ while (n < by && (line[n] === ' ' || line[n] === '\t')) {
627
+ n += 1;
628
+ }
629
+ return line.slice(n);
630
+ }
631
+ /**
632
+ * An answer, as the blocks it is made of.
633
+ *
634
+ * Read top down, one line at a time, because that is the order the text
635
+ * arrives in and a stream must be readable before it is finished. The two
636
+ * verbatim kinds win over everything: a pipe inside a fence is whatever the
637
+ * code says it is, and a `#` inside one is a comment, not a heading.
638
+ */
639
+ export function blocksOf(text) {
640
+ const out = [];
641
+ let prose;
642
+ let fence;
643
+ let table;
644
+ const closeProse = () => {
645
+ if (prose) {
646
+ out.push({
647
+ kind: prose.kind,
648
+ ...(prose.level === undefined ? {} : { level: prose.level }),
649
+ ...(prose.marker === undefined ? {} : { marker: prose.marker }),
650
+ spans: inlineOf(prose.lines.join('\n').trim()),
651
+ });
652
+ prose = undefined;
653
+ }
654
+ };
655
+ const closeTable = () => {
656
+ if (table) {
657
+ out.push(gatherTable(table));
658
+ table = undefined;
659
+ }
660
+ };
661
+ const closeFence = () => {
662
+ if (fence) {
663
+ out.push({
664
+ kind: 'code',
665
+ ...(fence.title ? { title: fence.title } : {}),
666
+ lines: fence.lines,
667
+ });
668
+ fence = undefined;
669
+ }
670
+ };
671
+ const settle = () => {
672
+ closeProse();
673
+ closeTable();
365
674
  };
366
675
  for (const raw of text.split('\n')) {
367
- const fence = /^\s*```+\s*(\S*)/.exec(raw);
368
- if (!fence) {
369
- current.lines.push(raw);
676
+ if (fence) {
677
+ if (raw.trimStart().startsWith(fence.marker)) {
678
+ closeFence();
679
+ }
680
+ else {
681
+ fence.lines.push(dedent(raw, fence.indent));
682
+ }
683
+ continue;
684
+ }
685
+ const opening = FENCE.exec(raw);
686
+ if (opening) {
687
+ settle();
688
+ fence = {
689
+ marker: opening[2].slice(0, 3),
690
+ indent: opening[1].length,
691
+ ...(opening[3] ? { title: opening[3] } : {}),
692
+ lines: [],
693
+ };
370
694
  continue;
371
695
  }
372
- flush();
373
- current = current.code
374
- ? { code: false, lines: [] }
375
- : { code: true, title: fence[1] || undefined, lines: [] };
696
+ // A blank line ends whatever was being gathered and is not itself a block.
697
+ if (!raw.trim()) {
698
+ settle();
699
+ continue;
700
+ }
701
+ if (TABLE_ROW.test(raw)) {
702
+ closeProse();
703
+ (table ??= []).push(raw.trim());
704
+ continue;
705
+ }
706
+ closeTable();
707
+ const heading = HEADING.exec(raw);
708
+ if (heading) {
709
+ closeProse();
710
+ out.push({
711
+ kind: 'heading',
712
+ level: heading[1].length,
713
+ spans: inlineOf(heading[2]),
714
+ });
715
+ continue;
716
+ }
717
+ // Before the item check, so `* * *` is a rule and not a list of one star.
718
+ if (RULE.test(raw)) {
719
+ closeProse();
720
+ out.push({ kind: 'rule' });
721
+ continue;
722
+ }
723
+ const quote = QUOTE.exec(raw);
724
+ if (quote) {
725
+ if (prose?.kind !== 'quote') {
726
+ closeProse();
727
+ prose = { kind: 'quote', lines: [] };
728
+ }
729
+ prose.lines.push(quote[1]);
730
+ continue;
731
+ }
732
+ const item = ITEM.exec(raw);
733
+ if (item) {
734
+ closeProse();
735
+ const level = Math.min(BULLETS.length - 1, Math.floor(item[1].length / 2));
736
+ const number = item[3];
737
+ prose = {
738
+ kind: 'item',
739
+ level,
740
+ marker: number ? `${number}.` : BULLETS[level],
741
+ lines: [item[4]],
742
+ };
743
+ continue;
744
+ }
745
+ // A line under an item belongs to it; a line under nothing starts a paragraph.
746
+ if (prose) {
747
+ prose.lines.push(raw.trim());
748
+ }
749
+ else {
750
+ prose = { kind: 'paragraph', lines: [raw] };
751
+ }
376
752
  }
377
- flush();
753
+ settle();
754
+ closeFence();
378
755
  return out;
379
756
  }
757
+ // ---------------------------------------------------------------------------
758
+ // Back to text
759
+ // ---------------------------------------------------------------------------
760
+ /** What a run of spans measures, which is the text and never the styling. */
761
+ const lengthOf = (spans) => spans.reduce((n, s) => n + s.text.length, 0);
762
+ /** A cell in its column, set where the rule row asked for it. */
763
+ function padOf(spans, width, align) {
764
+ const slack = Math.max(0, width - lengthOf(spans));
765
+ const lead = align === 'right' ? slack : align === 'center' ? Math.floor(slack / 2) : 0;
766
+ return [
767
+ ...(lead > 0 ? [{ text: ' '.repeat(lead) }] : []),
768
+ ...spans,
769
+ ...(slack > lead ? [{ text: ' '.repeat(slack - lead) }] : []),
770
+ ];
771
+ }
772
+ /**
773
+ * A table as its rows, each one a single run of spans.
774
+ *
775
+ * Columns are laid out here and nowhere else, so the drawn table and the plain
776
+ * one are the same table: a cell's width is the length of its text, which is
777
+ * what a `Span` measures whatever styling rides beside it. Nothing a model
778
+ * writes in a cell can move the cell after it.
779
+ */
780
+ export function gridOf(block) {
781
+ const cells = block.cells ?? [];
782
+ const count = Math.max(0, ...cells.map((row) => row.length));
783
+ if (count === 0) {
784
+ return [];
785
+ }
786
+ const widths = Array.from({ length: count }, (_, i) => Math.max(3, ...cells.map((row) => lengthOf(row[i] ?? []))));
787
+ const rows = cells.map((row) => {
788
+ const out = [];
789
+ widths.forEach((width, i) => {
790
+ out.push({ text: i === 0 ? '| ' : ' | ' });
791
+ out.push(...padOf(row[i] ?? [], width, block.align?.[i] ?? 'left'));
792
+ });
793
+ out.push({ text: ' |' });
794
+ return out;
795
+ });
796
+ if (block.align && rows.length > 0) {
797
+ rows.splice(1, 0, [{ text: `|${widths.map((w) => '-'.repeat(w + 2)).join('|')}|` }]);
798
+ }
799
+ return rows;
800
+ }
801
+ /**
802
+ * A block as the row or rows it reads as, with its markers resolved rather
803
+ * than shown, and nothing styled.
804
+ *
805
+ * This is what a sink that cannot style gets — the pre-answer stream, and the
806
+ * plain box `zen meta` draws — so that the text does not visibly change shape
807
+ * at the moment it settles.
808
+ */
809
+ export function textOf(block, width = 0) {
810
+ if (block.kind === 'code') {
811
+ // The same chrome the settled answer draws, so a fence that is still
812
+ // arriving is already the shape it will end up as.
813
+ return [
814
+ `\u250c\u2500${block.title ? ` ${block.title}` : ''}`,
815
+ ...(block.lines ?? []).map((l) => `\u2502 ${l}`),
816
+ '\u2514\u2500',
817
+ ].join('\n');
818
+ }
819
+ if (block.lines) {
820
+ return block.lines.join('\n');
821
+ }
822
+ if (block.kind === 'table') {
823
+ return gridOf(block)
824
+ .map((row) => row.map((s) => s.text).join(''))
825
+ .join('\n');
826
+ }
827
+ if (block.kind === 'rule') {
828
+ return '\u2500'.repeat(Math.max(3, width));
829
+ }
830
+ const body = (block.spans ?? []).map((s) => s.text).join('');
831
+ if (block.kind === 'item') {
832
+ return `${' '.repeat(block.level ?? 0)}${block.marker ?? BULLETS[0]} ${body}`;
833
+ }
834
+ if (block.kind === 'quote') {
835
+ return `\u2502 ${body}`;
836
+ }
837
+ return body;
838
+ }
839
+ /** A whole answer with its markup resolved instead of shown. Still plain text. */
840
+ export function unmarked(text, width = 0) {
841
+ const rows = [];
842
+ let previous;
843
+ for (const block of blocksOf(text)) {
844
+ // Items of one list are one thing; a blank row between them is two lists.
845
+ if (previous && !(block.kind === 'item' && previous.kind === 'item')) {
846
+ rows.push('');
847
+ }
848
+ rows.push(textOf(block, width));
849
+ previous = block;
850
+ }
851
+ return rows.join('\n');
852
+ }
380
853
  //# sourceMappingURL=wrap.js.map