ucode-agent 1.44.0 → 1.47.0

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,776 +1,814 @@
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 bannerRGB(row, rows = BANNER.length) {
94
- const t = rows > 1 ? Math.min(1, Math.max(0, row / (rows - 1))) : 0;
95
- return GRADIENT_TOP.map((from, i) => Math.round(from + (GRADIENT_BOTTOM[i] - from) * t));
96
- }
97
-
98
- export function bannerPaint(row, rows = BANNER.length) {
99
- const hex = bannerRGB(row, rows).map((v) => v.toString(16).padStart(2, '0')).join('');
100
- return chalk.hex(`#${hex}`);
101
- }
102
-
103
- /**
104
- * The rail beside something you said.
105
- *
106
- * A box around every user message draws two full-width rules per turn, and a
107
- * long session becomes a ladder. A half-block in the left column is the same
108
- * landmark — findable at a glance, scrollable to — for a fortieth of the ink.
109
- */
110
- export const RAIL = '▌';
111
-
112
- /**
113
- * Which mode is live, as a filled pill.
114
- *
115
- * A glyph and a word is a label; a block of colour with the word knocked out
116
- * of it is a control, and the mode is the one thing on the status row you can
117
- * actually change. Build is the solid blue — it may edit and run. Plan is the
118
- * same shape muted, because a read-only mode should not look armed.
119
- *
120
- * Small enough not to be the background painting that was taken out of here
121
- * once: it is the width of the word, the way a diff's tint is the width of the
122
- * line it marks.
123
- */
124
- export const BUILD_CHIP = chalk.bgHex('#4d8dff').hex('#0b1220').bold;
125
- export const PLAN_CHIP = chalk.bgHex('#24344f').hex('#8fbcff').bold;
126
-
127
- export const modeChip = (mode) =>
128
- mode === 'plan' ? PLAN_CHIP(' PLAN ') : BUILD_CHIP(' BUILD ');
129
-
130
- /** The spinner. Braille dots, because they animate in place without jitter. */
131
- export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
132
-
133
- // ---------------------------------------------------------------------------
134
- // Boxes
135
- // ---------------------------------------------------------------------------
136
-
137
- export const BOX = {
138
- topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯',
139
- h: '─', v: '│',
140
- };
141
-
142
- export const boxTop = (width, paint = blue) =>
143
- paint(BOX.topLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.topRight);
144
-
145
- export const boxBottom = (width, paint = blue) =>
146
- paint(BOX.bottomLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.bottomRight);
147
-
148
- /** One row inside a box, padded so the right border lands in the same column. */
149
- export const boxRow = (content, width, paint = blue) =>
150
- paint(BOX.v) + padVis(content, Math.max(0, width - 2)) + paint(BOX.v);
151
-
152
- // ---------------------------------------------------------------------------
153
- // Widths, with escape codes discounted
154
- // ---------------------------------------------------------------------------
155
-
156
- /** The string with its colour codes stripped — what the terminal actually shows. */
157
- export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
158
-
159
- /**
160
- * How many columns one character occupies.
161
- *
162
- * Not every character is one cell wide, and counting them as though they were
163
- * is how a box tears: the right border of a row holding CJK or an emoji lands
164
- * one or two columns early, and every frame after it looks broken. It cost us
165
- * a crooked credit line in the header for months — and any app whose name the
166
- * model writes in Japanese would have done the same to the transcript.
167
- *
168
- * Three widths. Combining marks and the variation selectors hang off the
169
- * character before them and take no room of their own. The wide ranges — CJK,
170
- * Hangul, kana, fullwidth forms, and the emoji planes — are drawn two cells
171
- * wide by every terminal worth supporting. Everything else is one.
172
- *
173
- * Ranges rather than a dependency: this is the whole of what a terminal needs,
174
- * and a table of every Unicode width would be a megabyte to get the last
175
- * fraction of a percent right.
176
- */
177
- export function charWidth(code) {
178
- // Zero: combining marks, joiners, variation selectors.
179
- if ((code >= 0x0300 && code <= 0x036f)
180
- || (code >= 0x200b && code <= 0x200f)
181
- || (code >= 0xfe00 && code <= 0xfe0f)
182
- || (code >= 0xe0100 && code <= 0xe01ef)
183
- || code === 0x200d) return 0;
184
-
185
- // Two: the wide and fullwidth blocks, and the emoji planes.
186
- if ((code >= 0x1100 && code <= 0x115f)
187
- || (code >= 0x2e80 && code <= 0x303e)
188
- || (code >= 0x3041 && code <= 0x33ff)
189
- || (code >= 0x3400 && code <= 0x4dbf)
190
- || (code >= 0x4e00 && code <= 0x9fff)
191
- || (code >= 0xa000 && code <= 0xa4cf)
192
- || (code >= 0xac00 && code <= 0xd7a3)
193
- || (code >= 0xf900 && code <= 0xfaff)
194
- || (code >= 0xfe30 && code <= 0xfe6f)
195
- || (code >= 0xff00 && code <= 0xff60)
196
- || (code >= 0xffe0 && code <= 0xffe6)
197
- || (code >= 0x1f300 && code <= 0x1f64f)
198
- || (code >= 0x1f680 && code <= 0x1f6ff)
199
- || (code >= 0x1f900 && code <= 0x1f9ff)
200
- || (code >= 0x20000 && code <= 0x3fffd)) return 2;
201
-
202
- return 1;
203
- }
204
-
205
- /** The columns a string takes up once its colour codes are discounted. */
206
- export function visLen(s) {
207
- const text = bare(s);
208
- let cells = 0;
209
- for (let i = 0; i < text.length;) {
210
- const cp = text.codePointAt(i);
211
- const ch = String.fromCodePoint(cp);
212
- i += ch.length;
213
- // A variation selector turns the character before it into an emoji, and
214
- // an emoji is two cells wide however narrow its text form was.
215
- if (text.codePointAt(i) === 0xfe0f) { cells += 2; i += 1; continue; }
216
- cells += charWidth(cp);
217
- }
218
- return cells;
219
- }
220
-
221
- /** The first `width` visible characters, with escape sequences left intact. */
222
- export function sliceVis(s, width) {
223
- let out = '';
224
- let seen = 0;
225
- for (let i = 0; i < s.length; i++) {
226
- if (s[i] === '\x1b') {
227
- const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
228
- if (m) { out += m[0]; i += m[0].length - 1; continue; }
229
- }
230
- // A wide character that would straddle the edge is left off entirely:
231
- // half of one is a replacement glyph in most terminals and a torn border
232
- // in the rest.
233
- const cp = s.codePointAt(i);
234
- const ch = String.fromCodePoint(cp);
235
- const selector = s.codePointAt(i + ch.length) === 0xfe0f;
236
- const w = selector ? 2 : charWidth(cp);
237
- if (seen + w > width) break;
238
- out += selector ? ch + String.fromCodePoint(0xfe0f) : ch;
239
- i += (selector ? ch.length + 1 : ch.length) - 1;
240
- seen += w;
241
- }
242
- return out;
243
- }
244
-
245
- /** Pad or hard-cut a possibly-coloured string to an exact visible width. */
246
- export function padVis(s, width) {
247
- const len = visLen(s);
248
- if (len === width) return s;
249
- if (len < width) return s + ' '.repeat(width - len);
250
- return `${sliceVis(s, width)}\x1b[0m`;
251
- }
252
-
253
- export function clip(text, max) {
254
- const s = String(text ?? '');
255
- if (max <= 1) return '';
256
- return s.length > max ? `${s.slice(0, max - 1)}…` : s;
257
- }
258
-
259
- /**
260
- * Word-wrap text that may already be coloured.
261
- *
262
- * Escape sequences have no width, and whichever styles are open at a break get
263
- * reopened on the next line — otherwise a wrapped sentence loses its colour
264
- * halfway through.
265
- */
266
- export function wrapAnsi(text, width) {
267
- if (width < 4) return [text];
268
-
269
- const lines = [];
270
- let line = '';
271
- let seen = 0;
272
- let open = '';
273
- let lastSpace = -1;
274
- let lastSpaceSeen = 0;
275
-
276
- const flush = (upto = null) => {
277
- if (upto === null) {
278
- lines.push(line);
279
- line = open;
280
- seen = 0;
281
- } else {
282
- lines.push(line.slice(0, upto));
283
- const carry = line.slice(upto).replace(/^ +/, '');
284
- line = open + carry;
285
- seen = visLen(carry);
286
- }
287
- lastSpace = -1;
288
- };
289
-
290
- for (let i = 0; i < text.length; i++) {
291
- if (text[i] === '\x1b') {
292
- const m = /^\x1b\[[0-9;]*m/.exec(text.slice(i));
293
- if (m) {
294
- line += m[0];
295
- open = m[0] === '\x1b[0m' ? '' : open + m[0];
296
- i += m[0].length - 1;
297
- continue;
298
- }
299
- }
300
- if (text[i] === ' ') { lastSpace = line.length; lastSpaceSeen = seen; }
301
- const cp = text.codePointAt(i);
302
- const ch = String.fromCodePoint(cp);
303
- line += ch;
304
- i += ch.length - 1;
305
- seen += charWidth(cp);
306
- if (seen >= width) {
307
- // Break at a word boundary unless that would leave a stub behind.
308
- if (lastSpace > 0 && lastSpaceSeen > width * 0.4) flush(lastSpace);
309
- else flush();
310
- }
311
- }
312
-
313
- if (visLen(line)) lines.push(line);
314
- return lines.length ? lines : [''];
315
- }
316
-
317
- // ---------------------------------------------------------------------------
318
- // Small formatters
319
- // ---------------------------------------------------------------------------
320
-
321
- export function formatTokens(n) {
322
- if (!n) return '0';
323
- if (n < 1000) return String(n);
324
- if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
325
- return `${(n / 1_000_000).toFixed(1)}M`;
326
- }
327
-
328
- export function today() {
329
- return new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
330
- }
331
-
332
- /** Shorten a path for display: home becomes ~, a long middle collapses. */
333
- export function shortenPath(p, max = 40) {
334
- let out = String(p);
335
- const home = process.env.USERPROFILE || process.env.HOME || '';
336
- if (home && out.startsWith(home)) out = `~${out.slice(home.length)}`;
337
- if (out.length <= max) return out;
338
-
339
- const parts = out.split(/[\\/]/);
340
- if (parts.length <= 3) return `…${out.slice(-(max - 1))}`;
341
- const sep = out.includes('\\') ? '\\' : '/';
342
- return `${parts[0]}${sep}…${sep}${parts.slice(-2).join(sep)}`;
343
- }
344
-
345
- export function relativeTime(iso) {
346
- if (!iso) return 'unknown';
347
- const then = new Date(iso).getTime();
348
- if (Number.isNaN(then)) return 'unknown';
349
-
350
- const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
351
- if (secs < 60) return 'just now';
352
- const mins = Math.round(secs / 60);
353
- if (mins < 60) return `${mins}m ago`;
354
- const hours = Math.round(mins / 60);
355
- if (hours < 24) return `${hours}h ago`;
356
- const days = Math.round(hours / 24);
357
- return days < 30 ? `${days}d ago` : new Date(iso).toISOString().slice(0, 10);
358
- }
359
-
360
- /**
361
- * Trim a trailing full stop off a live status line.
362
- *
363
- * "Listing src" is a label on work in progress. "Listing src." is a sentence,
364
- * and a sentence that ends while the thing it describes is still happening
365
- * reads as finished when it is not. Models add the full stop by habit; this
366
- * takes it back off.
367
- *
368
- * Only after a word, though. A dot that follows a space is the whole point of
369
- * the line — "Listing ." names the current directory — and trimming that turns
370
- * a label into a fragment.
371
- */
372
- export function asLabel(text) {
373
- return String(text ?? '')
374
- .trim()
375
- .replace(/\s+/g, ' ')
376
- // A trailing stop, from a model's sentence or a tool's own output
377
- // ("Building…", "Completing…"), is noise on a one-line label. A dot that
378
- // is the argument itself — "Listing ." — is not, so a word has to come
379
- // before it.
380
- .replace(/(?<=[\w)\]"'`])[.。…]+$/, '');
381
- }
382
-
383
- /**
384
- * The model's checklist, as one short line — done ticked, the current item
385
- * marked, the rest dim — so progress is visible without taking over the screen.
386
- */
387
- /**
388
- * The plan, as a block rather than a sentence.
389
- *
390
- * Six steps joined with separators made one line far wider than any terminal,
391
- * so it wrapped — and a wrapped checklist has its ticks in the middle of the
392
- * text, which is unreadable. Down the page each step keeps its own row, its
393
- * mark stays in the left column, and the eye can find the one in progress
394
- * without reading any of the others.
395
- *
396
- * Returns the rows; the caller pushes them.
397
- */
398
- /**
399
- * How far along, as a bar rather than as arithmetic.
400
- *
401
- * "2/4" is a sum the reader has to do; a bar is the answer to it, read at a
402
- * glance. Ten cells whatever the plan's length, so the row does not change
403
- * width as steps are added and the eye keeps one edge to measure against.
404
- *
405
- * Heavy and light box-drawing, not block shading: those two are already the
406
- * frame of every box on screen, so they are the two glyphs this app can be
407
- * certain the terminal has and draws one cell wide.
408
- */
409
- export const BAR_CELLS = 10;
410
-
411
- export function progressBar(done, total, cells = BAR_CELLS) {
412
- const ratio = total > 0 ? Math.min(1, Math.max(0, done / total)) : 0;
413
- const fill = Math.round(ratio * cells);
414
- return blue('━'.repeat(fill)) + dim('─'.repeat(Math.max(0, cells - fill)));
415
- }
416
-
417
- export function planRows(items) {
418
- const list = (Array.isArray(items) ? items : []).slice(0, 8);
419
- if (!list.length) return [];
420
- const done = list.filter((i) => i?.done).length;
421
- const current = list.findIndex((i) => !i?.done);
422
-
423
- // One left edge for the whole transcript: markers in column zero, every
424
- // piece of content at column two. The plan used to sit at two and four, so
425
- // three different margins ran down the page and the eye had no line to
426
- // follow.
427
- const rows = [`${progressBar(done, list.length)} ${sky(`${done}/${list.length}`)}`];
428
- list.forEach((item, i) => {
429
- const text = clip(String(item?.text ?? '').trim(), 64);
430
- if (item?.done) rows.push(` ${theme.ok('✓')} ${dim(text)}`);
431
- else if (i === current) rows.push(` ${blue('▸')} ${chalk.white(text)}`);
432
- else rows.push(` ${dim('○')} ${dim(text)}`);
433
- });
434
- return rows;
435
- }
436
-
437
- /** Kept for the plain interface, which has one line to work with. */
438
- export function planLine(items) {
439
- const list = (Array.isArray(items) ? items : []).slice(0, 6);
440
- if (!list.length) return '';
441
- const done = list.filter((i) => i?.done).length;
442
- const current = list.findIndex((i) => !i?.done);
443
- const now = current === -1 ? 'done' : clip(String(list[current]?.text ?? '').trim(), 40);
444
- return ` ${sky(`plan ${done}/${list.length}`)} ${chalk.white(now)}`;
445
- }
446
-
447
- /**
448
- * Narration: what the agent is doing, as opposed to what it has to say.
449
- *
450
- * These lines are scaffolding — "Reading screen.js", "Checking types". They
451
- * are worth seeing and not worth reading, and at full strength they compete
452
- * with the answer, which is the thing the user is actually here for. A
453
- * terminal has no smaller size to set, so the only axis available is weight:
454
- * faint, and a step down in colour. The answer stays at full strength and
455
- * wins the page by contrast rather than by shouting.
456
- */
457
- export const narration = (text) => chalk.dim(text);
458
-
459
- /**
460
- * The bullet beside a narration line: present, not loud.
461
- *
462
- * Three shapes rather than one dot repeated. Every step drawn identically made
463
- * a long transcript a column of the same mark forty times over, which reads as
464
- * output rather than as work — and the shape is free, where a fourth colour
465
- * would not be. A diamond is hollow when the agent is only looking at
466
- * something and filled when it changes it, and a run of a command points
467
- * forward. Anything unmapped keeps the original dot.
468
- *
469
- * All of them stay dim: the glyph carries the kind, the weight still says this
470
- * is scaffolding and the answer below is the thing to read.
471
- */
472
- const MARKS = {
473
- Reading: ['◇', deep], Listing: ['◇', deep], Looking: ['◇', deep],
474
- Asking: ['◇', deep], Mapping: ['◇', deep], Searching: ['◇', deep],
475
- Finding: ['◇', deep],
476
- Writing: ['◆', blue], Editing: ['◆', blue], Adding: ['◆', blue],
477
- Renaming: ['◆', blue],
478
- Running: ['▸', sky], Checking: ['▸', sky],
479
- };
480
-
481
- export const narrationMark = (kind) => {
482
- const [glyph, paint] = MARKS[kind] ?? ['●', deep];
483
- return chalk.dim(paint(glyph));
484
- };
485
-
486
- /**
487
- * The file or command a step is about, lit so the line can be scanned.
488
- *
489
- * "Which file did it touch" is the one question a reader puts to a transcript
490
- * of tool calls, and dimming the whole line made the answer as faint as the
491
- * verb in front of it. The verb stays faint — there are only a dozen of them
492
- * and they repeat — and the part that differs every time carries the colour.
493
- */
494
- const paintStep = (label) => {
495
- const text = String(label ?? '');
496
- const space = text.indexOf(' ');
497
- if (space < 0) return narration(text);
498
-
499
- const target = text.slice(space + 1);
500
- // "Read 2 files" is a tally, not a path. Lighting it up would point the eye
501
- // at a number that says nothing about where the work happened.
502
- if (/^\d/.test(target)) return narration(text);
503
-
504
- return `${narration(text.slice(0, space))} ${blue(target)}`;
505
- };
506
-
507
- /**
508
- * How a run of the same kind of step reads once it is over.
509
- *
510
- * While it happens, "Running npm test" is the useful thing to show. Once
511
- * three of them have happened, three near-identical lines are just noise
512
- * between the reader and the answer, so they fold into one: "Ran 3 commands".
513
- * The present tense belongs to the thing happening now; the past tense to the
514
- * summary of what did.
515
- */
516
- const GROUPS = {
517
- Running: ['Ran', 'command', 'commands'],
518
- Reading: ['Read', 'file', 'files'],
519
- Searching: ['Searched', 'time', 'times'],
520
- Finding: ['Found', 'pattern', 'patterns'],
521
- Listing: ['Listed', 'directory', 'directories'],
522
- Writing: ['Wrote', 'file', 'files'],
523
- Editing: ['Edited', 'file', 'files'],
524
- Checking: ['Checked', 'thing', 'things'],
525
- Looking: ['Looked up', 'name', 'names'],
526
- Asking: ['Asked about', 'name', 'names'],
527
- Mapping: ['Mapped', 'folder', 'folders'],
528
- Adding: ['Added', 'block', 'blocks'],
529
- Renaming: ['Renamed', 'name', 'names'],
530
- };
531
-
532
- /** The first word of a label, which is what decides whether two steps match. */
533
- export const groupKind = (label) => String(label ?? '').trim().split(/\s+/)[0] ?? '';
534
-
535
- /** One line standing in for `count` steps that all began with the same word. */
536
- export function groupLabel(label, count) {
537
- if (count <= 1) return String(label ?? '');
538
- const g = GROUPS[groupKind(label)];
539
- if (!g) return `${label} (+${count - 1} more)`;
540
- const [past, one, many] = g;
541
- return `${past} ${count} ${count === 1 ? one : many}`;
542
- }
543
-
544
- /** The part of a label after its opening word: the file or command it is about. */
545
- export const groupTarget = (label) => String(label ?? '').trim().split(/\s+/).slice(1).join(' ');
546
-
547
- /**
548
- * One narration line, standing for everything that happened under it.
549
- *
550
- * The transcript is a record of what was done, not a copy of what was
551
- * written. A 539-line file printed into it buries the answer and tells the
552
- * reader nothing they could not get from the file itself, so a change is its
553
- * two numbers. Several steps on one file stay one line naming that file;
554
- * several files become a count.
555
- *
556
- * The line comes back painted, so the label handed in has to be through
557
- * asLabel() already: run that over this and its regexes would be reading
558
- * escape sequences instead of the last word.
559
- */
560
- export function runLine({ label, count = 1, targets = [], added = 0, removed = 0, stat = '' }) {
561
- // What came of the step, in the same place a change puts its two numbers:
562
- // on the line that named the step, never underneath it. A look that found
563
- // three things to fix is the one fact worth carrying out of a look, and
564
- // without it the most thorough check ucode runs is the quietest thing on
565
- // screen — it opens the app at two widths, screenshots both and has them
566
- // reviewed, and said nothing about any of it.
567
- const counts = added || removed
568
- ? ` ${chalk.hex('#3fb950')(`+${added}`)} ${chalk.hex('#f2939c')(`-${removed}`)}`
569
- : (stat ? ` ${sky(stat)}` : '');
570
- if (count <= 1) return `${paintStep(label)}${counts}`;
571
-
572
- const g = GROUPS[groupKind(label)];
573
- const unique = [...new Set(targets.filter(Boolean))];
574
- if (g && unique.length === 1) return `${paintStep(`${g[0]} ${unique[0]}`)}${counts}`;
575
- if (!g) return `${paintStep(`${label} (+${count - 1} more)`)}${counts}`;
576
- return `${paintStep(`${g[0]} ${count} ${count === 1 ? g[1] : g[2]}`)}${counts}`;
577
- }
578
-
579
- /**
580
- * The reply, with any pasted code taken out of it.
581
- *
582
- * The model is asked not to paste code into its answer, and mostly does not.
583
- * When it does, a fenced block of forty lines pushes the two sentences worth
584
- * reading off the screen — and the code is already in the file it just wrote.
585
- * A fence becomes a note of what it was, and the prose stays.
586
- *
587
- * A short block is left alone: three lines showing a command to run, or the
588
- * one line that changed, is the kind of thing worth having in the answer.
589
- */
590
- /**
591
- * A sentence that exists only to introduce what comes next.
592
- *
593
- * "Here's the complete app:" followed by forty lines of code, with the code
594
- * taken out, is a colon pointing at nothing — which reads as the reply having
595
- * been cut off mid-thought. The lead-in goes with what it was leading to.
596
- */
597
- const LEAD_IN = /(?:^|\n)[^\n]{0,80}:[ \t]*\n+$/;
598
-
599
- export function withoutCodeBlocks(text, keepLines = 4) {
600
- const FENCE = /```([A-Za-z0-9+-]*)\n([\s\S]*?)```/g;
601
- return String(text ?? '').replace(FENCE, (all, lang, body) => {
602
- const lines = body.replace(/\n+$/, '').split('\n');
603
- if (lines.length <= keepLines) return all;
604
- const what = lang ? `${lang} ` : '';
605
- return `_[${lines.length} lines of ${what}code — it is in the file, not worth repeating here]_`;
606
- });
607
- }
608
-
609
- /**
610
- * The reply as it should be read: no pasted code, and no sentence left
611
- * pointing at code that is no longer there.
612
- */
613
- /**
614
- * The reply as it should be read.
615
- *
616
- * A long pasted block goes, and so does the sentence that introduced it — a
617
- * colon pointing at nothing reads as the reply having been cut off. A short
618
- * block stays: three lines showing a command to run belong in an answer.
619
- */
620
- export function tidyReply(text, keepLines = 4, prompt = '') {
621
- const MARK = "\u0000CUT\u0000";
622
- const FENCE = new RegExp("```([A-Za-z0-9+-]*)\\n([\\s\\S]*?)```", "g");
623
-
624
- const marked = String(text ?? "").replace(FENCE, (all, lang, body) => {
625
- const rows = body.replace(new RegExp("\\n+$"), "").split("\n");
626
- return rows.length <= keepLines ? all : MARK;
627
- });
628
-
629
- const leadIn = new RegExp("(?:^|\\n)[^\\n]{0,80}:[ \t]*\\n+" + MARK, "g");
630
- const body = marked
631
- .replace(leadIn, "\n")
632
- .split(MARK).join("")
633
- .replace(new RegExp("\\n{3,}", "g"), "\n\n")
634
- .trim();
635
- return withoutRestatement(body, prompt);
636
- }
637
-
638
- /**
639
- * The words of a line worth comparing: lowercase, no punctuation, and nothing
640
- * short enough to turn up in any sentence at all.
641
- */
642
- const significant = (s) => String(s ?? '')
643
- .toLowerCase()
644
- .replace(/[^a-z0-9\s]+/g, ' ')
645
- .split(/\s+/)
646
- .filter((w) => w.length >= 3);
647
-
648
- /**
649
- * Is this line the request handed back?
650
- *
651
- * A list of phrases catches the openings a model reaches for out of habit, but
652
- * the commonest way of repeating a request is simply saying it again in the
653
- * asker's own words — "A Next.js habit tracker with a clean dashboard, coming
654
- * right up" — and no list will ever match that. So the line is compared with
655
- * what was actually typed.
656
- *
657
- * Three guards keep it off real answers. A prompt of four significant words or
658
- * fewer is never matched, because at that length an overlap means nothing. A
659
- * line much longer than the prompt is saying more than the prompt did, so it
660
- * is content. And the bar is four fifths of the prompt's words rather than a
661
- * majority: an answer naturally shares nouns with the request that prompted
662
- * it, and only something repeating nearly all of it is a repetition.
663
- */
664
- export function echoesPrompt(line, prompt) {
665
- const want = [...new Set(significant(prompt))];
666
- if (want.length < 4) return false;
667
-
668
- const text = String(line ?? '');
669
- if (text.length > String(prompt ?? '').length * 2.5) return false;
670
-
671
- const have = new Set(significant(text));
672
- return want.filter((w) => have.has(w)).length / want.length >= 0.8;
673
- }
674
-
675
- /**
676
- * The reply with any opening that reads the request back taken off the front.
677
- *
678
- * This ran only on the closing message before, and it belongs on every one. A
679
- * model that answers "You asked me to add a dark mode toggle — done" has spent
680
- * its first line telling someone something they typed themselves, and the line
681
- * directly above it on screen is already their own message, in their own
682
- * words, against a rail. Two copies of the request and one of the answer is
683
- * the wrong ratio.
684
- *
685
- * It never returns nothing. A reply that is only a restatement is still the
686
- * whole of the reply, and an empty answer on screen reads as a crash.
687
- */
688
- export function withoutRestatement(text, prompt = '') {
689
- const rows = String(text ?? '').replace(/\r/g, '').split('\n');
690
-
691
- let start = 0;
692
- while (start < rows.length) {
693
- const line = rows[start].trim();
694
- if (!line) { start++; continue; }
695
- if (!RESTATED.test(line) && !echoesPrompt(line, prompt)) break;
696
- start++;
697
- }
698
-
699
- return rows.slice(start).join('\n').trim() || String(text ?? '').trim();
700
- }
701
-
702
- /**
703
- * The closing message, cut to what a terminal can take.
704
- *
705
- * A model that finishes a build by walking back through the request — every
706
- * feature ticked off, every file listed — leaves that as the last thing on
707
- * screen, and the whole session then reads like a status report. Eight lines
708
- * is the whole of it: what it is, and how to try it.
709
- *
710
- * What goes: an opening that reads the request back, and the middle of a list
711
- * too long to be worth reading. What stays: the first lines, the line that
712
- * admits something is unfinished, and the line naming a file or a command —
713
- * the two the user actually acts on, and both of them live at the end.
714
- */
715
- export const ANSWER_LINES = 8;
716
- const ANSWER_ROOM = 600; // eight wrapped lines of prose, for a reply with no line breaks in it
717
-
718
- export const RESTATED = /^(?:(?:sure|ok|okay|got it|understood|alright|right)\b[\s,!.—-]*)?(?:you(?:'ve| have)? (?:asked|want|wanted|requested|said|would like|need)\b|the (?:request|task|ask)\b|as (?:you )?requested\b|here(?:'s| is) what (?:you asked|i)\b|i(?:'ll| will|'m going to| am going to) (?:build|create|make|add|write|implement)\b|let(?:'s| us) (?:build|create|make|add|write|implement)\b|to (?:summarise|summarize|recap)\b|(?:request|task|summary|recap|overview)\s*:)/i;
719
- 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;
720
- 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;
721
- const BULLET = /^\s*(?:[-*•>]|\d+[.)]|[✓✔✅☑])\s+/;
722
-
723
- export function trimAnswer(text, max = ANSWER_LINES) {
724
- const all = String(text ?? '').replace(/\r/g, '').split('\n');
725
-
726
- let start = 0;
727
- while (start < all.length && (!all[start].trim() || RESTATED.test(all[start].trim()))) start++;
728
- const rows = all.slice(start);
729
-
730
- const body = rows.map((row, i) => ({ row, i })).filter((r) => r.row.trim());
731
- if (!body.length) return '';
732
-
733
- let kept;
734
- if (body.length <= max) {
735
- kept = body.map((r) => r.i);
736
- } else {
737
- // Searched from the end: the caveat and the how-to-try-it line are the
738
- // last things written, and they are the two worth pulling out of the part
739
- // being dropped.
740
- const tail = body.slice(Math.max(1, max - 2));
741
- const pick = (re) => [...tail].reverse().find((r) => re.test(r.row))?.i;
742
- const rescued = [...new Set([pick(CAVEAT), pick(ACTIONABLE)])].filter((i) => i !== undefined);
743
- const head = body.slice(0, max - rescued.length).map((r) => r.i);
744
- kept = [...new Set([...head, ...rescued])].sort((a, b) => a - b);
745
- }
746
-
747
- const out = [];
748
- let previous = -1;
749
- for (const i of kept) {
750
- if (previous >= 0 && i > previous + 1) out.push(''); // a gap in the middle is a paragraph break
751
- // A line lifted out of a list is no longer in one.
752
- out.push(previous >= 0 && i > previous + 1 ? rows[i].replace(BULLET, '') : rows[i]);
753
- previous = i;
754
- }
755
-
756
- return withinRoom(out.join('\n').replace(/\n{3,}/g, '\n\n').trim());
757
- }
758
-
759
- /**
760
- * One long paragraph is one line and fills the screen anyway. Whole sentences
761
- * only: a reply cut mid-clause reads as a crash rather than as an ending.
762
- */
763
- function withinRoom(text, room = ANSWER_ROOM) {
764
- if (text.length <= room) return text;
765
-
766
- const parts = text.split(/(?<=[.!?])(\s+)/);
767
- let out = '';
768
- let sentences = 0;
769
- for (let i = 0; i < parts.length; i += 2) {
770
- const next = out + parts[i] + (parts[i + 1] ?? '');
771
- if (sentences >= 2 && next.trimEnd().length > room) break;
772
- out = next;
773
- sentences++;
774
- }
775
- return (out.trim() || text.slice(0, room)).trim();
776
- }
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 bannerRGB(row, rows = BANNER.length) {
94
+ const t = rows > 1 ? Math.min(1, Math.max(0, row / (rows - 1))) : 0;
95
+ return GRADIENT_TOP.map((from, i) => Math.round(from + (GRADIENT_BOTTOM[i] - from) * t));
96
+ }
97
+
98
+ export function bannerPaint(row, rows = BANNER.length) {
99
+ const hex = bannerRGB(row, rows).map((v) => v.toString(16).padStart(2, '0')).join('');
100
+ return chalk.hex(`#${hex}`);
101
+ }
102
+
103
+ /**
104
+ * The rail beside something you said.
105
+ *
106
+ * A box around every user message draws two full-width rules per turn, and a
107
+ * long session becomes a ladder. A half-block in the left column is the same
108
+ * landmark — findable at a glance, scrollable to — for a fortieth of the ink.
109
+ */
110
+ export const RAIL = '▌';
111
+
112
+ /**
113
+ * Which mode is live, as a filled pill.
114
+ *
115
+ * A glyph and a word is a label; a block of colour with the word knocked out
116
+ * of it is a control, and the mode is the one thing on the status row you can
117
+ * actually change. Build is the solid blue — it may edit and run. Plan is the
118
+ * same shape muted, because a read-only mode should not look armed.
119
+ *
120
+ * Small enough not to be the background painting that was taken out of here
121
+ * once: it is the width of the word, the way a diff's tint is the width of the
122
+ * line it marks.
123
+ */
124
+ export const BUILD_CHIP = chalk.bgHex('#4d8dff').hex('#0b1220').bold;
125
+ export const PLAN_CHIP = chalk.bgHex('#24344f').hex('#8fbcff').bold;
126
+
127
+ export const modeChip = (mode) =>
128
+ mode === 'plan' ? PLAN_CHIP(' PLAN ') : BUILD_CHIP(' BUILD ');
129
+
130
+ /** The spinner. Braille dots, because they animate in place without jitter. */
131
+ export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Boxes
135
+ // ---------------------------------------------------------------------------
136
+
137
+ export const BOX = {
138
+ topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯',
139
+ h: '─', v: '│',
140
+ };
141
+
142
+ export const boxTop = (width, paint = blue) =>
143
+ paint(BOX.topLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.topRight);
144
+
145
+ export const boxBottom = (width, paint = blue) =>
146
+ paint(BOX.bottomLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.bottomRight);
147
+
148
+ /** One row inside a box, padded so the right border lands in the same column. */
149
+ export const boxRow = (content, width, paint = blue) =>
150
+ paint(BOX.v) + padVis(content, Math.max(0, width - 2)) + paint(BOX.v);
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Widths, with escape codes discounted
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** The string with its colour codes stripped — what the terminal actually shows. */
157
+ export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
158
+
159
+ /**
160
+ * How many columns one character occupies.
161
+ *
162
+ * Not every character is one cell wide, and counting them as though they were
163
+ * is how a box tears: the right border of a row holding CJK or an emoji lands
164
+ * one or two columns early, and every frame after it looks broken. It cost us
165
+ * a crooked credit line in the header for months — and any app whose name the
166
+ * model writes in Japanese would have done the same to the transcript.
167
+ *
168
+ * Three widths. Combining marks and the variation selectors hang off the
169
+ * character before them and take no room of their own. The wide ranges — CJK,
170
+ * Hangul, kana, fullwidth forms, and the emoji planes — are drawn two cells
171
+ * wide by every terminal worth supporting. Everything else is one.
172
+ *
173
+ * Ranges rather than a dependency: this is the whole of what a terminal needs,
174
+ * and a table of every Unicode width would be a megabyte to get the last
175
+ * fraction of a percent right.
176
+ */
177
+ export function charWidth(code) {
178
+ // Zero: combining marks, joiners, variation selectors.
179
+ if ((code >= 0x0300 && code <= 0x036f)
180
+ || (code >= 0x200b && code <= 0x200f)
181
+ || (code >= 0xfe00 && code <= 0xfe0f)
182
+ || (code >= 0xe0100 && code <= 0xe01ef)
183
+ || code === 0x200d) return 0;
184
+
185
+ // Two: the wide and fullwidth blocks, and the emoji planes.
186
+ if ((code >= 0x1100 && code <= 0x115f)
187
+ || (code >= 0x2e80 && code <= 0x303e)
188
+ || (code >= 0x3041 && code <= 0x33ff)
189
+ || (code >= 0x3400 && code <= 0x4dbf)
190
+ || (code >= 0x4e00 && code <= 0x9fff)
191
+ || (code >= 0xa000 && code <= 0xa4cf)
192
+ || (code >= 0xac00 && code <= 0xd7a3)
193
+ || (code >= 0xf900 && code <= 0xfaff)
194
+ || (code >= 0xfe30 && code <= 0xfe6f)
195
+ || (code >= 0xff00 && code <= 0xff60)
196
+ || (code >= 0xffe0 && code <= 0xffe6)
197
+ || (code >= 0x1f300 && code <= 0x1f64f)
198
+ || (code >= 0x1f680 && code <= 0x1f6ff)
199
+ || (code >= 0x1f900 && code <= 0x1f9ff)
200
+ || (code >= 0x20000 && code <= 0x3fffd)) return 2;
201
+
202
+ return 1;
203
+ }
204
+
205
+ /** The columns a string takes up once its colour codes are discounted. */
206
+ export function visLen(s) {
207
+ const text = bare(s);
208
+ let cells = 0;
209
+ for (let i = 0; i < text.length;) {
210
+ const cp = text.codePointAt(i);
211
+ const ch = String.fromCodePoint(cp);
212
+ i += ch.length;
213
+ // A variation selector turns the character before it into an emoji, and
214
+ // an emoji is two cells wide however narrow its text form was.
215
+ if (text.codePointAt(i) === 0xfe0f) { cells += 2; i += 1; continue; }
216
+ cells += charWidth(cp);
217
+ }
218
+ return cells;
219
+ }
220
+
221
+ /** The first `width` visible characters, with escape sequences left intact. */
222
+ export function sliceVis(s, width) {
223
+ let out = '';
224
+ let seen = 0;
225
+ for (let i = 0; i < s.length; i++) {
226
+ if (s[i] === '\x1b') {
227
+ const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
228
+ if (m) { out += m[0]; i += m[0].length - 1; continue; }
229
+ }
230
+ // A wide character that would straddle the edge is left off entirely:
231
+ // half of one is a replacement glyph in most terminals and a torn border
232
+ // in the rest.
233
+ const cp = s.codePointAt(i);
234
+ const ch = String.fromCodePoint(cp);
235
+ const selector = s.codePointAt(i + ch.length) === 0xfe0f;
236
+ const w = selector ? 2 : charWidth(cp);
237
+ if (seen + w > width) break;
238
+ out += selector ? ch + String.fromCodePoint(0xfe0f) : ch;
239
+ i += (selector ? ch.length + 1 : ch.length) - 1;
240
+ seen += w;
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /** Pad or hard-cut a possibly-coloured string to an exact visible width. */
246
+ export function padVis(s, width) {
247
+ const len = visLen(s);
248
+ if (len === width) return s;
249
+ if (len < width) return s + ' '.repeat(width - len);
250
+ return `${sliceVis(s, width)}\x1b[0m`;
251
+ }
252
+
253
+ export function clip(text, max) {
254
+ const s = String(text ?? '');
255
+ if (max <= 1) return '';
256
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
257
+ }
258
+
259
+ /**
260
+ * Word-wrap text that may already be coloured.
261
+ *
262
+ * Escape sequences have no width, and whichever styles are open at a break get
263
+ * reopened on the next line — otherwise a wrapped sentence loses its colour
264
+ * halfway through.
265
+ */
266
+ export function wrapAnsi(text, width) {
267
+ if (width < 4) return [text];
268
+
269
+ const lines = [];
270
+ let line = '';
271
+ let seen = 0;
272
+ let open = '';
273
+ let lastSpace = -1;
274
+ let lastSpaceSeen = 0;
275
+
276
+ const flush = (upto = null) => {
277
+ if (upto === null) {
278
+ lines.push(line);
279
+ line = open;
280
+ seen = 0;
281
+ } else {
282
+ lines.push(line.slice(0, upto));
283
+ const carry = line.slice(upto).replace(/^ +/, '');
284
+ line = open + carry;
285
+ seen = visLen(carry);
286
+ }
287
+ lastSpace = -1;
288
+ };
289
+
290
+ for (let i = 0; i < text.length; i++) {
291
+ if (text[i] === '\x1b') {
292
+ const m = /^\x1b\[[0-9;]*m/.exec(text.slice(i));
293
+ if (m) {
294
+ line += m[0];
295
+ open = m[0] === '\x1b[0m' ? '' : open + m[0];
296
+ i += m[0].length - 1;
297
+ continue;
298
+ }
299
+ }
300
+ if (text[i] === ' ') { lastSpace = line.length; lastSpaceSeen = seen; }
301
+ const cp = text.codePointAt(i);
302
+ const ch = String.fromCodePoint(cp);
303
+ line += ch;
304
+ i += ch.length - 1;
305
+ seen += charWidth(cp);
306
+ if (seen >= width) {
307
+ // Break at a word boundary unless that would leave a stub behind.
308
+ if (lastSpace > 0 && lastSpaceSeen > width * 0.4) flush(lastSpace);
309
+ else flush();
310
+ }
311
+ }
312
+
313
+ if (visLen(line)) lines.push(line);
314
+ return lines.length ? lines : [''];
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // Small formatters
319
+ // ---------------------------------------------------------------------------
320
+
321
+ export function formatTokens(n) {
322
+ if (!n) return '0';
323
+ if (n < 1000) return String(n);
324
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
325
+ return `${(n / 1_000_000).toFixed(1)}M`;
326
+ }
327
+
328
+ export function today() {
329
+ return new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
330
+ }
331
+
332
+ /** Shorten a path for display: home becomes ~, a long middle collapses. */
333
+ export function shortenPath(p, max = 40) {
334
+ let out = String(p);
335
+ const home = process.env.USERPROFILE || process.env.HOME || '';
336
+ if (home && out.startsWith(home)) out = `~${out.slice(home.length)}`;
337
+ if (out.length <= max) return out;
338
+
339
+ const parts = out.split(/[\\/]/);
340
+ if (parts.length <= 3) return `…${out.slice(-(max - 1))}`;
341
+ const sep = out.includes('\\') ? '\\' : '/';
342
+ return `${parts[0]}${sep}…${sep}${parts.slice(-2).join(sep)}`;
343
+ }
344
+
345
+ export function relativeTime(iso) {
346
+ if (!iso) return 'unknown';
347
+ const then = new Date(iso).getTime();
348
+ if (Number.isNaN(then)) return 'unknown';
349
+
350
+ const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
351
+ if (secs < 60) return 'just now';
352
+ const mins = Math.round(secs / 60);
353
+ if (mins < 60) return `${mins}m ago`;
354
+ const hours = Math.round(mins / 60);
355
+ if (hours < 24) return `${hours}h ago`;
356
+ const days = Math.round(hours / 24);
357
+ return days < 30 ? `${days}d ago` : new Date(iso).toISOString().slice(0, 10);
358
+ }
359
+
360
+ /**
361
+ * Trim a trailing full stop off a live status line.
362
+ *
363
+ * "Listing src" is a label on work in progress. "Listing src." is a sentence,
364
+ * and a sentence that ends while the thing it describes is still happening
365
+ * reads as finished when it is not. Models add the full stop by habit; this
366
+ * takes it back off.
367
+ *
368
+ * Only after a word, though. A dot that follows a space is the whole point of
369
+ * the line — "Listing ." names the current directory — and trimming that turns
370
+ * a label into a fragment.
371
+ */
372
+ export function asLabel(text) {
373
+ return String(text ?? '')
374
+ .trim()
375
+ .replace(/\s+/g, ' ')
376
+ // A trailing stop, from a model's sentence or a tool's own output
377
+ // ("Building…", "Completing…"), is noise on a one-line label. A dot that
378
+ // is the argument itself — "Listing ." — is not, so a word has to come
379
+ // before it.
380
+ .replace(/(?<=[\w)\]"'`])[.。…]+$/, '');
381
+ }
382
+
383
+ /** The longest a narration line may be before it is clipped. */
384
+ const NARRATION_MAX = 120;
385
+
386
+ /**
387
+ * Any reply that came alongside a tool call, cut down to one status line.
388
+ *
389
+ * Mid-build the model narrates: a paragraph on what it is about to do, then a
390
+ * tool call that does it. Printed in full that paragraph is the loudest thing
391
+ * on screen and it is about work that has not happened yet — the file being
392
+ * written scrolls past underneath it. Only the closing message is an answer;
393
+ * everything before it is commentary, and commentary belongs on one dim line.
394
+ *
395
+ * The first sentence is kept because that is the one saying what is happening
396
+ * now. Code blocks, headings and bullets are dropped outright: none of them
397
+ * survive being squeezed into a single line, and half a fence is worse than
398
+ * no fence.
399
+ */
400
+ export function asNarrationLine(text) {
401
+ const flat = String(text ?? '')
402
+ .replace(/```[\s\S]*?```/g, ' ')
403
+ .replace(/^\s*#{1,6}\s*/gm, '')
404
+ .replace(/^\s*[-*+]\s+/gm, '')
405
+ .replace(/^\s*\d+[.)]\s+/gm, '')
406
+ .replace(/\s+/g, ' ')
407
+ .trim();
408
+ if (!flat) return '';
409
+ const first = /^(.+?[.!?])(?:\s|$)/.exec(flat);
410
+ return asLabel((first?.[1] ?? flat).slice(0, NARRATION_MAX));
411
+ }
412
+
413
+ /**
414
+ * The model's checklist, as one short line — done ticked, the current item
415
+ * marked, the rest dim — so progress is visible without taking over the screen.
416
+ */
417
+ /**
418
+ * The plan, as a block rather than a sentence.
419
+ *
420
+ * Six steps joined with separators made one line far wider than any terminal,
421
+ * so it wrapped — and a wrapped checklist has its ticks in the middle of the
422
+ * text, which is unreadable. Down the page each step keeps its own row, its
423
+ * mark stays in the left column, and the eye can find the one in progress
424
+ * without reading any of the others.
425
+ *
426
+ * Returns the rows; the caller pushes them.
427
+ */
428
+ /**
429
+ * How far along, as a bar rather than as arithmetic.
430
+ *
431
+ * "2/4" is a sum the reader has to do; a bar is the answer to it, read at a
432
+ * glance. Ten cells whatever the plan's length, so the row does not change
433
+ * width as steps are added and the eye keeps one edge to measure against.
434
+ *
435
+ * Heavy and light box-drawing, not block shading: those two are already the
436
+ * frame of every box on screen, so they are the two glyphs this app can be
437
+ * certain the terminal has and draws one cell wide.
438
+ */
439
+ export const BAR_CELLS = 10;
440
+
441
+ export function progressBar(done, total, cells = BAR_CELLS) {
442
+ const ratio = total > 0 ? Math.min(1, Math.max(0, done / total)) : 0;
443
+ const fill = Math.round(ratio * cells);
444
+ return blue('━'.repeat(fill)) + dim('─'.repeat(Math.max(0, cells - fill)));
445
+ }
446
+
447
+ export function planRows(items) {
448
+ const list = (Array.isArray(items) ? items : []).slice(0, 8);
449
+ if (!list.length) return [];
450
+ const done = list.filter((i) => i?.done).length;
451
+ const current = list.findIndex((i) => !i?.done);
452
+
453
+ // One left edge for the whole transcript: markers in column zero, every
454
+ // piece of content at column two. The plan used to sit at two and four, so
455
+ // three different margins ran down the page and the eye had no line to
456
+ // follow.
457
+ const rows = [`${progressBar(done, list.length)} ${sky(`${done}/${list.length}`)}`];
458
+ list.forEach((item, i) => {
459
+ const text = clip(String(item?.text ?? '').trim(), 64);
460
+ if (item?.done) rows.push(` ${theme.ok('✓')} ${dim(text)}`);
461
+ else if (i === current) rows.push(` ${blue('▸')} ${chalk.white(text)}`);
462
+ else rows.push(` ${dim('○')} ${dim(text)}`);
463
+ });
464
+ return rows;
465
+ }
466
+
467
+ /** Kept for the plain interface, which has one line to work with. */
468
+ export function planLine(items) {
469
+ const list = (Array.isArray(items) ? items : []).slice(0, 6);
470
+ if (!list.length) return '';
471
+ const done = list.filter((i) => i?.done).length;
472
+ const current = list.findIndex((i) => !i?.done);
473
+ const now = current === -1 ? 'done' : clip(String(list[current]?.text ?? '').trim(), 40);
474
+ return ` ${sky(`plan ${done}/${list.length}`)} ${chalk.white(now)}`;
475
+ }
476
+
477
+ /**
478
+ * Narration: what the agent is doing, as opposed to what it has to say.
479
+ *
480
+ * These lines are scaffolding — "Reading screen.js", "Checking types". They
481
+ * are worth seeing and not worth reading, and at full strength they compete
482
+ * with the answer, which is the thing the user is actually here for. A
483
+ * terminal has no smaller size to set, so the only axis available is weight:
484
+ * faint, and a step down in colour. The answer stays at full strength and
485
+ * wins the page by contrast rather than by shouting.
486
+ */
487
+ export const narration = (text) => chalk.dim(text);
488
+
489
+ /**
490
+ * The bullet beside a narration line: present, not loud.
491
+ *
492
+ * Three shapes rather than one dot repeated. Every step drawn identically made
493
+ * a long transcript a column of the same mark forty times over, which reads as
494
+ * output rather than as work — and the shape is free, where a fourth colour
495
+ * would not be. A diamond is hollow when the agent is only looking at
496
+ * something and filled when it changes it, and a run of a command points
497
+ * forward. Anything unmapped keeps the original dot.
498
+ *
499
+ * All of them stay dim: the glyph carries the kind, the weight still says this
500
+ * is scaffolding and the answer below is the thing to read.
501
+ */
502
+ const MARKS = {
503
+ Reading: ['◇', deep], Listing: ['◇', deep], Looking: ['◇', deep],
504
+ Asking: ['◇', deep], Mapping: ['◇', deep], Searching: ['◇', deep],
505
+ Finding: ['◇', deep],
506
+ Writing: ['◆', blue], Editing: ['◆', blue], Adding: ['◆', blue],
507
+ Renaming: ['◆', blue],
508
+ Running: ['▸', sky], Checking: ['▸', sky],
509
+ };
510
+
511
+ export const narrationMark = (kind) => {
512
+ const [glyph, paint] = MARKS[kind] ?? ['●', deep];
513
+ return chalk.dim(paint(glyph));
514
+ };
515
+
516
+ /**
517
+ * The file or command a step is about, lit so the line can be scanned.
518
+ *
519
+ * "Which file did it touch" is the one question a reader puts to a transcript
520
+ * of tool calls, and dimming the whole line made the answer as faint as the
521
+ * verb in front of it. The verb stays faint — there are only a dozen of them
522
+ * and they repeat — and the part that differs every time carries the colour.
523
+ */
524
+ const paintStep = (label) => {
525
+ const text = String(label ?? '');
526
+ const space = text.indexOf(' ');
527
+ if (space < 0) return narration(text);
528
+
529
+ const target = text.slice(space + 1);
530
+ // "Read 2 files" is a tally, not a path. Lighting it up would point the eye
531
+ // at a number that says nothing about where the work happened.
532
+ if (/^\d/.test(target)) return narration(text);
533
+
534
+ return `${narration(text.slice(0, space))} ${blue(target)}`;
535
+ };
536
+
537
+ /**
538
+ * How a run of the same kind of step reads once it is over.
539
+ *
540
+ * While it happens, "Running npm test" is the useful thing to show. Once
541
+ * three of them have happened, three near-identical lines are just noise
542
+ * between the reader and the answer, so they fold into one: "Ran 3 commands".
543
+ * The present tense belongs to the thing happening now; the past tense to the
544
+ * summary of what did.
545
+ */
546
+ const GROUPS = {
547
+ Running: ['Ran', 'command', 'commands'],
548
+ Reading: ['Read', 'file', 'files'],
549
+ Searching: ['Searched', 'time', 'times'],
550
+ Finding: ['Found', 'pattern', 'patterns'],
551
+ Listing: ['Listed', 'directory', 'directories'],
552
+ Writing: ['Wrote', 'file', 'files'],
553
+ Editing: ['Edited', 'file', 'files'],
554
+ Checking: ['Checked', 'thing', 'things'],
555
+ Looking: ['Looked up', 'name', 'names'],
556
+ Asking: ['Asked about', 'name', 'names'],
557
+ Mapping: ['Mapped', 'folder', 'folders'],
558
+ Adding: ['Added', 'block', 'blocks'],
559
+ Renaming: ['Renamed', 'name', 'names'],
560
+ };
561
+
562
+ /** The first word of a label, which is what decides whether two steps match. */
563
+ export const groupKind = (label) => String(label ?? '').trim().split(/\s+/)[0] ?? '';
564
+
565
+ /** One line standing in for `count` steps that all began with the same word. */
566
+ export function groupLabel(label, count) {
567
+ if (count <= 1) return String(label ?? '');
568
+ const g = GROUPS[groupKind(label)];
569
+ if (!g) return `${label} (+${count - 1} more)`;
570
+ const [past, one, many] = g;
571
+ return `${past} ${count} ${count === 1 ? one : many}`;
572
+ }
573
+
574
+ /** The part of a label after its opening word: the file or command it is about. */
575
+ export const groupTarget = (label) => String(label ?? '').trim().split(/\s+/).slice(1).join(' ');
576
+
577
+ /**
578
+ * One narration line, standing for everything that happened under it.
579
+ *
580
+ * The transcript is a record of what was done, not a copy of what was
581
+ * written. A 539-line file printed into it buries the answer and tells the
582
+ * reader nothing they could not get from the file itself, so a change is its
583
+ * two numbers. Several steps on one file stay one line naming that file;
584
+ * several files become a count.
585
+ *
586
+ * The line comes back painted, so the label handed in has to be through
587
+ * asLabel() already: run that over this and its regexes would be reading
588
+ * escape sequences instead of the last word.
589
+ */
590
+ export function runLine({ label, count = 1, targets = [], added = 0, removed = 0, stat = '' }) {
591
+ // What came of the step, in the same place a change puts its two numbers:
592
+ // on the line that named the step, never underneath it. A look that found
593
+ // three things to fix is the one fact worth carrying out of a look, and
594
+ // without it the most thorough check ucode runs is the quietest thing on
595
+ // screen — it opens the app at two widths, screenshots both and has them
596
+ // reviewed, and said nothing about any of it.
597
+ const counts = added || removed
598
+ ? ` ${chalk.hex('#3fb950')(`+${added}`)} ${chalk.hex('#f2939c')(`-${removed}`)}`
599
+ : (stat ? ` ${sky(stat)}` : '');
600
+ if (count <= 1) return `${paintStep(label)}${counts}`;
601
+
602
+ const g = GROUPS[groupKind(label)];
603
+ const unique = [...new Set(targets.filter(Boolean))];
604
+ if (g && unique.length === 1) return `${paintStep(`${g[0]} ${unique[0]}`)}${counts}`;
605
+ if (!g) return `${paintStep(`${label} (+${count - 1} more)`)}${counts}`;
606
+ return `${paintStep(`${g[0]} ${count} ${count === 1 ? g[1] : g[2]}`)}${counts}`;
607
+ }
608
+
609
+ /**
610
+ * The reply, with any pasted code taken out of it.
611
+ *
612
+ * The model is asked not to paste code into its answer, and mostly does not.
613
+ * When it does, a fenced block of forty lines pushes the two sentences worth
614
+ * reading off the screen — and the code is already in the file it just wrote.
615
+ * A fence becomes a note of what it was, and the prose stays.
616
+ *
617
+ * A short block is left alone: three lines showing a command to run, or the
618
+ * one line that changed, is the kind of thing worth having in the answer.
619
+ */
620
+ /**
621
+ * A sentence that exists only to introduce what comes next.
622
+ *
623
+ * "Here's the complete app:" followed by forty lines of code, with the code
624
+ * taken out, is a colon pointing at nothing — which reads as the reply having
625
+ * been cut off mid-thought. The lead-in goes with what it was leading to.
626
+ */
627
+ const LEAD_IN = /(?:^|\n)[^\n]{0,80}:[ \t]*\n+$/;
628
+
629
+ export function withoutCodeBlocks(text, keepLines = 4) {
630
+ const FENCE = /```([A-Za-z0-9+-]*)\n([\s\S]*?)```/g;
631
+ return String(text ?? '').replace(FENCE, (all, lang, body) => {
632
+ const lines = body.replace(/\n+$/, '').split('\n');
633
+ if (lines.length <= keepLines) return all;
634
+ const what = lang ? `${lang} ` : '';
635
+ return `_[${lines.length} lines of ${what}code — it is in the file, not worth repeating here]_`;
636
+ });
637
+ }
638
+
639
+ /**
640
+ * The reply as it should be read: no pasted code, and no sentence left
641
+ * pointing at code that is no longer there.
642
+ */
643
+ /**
644
+ * The reply as it should be read.
645
+ *
646
+ * A long pasted block goes, and so does the sentence that introduced it — a
647
+ * colon pointing at nothing reads as the reply having been cut off. A short
648
+ * block stays: three lines showing a command to run belong in an answer.
649
+ */
650
+ export function tidyReply(text, keepLines = 4, prompt = '') {
651
+ const MARK = "\u0000CUT\u0000";
652
+ const FENCE = new RegExp("```([A-Za-z0-9+-]*)\\n([\\s\\S]*?)```", "g");
653
+
654
+ const marked = String(text ?? "").replace(FENCE, (all, lang, body) => {
655
+ const rows = body.replace(new RegExp("\\n+$"), "").split("\n");
656
+ return rows.length <= keepLines ? all : MARK;
657
+ });
658
+
659
+ const leadIn = new RegExp("(?:^|\\n)[^\\n]{0,80}:[ \t]*\\n+" + MARK, "g");
660
+ const body = marked
661
+ .replace(leadIn, "\n")
662
+ .split(MARK).join("")
663
+ .replace(new RegExp("\\n{3,}", "g"), "\n\n")
664
+ .trim();
665
+ return withoutRestatement(body, prompt);
666
+ }
667
+
668
+ /**
669
+ * The words of a line worth comparing: lowercase, no punctuation, and nothing
670
+ * short enough to turn up in any sentence at all.
671
+ */
672
+ const significant = (s) => String(s ?? '')
673
+ .toLowerCase()
674
+ .replace(/[^a-z0-9\s]+/g, ' ')
675
+ .split(/\s+/)
676
+ .filter((w) => w.length >= 3);
677
+
678
+ /**
679
+ * Is this line the request handed back?
680
+ *
681
+ * A list of phrases catches the openings a model reaches for out of habit, but
682
+ * the commonest way of repeating a request is simply saying it again in the
683
+ * asker's own words — "A Next.js habit tracker with a clean dashboard, coming
684
+ * right up" — and no list will ever match that. So the line is compared with
685
+ * what was actually typed.
686
+ *
687
+ * Three guards keep it off real answers. A prompt of four significant words or
688
+ * fewer is never matched, because at that length an overlap means nothing. A
689
+ * line much longer than the prompt is saying more than the prompt did, so it
690
+ * is content. And the bar is four fifths of the prompt's words rather than a
691
+ * majority: an answer naturally shares nouns with the request that prompted
692
+ * it, and only something repeating nearly all of it is a repetition.
693
+ */
694
+ export function echoesPrompt(line, prompt) {
695
+ const want = [...new Set(significant(prompt))];
696
+ if (want.length < 4) return false;
697
+
698
+ const text = String(line ?? '');
699
+ if (text.length > String(prompt ?? '').length * 2.5) return false;
700
+
701
+ const have = new Set(significant(text));
702
+ return want.filter((w) => have.has(w)).length / want.length >= 0.8;
703
+ }
704
+
705
+ /**
706
+ * The reply with any opening that reads the request back taken off the front.
707
+ *
708
+ * This ran only on the closing message before, and it belongs on every one. A
709
+ * model that answers "You asked me to add a dark mode toggle — done" has spent
710
+ * its first line telling someone something they typed themselves, and the line
711
+ * directly above it on screen is already their own message, in their own
712
+ * words, against a rail. Two copies of the request and one of the answer is
713
+ * the wrong ratio.
714
+ *
715
+ * It never returns nothing. A reply that is only a restatement is still the
716
+ * whole of the reply, and an empty answer on screen reads as a crash.
717
+ */
718
+ export function withoutRestatement(text, prompt = '') {
719
+ const rows = String(text ?? '').replace(/\r/g, '').split('\n');
720
+
721
+ let start = 0;
722
+ while (start < rows.length) {
723
+ const line = rows[start].trim();
724
+ if (!line) { start++; continue; }
725
+ if (!RESTATED.test(line) && !echoesPrompt(line, prompt)) break;
726
+ start++;
727
+ }
728
+
729
+ return rows.slice(start).join('\n').trim() || String(text ?? '').trim();
730
+ }
731
+
732
+ /**
733
+ * The closing message, cut to what a terminal can take.
734
+ *
735
+ * A model that finishes a build by walking back through the request — every
736
+ * feature ticked off, every file listed — leaves that as the last thing on
737
+ * screen, and the whole session then reads like a status report. Eight lines
738
+ * is the whole of it: what it is, and how to try it.
739
+ *
740
+ * What goes: an opening that reads the request back, and the middle of a list
741
+ * too long to be worth reading. What stays: the first lines, the line that
742
+ * admits something is unfinished, and the line naming a file or a command —
743
+ * the two the user actually acts on, and both of them live at the end.
744
+ */
745
+ /**
746
+ * The closing message is read once, at the end, by someone who watched the
747
+ * whole build happen. What was made, where to see it, what is in it — that is
748
+ * three lines and a spare. Eight was room to re-narrate the build, and that is
749
+ * exactly what it filled with: the request read back, every feature ticked
750
+ * off, every file listed, none of it news to the person who just watched it
751
+ * scroll past.
752
+ */
753
+ export const ANSWER_LINES = 5;
754
+ const ANSWER_ROOM = 600; // eight wrapped lines of prose, for a reply with no line breaks in it
755
+
756
+ export const RESTATED = /^(?:(?:sure|ok|okay|got it|understood|alright|right)\b[\s,!.—-]*)?(?:you(?:'ve| have)? (?:asked|want|wanted|requested|said|would like|need)\b|the (?:request|task|ask)\b|as (?:you )?requested\b|here(?:'s| is) what (?:you asked|i)\b|i(?:'ll| will|'m going to| am going to) (?:build|create|make|add|write|implement)\b|let(?:'s| us) (?:build|create|make|add|write|implement)\b|to (?:summarise|summarize|recap)\b|(?:request|task|summary|recap|overview)\s*:)/i;
757
+ 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;
758
+ 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;
759
+ const BULLET = /^\s*(?:[-*•>]|\d+[.)]|[✓✔✅☑])\s+/;
760
+
761
+ export function trimAnswer(text, max = ANSWER_LINES) {
762
+ const all = String(text ?? '').replace(/\r/g, '').split('\n');
763
+
764
+ let start = 0;
765
+ while (start < all.length && (!all[start].trim() || RESTATED.test(all[start].trim()))) start++;
766
+ const rows = all.slice(start);
767
+
768
+ const body = rows.map((row, i) => ({ row, i })).filter((r) => r.row.trim());
769
+ if (!body.length) return '';
770
+
771
+ let kept;
772
+ if (body.length <= max) {
773
+ kept = body.map((r) => r.i);
774
+ } else {
775
+ // Searched from the end: the caveat and the how-to-try-it line are the
776
+ // last things written, and they are the two worth pulling out of the part
777
+ // being dropped.
778
+ const tail = body.slice(Math.max(1, max - 2));
779
+ const pick = (re) => [...tail].reverse().find((r) => re.test(r.row))?.i;
780
+ const rescued = [...new Set([pick(CAVEAT), pick(ACTIONABLE)])].filter((i) => i !== undefined);
781
+ const head = body.slice(0, max - rescued.length).map((r) => r.i);
782
+ kept = [...new Set([...head, ...rescued])].sort((a, b) => a - b);
783
+ }
784
+
785
+ const out = [];
786
+ let previous = -1;
787
+ for (const i of kept) {
788
+ if (previous >= 0 && i > previous + 1) out.push(''); // a gap in the middle is a paragraph break
789
+ // A line lifted out of a list is no longer in one.
790
+ out.push(previous >= 0 && i > previous + 1 ? rows[i].replace(BULLET, '') : rows[i]);
791
+ previous = i;
792
+ }
793
+
794
+ return withinRoom(out.join('\n').replace(/\n{3,}/g, '\n\n').trim());
795
+ }
796
+
797
+ /**
798
+ * One long paragraph is one line and fills the screen anyway. Whole sentences
799
+ * only: a reply cut mid-clause reads as a crash rather than as an ending.
800
+ */
801
+ function withinRoom(text, room = ANSWER_ROOM) {
802
+ if (text.length <= room) return text;
803
+
804
+ const parts = text.split(/(?<=[.!?])(\s+)/);
805
+ let out = '';
806
+ let sentences = 0;
807
+ for (let i = 0; i < parts.length; i += 2) {
808
+ const next = out + parts[i] + (parts[i + 1] ?? '');
809
+ if (sentences >= 2 && next.trimEnd().length > room) break;
810
+ out = next;
811
+ sentences++;
812
+ }
813
+ return (out.trim() || text.slice(0, room)).trim();
814
+ }