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