ucode-agent 1.29.1 → 1.30.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/ui/activity.js +98 -2
- package/src/ui/screen.js +51 -35
package/package.json
CHANGED
package/src/ui/activity.js
CHANGED
|
@@ -161,6 +161,102 @@ export function bannerSweep(line, row, rows, elapsed, { level = chalk.level } =
|
|
|
161
161
|
return out;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The bar that says work is happening, with no claim about how much is left.
|
|
167
|
+
*
|
|
168
|
+
* A spinner turning in one cell says "alive". A band of light running along a
|
|
169
|
+
* track says "alive, and going somewhere", which is the honest amount of
|
|
170
|
+
* progress an agent can report — it does not know how many steps are left, so
|
|
171
|
+
* a filling bar would be a lie and an indeterminate one is not.
|
|
172
|
+
*
|
|
173
|
+
* The band runs off both ends rather than bouncing inside the track: a bounce
|
|
174
|
+
* draws the eye to the turn, where a pass reads as something continuous going
|
|
175
|
+
* by. Below 256 colours there is nothing to fade through, so the track is
|
|
176
|
+
* simply dim and still, and the spinner beside it carries the motion.
|
|
177
|
+
*/
|
|
178
|
+
export const BAR_CELLS = 12;
|
|
179
|
+
|
|
180
|
+
/** The unlit track, and the crest of the band running along it. */
|
|
181
|
+
const TRACK_RGB = [0x24, 0x34, 0x4f];
|
|
182
|
+
const CREST_RGB = [0x8f, 0xbc, 0xff];
|
|
183
|
+
|
|
184
|
+
/** One pass of the band, in milliseconds. */
|
|
185
|
+
const BAR_PERIOD = 1400;
|
|
186
|
+
|
|
187
|
+
/** Half the width of the band, in cells. */
|
|
188
|
+
const BAR_BAND = 3.5;
|
|
189
|
+
|
|
190
|
+
export function indeterminate(cells, t, { level = chalk.level } = {}) {
|
|
191
|
+
const width = Math.max(0, Math.floor(cells));
|
|
192
|
+
if (!width) return '';
|
|
193
|
+
const track = '━'.repeat(width);
|
|
194
|
+
if (level < 2) return dim(track);
|
|
195
|
+
|
|
196
|
+
const centre = ((Math.max(0, t) % BAR_PERIOD) / BAR_PERIOD) * (width + BAR_BAND * 2) - BAR_BAND;
|
|
197
|
+
|
|
198
|
+
let out = '';
|
|
199
|
+
let run = '';
|
|
200
|
+
let runStep = -1;
|
|
201
|
+
const flush = () => {
|
|
202
|
+
if (!run) return;
|
|
203
|
+
const [r, g, b] = mix(TRACK_RGB, CREST_RGB, runStep / STEPS);
|
|
204
|
+
out += painter(level).rgb(r, g, b)(run);
|
|
205
|
+
run = '';
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
for (let i = 0; i < width; i++) {
|
|
209
|
+
const distance = Math.abs(i - centre);
|
|
210
|
+
const k = distance < BAR_BAND ? (Math.cos((Math.PI * distance) / BAR_BAND) + 1) / 2 : 0;
|
|
211
|
+
const step = Math.round(k * STEPS);
|
|
212
|
+
if (step !== runStep) { flush(); runStep = step; }
|
|
213
|
+
run += track[i];
|
|
214
|
+
}
|
|
215
|
+
flush();
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The whole live line: what is happening, that it is still happening, how long
|
|
221
|
+
* it has been happening, and how to stop it.
|
|
222
|
+
*
|
|
223
|
+
* This used to be squeezed into whatever the status row had spare between the
|
|
224
|
+
* model name and the percentage, which is why the label shimmered — it was the
|
|
225
|
+
* only way to look alive in twenty columns. With a row of its own the motion
|
|
226
|
+
* moves to the bar and the label can simply be read.
|
|
227
|
+
*
|
|
228
|
+
* Things are given up from the least useful end as the terminal narrows: the
|
|
229
|
+
* hint first, then the bar shortens, then it goes, then the label is clipped.
|
|
230
|
+
* The spinner is the last thing standing, because a line with nothing moving
|
|
231
|
+
* on it says the program has hung.
|
|
232
|
+
*/
|
|
233
|
+
export function workingLine({
|
|
234
|
+
glyph, label = '', elapsed = '', hint = 'esc to stop', room, t = 0, level = chalk.level,
|
|
235
|
+
} = {}) {
|
|
236
|
+
const text = String(label ?? '');
|
|
237
|
+
if (room < 3) return glyph;
|
|
238
|
+
|
|
239
|
+
const tail = [elapsed, hint].filter(Boolean).join(' ');
|
|
240
|
+
const shortTail = elapsed || '';
|
|
241
|
+
|
|
242
|
+
const draw = (cells, tailText, labelRoom) => {
|
|
243
|
+
let out = `${glyph} ${sky(clip(text, labelRoom))}`;
|
|
244
|
+
if (cells) out += ` ${indeterminate(cells, t, { level })}`;
|
|
245
|
+
if (tailText) out += ` ${dim(tailText)}`;
|
|
246
|
+
return out;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
for (const [cells, tailText] of [
|
|
250
|
+
[BAR_CELLS, tail], [BAR_CELLS, shortTail], [8, shortTail], [0, shortTail], [0, ''],
|
|
251
|
+
]) {
|
|
252
|
+
const fixed = 2 + (cells ? cells + 3 : 0) + (tailText ? tailText.length + 3 : 0);
|
|
253
|
+
const labelRoom = room - fixed;
|
|
254
|
+
if (labelRoom >= Math.min(MIN_LABEL, text.length)) return draw(cells, tailText, labelRoom);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return `${glyph} ${sky(clip(text, Math.max(1, room - 2)))}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
164
260
|
/**
|
|
165
261
|
* The spinner glyph for a frame, breathing slowly between two blues.
|
|
166
262
|
*
|
|
@@ -170,8 +266,8 @@ export function bannerSweep(line, row, rows, elapsed, { level = chalk.level } =
|
|
|
170
266
|
export function spinnerGlyph(frame, t, { level = chalk.level } = {}) {
|
|
171
267
|
const glyph = SPINNER[((frame % SPINNER.length) + SPINNER.length) % SPINNER.length];
|
|
172
268
|
if (level < 2) return theme.blue(glyph);
|
|
173
|
-
const k = (Math.sin((Math.max(0, t) /
|
|
174
|
-
const [r, g, b] = mix([
|
|
269
|
+
const k = (Math.sin((Math.max(0, t) / 1000) * Math.PI * 2) + 1) / 2;
|
|
270
|
+
const [r, g, b] = mix([0x2f, 0x6f, 0xe0], [0x9f, 0xc6, 0xff], k);
|
|
175
271
|
return painter(level).rgb(r, g, b)(glyph);
|
|
176
272
|
}
|
|
177
273
|
|
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';
|
|
@@ -100,10 +100,16 @@ const at = (row, col) => `${ESC}[${row};${col}H`;
|
|
|
100
100
|
const title = (t) => `${ESC}]0;${t}\x07`;
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* Fixed rows below the header: the gap under it, the
|
|
104
|
-
* the input box's two borders, the blank row inside it, and the
|
|
103
|
+
* Fixed rows below the header: the gap under it, the live line, the gap above
|
|
104
|
+
* the input box, the box's two borders, the blank row inside it, and the
|
|
105
|
+
* status row.
|
|
106
|
+
*
|
|
107
|
+
* The live line's row is held whether anything is running or not. Adding it
|
|
108
|
+
* only while busy would move the whole transcript up a row at the start of
|
|
109
|
+
* every turn and back down at the end of it, which reads as the screen
|
|
110
|
+
* flinching each time you press return.
|
|
105
111
|
*/
|
|
106
|
-
const CHROME_BELOW =
|
|
112
|
+
const CHROME_BELOW = 7;
|
|
107
113
|
|
|
108
114
|
/** How long one sentence of reasoning holds the line before the next takes it. */
|
|
109
115
|
const THOUGHT_HOLD_MS = 1100;
|
|
@@ -915,8 +921,10 @@ export class Screen {
|
|
|
915
921
|
* the same on every line of every session, so it was decoration that had to
|
|
916
922
|
* be read past to reach the two things that do change.
|
|
917
923
|
*
|
|
918
|
-
* The
|
|
919
|
-
*
|
|
924
|
+
* The spinner used to borrow the middle of this row while something ran. It
|
|
925
|
+
* has a row of its own outside the box now, so the only thing that ever
|
|
926
|
+
* appears between the model and the percentage is a flash — a reply to
|
|
927
|
+
* something you just pressed, gone a moment later.
|
|
920
928
|
*/
|
|
921
929
|
statusRow(width = this.width()) {
|
|
922
930
|
const inner = width - 2; // the space between the two borders
|
|
@@ -928,41 +936,44 @@ export class Screen {
|
|
|
928
936
|
this.chipTo = 2 + visLen(chip);
|
|
929
937
|
|
|
930
938
|
const between = Math.max(1, inner - visLen(left) - visLen(right));
|
|
939
|
+
const middle = this.flashText ? dim(clip(this.flashText, between - 2)) : '';
|
|
931
940
|
|
|
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
941
|
const tail = middle ? `${middle} ` : '';
|
|
960
942
|
const pad = Math.max(1, inner - visLen(left) - visLen(tail) - visLen(right));
|
|
961
943
|
return padVis(left + ' '.repeat(pad) + tail + right, inner);
|
|
962
944
|
}
|
|
963
945
|
|
|
964
946
|
/**
|
|
965
|
-
*
|
|
947
|
+
* What is happening right now: the spinner, what it is doing, how long it has
|
|
948
|
+
* been doing it, and the way out.
|
|
949
|
+
*
|
|
950
|
+
* It used to live in whatever space the status row had spare between the
|
|
951
|
+
* model name and the percentage — inside the box you type into, which is the
|
|
952
|
+
* one place on screen that is about you rather than about the agent. Out
|
|
953
|
+
* here it sits directly under the steps it belongs to, in the same column,
|
|
954
|
+
* and has room for a bar instead of a shimmer.
|
|
955
|
+
*/
|
|
956
|
+
activityLine(width = this.width()) {
|
|
957
|
+
if (!this.busy()) return '';
|
|
958
|
+
const now = Date.now();
|
|
959
|
+
const since = this.activity?.start ?? this.status.since ?? now;
|
|
960
|
+
return workingLine({
|
|
961
|
+
glyph: spinnerGlyph(this.tick, now),
|
|
962
|
+
label: this.status.busy ? this.status.text : 'working',
|
|
963
|
+
elapsed: now - since >= 1000 ? formatDuration(now - since) : '',
|
|
964
|
+
hint: 'esc to stop',
|
|
965
|
+
room: Math.max(4, width),
|
|
966
|
+
t: now,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Which row the live line is painted on, 1-based. */
|
|
971
|
+
activityRowAt() {
|
|
972
|
+
return this.rows - this.inputLines().rows.length - 5;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Repaint only the moving rows, leaving the caret where the user left it.
|
|
966
977
|
*
|
|
967
978
|
* It is the second row from the bottom now — the box's own border is below
|
|
968
979
|
* it — so the row is written with its borders rather than as a bare line.
|
|
@@ -976,9 +987,11 @@ export class Screen {
|
|
|
976
987
|
return;
|
|
977
988
|
}
|
|
978
989
|
const [row, col] = this.caret();
|
|
990
|
+
const width = this.width();
|
|
979
991
|
this.output.write(
|
|
980
992
|
HIDE +
|
|
981
|
-
at(this.
|
|
993
|
+
at(this.activityRowAt(), 1) + CLEAR_LINE + padVis(this.activityLine(width), width) +
|
|
994
|
+
at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(width), width, this.borderPaint()) +
|
|
982
995
|
at(row, col) + SHOW
|
|
983
996
|
);
|
|
984
997
|
}
|
|
@@ -1477,6 +1490,9 @@ export class Screen {
|
|
|
1477
1490
|
...this.headerLines(),
|
|
1478
1491
|
'',
|
|
1479
1492
|
...window,
|
|
1493
|
+
// What is happening right now, in the same column as the steps above it,
|
|
1494
|
+
// because it is the next one of those. Empty between turns.
|
|
1495
|
+
this.activityLine(width),
|
|
1480
1496
|
// Always one clear row between the last thing said and the box you type
|
|
1481
1497
|
// in. Without it the newest line of output sits against the border and
|
|
1482
1498
|
// reads as part of the input rather than as the answer above it.
|