ucode-agent 1.23.0 → 1.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA and Cohere models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
package/src/core/loop.js CHANGED
@@ -435,6 +435,13 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
435
435
  '',
436
436
  '## How to work',
437
437
  '',
438
+ 'AN APP THAT IS AWKWARD TO USE IS NOT FINISHED. Before you call it done, look at',
439
+ 'what you built as someone using it for the first time: is the button inside its',
440
+ 'field or sitting on top of it, is there room to breathe between things, does the',
441
+ 'empty state say anything, can every control be reached from the keyboard, does',
442
+ 'it hold together at 360px wide. Controls that overlap or crowd each other are a',
443
+ 'bug in the same way a crash is - fix it before you say a word about being done.',
444
+ '',
438
445
  'Before building anything, turn the request into a list of what it must do — every',
439
446
  'feature named, and the ones any user would expect whether or not they were named',
440
447
  '(an empty state, an error state, the keyboard doing the obvious thing, working on',
@@ -489,7 +496,9 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
489
496
  'Open with one line: I will build Tide for you - a tasks app in a single HTML',
490
497
  'file. Then one line each time you start a new piece of the work: Now the',
491
498
  'components. / Now making the filter row. / Onto the animations. Then one',
492
- 'line at the end: what it does and how to try it.',
499
+ 'line at the end saying it is done. Do not explain where the file is or how to',
500
+ 'open it - they asked for it, they know where it went, and being told reads as',
501
+ 'being talked down to.',
493
502
  '',
494
503
  'Those lines are the whole of what the user reads. They are not thinking out',
495
504
  'loud: never "I need to", never "Let me", never "The user wants", never a plan',
package/src/ui/screen.js CHANGED
@@ -39,7 +39,7 @@ import chalk from 'chalk';
39
39
  import {
40
40
  theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
41
41
  boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
42
- shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, withoutCodeBlocks } from './theme.js';
42
+ shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, tidyReply } from './theme.js';
43
43
  import { FRAME_MS, fitActivity, shimmer, spinnerGlyph, formatDuration, doneLine, stepPaint } from './activity.js';
44
44
  import { renderer, render, polish } from './markdown.js';
45
45
  import { VERSION } from '../core/version.js';
@@ -258,7 +258,7 @@ export class Screen {
258
258
  if (!text?.trim()) return;
259
259
  this.endRun();
260
260
  this.add('');
261
- this.add(render(this.md, withoutCodeBlocks(text)));
261
+ this.add(render(this.md, tidyReply(text)));
262
262
  this.add('');
263
263
  this.render();
264
264
  }
