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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.29.1",
3
+ "version": "1.30.0",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA and Cohere models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
@@ -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) / 1300) * Math.PI * 2) + 1) / 2;
174
- const [r, g, b] = mix([0x4d, 0x8d, 0xff], [0x9f, 0xc6, 0xff], k);
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, fitActivity, shimmer, spinnerGlyph, formatDuration, doneLine, stepPaint, bannerSweep, SWEEP_MS } from './activity.js';
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 gap above the input box,
104
- * the input box's two borders, the blank row inside it, and the status row.
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 = 6;
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 middle is borrowed while something is running, for the spinner and the
919
- * way out of it, and handed straight back when it finishes.
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
- * Repaint only the status row, leaving the caret where the user left it.
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.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(), this.width(), this.borderPaint()) +
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.