ucode-agent 1.30.0 → 1.32.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.32.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
@@ -556,6 +556,34 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
556
556
  'already contains it. Do not re-check work the checks have already reported on.',
557
557
  'Fast is not sloppy: it is the same work with the waiting taken out.',
558
558
  '',
559
+ 'DESIGN IT BEFORE YOU TYPE IT. Fast means fewer round trips. It does not mean a',
560
+ 'default theme, and an app that goes out in the palette its starter came with is',
561
+ 'not a fast build, it is an undesigned one. There is no design pass after the',
562
+ 'create_app call, because there is no after — so the decisions happen before it.',
563
+ '',
564
+ 'Three of them, and you hold them for the whole build:',
565
+ ' - A TONE, one word you commit to: clinical, warm, editorial, technical,',
566
+ ' playful, industrial, calm, dense. "Modern and clean" is not a tone.',
567
+ ' - An ACCENT that is not the one the starter shipped with.',
568
+ ' - ONE memorable thing this app has that other apps do not: a colour, a type',
569
+ ' move, a texture, one interaction. Exactly one. It is the difference between',
570
+ ' a design and a theme.',
571
+ 'Name the tone and the accent in your opening line, so they are settled before any',
572
+ 'file exists: "I will build Tide - a tasks app in one HTML file, calm, warm grey',
573
+ 'with a single amber accent." That is not narrating a plan, that is the decision.',
574
+ '',
575
+ 'Then the tokens are the first thing in the file, in the same call as everything',
576
+ 'else: the type scale, the space scale, neutrals that carry a hue, the accent and',
577
+ 'its semantics. Nothing after that uses a raw value. IN next-shadcn THAT MEANS',
578
+ 'globals.css IS RE-TINTED IN THAT SAME create_app CALL. Shipping the palette the',
579
+ 'starter came with is the commonest way a build looks generated, and it is the',
580
+ 'first thing anyone notices. A blocked-out page in the colours this app chose',
581
+ 'beats a finished page in the ones it was handed.',
582
+ '',
583
+ 'add_block gives you structure, never a look. A block arrives with no opinion',
584
+ 'about this app and is yours to tint the moment it lands. Assembling blocks and',
585
+ 'shipping them as they came is the quick way to something nobody designed.',
586
+ '',
559
587
  'Never repeat the request back. Not as a summary, not as a restatement, not as',
560
588
  'a list of what was asked for. They wrote it and it is on the screen above you.',
561
589
  'Do not narrate your planning either - which files you will make, what order you',
@@ -665,6 +693,11 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
665
693
  ' reports - errors, layout that overflows a phone, the review points worth fixing -',
666
694
  ' in one pass, then look once more. A clean second look means it is done: report',
667
695
  ' back instead of polishing in circles. Never call an interface finished unlooked at.',
696
+ '- ANYTHING THAT NEEDS A SERVER IS LEFT RUNNING. If the app has a dev server -',
697
+ ' Next.js, Vite, anything with an npm run dev - start it and leave it up when you',
698
+ ' finish. ucode opens it in the browser for the user as soon as it is ready, so a',
699
+ ' build that ends with the server stopped ends with nothing to look at. A one-page',
700
+ ' app with no server needs none of this: the file is the app.',
668
701
  ' name, handles keys and returns the live link. Build locally first.',
669
702
  '- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
670
703
  ' would ask a question, or it fails instead of waiting: create-next-app --yes,',
@@ -1034,6 +1067,11 @@ export class Agent {
1034
1067
  if (message.short) this.short.add(skill.name);
1035
1068
  this.push(message);
1036
1069
  this.ui.note(`${skill.name} skill loaded for this${message.short ? ' (short form)' : ''}`);
1070
+ // A clear row under it. This is bookkeeping about the turn, not part of
1071
+ // it, and against the first line of thinking the two read as one block —
1072
+ // the notice looks like the opening of the answer rather than a note
1073
+ // about how the answer is being reached.
1074
+ this.ui.blank();
1037
1075
  }
1038
1076
  }
1039
1077
 
@@ -1535,15 +1573,19 @@ export class Agent {
1535
1573
 
1536
1574
  /** A dev server came up during this turn: open it in the browser, once. */
1537
1575
  /**
1538
- * Open the running app in a browser — only when asked.
1576
+ * Open the running app in a browser as soon as a dev server is up.
1577
+ *
1578
+ * This was turned off once, on the grounds that a window seizing the screen
1579
+ * mid-thought is startling and worse during a demo. It only fires at the end
1580
+ * of a finished turn, though, not mid-thought — and the thing the user asked
1581
+ * for is a running app, not a URL they then have to go and click. Being
1582
+ * handed a link to the thing you asked to be built is the last step of the
1583
+ * job left undone.
1539
1584
  *
1540
- * This used to happen on its own whenever a dev server came up. Something
1541
- * seizing the screen mid-thought is startling at the best of times, and
1542
- * during a demo it is worse. UCODE_OPEN=1 brings the old behaviour back for
1543
- * anyone who liked it; otherwise the URL is on screen to click.
1585
+ * UCODE_OPEN=0 turns it off for anyone who wants the link and nothing else.
1544
1586
  */
1545
1587
  openWhenReady(since) {
1546
- if (!this.full || process.env.UCODE_OPEN !== '1') return;
1588
+ if (!this.full || process.env.UCODE_OPEN === '0') return;
1547
1589
  const server = serversReadySince(since).at(-1);
1548
1590
  if (!server || (this.opened ??= new Set()).has(server.url)) return;
1549
1591
  this.opened.add(server.url);
@@ -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.