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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.29.1",
3
+ "version": "1.31.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",
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
 
@@ -109,26 +109,36 @@ export function shimmer(text, t, { level = chalk.level } = {}) {
109
109
  }
110
110
 
111
111
  /**
112
- * The wordmark with a light passing across it, once, at launch.
112
+ * The wordmark being lit, once, at launch.
113
113
  *
114
- * The first thing anyone sees of a program is the half second before they can
115
- * type, and ucode was spending it showing a finished picture. A band of light
116
- * crossing the mark left to right in that same half second costs nothing, is
117
- * over before it can annoy anyone, and is the difference between a logo that
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
- * Every row is swept from the same clock, so the light is a vertical bar
121
- * travelling across the whole wordmark rather than six separate glints. It
122
- * blends out of the row's own gradient colour, not out of a flat blue, so the
123
- * moment it passes the mark is exactly what it will look like at rest.
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 = 620;
135
+ export const SWEEP_MS = 1300;
129
136
 
130
137
  /** Half the width of the travelling band, in characters. */
131
- const SWEEP_BAND = 7;
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 runStep = -1;
155
+ let runKey = '';
156
+ let runRGB = null;
146
157
  const flush = () => {
147
158
  if (!run) return;
148
- const [r, g, b] = mix(rest, PEAK_RGB, runStep / STEPS);
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 distance = Math.abs(i - centre);
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
- if (step !== runStep) { flush(); runStep = step; }
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) / 1300) * Math.PI * 2) + 1) / 2;
174
- const [r, g, b] = mix([0x4d, 0x8d, 0xff], [0x9f, 0xc6, 0xff], k);
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, 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';
@@ -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 middle is borrowed while something is running, for the spinner and the
919
- * way out of it, and handed straight back when it finishes.
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
- const right = `${this.percentChip()} `;
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
- * Repaint only the status row, leaving the caret where the user left it.
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
- at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(), this.width(), this.borderPaint()) +
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
- const end = Math.max(0, this.lines.length - this.scroll);
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) : this.lines.slice(start, end);
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 = [