@zenera/cli 1.1.18 → 1.1.20

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
@@ -82,13 +82,21 @@ export function wrap(text, width) {
82
82
  // ---------------------------------------------------------------------------
83
83
  /** The three footer rows, its margin, the prompt, and a row in hand. */
84
84
  export const CHROME_ROWS = 7;
85
- /** How much of the reasoning stream is worth showing. It is a progress bar. */
86
- export const THINKING_ROWS = 6;
85
+ /**
86
+ * How much of the reasoning stream is worth showing.
87
+ *
88
+ * One row. A reasoning summary is not a document to be read while it arrives —
89
+ * it is the model saying what it is about to do, and the sentence that says so
90
+ * is the heading it writes for itself. Six rows of it pushed the answer and the
91
+ * work down the screen to make room for a paragraph nobody finishes reading,
92
+ * and the whole chain is in the trajectory either way.
93
+ */
94
+ export const THINKING_ROWS = 1;
87
95
  /** How much of the frame the work in flight may take. A branch is a box now,
88
- * not a row, so a fan-out of two costs fourteen of these. */
89
- export const ACTIVITY_ROWS = 16;
90
- /** The rule that opens a reasoning block and the one that closes it. */
91
- export const THINKING_CHROME = 2;
96
+ * not a row, so a fan-out of two costs twenty-two of these. */
97
+ export const ACTIVITY_ROWS = 24;
98
+ /** The width of the elapsed-time column every call row is drawn behind. */
99
+ export const TIME_COL = 5;
92
100
  /**
93
101
  * How many rows each repainting block may draw in a terminal `rows` tall.
94
102
  *
@@ -96,27 +104,28 @@ export const THINKING_CHROME = 2;
96
104
  * arrives matters more than the machinery producing it. Reasoning yields to it
97
105
  * in turn, because it is a progress indicator and the answer is the point.
98
106
  *
99
- * `thinking` is how many rows of reasoning are wanted, counting text only: none
100
- * when there is no reasoning, fewer than `THINKING_ROWS` once the stream has
101
- * settled and its tail is history rather than progress. The two rules are
102
- * charged on top, so a block too cramped to be worth boxing is not drawn at all
103
- * rather than drawn as a border with a line in it.
107
+ * `thinking` is how many rows of reasoning are wanted — one while the model is
108
+ * reasoning, none otherwise — and it never takes the answer's last row.
104
109
  */
105
110
  export function budgetOf(rows, activity, thinking) {
106
111
  const total = Math.max(2, rows - CHROME_ROWS);
107
112
  const shown = Math.min(Math.max(0, activity), ACTIVITY_ROWS, Math.max(0, total - 2));
108
113
  const rest = total - shown;
109
- const room = rest - 1 - THINKING_CHROME;
110
- const tail = room >= 1 ? Math.min(Math.max(0, thinking), THINKING_ROWS, room) : 0;
111
- return {
112
- activity: shown,
113
- thinking: tail,
114
- live: rest - tail - (tail ? THINKING_CHROME : 0),
115
- total,
116
- };
114
+ const tail = Math.min(Math.max(0, thinking), THINKING_ROWS, Math.max(0, rest - 1));
115
+ return { activity: shown, thinking: tail, live: rest - tail, total };
117
116
  }
118
- /** The rows a branch box may spend on its own calls and its reasoning. */
119
- 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;
120
129
  /** Rows a branch box spends on chrome: the title rule and the closing one. */
121
130
  export const BOX_CHROME = 2;
122
131
  /**
@@ -124,26 +133,51 @@ export const BOX_CHROME = 2;
124
133
  * activity region has to divide between them.
125
134
  *
126
135
  * Every branch gets the same number, because they are the same kind of thing
127
- * and a fan-out is read across, not down. A wide fork spends its rows on being
128
- * complete rather than on being detailed: eight branches showing one call each
129
- * is a picture of the fork, eight rows of one branch is not.
136
+ * and a fan-out is read across, not down.
130
137
  */
131
138
  export function branchRows(count, allowance) {
132
139
  if (count <= 0) {
133
140
  return 0;
134
141
  }
135
142
  const each = Math.floor(Math.max(0, allowance) / count) - BOX_CHROME;
136
- return Math.max(1, Math.min(BRANCH_ROWS, each));
143
+ return Math.max(BRANCH_MIN, Math.min(BRANCH_ROWS, each));
137
144
  }
138
145
  /**
139
- * How wide the answer is drawn. A line of prose spanning a 200-column terminal
140
- * 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
141
148
  * `examples/sdk/` puts its answer in a box of bounded width — the terminal is the
142
149
  * page, not the paragraph.
150
+ *
151
+ * `boxWidth` is what a whole answer is drawn at; this is the floor it starts from.
143
152
  */
144
153
  export function answerWidth(columns) {
145
154
  return Math.max(24, Math.min(columns - 4, 96));
146
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
+ }
147
181
  // ---------------------------------------------------------------------------
148
182
  // Reading a payload
149
183
  // ---------------------------------------------------------------------------
@@ -176,32 +210,644 @@ function unquote(raw) {
176
210
  const body = raw.length > 1 && raw.endsWith('"') ? raw.slice(1, -1) : raw.slice(1);
177
211
  return body.replace(/\\[nrt]/g, ' ').replace(/\\(["\\/])/g, '$1');
178
212
  }
213
+ /** Every field of a preview, first mention winning. */
214
+ export function fieldsOf(preview) {
215
+ const out = new Map();
216
+ for (const m of preview.matchAll(FIELD)) {
217
+ const key = m[1];
218
+ if (!out.has(key)) {
219
+ out.set(key, unquote(m[2]));
220
+ }
221
+ }
222
+ return out;
223
+ }
224
+ /** The text between `"key":[` and the first `]`, or nothing if there is no such array. */
225
+ function arrayBody(preview, key) {
226
+ const at = preview.indexOf(`"${key}"`);
227
+ if (at < 0) {
228
+ return undefined;
229
+ }
230
+ const open = preview.indexOf('[', at);
231
+ if (open < 0) {
232
+ return undefined;
233
+ }
234
+ const close = preview.indexOf(']', open);
235
+ return preview.slice(open + 1, close < 0 ? undefined : close);
236
+ }
237
+ /**
238
+ * An array argument, as the two things worth knowing about it: what is in it,
239
+ * when its items say their own names, and how many there are either way.
240
+ */
241
+ function itemsOf(preview, key) {
242
+ const body = arrayBody(preview, key);
243
+ if (body === undefined || !body.trim()) {
244
+ return { names: [], total: 0 };
245
+ }
246
+ if (/^\s*\{/.test(body)) {
247
+ const names = [...body.matchAll(/"name"\s*:\s*"([^"]*)"/g)].map((m) => m[1]);
248
+ return { names, total: Math.max(names.length, (body.match(/\{/g) ?? []).length) };
249
+ }
250
+ const names = [...body.matchAll(/"([^"]*)"/g)].map((m) => m[1]).filter(Boolean);
251
+ return { names, total: names.length };
252
+ }
253
+ /** A few things are worth naming; more than a few are worth counting. */
254
+ function named(items, unit) {
255
+ if (!items.total) {
256
+ return '';
257
+ }
258
+ if (items.names.length && items.names.length <= 2) {
259
+ return items.names.join(', ');
260
+ }
261
+ return `${items.total} ${items.total === 1 ? unit : `${unit}s`}`;
262
+ }
263
+ const CALLS = {
264
+ read_file: { verb: 'read', of: ['path'] },
265
+ list_dir: { verb: 'list', of: ['path'] },
266
+ find_files: { verb: 'find', of: ['pattern'] },
267
+ write_file: { verb: 'write', of: ['path'] },
268
+ apply_patch: { verb: 'patch' },
269
+ move_file: { verb: 'move', pair: ['from', 'to'] },
270
+ delete_file: { verb: 'delete', of: ['path'] },
271
+ run_command: { verb: 'run', of: ['command'] },
272
+ run_command_background: { verb: 'start', of: ['command'] },
273
+ read_command_output: { verb: 'output', of: ['job_id'] },
274
+ stop_command: { verb: 'stop', of: ['job_id'] },
275
+ memory_search: { verb: 'recall', of: ['query'] },
276
+ memory_load: { verb: 'read memory', count: ['ids', 'node'] },
277
+ memory_commit: { verb: 'remember', count: ['nodes', 'node'] },
278
+ memory_forget: { verb: 'forget', count: ['ids', 'node'] },
279
+ skill_search: { verb: 'find skill', of: ['query'] },
280
+ skill_load: { verb: 'load skill', count: ['names', 'skill'] },
281
+ fork: { verb: 'fork', count: ['branches', 'branch'] },
282
+ final_output: { verb: 'answer' },
283
+ search_api: { verb: 'search api', of: ['query'] },
284
+ list_api: { verb: 'list api', of: ['name', 'kind'] },
285
+ grep_api: { verb: 'grep api', of: ['pattern'] },
286
+ trace_api: { verb: 'trace api', of: ['pattern'] },
287
+ };
288
+ /** The handoff tools are minted per agent, so they are matched by prefix. */
289
+ const TRANSFER = 'transfer_to_';
290
+ /** A patch names its own files, and that is the only part of it worth a row. */
291
+ const PATCHED = /\*\*\* (?:Add|Update|Delete) File:\s*(.*?)(?:\\[nr]|"|$)/g;
292
+ export function describeCall(name, preview) {
293
+ if (name.startsWith(TRANSFER)) {
294
+ return { verb: 'hand off to', subject: name.slice(TRANSFER.length) };
295
+ }
296
+ const shape = CALLS[name];
297
+ if (!shape) {
298
+ return { verb: name, subject: readable(preview) };
299
+ }
300
+ if (name === 'apply_patch') {
301
+ const files = [...preview.matchAll(PATCHED)].map((m) => m[1].trim());
302
+ return { verb: shape.verb, subject: named({ names: files, total: files.length }, 'file') };
303
+ }
304
+ if (shape.pair) {
305
+ const fields = fieldsOf(preview);
306
+ const [from, to] = shape.pair;
307
+ return { verb: shape.verb, subject: `${fields.get(from) ?? ''} → ${fields.get(to) ?? ''}` };
308
+ }
309
+ if (shape.count) {
310
+ const [key, unit] = shape.count;
311
+ return { verb: shape.verb, subject: named(itemsOf(preview, key), unit) };
312
+ }
313
+ const fields = fieldsOf(preview);
314
+ for (const key of shape.of ?? []) {
315
+ const value = fields.get(key);
316
+ if (value) {
317
+ return { verb: shape.verb, subject: value };
318
+ }
319
+ }
320
+ return { verb: shape.verb, subject: '' };
321
+ }
322
+ /**
323
+ * What a finished call has to say for itself, in the few words a row has left.
324
+ *
325
+ * A `[]` suffix means "count it": the interesting thing about a listing is how
326
+ * much came back, and printing the first two of forty entries says less than
327
+ * the number does.
328
+ */
329
+ const RESULTS = {
330
+ read_file: ['lines'],
331
+ list_dir: ['entries[]'],
332
+ find_files: ['matches[]'],
333
+ write_file: ['bytes'],
334
+ apply_patch: ['files[]'],
335
+ move_file: ['to'],
336
+ delete_file: ['path'],
337
+ run_command: ['exit_code'],
338
+ run_command_background: ['job_id'],
339
+ };
340
+ export function summarise(name, preview) {
341
+ const fields = fieldsOf(preview);
342
+ // An error is the whole of what happened, and it is what the next turn
343
+ // will be about. Nothing else on the row competes with it.
344
+ const failed = fields.get('error');
345
+ if (failed) {
346
+ return failed;
347
+ }
348
+ const parts = [];
349
+ for (const key of RESULTS[name] ?? []) {
350
+ if (key.endsWith('[]')) {
351
+ const of = key.slice(0, -2);
352
+ const { total } = itemsOf(preview, of);
353
+ if (total) {
354
+ parts.push(`${total} ${of}`);
355
+ }
356
+ continue;
357
+ }
358
+ const value = fields.get(key);
359
+ if (value !== undefined && value !== '') {
360
+ parts.push(`${key.replace(/_/g, ' ')} ${value}`);
361
+ }
362
+ }
363
+ return parts.length ? parts.join(' · ') : readable(preview);
364
+ }
365
+ // ---------------------------------------------------------------------------
366
+ // The gist of a reasoning stream
367
+ // ---------------------------------------------------------------------------
368
+ /** How much of a stream is looked at for a heading. Its end is where it got to. */
369
+ const GIST_TAIL = 4000;
370
+ /** A reasoning summary writes its own headings, in bold. */
371
+ const BOLD = /\*\*([^*\n]{2,}?)\*\*/g;
372
+ /**
373
+ * One line saying where the model has got to.
374
+ *
375
+ * Reasoning summaries arrive as markdown and are structured by their own bold
376
+ * headings — Gemini emits one every few sentences, and they are the shortest
377
+ * true account of the step. The last one is where the model is now. Failing
378
+ * that the raw text is clipped, which is a fragment, but a moving fragment is
379
+ * still proof that something is happening.
380
+ */
381
+ export function gistOf(text, width) {
382
+ const tail = text.slice(-GIST_TAIL);
383
+ const heads = [...tail.matchAll(BOLD)];
384
+ const last = heads.at(-1)?.[1];
385
+ return clip(last ?? tail, width);
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;
398
+ /**
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.
406
+ */
407
+ export function inlineOf(text, style = {}, depth = 0) {
408
+ const out = [];
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;
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
+ }
179
495
  /**
180
- * Splits an answer on ``` fences. Prose is left exactly as it was — the common
181
- * answer has no fence in it and comes back in one piece — but a fenced block is
182
- * the one thing a terminal must not reflow: its indentation is its meaning, and
183
- * wrapping it as prose destroys it.
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.
184
602
  */
185
- export function segmentsOf(text) {
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) {
186
640
  const out = [];
187
- let current = { code: false, lines: [] };
188
- const flush = () => {
189
- if (current.lines.length) {
190
- out.push(current);
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;
191
669
  }
192
670
  };
671
+ const settle = () => {
672
+ closeProse();
673
+ closeTable();
674
+ };
193
675
  for (const raw of text.split('\n')) {
194
- const fence = /^\s*```+\s*(\S*)/.exec(raw);
195
- if (!fence) {
196
- 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
+ };
694
+ continue;
695
+ }
696
+ // A blank line ends whatever was being gathered and is not itself a block.
697
+ if (!raw.trim()) {
698
+ settle();
197
699
  continue;
198
700
  }
199
- flush();
200
- current = current.code
201
- ? { code: false, lines: [] }
202
- : { code: true, title: fence[1] || undefined, lines: [] };
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
+ }
203
752
  }
204
- flush();
753
+ settle();
754
+ closeFence();
205
755
  return out;
206
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
+ }
207
853
  //# sourceMappingURL=wrap.js.map