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/README.md +6 -5
- package/package.json +5 -3
- package/src/core/csscheck.js +77 -0
- package/src/core/history.js +6 -1
- package/src/core/jslogic.js +69 -0
- package/src/core/loop.js +3005 -2821
- package/src/core/provider.js +931 -924
- package/src/core/relink.js +2 -1
- package/src/tools/blocks.js +2 -2
- package/src/tools/browser.js +546 -496
- package/src/tools/files.js +750 -743
- package/src/tools/index.js +13 -10
- package/src/tools/rename.js +2 -2
- package/src/tools/scaffold.js +439 -424
- package/src/tools/shared.js +126 -0
- package/src/tools/shell.js +844 -818
- package/src/ui/plain.js +353 -353
- package/src/ui/screen.js +1699 -1692
- package/src/ui/theme.js +814 -776
- package/ucode.js +1 -1
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const
|
|
443
|
-
const
|
|
444
|
-
return
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
* The
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*/
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
})
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* The reply
|
|
611
|
-
*
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*/
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
.
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
*
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
* the
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
export
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
}
|
|
775
|
-
|
|
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
|
+
}
|