@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/README.md +6 -0
- package/dist/banner.d.ts +8 -0
- package/dist/banner.js +10 -4
- package/dist/commands/cache.js +1 -0
- package/dist/commands/check.js +34 -2
- package/dist/commands/index.js +1 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/meta.js +1 -1
- package/dist/commands/open.js +1 -1
- package/dist/main.js +8 -1
- package/dist/meta.js +19 -7
- package/dist/scaffold.d.ts +37 -0
- package/dist/scaffold.js +67 -3
- package/dist/term.d.ts +16 -0
- package/dist/term.js +91 -1
- package/dist/tui/app.js +493 -186
- package/dist/tui/theme.d.ts +36 -7
- package/dist/tui/theme.js +66 -36
- package/dist/tui/wrap.d.ts +140 -33
- package/dist/tui/wrap.js +690 -44
- package/dist/validate.js +113 -20
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +35 -10
- package/templates/editor/.github/skills/zen-cli/references/check.md +0 -2
- package/templates/editor/.github/skills/zen-cli/references/projects.md +20 -8
- package/templates/editor/.github/skills/zen-memory/SKILL.md +77 -33
- package/templates/editor/.github/skills/zen-memory/scripts/check-instructions.sh +2 -1
- package/templates/editor/.vscode/agents.schema.json +611 -0
- package/templates/editor/.vscode/extensions.json +3 -0
- package/templates/editor/.vscode/settings.json +8 -0
- package/templates/project/agents/fork-instructions.md +151 -0
- package/templates/project/agents/memory-instructions.md +120 -8
- package/templates/project/agents/tools-instructions.md +8 -0
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
|
-
/**
|
|
86
|
-
|
|
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
|
|
89
|
-
export const ACTIVITY_ROWS =
|
|
90
|
-
/** The
|
|
91
|
-
export const
|
|
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
|
|
100
|
-
*
|
|
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
|
|
110
|
-
|
|
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
|
|
119
|
-
export const BRANCH_ROWS =
|
|
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.
|
|
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(
|
|
143
|
+
return Math.max(BRANCH_MIN, Math.min(BRANCH_ROWS, each));
|
|
137
144
|
}
|
|
138
145
|
/**
|
|
139
|
-
* How wide
|
|
140
|
-
*
|
|
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
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
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 `` 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
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|