ucode-agent 1.15.0 → 1.16.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/activity.js +203 -203
- package/src/ui/screen.js +34 -7
- package/src/ui/theme.js +399 -378
package/package.json
CHANGED
package/src/ui/activity.js
CHANGED
|
@@ -1,203 +1,203 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* activity.js — what the status row shows while ucode is working.
|
|
3
|
-
*
|
|
4
|
-
* A long turn is minutes of the agent doing things the user did not type and
|
|
5
|
-
* cannot see coming. The status row is the one place that says it is still
|
|
6
|
-
* going, so it has to look alive at a glance without asking to be read: a
|
|
7
|
-
* spinner that turns, a soft band of light passing across the label, the
|
|
8
|
-
* step count ticking up, and the time the turn has taken so far.
|
|
9
|
-
*
|
|
10
|
-
* Everything here is a pure function of the text and the clock, so it can be
|
|
11
|
-
* tested without a terminal and painted at any frame rate.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import chalk, { Chalk } from 'chalk';
|
|
15
|
-
import { dim, sky, theme, clip, SPINNER } from './theme.js';
|
|
16
|
-
|
|
17
|
-
/** One painter per colour level, so a test can ask for truecolour on a pipe. */
|
|
18
|
-
const painters = new Map();
|
|
19
|
-
const painter = (level) => {
|
|
20
|
-
if (!painters.has(level)) painters.set(level, new Chalk({ level }));
|
|
21
|
-
return painters.get(level);
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
/** One frame every 85ms — just under twelve a second, smooth without being busy. */
|
|
25
|
-
export const FRAME_MS = 85;
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* A duration as a person says it: 0.4s, 14s, 2m 04s, 1h 07m.
|
|
29
|
-
*
|
|
30
|
-
* Seconds are zero-padded once there are minutes, so the text after the timer
|
|
31
|
-
* does not shift sideways every time the seconds roll from 9 to 10.
|
|
32
|
-
*/
|
|
33
|
-
export function formatDuration(ms) {
|
|
34
|
-
const value = Math.max(0, Number(ms) || 0);
|
|
35
|
-
if (value < 1000) return `${(value / 1000).toFixed(1)}s`;
|
|
36
|
-
const total = Math.floor(value / 1000);
|
|
37
|
-
if (total < 60) return `${total}s`;
|
|
38
|
-
const minutes = Math.floor(total / 60);
|
|
39
|
-
if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, '0')}s`;
|
|
40
|
-
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
// The shimmer
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* The two ends of the shimmer, both blue. The resting colour is muted enough
|
|
49
|
-
* to read as secondary text beside the model name; the peak is almost white,
|
|
50
|
-
* so the band reads as light passing over the words rather than a second
|
|
51
|
-
* colour arriving.
|
|
52
|
-
*/
|
|
53
|
-
const REST_RGB = [0x7a, 0x96, 0xc8];
|
|
54
|
-
const PEAK_RGB = [0xe6, 0xf0, 0xff];
|
|
55
|
-
|
|
56
|
-
/** Half the width of the band of light, in characters. */
|
|
57
|
-
const BAND = 3;
|
|
58
|
-
|
|
59
|
-
/** How fast the band travels, in characters a second. */
|
|
60
|
-
const SPEED =
|
|
61
|
-
|
|
62
|
-
/** Characters' worth of dark between one pass and the next. */
|
|
63
|
-
const PAUSE =
|
|
64
|
-
|
|
65
|
-
/** Brightness steps. Neighbouring letters that land on the same step share one escape code. */
|
|
66
|
-
const STEPS = 8;
|
|
67
|
-
|
|
68
|
-
const mix = (a, b, k) => a.map((v, i) => Math.round(v + (b[i] - v) * k));
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* The text with a soft band of light passing across it, left to right, then a
|
|
72
|
-
* short rest, then again.
|
|
73
|
-
*
|
|
74
|
-
* `t` is milliseconds on any clock; the band's position is a function of it,
|
|
75
|
-
* so a slow frame skips ahead rather than slowing the sweep down.
|
|
76
|
-
*
|
|
77
|
-
* Needs 256 colours or more. With 16 there are no in-between blues to fade
|
|
78
|
-
* through, and a band that jumps between two colours reads as flicker rather
|
|
79
|
-
* than light — so below that the label is simply dim, and never moves.
|
|
80
|
-
*/
|
|
81
|
-
export function shimmer(text, t, { level = chalk.level } = {}) {
|
|
82
|
-
const s = String(text ?? '');
|
|
83
|
-
if (!s || level < 2) return dim(s);
|
|
84
|
-
|
|
85
|
-
const cycle = s.length + BAND * 2 + PAUSE;
|
|
86
|
-
const centre = ((Math.max(0, t) / 1000) * SPEED) % cycle - BAND;
|
|
87
|
-
|
|
88
|
-
let out = '';
|
|
89
|
-
let run = '';
|
|
90
|
-
let runStep = -1;
|
|
91
|
-
const flush = () => {
|
|
92
|
-
if (!run) return;
|
|
93
|
-
const [r, g, b] = mix(REST_RGB, PEAK_RGB, runStep / STEPS);
|
|
94
|
-
out += painter(level).rgb(r, g, b)(run);
|
|
95
|
-
run = '';
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
for (let i = 0; i < s.length; i++) {
|
|
99
|
-
const distance = Math.abs(i - centre);
|
|
100
|
-
// A cosine falloff: brightest at the centre, fading smoothly to nothing
|
|
101
|
-
// at the edge of the band, so the light has no hard edge to it.
|
|
102
|
-
const k = distance < BAND ? (Math.cos((Math.PI * distance) / BAND) + 1) / 2 : 0;
|
|
103
|
-
const step = Math.round(k * STEPS);
|
|
104
|
-
if (step !== runStep) { flush(); runStep = step; }
|
|
105
|
-
run += s[i];
|
|
106
|
-
}
|
|
107
|
-
flush();
|
|
108
|
-
return out;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* The spinner glyph for a frame, breathing slowly between two blues.
|
|
113
|
-
*
|
|
114
|
-
* The pulse is slow — a little over a second a breath — so it reads as the
|
|
115
|
-
* glyph being alive rather than as a blink.
|
|
116
|
-
*/
|
|
117
|
-
export function spinnerGlyph(frame, t, { level = chalk.level } = {}) {
|
|
118
|
-
const glyph = SPINNER[((frame % SPINNER.length) + SPINNER.length) % SPINNER.length];
|
|
119
|
-
if (level < 2) return theme.blue(glyph);
|
|
120
|
-
const k = (Math.sin((Math.max(0, t) / 1300) * Math.PI * 2) + 1) / 2;
|
|
121
|
-
const [r, g, b] = mix([0x4d, 0x8d, 0xff], [0x9f, 0xc6, 0xff], k);
|
|
122
|
-
return painter(level).rgb(r, g, b)(glyph);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ---------------------------------------------------------------------------
|
|
126
|
-
// Fitting it into the room there is
|
|
127
|
-
// ---------------------------------------------------------------------------
|
|
128
|
-
|
|
129
|
-
/** Shorter than this, a label is a stub that says nothing, so it goes entirely. */
|
|
130
|
-
const MIN_LABEL = 10;
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* The middle of the status row, fitted to `room` columns.
|
|
134
|
-
*
|
|
135
|
-
* Parts, in the order they are given up when the terminal is too narrow for
|
|
136
|
-
* all of them:
|
|
137
|
-
*
|
|
138
|
-
* 1. the "esc to stop" hint — useful once, known after that
|
|
139
|
-
* 2. the end of the label — clipped with an ellipsis, down to a stub
|
|
140
|
-
* 3. the step count
|
|
141
|
-
* 4. the label itself
|
|
142
|
-
* 5. the elapsed time
|
|
143
|
-
*
|
|
144
|
-
* The spinner is the last thing standing: even with a single column left the
|
|
145
|
-
* row still shows that something is happening.
|
|
146
|
-
*
|
|
147
|
-
* `meta` is a list of { text, paint, keep } — keep marks the one that survives
|
|
148
|
-
* the longest (the timer). `paint` colours the label, which is where the
|
|
149
|
-
* shimmer comes in.
|
|
150
|
-
*/
|
|
151
|
-
export function fitActivity({ glyph, label = '', meta = [], hint = '', paint = dim }, room) {
|
|
152
|
-
if (room < 1) return '';
|
|
153
|
-
const items = meta.filter((m) => m && m.text);
|
|
154
|
-
const kept = items.filter((m) => m.keep);
|
|
155
|
-
const text = String(label ?? '');
|
|
156
|
-
|
|
157
|
-
const width = (labelLen, list, withHint) =>
|
|
158
|
-
1 +
|
|
159
|
-
(labelLen ? 1 + labelLen : 0) +
|
|
160
|
-
(list.length ? (labelLen ? 3 : 1) + list.map((m) => m.text).join(' · ').length : 0) +
|
|
161
|
-
(withHint && hint ? 2 + hint.length : 0);
|
|
162
|
-
|
|
163
|
-
const build = (labelText, list, withHint) => {
|
|
164
|
-
let out = glyph;
|
|
165
|
-
if (labelText) out += ` ${paint(labelText)}`;
|
|
166
|
-
if (list.length) {
|
|
167
|
-
out += labelText ? dim(' · ') : ' ';
|
|
168
|
-
out += list.map((m) => (m.paint ?? dim)(m.text)).join(dim(' · '));
|
|
169
|
-
}
|
|
170
|
-
if (withHint && hint) out += ` ${dim(hint)}`;
|
|
171
|
-
return out;
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
if (text) {
|
|
175
|
-
if (width(text.length, items, true) <= room) return build(text, items, true);
|
|
176
|
-
if (width(text.length, items, false) <= room) return build(text, items, false);
|
|
177
|
-
for (const list of [items, kept]) {
|
|
178
|
-
const labelRoom = room - width(0, list, false) - 1 - (list.length ? 2 : 0);
|
|
179
|
-
if (labelRoom >= MIN_LABEL) return build(clip(text, labelRoom), list, false);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
for (const list of [items, kept, []]) {
|
|
183
|
-
if (width(0, list, false) <= room) return build('', list, false);
|
|
184
|
-
}
|
|
185
|
-
return glyph;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* The line a finished turn leaves in the transcript: "✓ Done in 6m 12s · 25 steps".
|
|
190
|
-
*
|
|
191
|
-
* Green for the tick, because green means done and nothing else in this
|
|
192
|
-
* theme; the rest dim, because it is a footnote to the answer above it rather
|
|
193
|
-
* than something to read first.
|
|
194
|
-
*/
|
|
195
|
-
export function doneLine(ms, steps) {
|
|
196
|
-
const count = steps > 0 ? ` · ${steps} step${steps === 1 ? '' : 's'}` : '';
|
|
197
|
-
return `${theme.ok('✓')} ${dim(`Done in ${formatDuration(ms)}${count}`)}`;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** The step count, brighter for a moment right after it goes up. */
|
|
201
|
-
export function stepPaint(justMoved) {
|
|
202
|
-
return justMoved ? sky : dim;
|
|
203
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* activity.js — what the status row shows while ucode is working.
|
|
3
|
+
*
|
|
4
|
+
* A long turn is minutes of the agent doing things the user did not type and
|
|
5
|
+
* cannot see coming. The status row is the one place that says it is still
|
|
6
|
+
* going, so it has to look alive at a glance without asking to be read: a
|
|
7
|
+
* spinner that turns, a soft band of light passing across the label, the
|
|
8
|
+
* step count ticking up, and the time the turn has taken so far.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is a pure function of the text and the clock, so it can be
|
|
11
|
+
* tested without a terminal and painted at any frame rate.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import chalk, { Chalk } from 'chalk';
|
|
15
|
+
import { dim, sky, theme, clip, SPINNER } from './theme.js';
|
|
16
|
+
|
|
17
|
+
/** One painter per colour level, so a test can ask for truecolour on a pipe. */
|
|
18
|
+
const painters = new Map();
|
|
19
|
+
const painter = (level) => {
|
|
20
|
+
if (!painters.has(level)) painters.set(level, new Chalk({ level }));
|
|
21
|
+
return painters.get(level);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** One frame every 85ms — just under twelve a second, smooth without being busy. */
|
|
25
|
+
export const FRAME_MS = 85;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A duration as a person says it: 0.4s, 14s, 2m 04s, 1h 07m.
|
|
29
|
+
*
|
|
30
|
+
* Seconds are zero-padded once there are minutes, so the text after the timer
|
|
31
|
+
* does not shift sideways every time the seconds roll from 9 to 10.
|
|
32
|
+
*/
|
|
33
|
+
export function formatDuration(ms) {
|
|
34
|
+
const value = Math.max(0, Number(ms) || 0);
|
|
35
|
+
if (value < 1000) return `${(value / 1000).toFixed(1)}s`;
|
|
36
|
+
const total = Math.floor(value / 1000);
|
|
37
|
+
if (total < 60) return `${total}s`;
|
|
38
|
+
const minutes = Math.floor(total / 60);
|
|
39
|
+
if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, '0')}s`;
|
|
40
|
+
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// The shimmer
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The two ends of the shimmer, both blue. The resting colour is muted enough
|
|
49
|
+
* to read as secondary text beside the model name; the peak is almost white,
|
|
50
|
+
* so the band reads as light passing over the words rather than a second
|
|
51
|
+
* colour arriving.
|
|
52
|
+
*/
|
|
53
|
+
const REST_RGB = [0x7a, 0x96, 0xc8];
|
|
54
|
+
const PEAK_RGB = [0xe6, 0xf0, 0xff];
|
|
55
|
+
|
|
56
|
+
/** Half the width of the band of light, in characters. */
|
|
57
|
+
const BAND = 3;
|
|
58
|
+
|
|
59
|
+
/** How fast the band travels, in characters a second. */
|
|
60
|
+
const SPEED = 46;
|
|
61
|
+
|
|
62
|
+
/** Characters' worth of dark between one pass and the next. */
|
|
63
|
+
const PAUSE = 10;
|
|
64
|
+
|
|
65
|
+
/** Brightness steps. Neighbouring letters that land on the same step share one escape code. */
|
|
66
|
+
const STEPS = 8;
|
|
67
|
+
|
|
68
|
+
const mix = (a, b, k) => a.map((v, i) => Math.round(v + (b[i] - v) * k));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The text with a soft band of light passing across it, left to right, then a
|
|
72
|
+
* short rest, then again.
|
|
73
|
+
*
|
|
74
|
+
* `t` is milliseconds on any clock; the band's position is a function of it,
|
|
75
|
+
* so a slow frame skips ahead rather than slowing the sweep down.
|
|
76
|
+
*
|
|
77
|
+
* Needs 256 colours or more. With 16 there are no in-between blues to fade
|
|
78
|
+
* through, and a band that jumps between two colours reads as flicker rather
|
|
79
|
+
* than light — so below that the label is simply dim, and never moves.
|
|
80
|
+
*/
|
|
81
|
+
export function shimmer(text, t, { level = chalk.level } = {}) {
|
|
82
|
+
const s = String(text ?? '');
|
|
83
|
+
if (!s || level < 2) return dim(s);
|
|
84
|
+
|
|
85
|
+
const cycle = s.length + BAND * 2 + PAUSE;
|
|
86
|
+
const centre = ((Math.max(0, t) / 1000) * SPEED) % cycle - BAND;
|
|
87
|
+
|
|
88
|
+
let out = '';
|
|
89
|
+
let run = '';
|
|
90
|
+
let runStep = -1;
|
|
91
|
+
const flush = () => {
|
|
92
|
+
if (!run) return;
|
|
93
|
+
const [r, g, b] = mix(REST_RGB, PEAK_RGB, runStep / STEPS);
|
|
94
|
+
out += painter(level).rgb(r, g, b)(run);
|
|
95
|
+
run = '';
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < s.length; i++) {
|
|
99
|
+
const distance = Math.abs(i - centre);
|
|
100
|
+
// A cosine falloff: brightest at the centre, fading smoothly to nothing
|
|
101
|
+
// at the edge of the band, so the light has no hard edge to it.
|
|
102
|
+
const k = distance < BAND ? (Math.cos((Math.PI * distance) / BAND) + 1) / 2 : 0;
|
|
103
|
+
const step = Math.round(k * STEPS);
|
|
104
|
+
if (step !== runStep) { flush(); runStep = step; }
|
|
105
|
+
run += s[i];
|
|
106
|
+
}
|
|
107
|
+
flush();
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The spinner glyph for a frame, breathing slowly between two blues.
|
|
113
|
+
*
|
|
114
|
+
* The pulse is slow — a little over a second a breath — so it reads as the
|
|
115
|
+
* glyph being alive rather than as a blink.
|
|
116
|
+
*/
|
|
117
|
+
export function spinnerGlyph(frame, t, { level = chalk.level } = {}) {
|
|
118
|
+
const glyph = SPINNER[((frame % SPINNER.length) + SPINNER.length) % SPINNER.length];
|
|
119
|
+
if (level < 2) return theme.blue(glyph);
|
|
120
|
+
const k = (Math.sin((Math.max(0, t) / 1300) * Math.PI * 2) + 1) / 2;
|
|
121
|
+
const [r, g, b] = mix([0x4d, 0x8d, 0xff], [0x9f, 0xc6, 0xff], k);
|
|
122
|
+
return painter(level).rgb(r, g, b)(glyph);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Fitting it into the room there is
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/** Shorter than this, a label is a stub that says nothing, so it goes entirely. */
|
|
130
|
+
const MIN_LABEL = 10;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The middle of the status row, fitted to `room` columns.
|
|
134
|
+
*
|
|
135
|
+
* Parts, in the order they are given up when the terminal is too narrow for
|
|
136
|
+
* all of them:
|
|
137
|
+
*
|
|
138
|
+
* 1. the "esc to stop" hint — useful once, known after that
|
|
139
|
+
* 2. the end of the label — clipped with an ellipsis, down to a stub
|
|
140
|
+
* 3. the step count
|
|
141
|
+
* 4. the label itself
|
|
142
|
+
* 5. the elapsed time
|
|
143
|
+
*
|
|
144
|
+
* The spinner is the last thing standing: even with a single column left the
|
|
145
|
+
* row still shows that something is happening.
|
|
146
|
+
*
|
|
147
|
+
* `meta` is a list of { text, paint, keep } — keep marks the one that survives
|
|
148
|
+
* the longest (the timer). `paint` colours the label, which is where the
|
|
149
|
+
* shimmer comes in.
|
|
150
|
+
*/
|
|
151
|
+
export function fitActivity({ glyph, label = '', meta = [], hint = '', paint = dim }, room) {
|
|
152
|
+
if (room < 1) return '';
|
|
153
|
+
const items = meta.filter((m) => m && m.text);
|
|
154
|
+
const kept = items.filter((m) => m.keep);
|
|
155
|
+
const text = String(label ?? '');
|
|
156
|
+
|
|
157
|
+
const width = (labelLen, list, withHint) =>
|
|
158
|
+
1 +
|
|
159
|
+
(labelLen ? 1 + labelLen : 0) +
|
|
160
|
+
(list.length ? (labelLen ? 3 : 1) + list.map((m) => m.text).join(' · ').length : 0) +
|
|
161
|
+
(withHint && hint ? 2 + hint.length : 0);
|
|
162
|
+
|
|
163
|
+
const build = (labelText, list, withHint) => {
|
|
164
|
+
let out = glyph;
|
|
165
|
+
if (labelText) out += ` ${paint(labelText)}`;
|
|
166
|
+
if (list.length) {
|
|
167
|
+
out += labelText ? dim(' · ') : ' ';
|
|
168
|
+
out += list.map((m) => (m.paint ?? dim)(m.text)).join(dim(' · '));
|
|
169
|
+
}
|
|
170
|
+
if (withHint && hint) out += ` ${dim(hint)}`;
|
|
171
|
+
return out;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (text) {
|
|
175
|
+
if (width(text.length, items, true) <= room) return build(text, items, true);
|
|
176
|
+
if (width(text.length, items, false) <= room) return build(text, items, false);
|
|
177
|
+
for (const list of [items, kept]) {
|
|
178
|
+
const labelRoom = room - width(0, list, false) - 1 - (list.length ? 2 : 0);
|
|
179
|
+
if (labelRoom >= MIN_LABEL) return build(clip(text, labelRoom), list, false);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const list of [items, kept, []]) {
|
|
183
|
+
if (width(0, list, false) <= room) return build('', list, false);
|
|
184
|
+
}
|
|
185
|
+
return glyph;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The line a finished turn leaves in the transcript: "✓ Done in 6m 12s · 25 steps".
|
|
190
|
+
*
|
|
191
|
+
* Green for the tick, because green means done and nothing else in this
|
|
192
|
+
* theme; the rest dim, because it is a footnote to the answer above it rather
|
|
193
|
+
* than something to read first.
|
|
194
|
+
*/
|
|
195
|
+
export function doneLine(ms, steps) {
|
|
196
|
+
const count = steps > 0 ? ` · ${steps} step${steps === 1 ? '' : 's'}` : '';
|
|
197
|
+
return `${theme.ok('✓')} ${dim(`Done in ${formatDuration(ms)}${count}`)}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The step count, brighter for a moment right after it goes up. */
|
|
201
|
+
export function stepPaint(justMoved) {
|
|
202
|
+
return justMoved ? sky : dim;
|
|
203
|
+
}
|
package/src/ui/screen.js
CHANGED
|
@@ -39,7 +39,7 @@ import chalk from 'chalk';
|
|
|
39
39
|
import {
|
|
40
40
|
theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
|
|
41
41
|
boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
|
|
42
|
-
shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows } from './theme.js';
|
|
42
|
+
shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, withoutCodeBlocks } 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';
|
|
@@ -103,6 +103,9 @@ const title = (t) => `${ESC}]0;${t}\x07`;
|
|
|
103
103
|
*/
|
|
104
104
|
const CHROME_BELOW = 6;
|
|
105
105
|
|
|
106
|
+
/** How long one sentence of reasoning holds the line before the next takes it. */
|
|
107
|
+
const THOUGHT_HOLD_MS = 1100;
|
|
108
|
+
|
|
106
109
|
/** The wordmark only earns its place with room for the facts column beside it. */
|
|
107
110
|
const WORDMARK_NEEDS = BANNER_WIDTH + 30;
|
|
108
111
|
|
|
@@ -252,7 +255,7 @@ export class Screen {
|
|
|
252
255
|
if (!text?.trim()) return;
|
|
253
256
|
this.endRun();
|
|
254
257
|
this.add('');
|
|
255
|
-
this.add(render(this.md, text));
|
|
258
|
+
this.add(render(this.md, withoutCodeBlocks(text)));
|
|
256
259
|
this.add('');
|
|
257
260
|
this.render();
|
|
258
261
|
}
|
|
@@ -282,6 +285,11 @@ export class Screen {
|
|
|
282
285
|
this.add(boxRow(` ${lead} ${chalk.white(row)}`, width, edge));
|
|
283
286
|
});
|
|
284
287
|
this.add(boxBottom(width, edge));
|
|
288
|
+
// Room between what you asked for and what came back. Without it the reply
|
|
289
|
+
// starts against the bottom of your own message and the two read as one
|
|
290
|
+
// block of text.
|
|
291
|
+
this.add('');
|
|
292
|
+
this.add('');
|
|
285
293
|
this.render();
|
|
286
294
|
}
|
|
287
295
|
|
|
@@ -392,10 +400,17 @@ export class Screen {
|
|
|
392
400
|
if (this.run) this.paintRun({ live: false });
|
|
393
401
|
}
|
|
394
402
|
|
|
395
|
-
|
|
396
|
-
|
|
403
|
+
/**
|
|
404
|
+
* Something went wrong, and the model is the one who can do anything about it.
|
|
405
|
+
*
|
|
406
|
+
* A red line of machinery — a failed edit, a command that exited non-zero —
|
|
407
|
+
* reads as the tool being broken, when almost always it is a step the model
|
|
408
|
+
* corrects on its own a second later. It goes to the model; the screen stays
|
|
409
|
+
* for what is being built. Whatever is genuinely unrecoverable surfaces as
|
|
410
|
+
* the model saying so in words, which is the form worth reading.
|
|
411
|
+
*/
|
|
412
|
+
toolFailed() {
|
|
397
413
|
this.endRun();
|
|
398
|
-
this.push(`${dim(' └ ')}${theme.error(summary)}`);
|
|
399
414
|
}
|
|
400
415
|
|
|
401
416
|
/**
|
|
@@ -550,11 +565,21 @@ export class Screen {
|
|
|
550
565
|
|
|
551
566
|
this.thought = ((this.thought ?? '') + text).slice(-2000);
|
|
552
567
|
// The last sentence it has finished, or what it has written of the next.
|
|
553
|
-
const parts = this.thought.split(/(?<=[.!?])\s
|
|
568
|
+
const parts = this.thought.split(/(?<=[.!?])\s+|(?<=[.!?])(?=[A-Z])/).filter((p) => p.trim());
|
|
554
569
|
const latest = (parts[parts.length - 1] ?? '').replace(/\s+/g, ' ').trim();
|
|
555
570
|
if (!latest) return;
|
|
556
571
|
|
|
557
|
-
|
|
572
|
+
// A sentence that is replaced the instant the next one arrives cannot be
|
|
573
|
+
// read — it flashes. Each one holds the line for long enough to take in,
|
|
574
|
+
// and whatever arrived meanwhile shows when its turn comes.
|
|
575
|
+
const now = Date.now();
|
|
576
|
+
if (latest !== this.shownThought) {
|
|
577
|
+
if (this.shownThought !== undefined && now - (this.shownAt ?? 0) < THOUGHT_HOLD_MS) return;
|
|
578
|
+
this.shownThought = latest;
|
|
579
|
+
this.shownAt = now;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const line = ` ${shimmer(clip(this.shownThought, Math.max(20, this.width() - 6)), this.tick * FRAME_MS)}`;
|
|
558
583
|
if (this.thinkAt === undefined || this.lines[this.thinkAt] === undefined) {
|
|
559
584
|
this.thinkAt = this.lines.length;
|
|
560
585
|
this.push(line);
|
|
@@ -580,6 +605,8 @@ export class Screen {
|
|
|
580
605
|
this.render();
|
|
581
606
|
}
|
|
582
607
|
this.thought = '';
|
|
608
|
+
this.shownThought = undefined;
|
|
609
|
+
this.shownAt = undefined;
|
|
583
610
|
this.thoughtSince = undefined;
|
|
584
611
|
}
|
|
585
612
|
|
package/src/ui/theme.js
CHANGED
|
@@ -1,378 +1,399 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* theme.js — colour, boxes, and the string maths that keeps a terminal frame
|
|
3
|
-
* from tearing.
|
|
4
|
-
*
|
|
5
|
-
* Everything visual comes from here so the whole interface can be re-tinted by
|
|
6
|
-
* editing one block. ucode is blue: a single hue, three steps of it, and
|
|
7
|
-
* nothing else decorative. Red, amber and green are reserved — they mean
|
|
8
|
-
* failed, careful, and done, and they never appear for any other reason.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import chalk from 'chalk';
|
|
12
|
-
|
|
13
|
-
// One hue, three weights. Anything that needs a fourth is asking for emphasis
|
|
14
|
-
// it has not earned.
|
|
15
|
-
export const blue = chalk.hex('#4d8dff'); // structure: borders, the caret, the wordmark
|
|
16
|
-
export const sky = chalk.hex('#8fbcff'); // secondary: labels that still matter
|
|
17
|
-
export const deep = chalk.hex('#2f6fe0'); // pressed, quiet, behind
|
|
18
|
-
export const dim = chalk.dim;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The input box's own edge: the same blue, drawn bold.
|
|
22
|
-
*
|
|
23
|
-
* The input is the one thing on screen you act on, so it is the one box that
|
|
24
|
-
* gets the heavier line. Bold box-drawing renders brighter, and in most
|
|
25
|
-
* terminal fonts visibly thicker, which is enough to separate "where you type"
|
|
26
|
-
* from "what you are reading" without a second colour.
|
|
27
|
-
*/
|
|
28
|
-
export const edge = chalk.hex('#4d8dff').bold;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* The colour level to use for a stream, or null to leave chalk's guess alone.
|
|
32
|
-
*
|
|
33
|
-
* chalk decides from the environment, and some environments lie: TERM=dumb
|
|
34
|
-
* from an embedding shell, or a wrapper that strips COLORTERM. The result is a
|
|
35
|
-
* UI with every colour silently gone — a grey box where a blue one was drawn.
|
|
36
|
-
*
|
|
37
|
-
* The full-screen interface already depends on a terminal that understands VT
|
|
38
|
-
* sequences — it switches to the alternate screen and moves the cursor — and
|
|
39
|
-
* any terminal that handles those handles colour. So when that interface is
|
|
40
|
-
* running, the guess is overruled. NO_COLOR is still honoured, because that
|
|
41
|
-
* one is a person's explicit choice rather than an environment's accident.
|
|
42
|
-
*/
|
|
43
|
-
export function colourLevel(stream, env = process.env, current = chalk.level) {
|
|
44
|
-
if ('NO_COLOR' in env) return null;
|
|
45
|
-
if (!stream?.isTTY) return null;
|
|
46
|
-
if (current >= 2) return null;
|
|
47
|
-
return env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' || process.platform === 'win32' ? 3 : 2;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function ensureColour(stream) {
|
|
51
|
-
const level = colourLevel(stream);
|
|
52
|
-
if (level !== null) chalk.level = level;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export const theme = {
|
|
56
|
-
blue,
|
|
57
|
-
sky,
|
|
58
|
-
deep,
|
|
59
|
-
dim,
|
|
60
|
-
text: chalk.white,
|
|
61
|
-
error: chalk.red,
|
|
62
|
-
warn: chalk.hex('#e0a030'),
|
|
63
|
-
ok: chalk.hex('#3fb950'),
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
/** Tints for a diff: enough colour to scan, dim enough to read code through. */
|
|
67
|
-
export const ADDED = chalk.bgHex('#0e2a1a').hex('#7ee2a8');
|
|
68
|
-
export const REMOVED = chalk.bgHex('#331319').hex('#f2939c');
|
|
69
|
-
|
|
70
|
-
export const BANNER = [
|
|
71
|
-
'██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗',
|
|
72
|
-
'██║ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝',
|
|
73
|
-
'██║ ██║██║ ██║ ██║██║ ██║█████╗ ',
|
|
74
|
-
'██║ ██║██║ ██║ ██║██║ ██║██╔══╝ ',
|
|
75
|
-
'╚██████╔╝╚██████╗╚██████╔╝██████╔╝███████╗',
|
|
76
|
-
' ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝',
|
|
77
|
-
];
|
|
78
|
-
|
|
79
|
-
export const BANNER_WIDTH = Math.max(...BANNER.map((r) => r.length));
|
|
80
|
-
|
|
81
|
-
/** The spinner. Braille dots, because they animate in place without jitter. */
|
|
82
|
-
export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
83
|
-
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
// Boxes
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
|
-
|
|
88
|
-
export const BOX = {
|
|
89
|
-
topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯',
|
|
90
|
-
h: '─', v: '│',
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
export const boxTop = (width, paint = blue) =>
|
|
94
|
-
paint(BOX.topLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.topRight);
|
|
95
|
-
|
|
96
|
-
export const boxBottom = (width, paint = blue) =>
|
|
97
|
-
paint(BOX.bottomLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.bottomRight);
|
|
98
|
-
|
|
99
|
-
/** One row inside a box, padded so the right border lands in the same column. */
|
|
100
|
-
export const boxRow = (content, width, paint = blue) =>
|
|
101
|
-
paint(BOX.v) + padVis(content, Math.max(0, width - 2)) + paint(BOX.v);
|
|
102
|
-
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
|
-
// Widths, with escape codes discounted
|
|
105
|
-
// ---------------------------------------------------------------------------
|
|
106
|
-
|
|
107
|
-
/** The string with its colour codes stripped — what the terminal actually shows. */
|
|
108
|
-
export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
109
|
-
export const visLen = (s) => bare(s).length;
|
|
110
|
-
|
|
111
|
-
/** The first `width` visible characters, with escape sequences left intact. */
|
|
112
|
-
export function sliceVis(s, width) {
|
|
113
|
-
let out = '';
|
|
114
|
-
let seen = 0;
|
|
115
|
-
for (let i = 0; i < s.length; i++) {
|
|
116
|
-
if (s[i] === '\x1b') {
|
|
117
|
-
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
118
|
-
if (m) { out += m[0]; i += m[0].length - 1; continue; }
|
|
119
|
-
}
|
|
120
|
-
if (seen >= width) break;
|
|
121
|
-
out += s[i];
|
|
122
|
-
seen++;
|
|
123
|
-
}
|
|
124
|
-
return out;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Pad or hard-cut a possibly-coloured string to an exact visible width. */
|
|
128
|
-
export function padVis(s, width) {
|
|
129
|
-
const len = visLen(s);
|
|
130
|
-
if (len === width) return s;
|
|
131
|
-
if (len < width) return s + ' '.repeat(width - len);
|
|
132
|
-
return `${sliceVis(s, width)}\x1b[0m`;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export function clip(text, max) {
|
|
136
|
-
const s = String(text ?? '');
|
|
137
|
-
if (max <= 1) return '';
|
|
138
|
-
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Word-wrap text that may already be coloured.
|
|
143
|
-
*
|
|
144
|
-
* Escape sequences have no width, and whichever styles are open at a break get
|
|
145
|
-
* reopened on the next line — otherwise a wrapped sentence loses its colour
|
|
146
|
-
* halfway through.
|
|
147
|
-
*/
|
|
148
|
-
export function wrapAnsi(text, width) {
|
|
149
|
-
if (width < 4) return [text];
|
|
150
|
-
|
|
151
|
-
const lines = [];
|
|
152
|
-
let line = '';
|
|
153
|
-
let seen = 0;
|
|
154
|
-
let open = '';
|
|
155
|
-
let lastSpace = -1;
|
|
156
|
-
let lastSpaceSeen = 0;
|
|
157
|
-
|
|
158
|
-
const flush = (upto = null) => {
|
|
159
|
-
if (upto === null) {
|
|
160
|
-
lines.push(line);
|
|
161
|
-
line = open;
|
|
162
|
-
seen = 0;
|
|
163
|
-
} else {
|
|
164
|
-
lines.push(line.slice(0, upto));
|
|
165
|
-
const carry = line.slice(upto).replace(/^ +/, '');
|
|
166
|
-
line = open + carry;
|
|
167
|
-
seen = visLen(carry);
|
|
168
|
-
}
|
|
169
|
-
lastSpace = -1;
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
for (let i = 0; i < text.length; i++) {
|
|
173
|
-
if (text[i] === '\x1b') {
|
|
174
|
-
const m = /^\x1b\[[0-9;]*m/.exec(text.slice(i));
|
|
175
|
-
if (m) {
|
|
176
|
-
line += m[0];
|
|
177
|
-
open = m[0] === '\x1b[0m' ? '' : open + m[0];
|
|
178
|
-
i += m[0].length - 1;
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
if (text[i] === ' ') { lastSpace = line.length; lastSpaceSeen = seen; }
|
|
183
|
-
line += text[i];
|
|
184
|
-
seen++;
|
|
185
|
-
if (seen >= width) {
|
|
186
|
-
// Break at a word boundary unless that would leave a stub behind.
|
|
187
|
-
if (lastSpace > 0 && lastSpaceSeen > width * 0.4) flush(lastSpace);
|
|
188
|
-
else flush();
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (visLen(line)) lines.push(line);
|
|
193
|
-
return lines.length ? lines : [''];
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ---------------------------------------------------------------------------
|
|
197
|
-
// Small formatters
|
|
198
|
-
// ---------------------------------------------------------------------------
|
|
199
|
-
|
|
200
|
-
export function formatTokens(n) {
|
|
201
|
-
if (!n) return '0';
|
|
202
|
-
if (n < 1000) return String(n);
|
|
203
|
-
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
|
|
204
|
-
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function today() {
|
|
208
|
-
return new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** Shorten a path for display: home becomes ~, a long middle collapses. */
|
|
212
|
-
export function shortenPath(p, max = 40) {
|
|
213
|
-
let out = String(p);
|
|
214
|
-
const home = process.env.USERPROFILE || process.env.HOME || '';
|
|
215
|
-
if (home && out.startsWith(home)) out = `~${out.slice(home.length)}`;
|
|
216
|
-
if (out.length <= max) return out;
|
|
217
|
-
|
|
218
|
-
const parts = out.split(/[\\/]/);
|
|
219
|
-
if (parts.length <= 3) return `…${out.slice(-(max - 1))}`;
|
|
220
|
-
const sep = out.includes('\\') ? '\\' : '/';
|
|
221
|
-
return `${parts[0]}${sep}…${sep}${parts.slice(-2).join(sep)}`;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export function relativeTime(iso) {
|
|
225
|
-
if (!iso) return 'unknown';
|
|
226
|
-
const then = new Date(iso).getTime();
|
|
227
|
-
if (Number.isNaN(then)) return 'unknown';
|
|
228
|
-
|
|
229
|
-
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
|
230
|
-
if (secs < 60) return 'just now';
|
|
231
|
-
const mins = Math.round(secs / 60);
|
|
232
|
-
if (mins < 60) return `${mins}m ago`;
|
|
233
|
-
const hours = Math.round(mins / 60);
|
|
234
|
-
if (hours < 24) return `${hours}h ago`;
|
|
235
|
-
const days = Math.round(hours / 24);
|
|
236
|
-
return days < 30 ? `${days}d ago` : new Date(iso).toISOString().slice(0, 10);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Trim a trailing full stop off a live status line.
|
|
241
|
-
*
|
|
242
|
-
* "Listing src" is a label on work in progress. "Listing src." is a sentence,
|
|
243
|
-
* and a sentence that ends while the thing it describes is still happening
|
|
244
|
-
* reads as finished when it is not. Models add the full stop by habit; this
|
|
245
|
-
* takes it back off.
|
|
246
|
-
*
|
|
247
|
-
* Only after a word, though. A dot that follows a space is the whole point of
|
|
248
|
-
* the line — "Listing ." names the current directory — and trimming that turns
|
|
249
|
-
* a label into a fragment.
|
|
250
|
-
*/
|
|
251
|
-
export function asLabel(text) {
|
|
252
|
-
return String(text ?? '')
|
|
253
|
-
.trim()
|
|
254
|
-
.replace(/\s+/g, ' ')
|
|
255
|
-
// A trailing stop, from a model's sentence or a tool's own output
|
|
256
|
-
// ("Building…", "Completing…"), is noise on a one-line label. A dot that
|
|
257
|
-
// is the argument itself — "Listing ." — is not, so a word has to come
|
|
258
|
-
// before it.
|
|
259
|
-
.replace(/(?<=[\w)\]"'`])[.。…]+$/, '');
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* The model's checklist, as one short line — done ticked, the current item
|
|
264
|
-
* marked, the rest dim — so progress is visible without taking over the screen.
|
|
265
|
-
*/
|
|
266
|
-
/**
|
|
267
|
-
* The plan, as a block rather than a sentence.
|
|
268
|
-
*
|
|
269
|
-
* Six steps joined with separators made one line far wider than any terminal,
|
|
270
|
-
* so it wrapped — and a wrapped checklist has its ticks in the middle of the
|
|
271
|
-
* text, which is unreadable. Down the page each step keeps its own row, its
|
|
272
|
-
* mark stays in the left column, and the eye can find the one in progress
|
|
273
|
-
* without reading any of the others.
|
|
274
|
-
*
|
|
275
|
-
* Returns the rows; the caller pushes them.
|
|
276
|
-
*/
|
|
277
|
-
export function planRows(items) {
|
|
278
|
-
const list = (Array.isArray(items) ? items : []).slice(0, 8);
|
|
279
|
-
if (!list.length) return [];
|
|
280
|
-
const done = list.filter((i) => i?.done).length;
|
|
281
|
-
const current = list.findIndex((i) => !i?.done);
|
|
282
|
-
|
|
283
|
-
const rows = [` ${sky(`plan ${done}/${list.length}`)}`];
|
|
284
|
-
list.forEach((item, i) => {
|
|
285
|
-
const text = clip(String(item?.text ?? '').trim(), 64);
|
|
286
|
-
if (item?.done) rows.push(` ${theme.ok('✓')} ${dim(text)}`);
|
|
287
|
-
else if (i === current) rows.push(` ${blue('▸')} ${chalk.white(text)}`);
|
|
288
|
-
else rows.push(` ${dim('○')} ${dim(text)}`);
|
|
289
|
-
});
|
|
290
|
-
return rows;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Kept for the plain interface, which has one line to work with. */
|
|
294
|
-
export function planLine(items) {
|
|
295
|
-
const list = (Array.isArray(items) ? items : []).slice(0, 6);
|
|
296
|
-
if (!list.length) return '';
|
|
297
|
-
const done = list.filter((i) => i?.done).length;
|
|
298
|
-
const current = list.findIndex((i) => !i?.done);
|
|
299
|
-
const now = current === -1 ? 'done' : clip(String(list[current]?.text ?? '').trim(), 40);
|
|
300
|
-
return ` ${sky(`plan ${done}/${list.length}`)} ${chalk.white(now)}`;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Narration: what the agent is doing, as opposed to what it has to say.
|
|
305
|
-
*
|
|
306
|
-
* These lines are scaffolding — "Reading screen.js", "Checking types". They
|
|
307
|
-
* are worth seeing and not worth reading, and at full strength they compete
|
|
308
|
-
* with the answer, which is the thing the user is actually here for. A
|
|
309
|
-
* terminal has no smaller size to set, so the only axis available is weight:
|
|
310
|
-
* faint, and a step down in colour. The answer stays at full strength and
|
|
311
|
-
* wins the page by contrast rather than by shouting.
|
|
312
|
-
*/
|
|
313
|
-
export const narration = (text) => chalk.dim(text);
|
|
314
|
-
|
|
315
|
-
/** The bullet beside a narration line: present, not loud. */
|
|
316
|
-
export const narrationMark = () => chalk.dim(deep('●'));
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* How a run of the same kind of step reads once it is over.
|
|
320
|
-
*
|
|
321
|
-
* While it happens, "Running npm test" is the useful thing to show. Once
|
|
322
|
-
* three of them have happened, three near-identical lines are just noise
|
|
323
|
-
* between the reader and the answer, so they fold into one: "Ran 3 commands".
|
|
324
|
-
* The present tense belongs to the thing happening now; the past tense to the
|
|
325
|
-
* summary of what did.
|
|
326
|
-
*/
|
|
327
|
-
const GROUPS = {
|
|
328
|
-
Running: ['Ran', 'command', 'commands'],
|
|
329
|
-
Reading: ['Read', 'file', 'files'],
|
|
330
|
-
Searching: ['Searched', 'time', 'times'],
|
|
331
|
-
Finding: ['Found', 'pattern', 'patterns'],
|
|
332
|
-
Listing: ['Listed', 'directory', 'directories'],
|
|
333
|
-
Writing: ['Wrote', 'file', 'files'],
|
|
334
|
-
Editing: ['Edited', 'file', 'files'],
|
|
335
|
-
Checking: ['Checked', 'thing', 'things'],
|
|
336
|
-
Looking: ['Looked up', 'name', 'names'],
|
|
337
|
-
Asking: ['Asked about', 'name', 'names'],
|
|
338
|
-
Mapping: ['Mapped', 'folder', 'folders'],
|
|
339
|
-
Adding: ['Added', 'block', 'blocks'],
|
|
340
|
-
Renaming: ['Renamed', 'name', 'names'],
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
/** The first word of a label, which is what decides whether two steps match. */
|
|
344
|
-
export const groupKind = (label) => String(label ?? '').trim().split(/\s+/)[0] ?? '';
|
|
345
|
-
|
|
346
|
-
/** One line standing in for `count` steps that all began with the same word. */
|
|
347
|
-
export function groupLabel(label, count) {
|
|
348
|
-
if (count <= 1) return String(label ?? '');
|
|
349
|
-
const g = GROUPS[groupKind(label)];
|
|
350
|
-
if (!g) return `${label} (+${count - 1} more)`;
|
|
351
|
-
const [past, one, many] = g;
|
|
352
|
-
return `${past} ${count} ${count === 1 ? one : many}`;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/** The part of a label after its opening word: the file or command it is about. */
|
|
356
|
-
export const groupTarget = (label) => String(label ?? '').trim().split(/\s+/).slice(1).join(' ');
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* One narration line, standing for everything that happened under it.
|
|
360
|
-
*
|
|
361
|
-
* The transcript is a record of what was done, not a copy of what was
|
|
362
|
-
* written. A 539-line file printed into it buries the answer and tells the
|
|
363
|
-
* reader nothing they could not get from the file itself, so a change is its
|
|
364
|
-
* two numbers. Several steps on one file stay one line naming that file;
|
|
365
|
-
* several files become a count.
|
|
366
|
-
*/
|
|
367
|
-
export function runLine({ label, count = 1, targets = [], added = 0, removed = 0 }) {
|
|
368
|
-
const counts = added || removed
|
|
369
|
-
? ` ${chalk.hex('#3fb950')(`+${added}`)} ${chalk.hex('#f2939c')(`-${removed}`)}`
|
|
370
|
-
: '';
|
|
371
|
-
if (count <= 1) return `${label}${counts}`;
|
|
372
|
-
|
|
373
|
-
const g = GROUPS[groupKind(label)];
|
|
374
|
-
const unique = [...new Set(targets.filter(Boolean))];
|
|
375
|
-
if (g && unique.length === 1) return `${g[0]} ${unique[0]}${counts}`;
|
|
376
|
-
if (!g) return `${label} (+${count - 1} more)${counts}`;
|
|
377
|
-
return `${g[0]} ${count} ${count === 1 ? g[1] : g[2]}${counts}`;
|
|
378
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* theme.js — colour, boxes, and the string maths that keeps a terminal frame
|
|
3
|
+
* from tearing.
|
|
4
|
+
*
|
|
5
|
+
* Everything visual comes from here so the whole interface can be re-tinted by
|
|
6
|
+
* editing one block. ucode is blue: a single hue, three steps of it, and
|
|
7
|
+
* nothing else decorative. Red, amber and green are reserved — they mean
|
|
8
|
+
* failed, careful, and done, and they never appear for any other reason.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
|
|
13
|
+
// One hue, three weights. Anything that needs a fourth is asking for emphasis
|
|
14
|
+
// it has not earned.
|
|
15
|
+
export const blue = chalk.hex('#4d8dff'); // structure: borders, the caret, the wordmark
|
|
16
|
+
export const sky = chalk.hex('#8fbcff'); // secondary: labels that still matter
|
|
17
|
+
export const deep = chalk.hex('#2f6fe0'); // pressed, quiet, behind
|
|
18
|
+
export const dim = chalk.dim;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The input box's own edge: the same blue, drawn bold.
|
|
22
|
+
*
|
|
23
|
+
* The input is the one thing on screen you act on, so it is the one box that
|
|
24
|
+
* gets the heavier line. Bold box-drawing renders brighter, and in most
|
|
25
|
+
* terminal fonts visibly thicker, which is enough to separate "where you type"
|
|
26
|
+
* from "what you are reading" without a second colour.
|
|
27
|
+
*/
|
|
28
|
+
export const edge = chalk.hex('#4d8dff').bold;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The colour level to use for a stream, or null to leave chalk's guess alone.
|
|
32
|
+
*
|
|
33
|
+
* chalk decides from the environment, and some environments lie: TERM=dumb
|
|
34
|
+
* from an embedding shell, or a wrapper that strips COLORTERM. The result is a
|
|
35
|
+
* UI with every colour silently gone — a grey box where a blue one was drawn.
|
|
36
|
+
*
|
|
37
|
+
* The full-screen interface already depends on a terminal that understands VT
|
|
38
|
+
* sequences — it switches to the alternate screen and moves the cursor — and
|
|
39
|
+
* any terminal that handles those handles colour. So when that interface is
|
|
40
|
+
* running, the guess is overruled. NO_COLOR is still honoured, because that
|
|
41
|
+
* one is a person's explicit choice rather than an environment's accident.
|
|
42
|
+
*/
|
|
43
|
+
export function colourLevel(stream, env = process.env, current = chalk.level) {
|
|
44
|
+
if ('NO_COLOR' in env) return null;
|
|
45
|
+
if (!stream?.isTTY) return null;
|
|
46
|
+
if (current >= 2) return null;
|
|
47
|
+
return env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' || process.platform === 'win32' ? 3 : 2;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ensureColour(stream) {
|
|
51
|
+
const level = colourLevel(stream);
|
|
52
|
+
if (level !== null) chalk.level = level;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const theme = {
|
|
56
|
+
blue,
|
|
57
|
+
sky,
|
|
58
|
+
deep,
|
|
59
|
+
dim,
|
|
60
|
+
text: chalk.white,
|
|
61
|
+
error: chalk.red,
|
|
62
|
+
warn: chalk.hex('#e0a030'),
|
|
63
|
+
ok: chalk.hex('#3fb950'),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Tints for a diff: enough colour to scan, dim enough to read code through. */
|
|
67
|
+
export const ADDED = chalk.bgHex('#0e2a1a').hex('#7ee2a8');
|
|
68
|
+
export const REMOVED = chalk.bgHex('#331319').hex('#f2939c');
|
|
69
|
+
|
|
70
|
+
export const BANNER = [
|
|
71
|
+
'██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗',
|
|
72
|
+
'██║ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝',
|
|
73
|
+
'██║ ██║██║ ██║ ██║██║ ██║█████╗ ',
|
|
74
|
+
'██║ ██║██║ ██║ ██║██║ ██║██╔══╝ ',
|
|
75
|
+
'╚██████╔╝╚██████╗╚██████╔╝██████╔╝███████╗',
|
|
76
|
+
' ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝',
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
export const BANNER_WIDTH = Math.max(...BANNER.map((r) => r.length));
|
|
80
|
+
|
|
81
|
+
/** The spinner. Braille dots, because they animate in place without jitter. */
|
|
82
|
+
export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Boxes
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export const BOX = {
|
|
89
|
+
topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯',
|
|
90
|
+
h: '─', v: '│',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const boxTop = (width, paint = blue) =>
|
|
94
|
+
paint(BOX.topLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.topRight);
|
|
95
|
+
|
|
96
|
+
export const boxBottom = (width, paint = blue) =>
|
|
97
|
+
paint(BOX.bottomLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.bottomRight);
|
|
98
|
+
|
|
99
|
+
/** One row inside a box, padded so the right border lands in the same column. */
|
|
100
|
+
export const boxRow = (content, width, paint = blue) =>
|
|
101
|
+
paint(BOX.v) + padVis(content, Math.max(0, width - 2)) + paint(BOX.v);
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Widths, with escape codes discounted
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/** The string with its colour codes stripped — what the terminal actually shows. */
|
|
108
|
+
export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
109
|
+
export const visLen = (s) => bare(s).length;
|
|
110
|
+
|
|
111
|
+
/** The first `width` visible characters, with escape sequences left intact. */
|
|
112
|
+
export function sliceVis(s, width) {
|
|
113
|
+
let out = '';
|
|
114
|
+
let seen = 0;
|
|
115
|
+
for (let i = 0; i < s.length; i++) {
|
|
116
|
+
if (s[i] === '\x1b') {
|
|
117
|
+
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
118
|
+
if (m) { out += m[0]; i += m[0].length - 1; continue; }
|
|
119
|
+
}
|
|
120
|
+
if (seen >= width) break;
|
|
121
|
+
out += s[i];
|
|
122
|
+
seen++;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Pad or hard-cut a possibly-coloured string to an exact visible width. */
|
|
128
|
+
export function padVis(s, width) {
|
|
129
|
+
const len = visLen(s);
|
|
130
|
+
if (len === width) return s;
|
|
131
|
+
if (len < width) return s + ' '.repeat(width - len);
|
|
132
|
+
return `${sliceVis(s, width)}\x1b[0m`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function clip(text, max) {
|
|
136
|
+
const s = String(text ?? '');
|
|
137
|
+
if (max <= 1) return '';
|
|
138
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Word-wrap text that may already be coloured.
|
|
143
|
+
*
|
|
144
|
+
* Escape sequences have no width, and whichever styles are open at a break get
|
|
145
|
+
* reopened on the next line — otherwise a wrapped sentence loses its colour
|
|
146
|
+
* halfway through.
|
|
147
|
+
*/
|
|
148
|
+
export function wrapAnsi(text, width) {
|
|
149
|
+
if (width < 4) return [text];
|
|
150
|
+
|
|
151
|
+
const lines = [];
|
|
152
|
+
let line = '';
|
|
153
|
+
let seen = 0;
|
|
154
|
+
let open = '';
|
|
155
|
+
let lastSpace = -1;
|
|
156
|
+
let lastSpaceSeen = 0;
|
|
157
|
+
|
|
158
|
+
const flush = (upto = null) => {
|
|
159
|
+
if (upto === null) {
|
|
160
|
+
lines.push(line);
|
|
161
|
+
line = open;
|
|
162
|
+
seen = 0;
|
|
163
|
+
} else {
|
|
164
|
+
lines.push(line.slice(0, upto));
|
|
165
|
+
const carry = line.slice(upto).replace(/^ +/, '');
|
|
166
|
+
line = open + carry;
|
|
167
|
+
seen = visLen(carry);
|
|
168
|
+
}
|
|
169
|
+
lastSpace = -1;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < text.length; i++) {
|
|
173
|
+
if (text[i] === '\x1b') {
|
|
174
|
+
const m = /^\x1b\[[0-9;]*m/.exec(text.slice(i));
|
|
175
|
+
if (m) {
|
|
176
|
+
line += m[0];
|
|
177
|
+
open = m[0] === '\x1b[0m' ? '' : open + m[0];
|
|
178
|
+
i += m[0].length - 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (text[i] === ' ') { lastSpace = line.length; lastSpaceSeen = seen; }
|
|
183
|
+
line += text[i];
|
|
184
|
+
seen++;
|
|
185
|
+
if (seen >= width) {
|
|
186
|
+
// Break at a word boundary unless that would leave a stub behind.
|
|
187
|
+
if (lastSpace > 0 && lastSpaceSeen > width * 0.4) flush(lastSpace);
|
|
188
|
+
else flush();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (visLen(line)) lines.push(line);
|
|
193
|
+
return lines.length ? lines : [''];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Small formatters
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
export function formatTokens(n) {
|
|
201
|
+
if (!n) return '0';
|
|
202
|
+
if (n < 1000) return String(n);
|
|
203
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
|
|
204
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function today() {
|
|
208
|
+
return new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Shorten a path for display: home becomes ~, a long middle collapses. */
|
|
212
|
+
export function shortenPath(p, max = 40) {
|
|
213
|
+
let out = String(p);
|
|
214
|
+
const home = process.env.USERPROFILE || process.env.HOME || '';
|
|
215
|
+
if (home && out.startsWith(home)) out = `~${out.slice(home.length)}`;
|
|
216
|
+
if (out.length <= max) return out;
|
|
217
|
+
|
|
218
|
+
const parts = out.split(/[\\/]/);
|
|
219
|
+
if (parts.length <= 3) return `…${out.slice(-(max - 1))}`;
|
|
220
|
+
const sep = out.includes('\\') ? '\\' : '/';
|
|
221
|
+
return `${parts[0]}${sep}…${sep}${parts.slice(-2).join(sep)}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function relativeTime(iso) {
|
|
225
|
+
if (!iso) return 'unknown';
|
|
226
|
+
const then = new Date(iso).getTime();
|
|
227
|
+
if (Number.isNaN(then)) return 'unknown';
|
|
228
|
+
|
|
229
|
+
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
|
230
|
+
if (secs < 60) return 'just now';
|
|
231
|
+
const mins = Math.round(secs / 60);
|
|
232
|
+
if (mins < 60) return `${mins}m ago`;
|
|
233
|
+
const hours = Math.round(mins / 60);
|
|
234
|
+
if (hours < 24) return `${hours}h ago`;
|
|
235
|
+
const days = Math.round(hours / 24);
|
|
236
|
+
return days < 30 ? `${days}d ago` : new Date(iso).toISOString().slice(0, 10);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Trim a trailing full stop off a live status line.
|
|
241
|
+
*
|
|
242
|
+
* "Listing src" is a label on work in progress. "Listing src." is a sentence,
|
|
243
|
+
* and a sentence that ends while the thing it describes is still happening
|
|
244
|
+
* reads as finished when it is not. Models add the full stop by habit; this
|
|
245
|
+
* takes it back off.
|
|
246
|
+
*
|
|
247
|
+
* Only after a word, though. A dot that follows a space is the whole point of
|
|
248
|
+
* the line — "Listing ." names the current directory — and trimming that turns
|
|
249
|
+
* a label into a fragment.
|
|
250
|
+
*/
|
|
251
|
+
export function asLabel(text) {
|
|
252
|
+
return String(text ?? '')
|
|
253
|
+
.trim()
|
|
254
|
+
.replace(/\s+/g, ' ')
|
|
255
|
+
// A trailing stop, from a model's sentence or a tool's own output
|
|
256
|
+
// ("Building…", "Completing…"), is noise on a one-line label. A dot that
|
|
257
|
+
// is the argument itself — "Listing ." — is not, so a word has to come
|
|
258
|
+
// before it.
|
|
259
|
+
.replace(/(?<=[\w)\]"'`])[.。…]+$/, '');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The model's checklist, as one short line — done ticked, the current item
|
|
264
|
+
* marked, the rest dim — so progress is visible without taking over the screen.
|
|
265
|
+
*/
|
|
266
|
+
/**
|
|
267
|
+
* The plan, as a block rather than a sentence.
|
|
268
|
+
*
|
|
269
|
+
* Six steps joined with separators made one line far wider than any terminal,
|
|
270
|
+
* so it wrapped — and a wrapped checklist has its ticks in the middle of the
|
|
271
|
+
* text, which is unreadable. Down the page each step keeps its own row, its
|
|
272
|
+
* mark stays in the left column, and the eye can find the one in progress
|
|
273
|
+
* without reading any of the others.
|
|
274
|
+
*
|
|
275
|
+
* Returns the rows; the caller pushes them.
|
|
276
|
+
*/
|
|
277
|
+
export function planRows(items) {
|
|
278
|
+
const list = (Array.isArray(items) ? items : []).slice(0, 8);
|
|
279
|
+
if (!list.length) return [];
|
|
280
|
+
const done = list.filter((i) => i?.done).length;
|
|
281
|
+
const current = list.findIndex((i) => !i?.done);
|
|
282
|
+
|
|
283
|
+
const rows = [` ${sky(`plan ${done}/${list.length}`)}`];
|
|
284
|
+
list.forEach((item, i) => {
|
|
285
|
+
const text = clip(String(item?.text ?? '').trim(), 64);
|
|
286
|
+
if (item?.done) rows.push(` ${theme.ok('✓')} ${dim(text)}`);
|
|
287
|
+
else if (i === current) rows.push(` ${blue('▸')} ${chalk.white(text)}`);
|
|
288
|
+
else rows.push(` ${dim('○')} ${dim(text)}`);
|
|
289
|
+
});
|
|
290
|
+
return rows;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Kept for the plain interface, which has one line to work with. */
|
|
294
|
+
export function planLine(items) {
|
|
295
|
+
const list = (Array.isArray(items) ? items : []).slice(0, 6);
|
|
296
|
+
if (!list.length) return '';
|
|
297
|
+
const done = list.filter((i) => i?.done).length;
|
|
298
|
+
const current = list.findIndex((i) => !i?.done);
|
|
299
|
+
const now = current === -1 ? 'done' : clip(String(list[current]?.text ?? '').trim(), 40);
|
|
300
|
+
return ` ${sky(`plan ${done}/${list.length}`)} ${chalk.white(now)}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Narration: what the agent is doing, as opposed to what it has to say.
|
|
305
|
+
*
|
|
306
|
+
* These lines are scaffolding — "Reading screen.js", "Checking types". They
|
|
307
|
+
* are worth seeing and not worth reading, and at full strength they compete
|
|
308
|
+
* with the answer, which is the thing the user is actually here for. A
|
|
309
|
+
* terminal has no smaller size to set, so the only axis available is weight:
|
|
310
|
+
* faint, and a step down in colour. The answer stays at full strength and
|
|
311
|
+
* wins the page by contrast rather than by shouting.
|
|
312
|
+
*/
|
|
313
|
+
export const narration = (text) => chalk.dim(text);
|
|
314
|
+
|
|
315
|
+
/** The bullet beside a narration line: present, not loud. */
|
|
316
|
+
export const narrationMark = () => chalk.dim(deep('●'));
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* How a run of the same kind of step reads once it is over.
|
|
320
|
+
*
|
|
321
|
+
* While it happens, "Running npm test" is the useful thing to show. Once
|
|
322
|
+
* three of them have happened, three near-identical lines are just noise
|
|
323
|
+
* between the reader and the answer, so they fold into one: "Ran 3 commands".
|
|
324
|
+
* The present tense belongs to the thing happening now; the past tense to the
|
|
325
|
+
* summary of what did.
|
|
326
|
+
*/
|
|
327
|
+
const GROUPS = {
|
|
328
|
+
Running: ['Ran', 'command', 'commands'],
|
|
329
|
+
Reading: ['Read', 'file', 'files'],
|
|
330
|
+
Searching: ['Searched', 'time', 'times'],
|
|
331
|
+
Finding: ['Found', 'pattern', 'patterns'],
|
|
332
|
+
Listing: ['Listed', 'directory', 'directories'],
|
|
333
|
+
Writing: ['Wrote', 'file', 'files'],
|
|
334
|
+
Editing: ['Edited', 'file', 'files'],
|
|
335
|
+
Checking: ['Checked', 'thing', 'things'],
|
|
336
|
+
Looking: ['Looked up', 'name', 'names'],
|
|
337
|
+
Asking: ['Asked about', 'name', 'names'],
|
|
338
|
+
Mapping: ['Mapped', 'folder', 'folders'],
|
|
339
|
+
Adding: ['Added', 'block', 'blocks'],
|
|
340
|
+
Renaming: ['Renamed', 'name', 'names'],
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/** The first word of a label, which is what decides whether two steps match. */
|
|
344
|
+
export const groupKind = (label) => String(label ?? '').trim().split(/\s+/)[0] ?? '';
|
|
345
|
+
|
|
346
|
+
/** One line standing in for `count` steps that all began with the same word. */
|
|
347
|
+
export function groupLabel(label, count) {
|
|
348
|
+
if (count <= 1) return String(label ?? '');
|
|
349
|
+
const g = GROUPS[groupKind(label)];
|
|
350
|
+
if (!g) return `${label} (+${count - 1} more)`;
|
|
351
|
+
const [past, one, many] = g;
|
|
352
|
+
return `${past} ${count} ${count === 1 ? one : many}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The part of a label after its opening word: the file or command it is about. */
|
|
356
|
+
export const groupTarget = (label) => String(label ?? '').trim().split(/\s+/).slice(1).join(' ');
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* One narration line, standing for everything that happened under it.
|
|
360
|
+
*
|
|
361
|
+
* The transcript is a record of what was done, not a copy of what was
|
|
362
|
+
* written. A 539-line file printed into it buries the answer and tells the
|
|
363
|
+
* reader nothing they could not get from the file itself, so a change is its
|
|
364
|
+
* two numbers. Several steps on one file stay one line naming that file;
|
|
365
|
+
* several files become a count.
|
|
366
|
+
*/
|
|
367
|
+
export function runLine({ label, count = 1, targets = [], added = 0, removed = 0 }) {
|
|
368
|
+
const counts = added || removed
|
|
369
|
+
? ` ${chalk.hex('#3fb950')(`+${added}`)} ${chalk.hex('#f2939c')(`-${removed}`)}`
|
|
370
|
+
: '';
|
|
371
|
+
if (count <= 1) return `${label}${counts}`;
|
|
372
|
+
|
|
373
|
+
const g = GROUPS[groupKind(label)];
|
|
374
|
+
const unique = [...new Set(targets.filter(Boolean))];
|
|
375
|
+
if (g && unique.length === 1) return `${g[0]} ${unique[0]}${counts}`;
|
|
376
|
+
if (!g) return `${label} (+${count - 1} more)${counts}`;
|
|
377
|
+
return `${g[0]} ${count} ${count === 1 ? g[1] : g[2]}${counts}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The reply, with any pasted code taken out of it.
|
|
382
|
+
*
|
|
383
|
+
* The model is asked not to paste code into its answer, and mostly does not.
|
|
384
|
+
* When it does, a fenced block of forty lines pushes the two sentences worth
|
|
385
|
+
* reading off the screen — and the code is already in the file it just wrote.
|
|
386
|
+
* A fence becomes a note of what it was, and the prose stays.
|
|
387
|
+
*
|
|
388
|
+
* A short block is left alone: three lines showing a command to run, or the
|
|
389
|
+
* one line that changed, is the kind of thing worth having in the answer.
|
|
390
|
+
*/
|
|
391
|
+
export function withoutCodeBlocks(text, keepLines = 4) {
|
|
392
|
+
const FENCE = /```([A-Za-z0-9+-]*)\n([\s\S]*?)```/g;
|
|
393
|
+
return String(text ?? '').replace(FENCE, (all, lang, body) => {
|
|
394
|
+
const lines = body.replace(/\n+$/, '').split('\n');
|
|
395
|
+
if (lines.length <= keepLines) return all;
|
|
396
|
+
const what = lang ? `${lang} ` : '';
|
|
397
|
+
return `_[${lines.length} lines of ${what}code — it is in the file, not worth repeating here]_`;
|
|
398
|
+
});
|
|
399
|
+
}
|