ucode-agent 1.27.0 → 1.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/theme.js CHANGED
@@ -1,512 +1,586 @@
1
- /**
2
- * theme.js — colour, boxes, and the string maths that keeps a terminal frame
3
- * from tearing.
4
- *
5
- * Everything visual comes from here so the whole interface can be re-tinted by
6
- * editing one block. ucode is blue: a single hue, three steps of it, and
7
- * nothing else decorative. Red, amber and green are reserved — they mean
8
- * failed, careful, and done, and they never appear for any other reason.
9
- */
10
-
11
- import chalk from 'chalk';
12
-
13
- // One hue, three weights. Anything that needs a fourth is asking for emphasis
14
- // it has not earned.
15
- export const blue = chalk.hex('#4d8dff'); // structure: borders, the caret, the wordmark
16
- export const sky = chalk.hex('#8fbcff'); // secondary: labels that still matter
17
- export const deep = chalk.hex('#2f6fe0'); // pressed, quiet, behind
18
- export const dim = chalk.dim;
19
-
20
- /**
21
- * The input box's own edge: the same blue, drawn bold.
22
- *
23
- * The input is the one thing on screen you act on, so it is the one box that
24
- * gets the heavier line. Bold box-drawing renders brighter, and in most
25
- * terminal fonts visibly thicker, which is enough to separate "where you type"
26
- * from "what you are reading" without a second colour.
27
- */
28
- export const edge = chalk.hex('#4d8dff').bold;
29
-
30
- /**
31
- * The colour level to use for a stream, or null to leave chalk's guess alone.
32
- *
33
- * chalk decides from the environment, and some environments lie: TERM=dumb
34
- * from an embedding shell, or a wrapper that strips COLORTERM. The result is a
35
- * UI with every colour silently gone — a grey box where a blue one was drawn.
36
- *
37
- * The full-screen interface already depends on a terminal that understands VT
38
- * sequences — it switches to the alternate screen and moves the cursor — and
39
- * any terminal that handles those handles colour. So when that interface is
40
- * running, the guess is overruled. NO_COLOR is still honoured, because that
41
- * one is a person's explicit choice rather than an environment's accident.
42
- */
43
- export function colourLevel(stream, env = process.env, current = chalk.level) {
44
- if ('NO_COLOR' in env) return null;
45
- if (!stream?.isTTY) return null;
46
- if (current >= 2) return null;
47
- return env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' || process.platform === 'win32' ? 3 : 2;
48
- }
49
-
50
- export function ensureColour(stream) {
51
- const level = colourLevel(stream);
52
- if (level !== null) chalk.level = level;
53
- }
54
-
55
- export const theme = {
56
- blue,
57
- sky,
58
- deep,
59
- dim,
60
- text: chalk.white,
61
- error: chalk.red,
62
- warn: chalk.hex('#e0a030'),
63
- ok: chalk.hex('#3fb950'),
64
- };
65
-
66
- /** Tints for a diff: enough colour to scan, dim enough to read code through. */
67
- export const ADDED = chalk.bgHex('#0e2a1a').hex('#7ee2a8');
68
- export const REMOVED = chalk.bgHex('#331319').hex('#f2939c');
69
-
70
- export const BANNER = [
71
- '██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗',
72
- '██║ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝',
73
- '██║ ██║██║ ██║ ██║██║ ██║█████╗ ',
74
- '██║ ██║██║ ██║ ██║██║ ██║██╔══╝ ',
75
- '╚██████╔╝╚██████╗╚██████╔╝██████╔╝███████╗',
76
- ' ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝',
77
- ];
78
-
79
- export const BANNER_WIDTH = Math.max(...BANNER.map((r) => r.length));
80
-
81
- /** The spinner. Braille dots, because they animate in place without jitter. */
82
- export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
83
-
84
- // ---------------------------------------------------------------------------
85
- // Boxes
86
- // ---------------------------------------------------------------------------
87
-
88
- export const BOX = {
89
- topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯',
90
- h: '─', v: '│',
91
- };
92
-
93
- export const boxTop = (width, paint = blue) =>
94
- paint(BOX.topLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.topRight);
95
-
96
- export const boxBottom = (width, paint = blue) =>
97
- paint(BOX.bottomLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.bottomRight);
98
-
99
- /** One row inside a box, padded so the right border lands in the same column. */
100
- export const boxRow = (content, width, paint = blue) =>
101
- paint(BOX.v) + padVis(content, Math.max(0, width - 2)) + paint(BOX.v);
102
-
103
- // ---------------------------------------------------------------------------
104
- // Widths, with escape codes discounted
105
- // ---------------------------------------------------------------------------
106
-
107
- /** The string with its colour codes stripped — what the terminal actually shows. */
108
- export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
109
- export const visLen = (s) => bare(s).length;
110
-
111
- /** The first `width` visible characters, with escape sequences left intact. */
112
- export function sliceVis(s, width) {
113
- let out = '';
114
- let seen = 0;
115
- for (let i = 0; i < s.length; i++) {
116
- if (s[i] === '\x1b') {
117
- const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
118
- if (m) { out += m[0]; i += m[0].length - 1; continue; }
119
- }
120
- if (seen >= width) break;
121
- out += s[i];
122
- seen++;
123
- }
124
- return out;
125
- }
126
-
127
- /** Pad or hard-cut a possibly-coloured string to an exact visible width. */
128
- export function padVis(s, width) {
129
- const len = visLen(s);
130
- if (len === width) return s;
131
- if (len < width) return s + ' '.repeat(width - len);
132
- return `${sliceVis(s, width)}\x1b[0m`;
133
- }
134
-
135
- export function clip(text, max) {
136
- const s = String(text ?? '');
137
- if (max <= 1) return '';
138
- return s.length > max ? `${s.slice(0, max - 1)}…` : s;
139
- }
140
-
141
- /**
142
- * Word-wrap text that may already be coloured.
143
- *
144
- * Escape sequences have no width, and whichever styles are open at a break get
145
- * reopened on the next line — otherwise a wrapped sentence loses its colour
146
- * halfway through.
147
- */
148
- export function wrapAnsi(text, width) {
149
- if (width < 4) return [text];
150
-
151
- const lines = [];
152
- let line = '';
153
- let seen = 0;
154
- let open = '';
155
- let lastSpace = -1;
156
- let lastSpaceSeen = 0;
157
-
158
- const flush = (upto = null) => {
159
- if (upto === null) {
160
- lines.push(line);
161
- line = open;
162
- seen = 0;
163
- } else {
164
- lines.push(line.slice(0, upto));
165
- const carry = line.slice(upto).replace(/^ +/, '');
166
- line = open + carry;
167
- seen = visLen(carry);
168
- }
169
- lastSpace = -1;
170
- };
171
-
172
- for (let i = 0; i < text.length; i++) {
173
- if (text[i] === '\x1b') {
174
- const m = /^\x1b\[[0-9;]*m/.exec(text.slice(i));
175
- if (m) {
176
- line += m[0];
177
- open = m[0] === '\x1b[0m' ? '' : open + m[0];
178
- i += m[0].length - 1;
179
- continue;
180
- }
181
- }
182
- if (text[i] === ' ') { lastSpace = line.length; lastSpaceSeen = seen; }
183
- line += text[i];
184
- seen++;
185
- if (seen >= width) {
186
- // Break at a word boundary unless that would leave a stub behind.
187
- if (lastSpace > 0 && lastSpaceSeen > width * 0.4) flush(lastSpace);
188
- else flush();
189
- }
190
- }
191
-
192
- if (visLen(line)) lines.push(line);
193
- return lines.length ? lines : [''];
194
- }
195
-
196
- // ---------------------------------------------------------------------------
197
- // Small formatters
198
- // ---------------------------------------------------------------------------
199
-
200
- export function formatTokens(n) {
201
- if (!n) return '0';
202
- if (n < 1000) return String(n);
203
- if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
204
- return `${(n / 1_000_000).toFixed(1)}M`;
205
- }
206
-
207
- export function today() {
208
- return new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
209
- }
210
-
211
- /** Shorten a path for display: home becomes ~, a long middle collapses. */
212
- export function shortenPath(p, max = 40) {
213
- let out = String(p);
214
- const home = process.env.USERPROFILE || process.env.HOME || '';
215
- if (home && out.startsWith(home)) out = `~${out.slice(home.length)}`;
216
- if (out.length <= max) return out;
217
-
218
- const parts = out.split(/[\\/]/);
219
- if (parts.length <= 3) return `…${out.slice(-(max - 1))}`;
220
- const sep = out.includes('\\') ? '\\' : '/';
221
- return `${parts[0]}${sep}…${sep}${parts.slice(-2).join(sep)}`;
222
- }
223
-
224
- export function relativeTime(iso) {
225
- if (!iso) return 'unknown';
226
- const then = new Date(iso).getTime();
227
- if (Number.isNaN(then)) return 'unknown';
228
-
229
- const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
230
- if (secs < 60) return 'just now';
231
- const mins = Math.round(secs / 60);
232
- if (mins < 60) return `${mins}m ago`;
233
- const hours = Math.round(mins / 60);
234
- if (hours < 24) return `${hours}h ago`;
235
- const days = Math.round(hours / 24);
236
- return days < 30 ? `${days}d ago` : new Date(iso).toISOString().slice(0, 10);
237
- }
238
-
239
- /**
240
- * Trim a trailing full stop off a live status line.
241
- *
242
- * "Listing src" is a label on work in progress. "Listing src." is a sentence,
243
- * and a sentence that ends while the thing it describes is still happening
244
- * reads as finished when it is not. Models add the full stop by habit; this
245
- * takes it back off.
246
- *
247
- * Only after a word, though. A dot that follows a space is the whole point of
248
- * the line — "Listing ." names the current directory — and trimming that turns
249
- * a label into a fragment.
250
- */
251
- export function asLabel(text) {
252
- return String(text ?? '')
253
- .trim()
254
- .replace(/\s+/g, ' ')
255
- // A trailing stop, from a model's sentence or a tool's own output
256
- // ("Building…", "Completing…"), is noise on a one-line label. A dot that
257
- // is the argument itself — "Listing ." — is not, so a word has to come
258
- // before it.
259
- .replace(/(?<=[\w)\]"'`])[.。…]+$/, '');
260
- }
261
-
262
- /**
263
- * The model's checklist, as one short line — done ticked, the current item
264
- * marked, the rest dim — so progress is visible without taking over the screen.
265
- */
266
- /**
267
- * The plan, as a block rather than a sentence.
268
- *
269
- * Six steps joined with separators made one line far wider than any terminal,
270
- * so it wrapped — and a wrapped checklist has its ticks in the middle of the
271
- * text, which is unreadable. Down the page each step keeps its own row, its
272
- * mark stays in the left column, and the eye can find the one in progress
273
- * without reading any of the others.
274
- *
275
- * Returns the rows; the caller pushes them.
276
- */
277
- export function planRows(items) {
278
- const list = (Array.isArray(items) ? items : []).slice(0, 8);
279
- if (!list.length) return [];
280
- const done = list.filter((i) => i?.done).length;
281
- const current = list.findIndex((i) => !i?.done);
282
-
283
- const rows = [` ${sky(`plan ${done}/${list.length}`)}`];
284
- list.forEach((item, i) => {
285
- const text = clip(String(item?.text ?? '').trim(), 64);
286
- if (item?.done) rows.push(` ${theme.ok('✓')} ${dim(text)}`);
287
- else if (i === current) rows.push(` ${blue('▸')} ${chalk.white(text)}`);
288
- else rows.push(` ${dim('○')} ${dim(text)}`);
289
- });
290
- return rows;
291
- }
292
-
293
- /** Kept for the plain interface, which has one line to work with. */
294
- export function planLine(items) {
295
- const list = (Array.isArray(items) ? items : []).slice(0, 6);
296
- if (!list.length) return '';
297
- const done = list.filter((i) => i?.done).length;
298
- const current = list.findIndex((i) => !i?.done);
299
- const now = current === -1 ? 'done' : clip(String(list[current]?.text ?? '').trim(), 40);
300
- return ` ${sky(`plan ${done}/${list.length}`)} ${chalk.white(now)}`;
301
- }
302
-
303
- /**
304
- * Narration: what the agent is doing, as opposed to what it has to say.
305
- *
306
- * These lines are scaffolding — "Reading screen.js", "Checking types". They
307
- * are worth seeing and not worth reading, and at full strength they compete
308
- * with the answer, which is the thing the user is actually here for. A
309
- * terminal has no smaller size to set, so the only axis available is weight:
310
- * faint, and a step down in colour. The answer stays at full strength and
311
- * wins the page by contrast rather than by shouting.
312
- */
313
- export const narration = (text) => chalk.dim(text);
314
-
315
- /** The bullet beside a narration line: present, not loud. */
316
- export const narrationMark = () => chalk.dim(deep('●'));
317
-
318
- /**
319
- * How a run of the same kind of step reads once it is over.
320
- *
321
- * While it happens, "Running npm test" is the useful thing to show. Once
322
- * three of them have happened, three near-identical lines are just noise
323
- * between the reader and the answer, so they fold into one: "Ran 3 commands".
324
- * The present tense belongs to the thing happening now; the past tense to the
325
- * summary of what did.
326
- */
327
- const GROUPS = {
328
- Running: ['Ran', 'command', 'commands'],
329
- Reading: ['Read', 'file', 'files'],
330
- Searching: ['Searched', 'time', 'times'],
331
- Finding: ['Found', 'pattern', 'patterns'],
332
- Listing: ['Listed', 'directory', 'directories'],
333
- Writing: ['Wrote', 'file', 'files'],
334
- Editing: ['Edited', 'file', 'files'],
335
- Checking: ['Checked', 'thing', 'things'],
336
- Looking: ['Looked up', 'name', 'names'],
337
- Asking: ['Asked about', 'name', 'names'],
338
- Mapping: ['Mapped', 'folder', 'folders'],
339
- Adding: ['Added', 'block', 'blocks'],
340
- Renaming: ['Renamed', 'name', 'names'],
341
- };
342
-
343
- /** The first word of a label, which is what decides whether two steps match. */
344
- export const groupKind = (label) => String(label ?? '').trim().split(/\s+/)[0] ?? '';
345
-
346
- /** One line standing in for `count` steps that all began with the same word. */
347
- export function groupLabel(label, count) {
348
- if (count <= 1) return String(label ?? '');
349
- const g = GROUPS[groupKind(label)];
350
- if (!g) return `${label} (+${count - 1} more)`;
351
- const [past, one, many] = g;
352
- return `${past} ${count} ${count === 1 ? one : many}`;
353
- }
354
-
355
- /** The part of a label after its opening word: the file or command it is about. */
356
- export const groupTarget = (label) => String(label ?? '').trim().split(/\s+/).slice(1).join(' ');
357
-
358
- /**
359
- * One narration line, standing for everything that happened under it.
360
- *
361
- * The transcript is a record of what was done, not a copy of what was
362
- * written. A 539-line file printed into it buries the answer and tells the
363
- * reader nothing they could not get from the file itself, so a change is its
364
- * two numbers. Several steps on one file stay one line naming that file;
365
- * several files become a count.
366
- */
367
- export function runLine({ label, count = 1, targets = [], added = 0, removed = 0 }) {
368
- const counts = added || removed
369
- ? ` ${chalk.hex('#3fb950')(`+${added}`)} ${chalk.hex('#f2939c')(`-${removed}`)}`
370
- : '';
371
- if (count <= 1) return `${label}${counts}`;
372
-
373
- const g = GROUPS[groupKind(label)];
374
- const unique = [...new Set(targets.filter(Boolean))];
375
- if (g && unique.length === 1) return `${g[0]} ${unique[0]}${counts}`;
376
- if (!g) return `${label} (+${count - 1} more)${counts}`;
377
- return `${g[0]} ${count} ${count === 1 ? g[1] : g[2]}${counts}`;
378
- }
379
-
380
- /**
381
- * The reply, with any pasted code taken out of it.
382
- *
383
- * The model is asked not to paste code into its answer, and mostly does not.
384
- * When it does, a fenced block of forty lines pushes the two sentences worth
385
- * reading off the screen — and the code is already in the file it just wrote.
386
- * A fence becomes a note of what it was, and the prose stays.
387
- *
388
- * A short block is left alone: three lines showing a command to run, or the
389
- * one line that changed, is the kind of thing worth having in the answer.
390
- */
391
- /**
392
- * A sentence that exists only to introduce what comes next.
393
- *
394
- * "Here's the complete app:" followed by forty lines of code, with the code
395
- * taken out, is a colon pointing at nothing — which reads as the reply having
396
- * been cut off mid-thought. The lead-in goes with what it was leading to.
397
- */
398
- const LEAD_IN = /(?:^|\n)[^\n]{0,80}:[ \t]*\n+$/;
399
-
400
- export function withoutCodeBlocks(text, keepLines = 4) {
401
- const FENCE = /```([A-Za-z0-9+-]*)\n([\s\S]*?)```/g;
402
- return String(text ?? '').replace(FENCE, (all, lang, body) => {
403
- const lines = body.replace(/\n+$/, '').split('\n');
404
- if (lines.length <= keepLines) return all;
405
- const what = lang ? `${lang} ` : '';
406
- return `_[${lines.length} lines of ${what}code — it is in the file, not worth repeating here]_`;
407
- });
408
- }
409
-
410
- /**
411
- * The reply as it should be read: no pasted code, and no sentence left
412
- * pointing at code that is no longer there.
413
- */
414
- /**
415
- * The reply as it should be read.
416
- *
417
- * A long pasted block goes, and so does the sentence that introduced it — a
418
- * colon pointing at nothing reads as the reply having been cut off. A short
419
- * block stays: three lines showing a command to run belong in an answer.
420
- */
421
- export function tidyReply(text, keepLines = 4) {
422
- const MARK = "\u0000CUT\u0000";
423
- const FENCE = new RegExp("```([A-Za-z0-9+-]*)\\n([\\s\\S]*?)```", "g");
424
-
425
- const marked = String(text ?? "").replace(FENCE, (all, lang, body) => {
426
- const rows = body.replace(new RegExp("\\n+$"), "").split("\n");
427
- return rows.length <= keepLines ? all : MARK;
428
- });
429
-
430
- const leadIn = new RegExp("(?:^|\\n)[^\\n]{0,80}:[ \t]*\\n+" + MARK, "g");
431
- return marked
432
- .replace(leadIn, "\n")
433
- .split(MARK).join("")
434
- .replace(new RegExp("\\n{3,}", "g"), "\n\n")
435
- .trim();
436
- }
437
-
438
- /**
439
- * The closing message, cut to what a terminal can take.
440
- *
441
- * A model that finishes a build by walking back through the request — every
442
- * feature ticked off, every file listed — leaves that as the last thing on
443
- * screen, and the whole session then reads like a status report. Eight lines
444
- * is the whole of it: what it is, and how to try it.
445
- *
446
- * What goes: an opening that reads the request back, and the middle of a list
447
- * too long to be worth reading. What stays: the first lines, the line that
448
- * admits something is unfinished, and the line naming a file or a command —
449
- * the two the user actually acts on, and both of them live at the end.
450
- */
451
- export const ANSWER_LINES = 8;
452
- const ANSWER_ROOM = 600; // eight wrapped lines of prose, for a reply with no line breaks in it
453
-
454
- const RESTATED = /^(?:you (?:asked|wanted|requested|said)\b|the (?:request|task|ask)\b|as (?:you )?requested\b|here(?:'s| is) what (?:you asked|i)\b|to (?:summarise|summarize|recap)\b|(?:request|task|summary|recap|overview)\s*:)/i;
455
- const CAVEAT = /\b(?:however|failed|couldn't|could not|cannot|can't|didn't|did not|isn't|is not|doesn't|does not|not (?:yet|wired|working|done|implemented)|missing|unfinished|except)\b/i;
456
- const ACTIONABLE = /\b(?:open|run|serve|visit|try|start|npm|npx|node|pnpm|yarn)\b|https?:\/\/|\.(?:html?|css|jsx?|tsx?|md|json|py|rs|go)\b/i;
457
- const BULLET = /^\s*(?:[-*•>]|\d+[.)]|[✓✔✅☑])\s+/;
458
-
459
- export function trimAnswer(text, max = ANSWER_LINES) {
460
- const all = String(text ?? '').replace(/\r/g, '').split('\n');
461
-
462
- let start = 0;
463
- while (start < all.length && (!all[start].trim() || RESTATED.test(all[start].trim()))) start++;
464
- const rows = all.slice(start);
465
-
466
- const body = rows.map((row, i) => ({ row, i })).filter((r) => r.row.trim());
467
- if (!body.length) return '';
468
-
469
- let kept;
470
- if (body.length <= max) {
471
- kept = body.map((r) => r.i);
472
- } else {
473
- // Searched from the end: the caveat and the how-to-try-it line are the
474
- // last things written, and they are the two worth pulling out of the part
475
- // being dropped.
476
- const tail = body.slice(Math.max(1, max - 2));
477
- const pick = (re) => [...tail].reverse().find((r) => re.test(r.row))?.i;
478
- const rescued = [...new Set([pick(CAVEAT), pick(ACTIONABLE)])].filter((i) => i !== undefined);
479
- const head = body.slice(0, max - rescued.length).map((r) => r.i);
480
- kept = [...new Set([...head, ...rescued])].sort((a, b) => a - b);
481
- }
482
-
483
- const out = [];
484
- let previous = -1;
485
- for (const i of kept) {
486
- if (previous >= 0 && i > previous + 1) out.push(''); // a gap in the middle is a paragraph break
487
- // A line lifted out of a list is no longer in one.
488
- out.push(previous >= 0 && i > previous + 1 ? rows[i].replace(BULLET, '') : rows[i]);
489
- previous = i;
490
- }
491
-
492
- return withinRoom(out.join('\n').replace(/\n{3,}/g, '\n\n').trim());
493
- }
494
-
495
- /**
496
- * One long paragraph is one line and fills the screen anyway. Whole sentences
497
- * only: a reply cut mid-clause reads as a crash rather than as an ending.
498
- */
499
- function withinRoom(text, room = ANSWER_ROOM) {
500
- if (text.length <= room) return text;
501
-
502
- const parts = text.split(/(?<=[.!?])(\s+)/);
503
- let out = '';
504
- let sentences = 0;
505
- for (let i = 0; i < parts.length; i += 2) {
506
- const next = out + parts[i] + (parts[i + 1] ?? '');
507
- if (sentences >= 2 && next.trimEnd().length > room) break;
508
- out = next;
509
- sentences++;
510
- }
511
- return (out.trim() || text.slice(0, room)).trim();
512
- }
1
+ /**
2
+ * theme.js — colour, boxes, and the string maths that keeps a terminal frame
3
+ * from tearing.
4
+ *
5
+ * Everything visual comes from here so the whole interface can be re-tinted by
6
+ * editing one block. ucode is blue: a single hue, three steps of it, and
7
+ * nothing else decorative. Red, amber and green are reserved — they mean
8
+ * failed, careful, and done, and they never appear for any other reason.
9
+ */
10
+
11
+ import chalk from 'chalk';
12
+
13
+ // One hue, three weights. Anything that needs a fourth is asking for emphasis
14
+ // it has not earned.
15
+ export const blue = chalk.hex('#4d8dff'); // structure: borders, the caret, the wordmark
16
+ export const sky = chalk.hex('#8fbcff'); // secondary: labels that still matter
17
+ export const deep = chalk.hex('#2f6fe0'); // pressed, quiet, behind
18
+ export const dim = chalk.dim;
19
+
20
+ /**
21
+ * The input box's own edge: the same blue, drawn bold.
22
+ *
23
+ * The input is the one thing on screen you act on, so it is the one box that
24
+ * gets the heavier line. Bold box-drawing renders brighter, and in most
25
+ * terminal fonts visibly thicker, which is enough to separate "where you type"
26
+ * from "what you are reading" without a second colour.
27
+ */
28
+ export const edge = chalk.hex('#4d8dff').bold;
29
+
30
+ /**
31
+ * The colour level to use for a stream, or null to leave chalk's guess alone.
32
+ *
33
+ * chalk decides from the environment, and some environments lie: TERM=dumb
34
+ * from an embedding shell, or a wrapper that strips COLORTERM. The result is a
35
+ * UI with every colour silently gone — a grey box where a blue one was drawn.
36
+ *
37
+ * The full-screen interface already depends on a terminal that understands VT
38
+ * sequences — it switches to the alternate screen and moves the cursor — and
39
+ * any terminal that handles those handles colour. So when that interface is
40
+ * running, the guess is overruled. NO_COLOR is still honoured, because that
41
+ * one is a person's explicit choice rather than an environment's accident.
42
+ */
43
+ export function colourLevel(stream, env = process.env, current = chalk.level) {
44
+ if ('NO_COLOR' in env) return null;
45
+ if (!stream?.isTTY) return null;
46
+ if (current >= 2) return null;
47
+ return env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' || process.platform === 'win32' ? 3 : 2;
48
+ }
49
+
50
+ export function ensureColour(stream) {
51
+ const level = colourLevel(stream);
52
+ if (level !== null) chalk.level = level;
53
+ }
54
+
55
+ export const theme = {
56
+ blue,
57
+ sky,
58
+ deep,
59
+ dim,
60
+ text: chalk.white,
61
+ error: chalk.red,
62
+ warn: chalk.hex('#e0a030'),
63
+ ok: chalk.hex('#3fb950'),
64
+ };
65
+
66
+ /** Tints for a diff: enough colour to scan, dim enough to read code through. */
67
+ export const ADDED = chalk.bgHex('#0e2a1a').hex('#7ee2a8');
68
+ export const REMOVED = chalk.bgHex('#331319').hex('#f2939c');
69
+
70
+ export const BANNER = [
71
+ '██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗',
72
+ '██║ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝',
73
+ '██║ ██║██║ ██║ ██║██║ ██║█████╗ ',
74
+ '██║ ██║██║ ██║ ██║██║ ██║██╔══╝ ',
75
+ '╚██████╔╝╚██████╗╚██████╔╝██████╔╝███████╗',
76
+ ' ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝',
77
+ ];
78
+
79
+ export const BANNER_WIDTH = Math.max(...BANNER.map((r) => r.length));
80
+
81
+ /**
82
+ * The wordmark, lit from the top.
83
+ *
84
+ * Six identical rows of one blue read as ASCII art that happened to be lying
85
+ * there. The same six stepped from sky down to deep read as a mark someone
86
+ * drew: the crown catches the light, and the two shadow rows settle back into
87
+ * the page. chalk downshifts the hex to whatever the terminal actually has, so
88
+ * on a 16-colour terminal this is flat blue again rather than nothing.
89
+ */
90
+ const GRADIENT_TOP = [0x8f, 0xbc, 0xff]; // sky, at the crown
91
+ const GRADIENT_BOTTOM = [0x2f, 0x6f, 0xe0]; // deep, in the shadow
92
+
93
+ export function bannerPaint(row, rows = BANNER.length) {
94
+ const t = rows > 1 ? Math.min(1, Math.max(0, row / (rows - 1))) : 0;
95
+ const hex = GRADIENT_TOP
96
+ .map((from, i) => Math.round(from + (GRADIENT_BOTTOM[i] - from) * t))
97
+ .map((v) => v.toString(16).padStart(2, '0'))
98
+ .join('');
99
+ return chalk.hex(`#${hex}`);
100
+ }
101
+
102
+ /**
103
+ * The rail beside something you said.
104
+ *
105
+ * A box around every user message draws two full-width rules per turn, and a
106
+ * long session becomes a ladder. A half-block in the left column is the same
107
+ * landmark — findable at a glance, scrollable to — for a fortieth of the ink.
108
+ */
109
+ export const RAIL = '▌';
110
+
111
+ /** The spinner. Braille dots, because they animate in place without jitter. */
112
+ export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Boxes
116
+ // ---------------------------------------------------------------------------
117
+
118
+ export const BOX = {
119
+ topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯',
120
+ h: '─', v: '│',
121
+ };
122
+
123
+ export const boxTop = (width, paint = blue) =>
124
+ paint(BOX.topLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.topRight);
125
+
126
+ export const boxBottom = (width, paint = blue) =>
127
+ paint(BOX.bottomLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.bottomRight);
128
+
129
+ /** One row inside a box, padded so the right border lands in the same column. */
130
+ export const boxRow = (content, width, paint = blue) =>
131
+ paint(BOX.v) + padVis(content, Math.max(0, width - 2)) + paint(BOX.v);
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Widths, with escape codes discounted
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /** The string with its colour codes stripped — what the terminal actually shows. */
138
+ export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
139
+ export const visLen = (s) => bare(s).length;
140
+
141
+ /** The first `width` visible characters, with escape sequences left intact. */
142
+ export function sliceVis(s, width) {
143
+ let out = '';
144
+ let seen = 0;
145
+ for (let i = 0; i < s.length; i++) {
146
+ if (s[i] === '\x1b') {
147
+ const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
148
+ if (m) { out += m[0]; i += m[0].length - 1; continue; }
149
+ }
150
+ if (seen >= width) break;
151
+ out += s[i];
152
+ seen++;
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /** Pad or hard-cut a possibly-coloured string to an exact visible width. */
158
+ export function padVis(s, width) {
159
+ const len = visLen(s);
160
+ if (len === width) return s;
161
+ if (len < width) return s + ' '.repeat(width - len);
162
+ return `${sliceVis(s, width)}\x1b[0m`;
163
+ }
164
+
165
+ export function clip(text, max) {
166
+ const s = String(text ?? '');
167
+ if (max <= 1) return '';
168
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
169
+ }
170
+
171
+ /**
172
+ * Word-wrap text that may already be coloured.
173
+ *
174
+ * Escape sequences have no width, and whichever styles are open at a break get
175
+ * reopened on the next line — otherwise a wrapped sentence loses its colour
176
+ * halfway through.
177
+ */
178
+ export function wrapAnsi(text, width) {
179
+ if (width < 4) return [text];
180
+
181
+ const lines = [];
182
+ let line = '';
183
+ let seen = 0;
184
+ let open = '';
185
+ let lastSpace = -1;
186
+ let lastSpaceSeen = 0;
187
+
188
+ const flush = (upto = null) => {
189
+ if (upto === null) {
190
+ lines.push(line);
191
+ line = open;
192
+ seen = 0;
193
+ } else {
194
+ lines.push(line.slice(0, upto));
195
+ const carry = line.slice(upto).replace(/^ +/, '');
196
+ line = open + carry;
197
+ seen = visLen(carry);
198
+ }
199
+ lastSpace = -1;
200
+ };
201
+
202
+ for (let i = 0; i < text.length; i++) {
203
+ if (text[i] === '\x1b') {
204
+ const m = /^\x1b\[[0-9;]*m/.exec(text.slice(i));
205
+ if (m) {
206
+ line += m[0];
207
+ open = m[0] === '\x1b[0m' ? '' : open + m[0];
208
+ i += m[0].length - 1;
209
+ continue;
210
+ }
211
+ }
212
+ if (text[i] === ' ') { lastSpace = line.length; lastSpaceSeen = seen; }
213
+ line += text[i];
214
+ seen++;
215
+ if (seen >= width) {
216
+ // Break at a word boundary unless that would leave a stub behind.
217
+ if (lastSpace > 0 && lastSpaceSeen > width * 0.4) flush(lastSpace);
218
+ else flush();
219
+ }
220
+ }
221
+
222
+ if (visLen(line)) lines.push(line);
223
+ return lines.length ? lines : [''];
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Small formatters
228
+ // ---------------------------------------------------------------------------
229
+
230
+ export function formatTokens(n) {
231
+ if (!n) return '0';
232
+ if (n < 1000) return String(n);
233
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
234
+ return `${(n / 1_000_000).toFixed(1)}M`;
235
+ }
236
+
237
+ export function today() {
238
+ return new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
239
+ }
240
+
241
+ /** Shorten a path for display: home becomes ~, a long middle collapses. */
242
+ export function shortenPath(p, max = 40) {
243
+ let out = String(p);
244
+ const home = process.env.USERPROFILE || process.env.HOME || '';
245
+ if (home && out.startsWith(home)) out = `~${out.slice(home.length)}`;
246
+ if (out.length <= max) return out;
247
+
248
+ const parts = out.split(/[\\/]/);
249
+ if (parts.length <= 3) return `…${out.slice(-(max - 1))}`;
250
+ const sep = out.includes('\\') ? '\\' : '/';
251
+ return `${parts[0]}${sep}…${sep}${parts.slice(-2).join(sep)}`;
252
+ }
253
+
254
+ export function relativeTime(iso) {
255
+ if (!iso) return 'unknown';
256
+ const then = new Date(iso).getTime();
257
+ if (Number.isNaN(then)) return 'unknown';
258
+
259
+ const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
260
+ if (secs < 60) return 'just now';
261
+ const mins = Math.round(secs / 60);
262
+ if (mins < 60) return `${mins}m ago`;
263
+ const hours = Math.round(mins / 60);
264
+ if (hours < 24) return `${hours}h ago`;
265
+ const days = Math.round(hours / 24);
266
+ return days < 30 ? `${days}d ago` : new Date(iso).toISOString().slice(0, 10);
267
+ }
268
+
269
+ /**
270
+ * Trim a trailing full stop off a live status line.
271
+ *
272
+ * "Listing src" is a label on work in progress. "Listing src." is a sentence,
273
+ * and a sentence that ends while the thing it describes is still happening
274
+ * reads as finished when it is not. Models add the full stop by habit; this
275
+ * takes it back off.
276
+ *
277
+ * Only after a word, though. A dot that follows a space is the whole point of
278
+ * the line — "Listing ." names the current directory — and trimming that turns
279
+ * a label into a fragment.
280
+ */
281
+ export function asLabel(text) {
282
+ return String(text ?? '')
283
+ .trim()
284
+ .replace(/\s+/g, ' ')
285
+ // A trailing stop, from a model's sentence or a tool's own output
286
+ // ("Building…", "Completing…"), is noise on a one-line label. A dot that
287
+ // is the argument itself — "Listing ." — is not, so a word has to come
288
+ // before it.
289
+ .replace(/(?<=[\w)\]"'`])[.。…]+$/, '');
290
+ }
291
+
292
+ /**
293
+ * The model's checklist, as one short line — done ticked, the current item
294
+ * marked, the rest dim — so progress is visible without taking over the screen.
295
+ */
296
+ /**
297
+ * The plan, as a block rather than a sentence.
298
+ *
299
+ * Six steps joined with separators made one line far wider than any terminal,
300
+ * so it wrapped — and a wrapped checklist has its ticks in the middle of the
301
+ * text, which is unreadable. Down the page each step keeps its own row, its
302
+ * mark stays in the left column, and the eye can find the one in progress
303
+ * without reading any of the others.
304
+ *
305
+ * Returns the rows; the caller pushes them.
306
+ */
307
+ /**
308
+ * How far along, as a bar rather than as arithmetic.
309
+ *
310
+ * "2/4" is a sum the reader has to do; a bar is the answer to it, read at a
311
+ * glance. Ten cells whatever the plan's length, so the row does not change
312
+ * width as steps are added and the eye keeps one edge to measure against.
313
+ *
314
+ * Heavy and light box-drawing, not block shading: those two are already the
315
+ * frame of every box on screen, so they are the two glyphs this app can be
316
+ * certain the terminal has and draws one cell wide.
317
+ */
318
+ export const BAR_CELLS = 10;
319
+
320
+ export function progressBar(done, total, cells = BAR_CELLS) {
321
+ const ratio = total > 0 ? Math.min(1, Math.max(0, done / total)) : 0;
322
+ const fill = Math.round(ratio * cells);
323
+ return blue('━'.repeat(fill)) + dim('─'.repeat(Math.max(0, cells - fill)));
324
+ }
325
+
326
+ export function planRows(items) {
327
+ const list = (Array.isArray(items) ? items : []).slice(0, 8);
328
+ if (!list.length) return [];
329
+ const done = list.filter((i) => i?.done).length;
330
+ const current = list.findIndex((i) => !i?.done);
331
+
332
+ const rows = [` ${progressBar(done, list.length)} ${sky(`${done}/${list.length}`)}`];
333
+ list.forEach((item, i) => {
334
+ const text = clip(String(item?.text ?? '').trim(), 64);
335
+ if (item?.done) rows.push(` ${theme.ok('✓')} ${dim(text)}`);
336
+ else if (i === current) rows.push(` ${blue('▸')} ${chalk.white(text)}`);
337
+ else rows.push(` ${dim('○')} ${dim(text)}`);
338
+ });
339
+ return rows;
340
+ }
341
+
342
+ /** Kept for the plain interface, which has one line to work with. */
343
+ export function planLine(items) {
344
+ const list = (Array.isArray(items) ? items : []).slice(0, 6);
345
+ if (!list.length) return '';
346
+ const done = list.filter((i) => i?.done).length;
347
+ const current = list.findIndex((i) => !i?.done);
348
+ const now = current === -1 ? 'done' : clip(String(list[current]?.text ?? '').trim(), 40);
349
+ return ` ${sky(`plan ${done}/${list.length}`)} ${chalk.white(now)}`;
350
+ }
351
+
352
+ /**
353
+ * Narration: what the agent is doing, as opposed to what it has to say.
354
+ *
355
+ * These lines are scaffolding — "Reading screen.js", "Checking types". They
356
+ * are worth seeing and not worth reading, and at full strength they compete
357
+ * with the answer, which is the thing the user is actually here for. A
358
+ * terminal has no smaller size to set, so the only axis available is weight:
359
+ * faint, and a step down in colour. The answer stays at full strength and
360
+ * wins the page by contrast rather than by shouting.
361
+ */
362
+ export const narration = (text) => chalk.dim(text);
363
+
364
+ /** The bullet beside a narration line: present, not loud. */
365
+ export const narrationMark = () => chalk.dim(deep('●'));
366
+
367
+ /**
368
+ * The file or command a step is about, lit so the line can be scanned.
369
+ *
370
+ * "Which file did it touch" is the one question a reader puts to a transcript
371
+ * of tool calls, and dimming the whole line made the answer as faint as the
372
+ * verb in front of it. The verb stays faint — there are only a dozen of them
373
+ * and they repeat — and the part that differs every time carries the colour.
374
+ */
375
+ const paintStep = (label) => {
376
+ const text = String(label ?? '');
377
+ const space = text.indexOf(' ');
378
+ if (space < 0) return narration(text);
379
+
380
+ const target = text.slice(space + 1);
381
+ // "Read 2 files" is a tally, not a path. Lighting it up would point the eye
382
+ // at a number that says nothing about where the work happened.
383
+ if (/^\d/.test(target)) return narration(text);
384
+
385
+ return `${narration(text.slice(0, space))} ${blue(target)}`;
386
+ };
387
+
388
+ /**
389
+ * How a run of the same kind of step reads once it is over.
390
+ *
391
+ * While it happens, "Running npm test" is the useful thing to show. Once
392
+ * three of them have happened, three near-identical lines are just noise
393
+ * between the reader and the answer, so they fold into one: "Ran 3 commands".
394
+ * The present tense belongs to the thing happening now; the past tense to the
395
+ * summary of what did.
396
+ */
397
+ const GROUPS = {
398
+ Running: ['Ran', 'command', 'commands'],
399
+ Reading: ['Read', 'file', 'files'],
400
+ Searching: ['Searched', 'time', 'times'],
401
+ Finding: ['Found', 'pattern', 'patterns'],
402
+ Listing: ['Listed', 'directory', 'directories'],
403
+ Writing: ['Wrote', 'file', 'files'],
404
+ Editing: ['Edited', 'file', 'files'],
405
+ Checking: ['Checked', 'thing', 'things'],
406
+ Looking: ['Looked up', 'name', 'names'],
407
+ Asking: ['Asked about', 'name', 'names'],
408
+ Mapping: ['Mapped', 'folder', 'folders'],
409
+ Adding: ['Added', 'block', 'blocks'],
410
+ Renaming: ['Renamed', 'name', 'names'],
411
+ };
412
+
413
+ /** The first word of a label, which is what decides whether two steps match. */
414
+ export const groupKind = (label) => String(label ?? '').trim().split(/\s+/)[0] ?? '';
415
+
416
+ /** One line standing in for `count` steps that all began with the same word. */
417
+ export function groupLabel(label, count) {
418
+ if (count <= 1) return String(label ?? '');
419
+ const g = GROUPS[groupKind(label)];
420
+ if (!g) return `${label} (+${count - 1} more)`;
421
+ const [past, one, many] = g;
422
+ return `${past} ${count} ${count === 1 ? one : many}`;
423
+ }
424
+
425
+ /** The part of a label after its opening word: the file or command it is about. */
426
+ export const groupTarget = (label) => String(label ?? '').trim().split(/\s+/).slice(1).join(' ');
427
+
428
+ /**
429
+ * One narration line, standing for everything that happened under it.
430
+ *
431
+ * The transcript is a record of what was done, not a copy of what was
432
+ * written. A 539-line file printed into it buries the answer and tells the
433
+ * reader nothing they could not get from the file itself, so a change is its
434
+ * two numbers. Several steps on one file stay one line naming that file;
435
+ * several files become a count.
436
+ *
437
+ * The line comes back painted, so the label handed in has to be through
438
+ * asLabel() already: run that over this and its regexes would be reading
439
+ * escape sequences instead of the last word.
440
+ */
441
+ export function runLine({ label, count = 1, targets = [], added = 0, removed = 0 }) {
442
+ const counts = added || removed
443
+ ? ` ${chalk.hex('#3fb950')(`+${added}`)} ${chalk.hex('#f2939c')(`-${removed}`)}`
444
+ : '';
445
+ if (count <= 1) return `${paintStep(label)}${counts}`;
446
+
447
+ const g = GROUPS[groupKind(label)];
448
+ const unique = [...new Set(targets.filter(Boolean))];
449
+ if (g && unique.length === 1) return `${paintStep(`${g[0]} ${unique[0]}`)}${counts}`;
450
+ if (!g) return `${paintStep(`${label} (+${count - 1} more)`)}${counts}`;
451
+ return `${paintStep(`${g[0]} ${count} ${count === 1 ? g[1] : g[2]}`)}${counts}`;
452
+ }
453
+
454
+ /**
455
+ * The reply, with any pasted code taken out of it.
456
+ *
457
+ * The model is asked not to paste code into its answer, and mostly does not.
458
+ * When it does, a fenced block of forty lines pushes the two sentences worth
459
+ * reading off the screen — and the code is already in the file it just wrote.
460
+ * A fence becomes a note of what it was, and the prose stays.
461
+ *
462
+ * A short block is left alone: three lines showing a command to run, or the
463
+ * one line that changed, is the kind of thing worth having in the answer.
464
+ */
465
+ /**
466
+ * A sentence that exists only to introduce what comes next.
467
+ *
468
+ * "Here's the complete app:" followed by forty lines of code, with the code
469
+ * taken out, is a colon pointing at nothing — which reads as the reply having
470
+ * been cut off mid-thought. The lead-in goes with what it was leading to.
471
+ */
472
+ const LEAD_IN = /(?:^|\n)[^\n]{0,80}:[ \t]*\n+$/;
473
+
474
+ export function withoutCodeBlocks(text, keepLines = 4) {
475
+ const FENCE = /```([A-Za-z0-9+-]*)\n([\s\S]*?)```/g;
476
+ return String(text ?? '').replace(FENCE, (all, lang, body) => {
477
+ const lines = body.replace(/\n+$/, '').split('\n');
478
+ if (lines.length <= keepLines) return all;
479
+ const what = lang ? `${lang} ` : '';
480
+ return `_[${lines.length} lines of ${what}code — it is in the file, not worth repeating here]_`;
481
+ });
482
+ }
483
+
484
+ /**
485
+ * The reply as it should be read: no pasted code, and no sentence left
486
+ * pointing at code that is no longer there.
487
+ */
488
+ /**
489
+ * The reply as it should be read.
490
+ *
491
+ * A long pasted block goes, and so does the sentence that introduced it — a
492
+ * colon pointing at nothing reads as the reply having been cut off. A short
493
+ * block stays: three lines showing a command to run belong in an answer.
494
+ */
495
+ export function tidyReply(text, keepLines = 4) {
496
+ const MARK = "\u0000CUT\u0000";
497
+ const FENCE = new RegExp("```([A-Za-z0-9+-]*)\\n([\\s\\S]*?)```", "g");
498
+
499
+ const marked = String(text ?? "").replace(FENCE, (all, lang, body) => {
500
+ const rows = body.replace(new RegExp("\\n+$"), "").split("\n");
501
+ return rows.length <= keepLines ? all : MARK;
502
+ });
503
+
504
+ const leadIn = new RegExp("(?:^|\\n)[^\\n]{0,80}:[ \t]*\\n+" + MARK, "g");
505
+ return marked
506
+ .replace(leadIn, "\n")
507
+ .split(MARK).join("")
508
+ .replace(new RegExp("\\n{3,}", "g"), "\n\n")
509
+ .trim();
510
+ }
511
+
512
+ /**
513
+ * The closing message, cut to what a terminal can take.
514
+ *
515
+ * A model that finishes a build by walking back through the request — every
516
+ * feature ticked off, every file listed — leaves that as the last thing on
517
+ * screen, and the whole session then reads like a status report. Eight lines
518
+ * is the whole of it: what it is, and how to try it.
519
+ *
520
+ * What goes: an opening that reads the request back, and the middle of a list
521
+ * too long to be worth reading. What stays: the first lines, the line that
522
+ * admits something is unfinished, and the line naming a file or a command —
523
+ * the two the user actually acts on, and both of them live at the end.
524
+ */
525
+ export const ANSWER_LINES = 8;
526
+ const ANSWER_ROOM = 600; // eight wrapped lines of prose, for a reply with no line breaks in it
527
+
528
+ const RESTATED = /^(?:you (?:asked|wanted|requested|said)\b|the (?:request|task|ask)\b|as (?:you )?requested\b|here(?:'s| is) what (?:you asked|i)\b|to (?:summarise|summarize|recap)\b|(?:request|task|summary|recap|overview)\s*:)/i;
529
+ const CAVEAT = /\b(?:however|failed|couldn't|could not|cannot|can't|didn't|did not|isn't|is not|doesn't|does not|not (?:yet|wired|working|done|implemented)|missing|unfinished|except)\b/i;
530
+ const ACTIONABLE = /\b(?:open|run|serve|visit|try|start|npm|npx|node|pnpm|yarn)\b|https?:\/\/|\.(?:html?|css|jsx?|tsx?|md|json|py|rs|go)\b/i;
531
+ const BULLET = /^\s*(?:[-*•>]|\d+[.)]|[✓✔✅☑])\s+/;
532
+
533
+ export function trimAnswer(text, max = ANSWER_LINES) {
534
+ const all = String(text ?? '').replace(/\r/g, '').split('\n');
535
+
536
+ let start = 0;
537
+ while (start < all.length && (!all[start].trim() || RESTATED.test(all[start].trim()))) start++;
538
+ const rows = all.slice(start);
539
+
540
+ const body = rows.map((row, i) => ({ row, i })).filter((r) => r.row.trim());
541
+ if (!body.length) return '';
542
+
543
+ let kept;
544
+ if (body.length <= max) {
545
+ kept = body.map((r) => r.i);
546
+ } else {
547
+ // Searched from the end: the caveat and the how-to-try-it line are the
548
+ // last things written, and they are the two worth pulling out of the part
549
+ // being dropped.
550
+ const tail = body.slice(Math.max(1, max - 2));
551
+ const pick = (re) => [...tail].reverse().find((r) => re.test(r.row))?.i;
552
+ const rescued = [...new Set([pick(CAVEAT), pick(ACTIONABLE)])].filter((i) => i !== undefined);
553
+ const head = body.slice(0, max - rescued.length).map((r) => r.i);
554
+ kept = [...new Set([...head, ...rescued])].sort((a, b) => a - b);
555
+ }
556
+
557
+ const out = [];
558
+ let previous = -1;
559
+ for (const i of kept) {
560
+ if (previous >= 0 && i > previous + 1) out.push(''); // a gap in the middle is a paragraph break
561
+ // A line lifted out of a list is no longer in one.
562
+ out.push(previous >= 0 && i > previous + 1 ? rows[i].replace(BULLET, '') : rows[i]);
563
+ previous = i;
564
+ }
565
+
566
+ return withinRoom(out.join('\n').replace(/\n{3,}/g, '\n\n').trim());
567
+ }
568
+
569
+ /**
570
+ * One long paragraph is one line and fills the screen anyway. Whole sentences
571
+ * only: a reply cut mid-clause reads as a crash rather than as an ending.
572
+ */
573
+ function withinRoom(text, room = ANSWER_ROOM) {
574
+ if (text.length <= room) return text;
575
+
576
+ const parts = text.split(/(?<=[.!?])(\s+)/);
577
+ let out = '';
578
+ let sentences = 0;
579
+ for (let i = 0; i < parts.length; i += 2) {
580
+ const next = out + parts[i] + (parts[i + 1] ?? '');
581
+ if (sentences >= 2 && next.trimEnd().length > room) break;
582
+ out = next;
583
+ sentences++;
584
+ }
585
+ return (out.trim() || text.slice(0, room)).trim();
586
+ }