ucode-agent 1.29.1 → 1.31.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 +5 -0
- package/src/ui/activity.js +136 -18
- package/src/ui/screen.js +67 -35
package/package.json
CHANGED
package/src/core/loop.js
CHANGED
|
@@ -1034,6 +1034,11 @@ export class Agent {
|
|
|
1034
1034
|
if (message.short) this.short.add(skill.name);
|
|
1035
1035
|
this.push(message);
|
|
1036
1036
|
this.ui.note(`${skill.name} skill loaded for this${message.short ? ' (short form)' : ''}`);
|
|
1037
|
+
// A clear row under it. This is bookkeeping about the turn, not part of
|
|
1038
|
+
// it, and against the first line of thinking the two read as one block —
|
|
1039
|
+
// the notice looks like the opening of the answer rather than a note
|
|
1040
|
+
// about how the answer is being reached.
|
|
1041
|
+
this.ui.blank();
|
|
1037
1042
|
}
|
|
1038
1043
|
}
|
|
1039
1044
|
|
package/src/ui/activity.js
CHANGED
|
@@ -109,26 +109,36 @@ export function shimmer(text, t, { level = chalk.level } = {}) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
|
-
* The wordmark
|
|
112
|
+
* The wordmark being lit, once, at launch.
|
|
113
113
|
*
|
|
114
|
-
* The first thing anyone sees of a program is the
|
|
115
|
-
*
|
|
116
|
-
* crossing the mark left to right
|
|
117
|
-
*
|
|
118
|
-
* was printed and one that arrived.
|
|
114
|
+
* The first thing anyone sees of a program is the moment before they can type,
|
|
115
|
+
* and ucode was spending it showing a finished picture. A band of light
|
|
116
|
+
* crossing the mark left to right costs nothing and is the difference between
|
|
117
|
+
* a logo that was printed and one that arrived.
|
|
119
118
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
119
|
+
* The light does not pass over a finished wordmark — it paints it. Ahead of
|
|
120
|
+
* the crest the letters sit almost unlit, a silhouette dark enough to read as
|
|
121
|
+
* "not yet" and light enough not to look like a hole in the screen. At the
|
|
122
|
+
* crest they flare to near-white. Behind it they settle into the row's own
|
|
123
|
+
* resting gradient and stay there. So the mark is drawn on by the light rather
|
|
124
|
+
* than glinting under it, and the instant the crest leaves a column that
|
|
125
|
+
* column is already exactly what it will look like for the rest of the
|
|
126
|
+
* session.
|
|
127
|
+
*
|
|
128
|
+
* Every row is lit from the same clock, so the crest is one bar standing
|
|
129
|
+
* upright and travelling across the whole wordmark rather than six separate
|
|
130
|
+
* glints, and the band is wide enough that the edge of it is never a line.
|
|
124
131
|
*
|
|
125
132
|
* Below 256 colours there are no in-between shades to fade through, so the
|
|
126
133
|
* mark is simply drawn finished — a two-colour "sweep" is a flicker.
|
|
127
134
|
*/
|
|
128
|
-
export const SWEEP_MS =
|
|
135
|
+
export const SWEEP_MS = 1300;
|
|
129
136
|
|
|
130
137
|
/** Half the width of the travelling band, in characters. */
|
|
131
|
-
const SWEEP_BAND =
|
|
138
|
+
const SWEEP_BAND = 9;
|
|
139
|
+
|
|
140
|
+
/** The wordmark before the light reaches it: present, not yet lit. */
|
|
141
|
+
const UNLIT_RGB = [0x16, 0x23, 0x3d];
|
|
132
142
|
|
|
133
143
|
export function bannerSweep(line, row, rows, elapsed, { level = chalk.level } = {}) {
|
|
134
144
|
const text = String(line ?? '');
|
|
@@ -142,25 +152,133 @@ export function bannerSweep(line, row, rows, elapsed, { level = chalk.level } =
|
|
|
142
152
|
|
|
143
153
|
let out = '';
|
|
144
154
|
let run = '';
|
|
145
|
-
let
|
|
155
|
+
let runKey = '';
|
|
156
|
+
let runRGB = null;
|
|
146
157
|
const flush = () => {
|
|
147
158
|
if (!run) return;
|
|
148
|
-
const [r, g, b] =
|
|
159
|
+
const [r, g, b] = runRGB;
|
|
149
160
|
out += painter(level).rgb(r, g, b)(run);
|
|
150
161
|
run = '';
|
|
151
162
|
};
|
|
152
163
|
|
|
153
164
|
for (let i = 0; i < text.length; i++) {
|
|
154
|
-
const
|
|
165
|
+
const offset = i - centre;
|
|
166
|
+
// Behind the crest the column is finished; ahead of it, still waiting.
|
|
167
|
+
const base = offset > 0 ? UNLIT_RGB : rest;
|
|
168
|
+
const distance = Math.abs(offset);
|
|
155
169
|
const k = distance < SWEEP_BAND ? (Math.cos((Math.PI * distance) / SWEEP_BAND) + 1) / 2 : 0;
|
|
170
|
+
|
|
171
|
+
// Quantised so neighbours landing on the same shade share one escape code.
|
|
156
172
|
const step = Math.round(k * STEPS);
|
|
157
|
-
|
|
173
|
+
const key = `${offset > 0 ? 'a' : 'b'}${step}`;
|
|
174
|
+
if (key !== runKey) {
|
|
175
|
+
flush();
|
|
176
|
+
runKey = key;
|
|
177
|
+
runRGB = mix(base, PEAK_RGB, step / STEPS);
|
|
178
|
+
}
|
|
158
179
|
run += text[i];
|
|
159
180
|
}
|
|
160
181
|
flush();
|
|
161
182
|
return out;
|
|
162
183
|
}
|
|
163
184
|
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The bar that says work is happening, with no claim about how much is left.
|
|
188
|
+
*
|
|
189
|
+
* A spinner turning in one cell says "alive". A band of light running along a
|
|
190
|
+
* track says "alive, and going somewhere", which is the honest amount of
|
|
191
|
+
* progress an agent can report — it does not know how many steps are left, so
|
|
192
|
+
* a filling bar would be a lie and an indeterminate one is not.
|
|
193
|
+
*
|
|
194
|
+
* The band runs off both ends rather than bouncing inside the track: a bounce
|
|
195
|
+
* draws the eye to the turn, where a pass reads as something continuous going
|
|
196
|
+
* by. Below 256 colours there is nothing to fade through, so the track is
|
|
197
|
+
* simply dim and still, and the spinner beside it carries the motion.
|
|
198
|
+
*/
|
|
199
|
+
export const BAR_CELLS = 12;
|
|
200
|
+
|
|
201
|
+
/** The unlit track, and the crest of the band running along it. */
|
|
202
|
+
const TRACK_RGB = [0x24, 0x34, 0x4f];
|
|
203
|
+
const CREST_RGB = [0x8f, 0xbc, 0xff];
|
|
204
|
+
|
|
205
|
+
/** One pass of the band, in milliseconds. */
|
|
206
|
+
const BAR_PERIOD = 1400;
|
|
207
|
+
|
|
208
|
+
/** Half the width of the band, in cells. */
|
|
209
|
+
const BAR_BAND = 3.5;
|
|
210
|
+
|
|
211
|
+
export function indeterminate(cells, t, { level = chalk.level } = {}) {
|
|
212
|
+
const width = Math.max(0, Math.floor(cells));
|
|
213
|
+
if (!width) return '';
|
|
214
|
+
const track = '━'.repeat(width);
|
|
215
|
+
if (level < 2) return dim(track);
|
|
216
|
+
|
|
217
|
+
const centre = ((Math.max(0, t) % BAR_PERIOD) / BAR_PERIOD) * (width + BAR_BAND * 2) - BAR_BAND;
|
|
218
|
+
|
|
219
|
+
let out = '';
|
|
220
|
+
let run = '';
|
|
221
|
+
let runStep = -1;
|
|
222
|
+
const flush = () => {
|
|
223
|
+
if (!run) return;
|
|
224
|
+
const [r, g, b] = mix(TRACK_RGB, CREST_RGB, runStep / STEPS);
|
|
225
|
+
out += painter(level).rgb(r, g, b)(run);
|
|
226
|
+
run = '';
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
for (let i = 0; i < width; i++) {
|
|
230
|
+
const distance = Math.abs(i - centre);
|
|
231
|
+
const k = distance < BAR_BAND ? (Math.cos((Math.PI * distance) / BAR_BAND) + 1) / 2 : 0;
|
|
232
|
+
const step = Math.round(k * STEPS);
|
|
233
|
+
if (step !== runStep) { flush(); runStep = step; }
|
|
234
|
+
run += track[i];
|
|
235
|
+
}
|
|
236
|
+
flush();
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The whole live line: what is happening, that it is still happening, how long
|
|
242
|
+
* it has been happening, and how to stop it.
|
|
243
|
+
*
|
|
244
|
+
* Two things move on it, and they say different things. The bar is the steady
|
|
245
|
+
* pulse that means the program is alive at all; the band of light crossing the
|
|
246
|
+
* label is on the label, so it is the thing being done right now that looks
|
|
247
|
+
* live. How long the turn has taken is not here — that is a fact about the
|
|
248
|
+
* session, and it belongs on the status row with the other two.
|
|
249
|
+
*
|
|
250
|
+
* Things are given up from the least useful end as the terminal narrows: the
|
|
251
|
+
* hint first, then the bar shortens, then it goes, then the label is clipped.
|
|
252
|
+
* The spinner is the last thing standing, because a line with nothing moving
|
|
253
|
+
* on it says the program has hung.
|
|
254
|
+
*/
|
|
255
|
+
export function workingLine({
|
|
256
|
+
glyph, label = '', elapsed = '', hint = 'esc to stop', room, t = 0, level = chalk.level,
|
|
257
|
+
} = {}) {
|
|
258
|
+
const text = String(label ?? '');
|
|
259
|
+
if (room < 3) return glyph;
|
|
260
|
+
|
|
261
|
+
const tail = [elapsed, hint].filter(Boolean).join(' ');
|
|
262
|
+
const shortTail = elapsed || '';
|
|
263
|
+
|
|
264
|
+
const draw = (cells, tailText, labelRoom) => {
|
|
265
|
+
let out = `${glyph} ${shimmer(clip(text, labelRoom), t, { level })}`;
|
|
266
|
+
if (cells) out += ` ${indeterminate(cells, t, { level })}`;
|
|
267
|
+
if (tailText) out += ` ${dim(tailText)}`;
|
|
268
|
+
return out;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
for (const [cells, tailText] of [
|
|
272
|
+
[BAR_CELLS, tail], [BAR_CELLS, shortTail], [8, shortTail], [0, shortTail], [0, ''],
|
|
273
|
+
]) {
|
|
274
|
+
const fixed = 2 + (cells ? cells + 3 : 0) + (tailText ? tailText.length + 3 : 0);
|
|
275
|
+
const labelRoom = room - fixed;
|
|
276
|
+
if (labelRoom >= Math.min(MIN_LABEL, text.length)) return draw(cells, tailText, labelRoom);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return `${glyph} ${shimmer(clip(text, Math.max(1, room - 2)), t, { level })}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
164
282
|
/**
|
|
165
283
|
* The spinner glyph for a frame, breathing slowly between two blues.
|
|
166
284
|
*
|
|
@@ -170,8 +288,8 @@ export function bannerSweep(line, row, rows, elapsed, { level = chalk.level } =
|
|
|
170
288
|
export function spinnerGlyph(frame, t, { level = chalk.level } = {}) {
|
|
171
289
|
const glyph = SPINNER[((frame % SPINNER.length) + SPINNER.length) % SPINNER.length];
|
|
172
290
|
if (level < 2) return theme.blue(glyph);
|
|
173
|
-
const k = (Math.sin((Math.max(0, t) /
|
|
174
|
-
const [r, g, b] = mix([
|
|
291
|
+
const k = (Math.sin((Math.max(0, t) / 1000) * Math.PI * 2) + 1) / 2;
|
|
292
|
+
const [r, g, b] = mix([0x2f, 0x6f, 0xe0], [0x9f, 0xc6, 0xff], k);
|
|
175
293
|
return painter(level).rgb(r, g, b)(glyph);
|
|
176
294
|
}
|
|
177
295
|
|
package/src/ui/screen.js
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
|
|
42
42
|
shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, tidyReply, trimAnswer,
|
|
43
43
|
bannerPaint, RAIL, modeChip } from './theme.js';
|
|
44
|
-
import { FRAME_MS,
|
|
44
|
+
import { FRAME_MS, spinnerGlyph, formatDuration, doneLine, workingLine, bannerSweep, SWEEP_MS } from './activity.js';
|
|
45
45
|
import { gitBranch } from '../core/git.js';
|
|
46
46
|
import { renderer, render, polish } from './markdown.js';
|
|
47
47
|
import { VERSION } from '../core/version.js';
|
|
@@ -915,54 +915,74 @@ export class Screen {
|
|
|
915
915
|
* the same on every line of every session, so it was decoration that had to
|
|
916
916
|
* be read past to reach the two things that do change.
|
|
917
917
|
*
|
|
918
|
-
* The
|
|
919
|
-
*
|
|
918
|
+
* The spinner used to borrow the middle of this row while something ran. It
|
|
919
|
+
* has a row of its own outside the box now, so the only thing that ever
|
|
920
|
+
* appears between the model and the percentage is a flash — a reply to
|
|
921
|
+
* something you just pressed, gone a moment later.
|
|
920
922
|
*/
|
|
921
923
|
statusRow(width = this.width()) {
|
|
922
924
|
const inner = width - 2; // the space between the two borders
|
|
923
925
|
const chip = this.modeChip();
|
|
924
926
|
const left = ` ${chip} ${chalk.white(this.model || '—')}`;
|
|
925
|
-
|
|
927
|
+
|
|
928
|
+
// How long the turn has taken, back in the box beside the other two facts
|
|
929
|
+
// about the session. It is not on the live line: that line says what is
|
|
930
|
+
// being done, and a clock ticking in the middle of it competes with the
|
|
931
|
+
// words for no reason. Under a second there is no number worth reading.
|
|
932
|
+
const now = Date.now();
|
|
933
|
+
const since = this.activity?.start ?? this.status.since ?? 0;
|
|
934
|
+
const running = this.busy() && since && now - since >= 1000;
|
|
935
|
+
const right = `${running ? `${dim(formatDuration(now - since))} ` : ''}${this.percentChip()} `;
|
|
926
936
|
|
|
927
937
|
// Where a click on the bottom row still counts as hitting the mode chip.
|
|
928
938
|
this.chipTo = 2 + visLen(chip);
|
|
929
939
|
|
|
930
940
|
const between = Math.max(1, inner - visLen(left) - visLen(right));
|
|
941
|
+
const middle = this.flashText ? dim(clip(this.flashText, between - 2)) : '';
|
|
931
942
|
|
|
932
|
-
let middle = '';
|
|
933
|
-
if (this.flashText) {
|
|
934
|
-
middle = dim(clip(this.flashText, between - 2));
|
|
935
|
-
} else if (this.status.busy || this.activity) {
|
|
936
|
-
// The whole turn, not just the current tool: the timer and step count
|
|
937
|
-
// keep going through the gaps between calls, so a long build never
|
|
938
|
-
// looks like it has stopped.
|
|
939
|
-
const now = Date.now();
|
|
940
|
-
const a = this.activity;
|
|
941
|
-
const since = a?.start ?? this.status.since ?? now;
|
|
942
|
-
const meta = [];
|
|
943
|
-
// No step count. It measures how much machinery ran, which is not
|
|
944
|
-
// something the person waiting has any use for; the elapsed time is.
|
|
945
|
-
void stepPaint;
|
|
946
|
-
if (now - since >= 1000) meta.push({ text: formatDuration(now - since), keep: true });
|
|
947
|
-
middle = fitActivity({
|
|
948
|
-
glyph: spinnerGlyph(this.tick, now),
|
|
949
|
-
label: this.status.busy ? this.status.text : 'working',
|
|
950
|
-
meta,
|
|
951
|
-
hint: 'esc to stop',
|
|
952
|
-
paint: (s) => shimmer(s, now),
|
|
953
|
-
}, between - 3);
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
// The percentage is pinned to the right border whatever is in the middle,
|
|
957
|
-
// with a gap kept in front of it so a long spinner label cannot run into
|
|
958
|
-
// the number and read as part of it.
|
|
959
943
|
const tail = middle ? `${middle} ` : '';
|
|
960
944
|
const pad = Math.max(1, inner - visLen(left) - visLen(tail) - visLen(right));
|
|
961
945
|
return padVis(left + ' '.repeat(pad) + tail + right, inner);
|
|
962
946
|
}
|
|
963
947
|
|
|
964
948
|
/**
|
|
965
|
-
*
|
|
949
|
+
* What is happening right now: the spinner, what it is doing, how long it has
|
|
950
|
+
* been doing it, and the way out.
|
|
951
|
+
*
|
|
952
|
+
* It used to live in whatever space the status row had spare between the
|
|
953
|
+
* model name and the percentage — inside the box you type into, which is the
|
|
954
|
+
* one place on screen that is about you rather than about the agent. Out
|
|
955
|
+
* here it sits directly under the steps it belongs to, in the same column,
|
|
956
|
+
* and has room for a bar instead of a shimmer.
|
|
957
|
+
*/
|
|
958
|
+
activityLine(width = this.width()) {
|
|
959
|
+
if (!this.busy()) return '';
|
|
960
|
+
const now = Date.now();
|
|
961
|
+
return workingLine({
|
|
962
|
+
glyph: spinnerGlyph(this.tick, now),
|
|
963
|
+
label: this.status.busy ? this.status.text : 'working',
|
|
964
|
+
hint: 'esc to stop',
|
|
965
|
+
room: Math.max(4, width),
|
|
966
|
+
t: now,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Which row the live line is painted on, 1-based, or 0 when it is not shown.
|
|
972
|
+
*
|
|
973
|
+
* It is the last line of the conversation, so its row moves as the
|
|
974
|
+
* conversation grows and stops moving once the viewport is full. Scrolled
|
|
975
|
+
* back, or with the picker open, it is not on screen at all and the cheap
|
|
976
|
+
* repaint has nothing to do.
|
|
977
|
+
*/
|
|
978
|
+
activityRowAt() {
|
|
979
|
+
if (this.scroll > 0 || this.picker) return 0;
|
|
980
|
+
const index = Math.min(this.lines.length + 1, this.viewportHeight()) - 1;
|
|
981
|
+
return index < 0 ? 0 : this.headerHeight() + 2 + index;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Repaint only the moving rows, leaving the caret where the user left it.
|
|
966
986
|
*
|
|
967
987
|
* It is the second row from the bottom now — the box's own border is below
|
|
968
988
|
* it — so the row is written with its borders rather than as a bare line.
|
|
@@ -976,9 +996,12 @@ export class Screen {
|
|
|
976
996
|
return;
|
|
977
997
|
}
|
|
978
998
|
const [row, col] = this.caret();
|
|
999
|
+
const width = this.width();
|
|
1000
|
+
const liveAt = this.activityRowAt();
|
|
979
1001
|
this.output.write(
|
|
980
1002
|
HIDE +
|
|
981
|
-
|
|
1003
|
+
(liveAt ? at(liveAt, 1) + CLEAR_LINE + padVis(this.activityLine(width), width) : '') +
|
|
1004
|
+
at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(width), width, this.borderPaint()) +
|
|
982
1005
|
at(row, col) + SHOW
|
|
983
1006
|
);
|
|
984
1007
|
}
|
|
@@ -1468,9 +1491,18 @@ export class Screen {
|
|
|
1468
1491
|
const width = this.width();
|
|
1469
1492
|
const height = this.viewportHeight();
|
|
1470
1493
|
|
|
1471
|
-
|
|
1494
|
+
// The live line is the last line of the conversation, not a fixture above
|
|
1495
|
+
// the input box. Pinned down there it sat at the bottom of the screen while
|
|
1496
|
+
// the message that started it was at the top, with the empty middle of the
|
|
1497
|
+
// viewport between them — so the thing being done looked unrelated to the
|
|
1498
|
+
// thing that had been asked. On the end of the transcript it arrives
|
|
1499
|
+
// directly under the prompt, which is where the eye already is.
|
|
1500
|
+
const live = this.activityLine(width);
|
|
1501
|
+
const said = live ? [...this.lines, live] : this.lines;
|
|
1502
|
+
|
|
1503
|
+
const end = Math.max(0, said.length - this.scroll);
|
|
1472
1504
|
const start = Math.max(0, end - height);
|
|
1473
|
-
const window = this.picker ? this.pickerLines(height) :
|
|
1505
|
+
const window = this.picker ? this.pickerLines(height) : said.slice(start, end);
|
|
1474
1506
|
while (window.length < height) window.push('');
|
|
1475
1507
|
|
|
1476
1508
|
const frame = [
|