ucode-agent 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/loop.js +18 -1
- package/src/ui/activity.js +203 -203
- package/src/ui/screen.js +75 -13
- package/src/ui/theme.js +399 -378
package/package.json
CHANGED
package/src/core/loop.js
CHANGED
|
@@ -434,6 +434,23 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
434
434
|
'',
|
|
435
435
|
'## How to work',
|
|
436
436
|
'',
|
|
437
|
+
'Before building anything, turn the request into a list of what it must do — every',
|
|
438
|
+
'feature named, and the ones any user would expect whether or not they were named',
|
|
439
|
+
'(an empty state, an error state, the keyboard doing the obvious thing, working on',
|
|
440
|
+
'a phone). Keep it as the plan. Build against it, then go through it one item at a',
|
|
441
|
+
'time before you say a word about being finished. Most of what gets missed was',
|
|
442
|
+
'never written down.',
|
|
443
|
+
'',
|
|
444
|
+
'Do not put code in your reply. Not a snippet, not "here is the key part", not a',
|
|
445
|
+
'summary of the file. It is already in the file and the user can open it; pasting',
|
|
446
|
+
'it again buries the one or two sentences that actually matter. Say what it does',
|
|
447
|
+
'and what to try.',
|
|
448
|
+
'',
|
|
449
|
+
'Do not claim it is done while anything is still running or unchecked. "I have',
|
|
450
|
+
'built it" said before the build finishes is worse than saying nothing: the user',
|
|
451
|
+
'believes you, looks, and finds it broken. Finish, check, then say so — and if',
|
|
452
|
+
'something is incomplete, say which part and why.',
|
|
453
|
+
'',
|
|
437
454
|
'FIRST, EVERY TIME: one short line saying what you are about to do, then the tool',
|
|
438
455
|
'calls. Never open a turn with a tool call and no words. "Right, the HTML',
|
|
439
456
|
'structure first." / "Now the state and the render loop." / "That is the layout',
|
|
@@ -986,7 +1003,7 @@ export class Agent {
|
|
|
986
1003
|
};
|
|
987
1004
|
// Only a real terminal has somewhere to stream into.
|
|
988
1005
|
if (this.full) {
|
|
989
|
-
opts.onThinking = () => this.ui.thinkingDelta();
|
|
1006
|
+
opts.onThinking = (delta) => this.ui.thinkingDelta(delta);
|
|
990
1007
|
opts.onText = (delta) => {
|
|
991
1008
|
if (!streaming) {
|
|
992
1009
|
streaming = true;
|
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';
|
|
@@ -101,7 +101,10 @@ const title = (t) => `${ESC}]0;${t}\x07`;
|
|
|
101
101
|
* Fixed rows below the header: the gap under it, the gap above the input box,
|
|
102
102
|
* the input box's two borders, the blank row inside it, and the status row.
|
|
103
103
|
*/
|
|
104
|
-
const CHROME_BELOW =
|
|
104
|
+
const CHROME_BELOW = 8;
|
|
105
|
+
|
|
106
|
+
/** How long one sentence of reasoning holds the line before the next takes it. */
|
|
107
|
+
const THOUGHT_HOLD_MS = 1100;
|
|
105
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;
|
|
@@ -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
|
}
|
|
@@ -392,10 +395,17 @@ export class Screen {
|
|
|
392
395
|
if (this.run) this.paintRun({ live: false });
|
|
393
396
|
}
|
|
394
397
|
|
|
395
|
-
|
|
396
|
-
|
|
398
|
+
/**
|
|
399
|
+
* Something went wrong, and the model is the one who can do anything about it.
|
|
400
|
+
*
|
|
401
|
+
* A red line of machinery — a failed edit, a command that exited non-zero —
|
|
402
|
+
* reads as the tool being broken, when almost always it is a step the model
|
|
403
|
+
* corrects on its own a second later. It goes to the model; the screen stays
|
|
404
|
+
* for what is being built. Whatever is genuinely unrecoverable surfaces as
|
|
405
|
+
* the model saying so in words, which is the form worth reading.
|
|
406
|
+
*/
|
|
407
|
+
toolFailed() {
|
|
397
408
|
this.endRun();
|
|
398
|
-
this.push(`${dim(' └ ')}${theme.error(summary)}`);
|
|
399
409
|
}
|
|
400
410
|
|
|
401
411
|
/**
|
|
@@ -532,17 +542,66 @@ export class Screen {
|
|
|
532
542
|
// than silence. The spinner counts the seconds so the wait is visibly alive,
|
|
533
543
|
// and the transcript gets one line afterwards saying how long it took.
|
|
534
544
|
|
|
535
|
-
|
|
545
|
+
/**
|
|
546
|
+
* The model's reasoning, live, one line at a time.
|
|
547
|
+
*
|
|
548
|
+
* Models reach for a tool before they say anything, so the first words of a
|
|
549
|
+
* step were arriving a minute after it began — while the reasoning channel
|
|
550
|
+
* had been streaming words the whole time and we were throwing them away to
|
|
551
|
+
* keep a timer. Its latest sentence now shows on one line that rewrites
|
|
552
|
+
* itself, which is something to read from the first second.
|
|
553
|
+
*
|
|
554
|
+
* It is scaffolding, not the answer: it shimmers while it is live and it is
|
|
555
|
+
* taken off the screen the moment the real reply starts.
|
|
556
|
+
*/
|
|
557
|
+
thinkingDelta(text = '') {
|
|
536
558
|
if (this.thoughtSince === undefined) this.thoughtSince = Date.now();
|
|
559
|
+
if (!text) return;
|
|
560
|
+
|
|
561
|
+
this.thought = ((this.thought ?? '') + text).slice(-2000);
|
|
562
|
+
// The last sentence it has finished, or what it has written of the next.
|
|
563
|
+
const parts = this.thought.split(/(?<=[.!?])\s+|(?<=[.!?])(?=[A-Z])/).filter((p) => p.trim());
|
|
564
|
+
const latest = (parts[parts.length - 1] ?? '').replace(/\s+/g, ' ').trim();
|
|
565
|
+
if (!latest) return;
|
|
566
|
+
|
|
567
|
+
// A sentence that is replaced the instant the next one arrives cannot be
|
|
568
|
+
// read — it flashes. Each one holds the line for long enough to take in,
|
|
569
|
+
// and whatever arrived meanwhile shows when its turn comes.
|
|
570
|
+
const now = Date.now();
|
|
571
|
+
if (latest !== this.shownThought) {
|
|
572
|
+
if (this.shownThought !== undefined && now - (this.shownAt ?? 0) < THOUGHT_HOLD_MS) return;
|
|
573
|
+
this.shownThought = latest;
|
|
574
|
+
this.shownAt = now;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const line = ` ${shimmer(clip(this.shownThought, Math.max(20, this.width() - 6)), this.tick * FRAME_MS)}`;
|
|
578
|
+
if (this.thinkAt === undefined || this.lines[this.thinkAt] === undefined) {
|
|
579
|
+
this.thinkAt = this.lines.length;
|
|
580
|
+
this.push(line);
|
|
581
|
+
} else {
|
|
582
|
+
this.lines[this.thinkAt] = line;
|
|
583
|
+
this.render();
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Keep the live thought moving between deltas. */
|
|
588
|
+
paintLiveThought() {
|
|
589
|
+
if (this.thinkAt !== undefined && this.lines[this.thinkAt] !== undefined) this.thinkingDelta('');
|
|
537
590
|
}
|
|
538
591
|
|
|
539
592
|
thinkingEnd() {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
593
|
+
// The thought was the wait; once there is a reply it has nothing to add,
|
|
594
|
+
// so it comes off the screen rather than settling into the transcript.
|
|
595
|
+
if (this.thinkAt !== undefined) {
|
|
596
|
+
this.lines.splice(this.thinkAt, 1);
|
|
597
|
+
if (this.run && this.run.at > this.thinkAt) this.run.at--;
|
|
598
|
+
for (const run of this.segment?.values() ?? []) if (run.at > this.thinkAt) run.at--;
|
|
599
|
+
this.thinkAt = undefined;
|
|
600
|
+
this.render();
|
|
601
|
+
}
|
|
602
|
+
this.thought = '';
|
|
603
|
+
this.shownThought = undefined;
|
|
604
|
+
this.shownAt = undefined;
|
|
546
605
|
this.thoughtSince = undefined;
|
|
547
606
|
}
|
|
548
607
|
|
|
@@ -886,6 +945,7 @@ export class Screen {
|
|
|
886
945
|
this.spinTimer = setInterval(() => {
|
|
887
946
|
this.tick++;
|
|
888
947
|
this.paintLiveRun();
|
|
948
|
+
this.paintLiveThought();
|
|
889
949
|
this.paintStatus();
|
|
890
950
|
}, FRAME_MS);
|
|
891
951
|
this.spinTimer.unref?.();
|
|
@@ -1300,6 +1360,8 @@ export class Screen {
|
|
|
1300
1360
|
// reads as part of the input rather than as the answer above it.
|
|
1301
1361
|
'',
|
|
1302
1362
|
...this.inputBox(),
|
|
1363
|
+
'',
|
|
1364
|
+
'',
|
|
1303
1365
|
];
|
|
1304
1366
|
|
|
1305
1367
|
// The cursor is hidden for the duration of the paint. Without this it is
|