@@ -777,7 +777,9 @@ export class Screen {
777
777
  const a = this.activity;
778
778
  const since = a?.start ?? this.status.since ?? now;
779
779
  const meta = [];
780
- if (a?.steps) meta.push({ text: `step ${a.steps}`, paint: stepPaint(now - a.movedAt < 900) });
780
+ // No step count. It measures how much machinery ran, which is not
781
+ // something the person waiting has any use for; the elapsed time is.
782
+ void stepPaint;
781
783
  if (now - since >= 1000) meta.push({ text: formatDuration(now - since), keep: true });
782
784
  middle = fitActivity({
783
785
  glyph: spinnerGlyph(this.tick, now),
package/src/ui/theme.js CHANGED
@@ -1,399 +1,436 @@
1
+ /**
2
+ * theme.js — colour, boxes, and the string maths that keeps a terminal frame
3
+ * from tearing.
4
+ *
5
+ * Everything visual comes from here so the whole interface can be re-tinted by
6
+ * editing one block. ucode is blue: a single hue, three steps of it, and
7
+ * nothing else decorative. Red, amber and green are reserved — they mean
8
+ * failed, careful, and done, and they never appear for any other reason.
9
+ */
10
+
11
+ import chalk from 'chalk';
12
+
13
+ // One hue, three weights. Anything that needs a fourth is asking for emphasis
14
+ // it has not earned.
15
+ export const blue = chalk.hex('#4d8dff'); // structure: borders, the caret, the wordmark
16
+ export const sky = chalk.hex('#8fbcff'); // secondary: labels that still matter
17
+ export const deep = chalk.hex('#2f6fe0'); // pressed, quiet, behind
18
+ export const dim = chalk.dim;
19
+
20
+ /**
21
+ * The input box's own edge: the same blue, drawn bold.
22
+ *
23
+ * The input is the one thing on screen you act on, so it is the one box that
24
+ * gets the heavier line. Bold box-drawing renders brighter, and in most
25
+ * terminal fonts visibly thicker, which is enough to separate "where you type"
26
+ * from "what you are reading" without a second colour.
27
+ */
28
+ export const edge = chalk.hex('#4d8dff').bold;
29
+
30
+ /**
31
+ * The colour level to use for a stream, or null to leave chalk's guess alone.
32
+ *
33
+ * chalk decides from the environment, and some environments lie: TERM=dumb
34
+ * from an embedding shell, or a wrapper that strips COLORTERM. The result is a
35
+ * UI with every colour silently gone — a grey box where a blue one was drawn.
36
+ *
37
+ * The full-screen interface already depends on a terminal that understands VT
38
+ * sequences — it switches to the alternate screen and moves the cursor — and
39
+ * any terminal that handles those handles colour. So when that interface is
40
+ * running, the guess is overruled. NO_COLOR is still honoured, because that
41
+ * one is a person's explicit choice rather than an environment's accident.
42
+ */
43
+ export function colourLevel(stream, env = process.env, current = chalk.level) {
44
+ if ('NO_COLOR' in env) return null;
45
+ if (!stream?.isTTY) return null;
46
+ if (current >= 2) return null;
47
+ return env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' || process.platform === 'win32' ? 3 : 2;
48
+ }
49
+
50
+ export function ensureColour(stream) {
51
+ const level = colourLevel(stream);
52
+ if (level !== null) chalk.level = level;
53
+ }
54
+
55
+ export const theme = {
56
+ blue,
57
+ sky,
58
+ deep,
59
+ dim,
60
+ text: chalk.white,
61
+ error: chalk.red,
62
+ warn: chalk.hex('#e0a030'),
63
+ ok: chalk.hex('#3fb950'),
64
+ };
65
+
66
+ /** Tints for a diff: enough colour to scan, dim enough to read code through. */
67
+ export const ADDED = chalk.bgHex('#0e2a1a').hex('#7ee2a8');
68
+ export const REMOVED = chalk.bgHex('#331319').hex('#f2939c');
69
+
70
+ export const BANNER = [
71
+ '██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗',
72
+ '██║ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝',
73
+ '██║ ██║██║ ██║ ██║██║ ██║█████╗ ',
74
+ '██║ ██║██║ ██║ ██║██║ ██║██╔══╝ ',
75
+ '╚██████╔╝╚██████╗╚██████╔╝██████╔╝███████╗',
76
+ ' ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝',
77
+ ];
78
+
79
+ export const BANNER_WIDTH = Math.max(...BANNER.map((r) => r.length));
80
+
81
+ /** The spinner. Braille dots, because they animate in place without jitter. */
82
+ export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Boxes
86
+ // ---------------------------------------------------------------------------
87
+
88
+ export const BOX = {
89
+ topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯',
90
+ h: '─', v: '│',
91
+ };
92
+
93
+ export const boxTop = (width, paint = blue) =>
94
+ paint(BOX.topLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.topRight);
95
+
96
+ export const boxBottom = (width, paint = blue) =>
97
+ paint(BOX.bottomLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.bottomRight);
98
+
99
+ /** One row inside a box, padded so the right border lands in the same column. */
100
+ export const boxRow = (content, width, paint = blue) =>
101
+ paint(BOX.v) + padVis(content, Math.max(0, width - 2)) + paint(BOX.v);
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Widths, with escape codes discounted
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /** The string with its colour codes stripped — what the terminal actually shows. */
108
+ export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
109
+ export const visLen = (s) => bare(s).length;
110
+
111
+ /** The first `width` visible characters, with escape sequences left intact. */
112
+ export function sliceVis(s, width) {
113
+ let out = '';
114
+ let seen = 0;
115
+ for (let i = 0; i < s.length; i++) {
116
+ if (s[i] === '\x1b') {
117
+ const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
118
+ if (m) { out += m[0]; i += m[0].length - 1; continue; }
119
+ }
120
+ if (seen >= width) break;
121
+ out += s[i];
122
+ seen++;
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /** Pad or hard-cut a possibly-coloured string to an exact visible width. */
128
+ export function padVis(s, width) {
129
+ const len = visLen(s);
130
+ if (len === width) return s;
131
+ if (len < width) return s + ' '.repeat(width - len);
132
+ return `${sliceVis(s, width)}\x1b[0m`;
133
+ }
134
+
135
+ export function clip(text, max) {
136
+ const s = String(text ?? '');
137
+ if (max <= 1) return '';
138
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
139
+ }
140
+
141
+ /**
142
+ * Word-wrap text that may already be coloured.
143
+ *
144
+ * Escape sequences have no width, and whichever styles are open at a break get
145
+ * reopened on the next line — otherwise a wrapped sentence loses its colour
146
+ * halfway through.
147
+ */
148
+ export function wrapAnsi(text, width) {
149
+ if (width < 4) return [text];
150
+
151
+ const lines = [];
152
+ let line = '';
153
+ let seen = 0;
154
+ let open = '';
155
+ let lastSpace = -1;
156
+ let lastSpaceSeen = 0;
157
+
158
+ const flush = (upto = null) => {
159
+ if (upto === null) {
160
+ lines.push(line);
161
+ line = open;
162
+ seen = 0;
163
+ } else {
164
+ lines.push(line.slice(0, upto));
165
+ const carry = line.slice(upto).replace(/^ +/, '');
166
+ line = open + carry;
167
+ seen = visLen(carry);
168
+ }
169
+ lastSpace = -1;
170
+ };
171
+
172
+ for (let i = 0; i < text.length; i++) {
173
+ if (text[i] === '\x1b') {
174
+ const m = /^\x1b\[[0-9;]*m/.exec(text.slice(i));
175
+ if (m) {
176
+ line += m[0];
177
+ open = m[0] === '\x1b[0m' ? '' : open + m[0];
178
+ i += m[0].length - 1;
179
+ continue;
180
+ }
181
+ }
182
+ if (text[i] === ' ') { lastSpace = line.length; lastSpaceSeen = seen; }
183
+ line += text[i];
184
+ seen++;
185
+ if (seen >= width) {
186
+ // Break at a word boundary unless that would leave a stub behind.
187
+ if (lastSpace > 0 && lastSpaceSeen > width * 0.4) flush(lastSpace);
188
+ else flush();
189
+ }
190
+ }
191
+
192
+ if (visLen(line)) lines.push(line);
193
+ return lines.length ? lines : [''];
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Small formatters
198
+ // ---------------------------------------------------------------------------
199
+
200
+ export function formatTokens(n) {
201
+ if (!n) return '0';
202
+ if (n < 1000) return String(n);
203
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
204
+ return `${(n / 1_000_000).toFixed(1)}M`;
205
+ }
206
+
207
+ export function today() {
208
+ return new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
209
+ }
210
+
211
+ /** Shorten a path for display: home becomes ~, a long middle collapses. */
212
+ export function shortenPath(p, max = 40) {
213
+ let out = String(p);
214
+ const home = process.env.USERPROFILE || process.env.HOME || '';
215
+ if (home && out.startsWith(home)) out = `~${out.slice(home.length)}`;
216
+ if (out.length <= max) return out;
217
+
218
+ const parts = out.split(/[\\/]/);
219
+ if (parts.length <= 3) return `…${out.slice(-(max - 1))}`;
220
+ const sep = out.includes('\\') ? '\\' : '/';
221
+ return `${parts[0]}${sep}…${sep}${parts.slice(-2).join(sep)}`;
222
+ }
223
+
224
+ export function relativeTime(iso) {
225
+ if (!iso) return 'unknown';
226
+ const then = new Date(iso).getTime();
227
+ if (Number.isNaN(then)) return 'unknown';
228
+
229
+ const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
230
+ if (secs < 60) return 'just now';
231
+ const mins = Math.round(secs / 60);
232
+ if (mins < 60) return `${mins}m ago`;
233
+ const hours = Math.round(mins / 60);
234
+ if (hours < 24) return `${hours}h ago`;
235
+ const days = Math.round(hours / 24);
236
+ return days < 30 ? `${days}d ago` : new Date(iso).toISOString().slice(0, 10);
237
+ }
238
+
239
+ /**
240
+ * Trim a trailing full stop off a live status line.
241
+ *
242
+ * "Listing src" is a label on work in progress. "Listing src." is a sentence,
243
+ * and a sentence that ends while the thing it describes is still happening
244
+ * reads as finished when it is not. Models add the full stop by habit; this
245
+ * takes it back off.
246
+ *
247
+ * Only after a word, though. A dot that follows a space is the whole point of
248
+ * the line — "Listing ." names the current directory — and trimming that turns
249
+ * a label into a fragment.
250
+ */
251
+ export function asLabel(text) {
252
+ return String(text ?? '')
253
+ .trim()
254
+ .replace(/\s+/g, ' ')
255
+ // A trailing stop, from a model's sentence or a tool's own output
256
+ // ("Building…", "Completing…"), is noise on a one-line label. A dot that
257
+ // is the argument itself — "Listing ." — is not, so a word has to come
258
+ // before it.
259
+ .replace(/(?<=[\w)\]"'`])[.。…]+$/, '');
260
+ }
261
+
262
+ /**
263
+ * The model's checklist, as one short line — done ticked, the current item
264
+ * marked, the rest dim — so progress is visible without taking over the screen.
265
+ */
266
+ /**
267
+ * The plan, as a block rather than a sentence.
268
+ *
269
+ * Six steps joined with separators made one line far wider than any terminal,
270
+ * so it wrapped — and a wrapped checklist has its ticks in the middle of the
271
+ * text, which is unreadable. Down the page each step keeps its own row, its
272
+ * mark stays in the left column, and the eye can find the one in progress
273
+ * without reading any of the others.
274
+ *
275
+ * Returns the rows; the caller pushes them.
276
+ */
277
+ export function planRows(items) {
278
+ const list = (Array.isArray(items) ? items : []).slice(0, 8);
279
+ if (!list.length) return [];
280
+ const done = list.filter((i) => i?.done).length;
281
+ const current = list.findIndex((i) => !i?.done);
282
+
283
+ const rows = [` ${sky(`plan ${done}/${list.length}`)}`];
284
+ list.forEach((item, i) => {
285
+ const text = clip(String(item?.text ?? '').trim(), 64);
286
+ if (item?.done) rows.push(` ${theme.ok('✓')} ${dim(text)}`);
287
+ else if (i === current) rows.push(` ${blue('▸')} ${chalk.white(text)}`);
288
+ else rows.push(` ${dim('○')} ${dim(text)}`);
289
+ });
290
+ return rows;
291
+ }
292
+
293
+ /** Kept for the plain interface, which has one line to work with. */
294
+ export function planLine(items) {
295
+ const list = (Array.isArray(items) ? items : []).slice(0, 6);
296
+ if (!list.length) return '';
297
+ const done = list.filter((i) => i?.done).length;
298
+ const current = list.findIndex((i) => !i?.done);
299
+ const now = current === -1 ? 'done' : clip(String(list[current]?.text ?? '').trim(), 40);
300
+ return ` ${sky(`plan ${done}/${list.length}`)} ${chalk.white(now)}`;
301
+ }
302
+
303
+ /**
304
+ * Narration: what the agent is doing, as opposed to what it has to say.
305
+ *
306
+ * These lines are scaffolding — "Reading screen.js", "Checking types". They
307
+ * are worth seeing and not worth reading, and at full strength they compete
308
+ * with the answer, which is the thing the user is actually here for. A
309
+ * terminal has no smaller size to set, so the only axis available is weight:
310
+ * faint, and a step down in colour. The answer stays at full strength and
311
+ * wins the page by contrast rather than by shouting.
312
+ */
313
+ export const narration = (text) => chalk.dim(text);
314
+
315
+ /** The bullet beside a narration line: present, not loud. */
316
+ export const narrationMark = () => chalk.dim(deep('●'));
317
+
318
+ /**
319
+ * How a run of the same kind of step reads once it is over.
320
+ *
321
+ * While it happens, "Running npm test" is the useful thing to show. Once
322
+ * three of them have happened, three near-identical lines are just noise
323
+ * between the reader and the answer, so they fold into one: "Ran 3 commands".
324
+ * The present tense belongs to the thing happening now; the past tense to the
325
+ * summary of what did.
326
+ */
327
+ const GROUPS = {
328
+ Running: ['Ran', 'command', 'commands'],
329
+ Reading: ['Read', 'file', 'files'],
330
+ Searching: ['Searched', 'time', 'times'],
331
+ Finding: ['Found', 'pattern', 'patterns'],
332
+ Listing: ['Listed', 'directory', 'directories'],
333
+ Writing: ['Wrote', 'file', 'files'],
334
+ Editing: ['Edited', 'file', 'files'],
335
+ Checking: ['Checked', 'thing', 'things'],
336
+ Looking: ['Looked up', 'name', 'names'],
337
+ Asking: ['Asked about', 'name', 'names'],
338
+ Mapping: ['Mapped', 'folder', 'folders'],
339
+ Adding: ['Added', 'block', 'blocks'],
340
+ Renaming: ['Renamed', 'name', 'names'],
341
+ };
342
+
343
+ /** The first word of a label, which is what decides whether two steps match. */
344
+ export const groupKind = (label) => String(label ?? '').trim().split(/\s+/)[0] ?? '';
345
+
346
+ /** One line standing in for `count` steps that all began with the same word. */
347
+ export function groupLabel(label, count) {
348
+ if (count <= 1) return String(label ?? '');
349
+ const g = GROUPS[groupKind(label)];
350
+ if (!g) return `${label} (+${count - 1} more)`;
351
+ const [past, one, many] = g;
352
+ return `${past} ${count} ${count === 1 ? one : many}`;
353
+ }
354
+
355
+ /** The part of a label after its opening word: the file or command it is about. */
356
+ export const groupTarget = (label) => String(label ?? '').trim().split(/\s+/).slice(1).join(' ');
357
+
358
+ /**
359
+ * One narration line, standing for everything that happened under it.
360
+ *
361
+ * The transcript is a record of what was done, not a copy of what was
362
+ * written. A 539-line file printed into it buries the answer and tells the
363
+ * reader nothing they could not get from the file itself, so a change is its
364
+ * two numbers. Several steps on one file stay one line naming that file;
365
+ * several files become a count.
366
+ */
367
+ export function runLine({ label, count = 1, targets = [], added = 0, removed = 0 }) {
368
+ const counts = added || removed
369
+ ? ` ${chalk.hex('#3fb950')(`+${added}`)} ${chalk.hex('#f2939c')(`-${removed}`)}`
370
+ : '';
371
+ if (count <= 1) return `${label}${counts}`;
372
+
373
+ const g = GROUPS[groupKind(label)];
374
+ const unique = [...new Set(targets.filter(Boolean))];
375
+ if (g && unique.length === 1) return `${g[0]} ${unique[0]}${counts}`;
376
+ if (!g) return `${label} (+${count - 1} more)${counts}`;
377
+ return `${g[0]} ${count} ${count === 1 ? g[1] : g[2]}${counts}`;
378
+ }
379
+
380
+ /**
381
+ * The reply, with any pasted code taken out of it.
382
+ *
383
+ * The model is asked not to paste code into its answer, and mostly does not.
384
+ * When it does, a fenced block of forty lines pushes the two sentences worth
385
+ * reading off the screen — and the code is already in the file it just wrote.
386
+ * A fence becomes a note of what it was, and the prose stays.
387
+ *
388
+ * A short block is left alone: three lines showing a command to run, or the
389
+ * one line that changed, is the kind of thing worth having in the answer.
390
+ */
391
+ /**
392
+ * A sentence that exists only to introduce what comes next.
393
+ *
394
+ * "Here's the complete app:" followed by forty lines of code, with the code
395
+ * taken out, is a colon pointing at nothing — which reads as the reply having
396
+ * been cut off mid-thought. The lead-in goes with what it was leading to.
397
+ */
398
+ const LEAD_IN = /(?:^|\n)[^\n]{0,80}:[ \t]*\n+$/;
399
+
400
+ export function withoutCodeBlocks(text, keepLines = 4) {
401
+ const FENCE = /```([A-Za-z0-9+-]*)\n([\s\S]*?)```/g;
402
+ return String(text ?? '').replace(FENCE, (all, lang, body) => {
403
+ const lines = body.replace(/\n+$/, '').split('\n');
404
+ if (lines.length <= keepLines) return all;
405
+ const what = lang ? `${lang} ` : '';
406
+ return `_[${lines.length} lines of ${what}code — it is in the file, not worth repeating here]_`;
407
+ });
408
+ }
409
+
410
+ /**
411
+ * The reply as it should be read: no pasted code, and no sentence left
412
+ * pointing at code that is no longer there.
413
+ */
1
414
  /**
2
- * theme.js colour, boxes, and the string maths that keeps a terminal frame
3
- * from tearing.
415
+ * The reply as it should be read.
4
416
  *
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.
417
+ * A long pasted block goes, and so does the sentence that introduced it a
418
+ * colon pointing at nothing reads as the reply having been cut off. A short
419
+ * block stays: three lines showing a command to run belong in an answer.
9
420
  */
421
+ export function tidyReply(text, keepLines = 4) {
422
+ const MARK = "\u0000CUT\u0000";
423
+ const FENCE = new RegExp("```([A-Za-z0-9+-]*)\\n([\\s\\S]*?)```", "g");
10
424
 
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)}`);
425
+ const marked = String(text ?? "").replace(FENCE, (all, lang, body) => {
426
+ const rows = body.replace(new RegExp("\\n+$"), "").split("\n");
427
+ return rows.length <= keepLines ? all : MARK;
289
428
  });
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
429
 
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
- export function withoutCodeBlocks(text, keepLines = 4) {
392
- const FENCE = /```([A-Za-z0-9+-]*)\n([\s\S]*?)```/g;
393
- return String(text ?? '').replace(FENCE, (all, lang, body) => {
394
- const lines = body.replace(/\n+$/, '').split('\n');
395
- if (lines.length <= keepLines) return all;
396
- const what = lang ? `${lang} ` : '';
397
- return `_[${lines.length} lines of ${what}code — it is in the file, not worth repeating here]_`;
398
- });
430
+ const leadIn = new RegExp("(?:^|\\n)[^\\n]{0,80}:[ \t]*\\n+" + MARK, "g");
431
+ return marked
432
+ .replace(leadIn, "\n")
433
+ .split(MARK).join("")
434
+ .replace(new RegExp("\\n{3,}", "g"), "\n\n")
435
+ .trim();
399
436
  }