ucode-agent 1.30.0 → 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.30.0",
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,19 +152,30 @@ 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();
@@ -220,10 +241,11 @@ export function indeterminate(cells, t, { level = chalk.level } = {}) {
220
241
  * The whole live line: what is happening, that it is still happening, how long
221
242
  * it has been happening, and how to stop it.
222
243
  *
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.
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.
227
249
  *
228
250
  * Things are given up from the least useful end as the terminal narrows: the
229
251
  * hint first, then the bar shortens, then it goes, then the label is clipped.
@@ -240,7 +262,7 @@ export function workingLine({
240
262
  const shortTail = elapsed || '';
241
263
 
242
264
  const draw = (cells, tailText, labelRoom) => {
243
- let out = `${glyph} ${sky(clip(text, labelRoom))}`;
265
+ let out = `${glyph} ${shimmer(clip(text, labelRoom), t, { level })}`;
244
266
  if (cells) out += ` ${indeterminate(cells, t, { level })}`;
245
267
  if (tailText) out += ` ${dim(tailText)}`;
246
268
  return out;
@@ -254,7 +276,7 @@ export function workingLine({
254
276
  if (labelRoom >= Math.min(MIN_LABEL, text.length)) return draw(cells, tailText, labelRoom);
255
277
  }
256
278
 
257
- return `${glyph} ${sky(clip(text, Math.max(1, room - 2)))}`;
279
+ return `${glyph} ${shimmer(clip(text, Math.max(1, room - 2)), t, { level })}`;
258
280
  }
259
281
 
260
282
  /**
package/src/ui/screen.js CHANGED
@@ -100,16 +100,10 @@ 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 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.
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.
111
105
  */
112
- const CHROME_BELOW = 7;
106
+ const CHROME_BELOW = 6;
113
107
 
114
108
  /** How long one sentence of reasoning holds the line before the next takes it. */
115
109
  const THOUGHT_HOLD_MS = 1100;
@@ -930,7 +924,15 @@ export class Screen {
930
924
  const inner = width - 2; // the space between the two borders
931
925
  const chip = this.modeChip();
932
926
  const left = ` ${chip} ${chalk.white(this.model || '—')}`;
933
- 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()} `;
934
936
 
935
937
  // Where a click on the bottom row still counts as hitting the mode chip.
936
938
  this.chipTo = 2 + visLen(chip);
@@ -956,20 +958,27 @@ export class Screen {
956
958
  activityLine(width = this.width()) {
957
959
  if (!this.busy()) return '';
958
960
  const now = Date.now();
959
- const since = this.activity?.start ?? this.status.since ?? now;
960
961
  return workingLine({
961
962
  glyph: spinnerGlyph(this.tick, now),
962
963
  label: this.status.busy ? this.status.text : 'working',
963
- elapsed: now - since >= 1000 ? formatDuration(now - since) : '',
964
964
  hint: 'esc to stop',
965
965
  room: Math.max(4, width),
966
966
  t: now,
967
967
  });
968
968
  }
969
969
 
970
- /** Which row the live line is painted on, 1-based. */
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
+ */
971
978
  activityRowAt() {
972
- return this.rows - this.inputLines().rows.length - 5;
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;
973
982
  }
974
983
 
975
984
  /**
@@ -988,9 +997,10 @@ export class Screen {
988
997
  }
989
998
  const [row, col] = this.caret();
990
999
  const width = this.width();
1000
+ const liveAt = this.activityRowAt();
991
1001
  this.output.write(
992
1002
  HIDE +
993
- at(this.activityRowAt(), 1) + CLEAR_LINE + padVis(this.activityLine(width), width) +
1003
+ (liveAt ? at(liveAt, 1) + CLEAR_LINE + padVis(this.activityLine(width), width) : '') +
994
1004
  at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(width), width, this.borderPaint()) +
995
1005
  at(row, col) + SHOW
996
1006
  );
@@ -1481,18 +1491,24 @@ export class Screen {
1481
1491
  const width = this.width();
1482
1492
  const height = this.viewportHeight();
1483
1493
 
1484
- 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);
1485
1504
  const start = Math.max(0, end - height);
1486
- const window = this.picker ? this.pickerLines(height) : this.lines.slice(start, end);
1505
+ const window = this.picker ? this.pickerLines(height) : said.slice(start, end);
1487
1506
  while (window.length < height) window.push('');
1488
1507
 
1489
1508
  const frame = [
1490
1509
  ...this.headerLines(),
1491
1510
  '',
1492
1511
  ...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),
1496
1512
  // Always one clear row between the last thing said and the box you type
1497
1513
  // in. Without it the newest line of output sits against the border and
1498
1514
  // reads as part of the input rather than as the answer above it.