ucode-agent 1.8.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/ui/screen.js +3 -3
- package/src/ui/theme.js +339 -322
package/package.json
CHANGED
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, BG_ON, BG_OFF, onBackground } from './theme.js';
|
|
42
|
+
shortenPath, asLabel, ensureColour, planLine, bare, BG_ON, BG_OFF, BG_WINDOW, BG_WINDOW_OFF, onBackground } 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';
|
|
@@ -149,7 +149,7 @@ export class Screen {
|
|
|
149
149
|
|
|
150
150
|
async start() {
|
|
151
151
|
ensureColour(this.output);
|
|
152
|
-
this.output.write(ALT_ON + MOUSE_ON + HIDE + BG_ON + `${ESC}[2J` + title(`ucode — ${path.basename(this.cwd)}`));
|
|
152
|
+
this.output.write(ALT_ON + MOUSE_ON + HIDE + BG_WINDOW + BG_ON + `${ESC}[2J` + title(`ucode — ${path.basename(this.cwd)}`));
|
|
153
153
|
this.input.setRawMode?.(true);
|
|
154
154
|
this.input.resume();
|
|
155
155
|
this.input.setEncoding('utf8');
|
|
@@ -173,7 +173,7 @@ export class Screen {
|
|
|
173
173
|
this.output.off?.('resize', this.onResize);
|
|
174
174
|
this.input.setRawMode?.(false);
|
|
175
175
|
this.input.pause();
|
|
176
|
-
this.output.write(BG_OFF + MOUSE_OFF + ALT_OFF + SHOW);
|
|
176
|
+
this.output.write(BG_OFF + BG_WINDOW_OFF + MOUSE_OFF + ALT_OFF + SHOW);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
close() {
|
package/src/ui/theme.js
CHANGED
|
@@ -1,322 +1,339 @@
|
|
|
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
|
-
export function planLine(items) {
|
|
267
|
-
const list = (Array.isArray(items) ? items : []).slice(0, 6);
|
|
268
|
-
if (!list.length) return '';
|
|
269
|
-
const done = list.filter((i) => i?.done).length;
|
|
270
|
-
const current = list.findIndex((i) => !i?.done);
|
|
271
|
-
const parts = list.map((item, i) => {
|
|
272
|
-
const text = clip(String(item?.text ?? '').trim(), 30);
|
|
273
|
-
if (item?.done) return `${theme.ok('✓')} ${dim(text)}`;
|
|
274
|
-
if (i === current) return `${blue('▸')} ${chalk.white(text)}`;
|
|
275
|
-
return dim(`○ ${text}`);
|
|
276
|
-
});
|
|
277
|
-
return ` ${sky(`plan ${done}/${list.length}`)} ${parts.join(dim(' · '))}`;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* The background ucode paints behind itself.
|
|
282
|
-
*
|
|
283
|
-
* A terminal's own background is whatever the person set it to years ago:
|
|
284
|
-
* white, solarized, a photograph. The interface was drawn for a dark one, and
|
|
285
|
-
* on a light terminal the dim greys it relies on turn to near-invisible smoke.
|
|
286
|
-
* So ucode paints its own ground for as long as it is running, and the
|
|
287
|
-
* alternate screen gives it back untouched on exit.
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
return
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*/
|
|
319
|
-
export
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
+
export function planLine(items) {
|
|
267
|
+
const list = (Array.isArray(items) ? items : []).slice(0, 6);
|
|
268
|
+
if (!list.length) return '';
|
|
269
|
+
const done = list.filter((i) => i?.done).length;
|
|
270
|
+
const current = list.findIndex((i) => !i?.done);
|
|
271
|
+
const parts = list.map((item, i) => {
|
|
272
|
+
const text = clip(String(item?.text ?? '').trim(), 30);
|
|
273
|
+
if (item?.done) return `${theme.ok('✓')} ${dim(text)}`;
|
|
274
|
+
if (i === current) return `${blue('▸')} ${chalk.white(text)}`;
|
|
275
|
+
return dim(`○ ${text}`);
|
|
276
|
+
});
|
|
277
|
+
return ` ${sky(`plan ${done}/${list.length}`)} ${parts.join(dim(' · '))}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The background ucode paints behind itself.
|
|
282
|
+
*
|
|
283
|
+
* A terminal's own background is whatever the person set it to years ago:
|
|
284
|
+
* white, solarized, a photograph. The interface was drawn for a dark one, and
|
|
285
|
+
* on a light terminal the dim greys it relies on turn to near-invisible smoke.
|
|
286
|
+
* So ucode paints its own ground for as long as it is running, and the
|
|
287
|
+
* alternate screen gives it back untouched on exit.
|
|
288
|
+
*
|
|
289
|
+
* Two things are needed, not one. Painting each row covers the rows ucode
|
|
290
|
+
* draws; it cannot reach the margin a terminal keeps below the last line or
|
|
291
|
+
* beside the last column, which stays the old colour and shows as a border of
|
|
292
|
+
* the wrong shade. So the terminal is also told, once, what its own background
|
|
293
|
+
* is — and told to put it back on the way out.
|
|
294
|
+
*/
|
|
295
|
+
export const BACKGROUND = process.env.UCODE_BG || '#000000';
|
|
296
|
+
|
|
297
|
+
const rgb = (hex) => {
|
|
298
|
+
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
|
|
299
|
+
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [19, 19, 22];
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/** Turn the background on. Everything painted after this sits on it. */
|
|
303
|
+
export const BG_ON = (() => {
|
|
304
|
+
if (process.env.NO_COLOR || process.env.UCODE_BG === 'off') return '';
|
|
305
|
+
const [r, g, b] = rgb(BACKGROUND);
|
|
306
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
307
|
+
})();
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Tell the terminal what its own background is.
|
|
311
|
+
*
|
|
312
|
+
* Painting each row covers the rows ucode draws, and nothing else. It cannot
|
|
313
|
+
* reach the margin a terminal keeps below the last line or beside the last
|
|
314
|
+
* column, which stays the old colour and reads as a border in the wrong
|
|
315
|
+
* shade. OSC 11 sets the window's background itself, which does reach those
|
|
316
|
+
* edges. A terminal that does not know the sequence ignores it in silence,
|
|
317
|
+
* and the per-row painting still covers everything ucode draws.
|
|
318
|
+
*/
|
|
319
|
+
export const BG_WINDOW = BG_ON ? `\x1b]11;${BACKGROUND}\x07` : '';
|
|
320
|
+
|
|
321
|
+
/** Put the terminal's own background back, exactly as it was. */
|
|
322
|
+
export const BG_WINDOW_OFF = BG_ON ? '\x1b]111\x07' : '';
|
|
323
|
+
|
|
324
|
+
/** Hand the terminal its own colours back. */
|
|
325
|
+
export const BG_OFF = BG_ON ? '\x1b[0m' : '';
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Keep the background on across a line that resets it.
|
|
329
|
+
*
|
|
330
|
+
* chalk closes a foreground with 39 and a background with 49, and 49 means
|
|
331
|
+
* "the terminal's default" — which is exactly the colour being painted over.
|
|
332
|
+
* A diff line, which sets its own background, would therefore punch a hole
|
|
333
|
+
* through to the terminal's ground for the rest of the line. Re-asserting the
|
|
334
|
+
* background after every reset closes those holes.
|
|
335
|
+
*/
|
|
336
|
+
export function onBackground(text) {
|
|
337
|
+
if (!BG_ON) return text;
|
|
338
|
+
return BG_ON + String(text).replace(/\x1b\[(?:0|49)m/g, (m) => m + BG_ON);
|
|
339
|
+
}
|