ucode-agent 1.29.0 → 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.0",
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;
@@ -186,6 +192,7 @@ export class Screen {
186
192
  this.intro = 0; // when the launch sweep began, 0 once it is over
187
193
  this.introTimer = null;
188
194
  this.pendingPrompt = null;
195
+ this.lastPrompt = ''; // the last thing the user said, for the echo check
189
196
  this.facts.branch = gitBranch(cwd);
190
197
 
191
198
  this.cols = output.columns || 80;
@@ -340,7 +347,8 @@ export class Screen {
340
347
  */
341
348
  assistant(text, { closing = false } = {}) {
342
349
  if (!text?.trim()) return;
343
- const body = closing ? trimAnswer(tidyReply(text)) : tidyReply(text);
350
+ const tidy = tidyReply(text, 4, this.lastPrompt);
351
+ const body = closing ? trimAnswer(tidy) : tidy;
344
352
  if (!body.trim()) return;
345
353
  this.endRun();
346
354
  this.add('');
@@ -367,6 +375,9 @@ export class Screen {
367
375
  * loud as the input box, none of them saying anything the rail does not.
368
376
  */
369
377
  userMessage(text) {
378
+ // Kept so the reply can be checked against it: an answer that opens by
379
+ // saying the request back is repeating the line directly above it.
380
+ this.lastPrompt = String(text ?? '');
370
381
  const room = Math.max(8, this.width() - 2); // the rail and the space after it
371
382
 
372
383
  const rows = [];
@@ -730,14 +741,21 @@ export class Screen {
730
741
  const room = Math.max(8, inner - BANNER_WIDTH - 6);
731
742
  const value = (v) => clip(String(v), Math.max(4, room - 10));
732
743
  const branch = this.facts.branch;
744
+
745
+ // The facts run together from the top with nothing between them, the credit
746
+ // sits on the last row, and whatever is left over is the gap between the
747
+ // two. Holding a row empty in the middle of the list — which is what a
748
+ // fixed six-row layout did when a fact was missing — reads as a line that
749
+ // failed to draw rather than as spacing.
733
750
  const facts = [
734
751
  ['dir', value(shortenPath(this.facts.cwd ?? this.cwd, room - 10))],
735
- branch ? ['branch', value(branch)] : ['', ''],
736
- VERSION ? ['version', value(this.facts.update ? `${VERSION} → ${this.facts.update} next start` : VERSION)] : ['', ''],
737
- ['', ''],
752
+ branch && ['branch', value(branch)],
753
+ VERSION && ['version', value(this.facts.update ? `${VERSION} → ${this.facts.update} next start` : VERSION)],
738
754
  ['keys', value('/help · esc interrupts · ctrl+b plan')],
739
- ['', 'made with ❤️ by om dixit'],
740
- ];
755
+ ].filter(Boolean).slice(0, BANNER.length - 1);
756
+
757
+ while (facts.length < BANNER.length - 1) facts.push(['', '']);
758
+ facts.push(['', 'made with ❤️ by om dixit']);
741
759
 
742
760
  const rows = BANNER.map((art, i) => {
743
761
  const [label, text] = facts[i] ?? ['', ''];
@@ -903,8 +921,10 @@ export class Screen {
903
921
  * the same on every line of every session, so it was decoration that had to
904
922
  * be read past to reach the two things that do change.
905
923
  *
906
- * The middle is borrowed while something is running, for the spinner and the
907
- * 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.
908
928
  */
909
929
  statusRow(width = this.width()) {
910
930
  const inner = width - 2; // the space between the two borders
@@ -916,41 +936,44 @@ export class Screen {
916
936
  this.chipTo = 2 + visLen(chip);
917
937
 
918
938
  const between = Math.max(1, inner - visLen(left) - visLen(right));
939
+ const middle = this.flashText ? dim(clip(this.flashText, between - 2)) : '';
919
940
 
920
- let middle = '';
921
- if (this.flashText) {
922
- middle = dim(clip(this.flashText, between - 2));
923
- } else if (this.status.busy || this.activity) {
924
- // The whole turn, not just the current tool: the timer and step count
925
- // keep going through the gaps between calls, so a long build never
926
- // looks like it has stopped.
927
- const now = Date.now();
928
- const a = this.activity;
929
- const since = a?.start ?? this.status.since ?? now;
930
- const meta = [];
931
- // No step count. It measures how much machinery ran, which is not
932
- // something the person waiting has any use for; the elapsed time is.
933
- void stepPaint;
934
- if (now - since >= 1000) meta.push({ text: formatDuration(now - since), keep: true });
935
- middle = fitActivity({
936
- glyph: spinnerGlyph(this.tick, now),
937
- label: this.status.busy ? this.status.text : 'working',
938
- meta,
939
- hint: 'esc to stop',
940
- paint: (s) => shimmer(s, now),
941
- }, between - 3);
942
- }
943
-
944
- // The percentage is pinned to the right border whatever is in the middle,
945
- // with a gap kept in front of it so a long spinner label cannot run into
946
- // the number and read as part of it.
947
941
  const tail = middle ? `${middle} ` : '';
948
942
  const pad = Math.max(1, inner - visLen(left) - visLen(tail) - visLen(right));
949
943
  return padVis(left + ' '.repeat(pad) + tail + right, inner);
950
944
  }
951
945
 
952
946
  /**
953
- * 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.
954
977
  *
955
978
  * It is the second row from the bottom now — the box's own border is below
956
979
  * it — so the row is written with its borders rather than as a bare line.
@@ -964,9 +987,11 @@ export class Screen {
964
987
  return;
965
988
  }
966
989
  const [row, col] = this.caret();
990
+ const width = this.width();
967
991
  this.output.write(
968
992
  HIDE +
969
- 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()) +
970
995
  at(row, col) + SHOW
971
996
  );
972
997
  }
@@ -1465,6 +1490,9 @@ export class Screen {
1465
1490
  ...this.headerLines(),
1466
1491
  '',
1467
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),
1468
1496
  // Always one clear row between the last thing said and the box you type
1469
1497
  // in. Without it the newest line of output sits against the border and
1470
1498
  // reads as part of the input rather than as the answer above it.
package/src/ui/theme.js CHANGED
@@ -535,7 +535,7 @@ export function withoutCodeBlocks(text, keepLines = 4) {
535
535
  * colon pointing at nothing reads as the reply having been cut off. A short
536
536
  * block stays: three lines showing a command to run belong in an answer.
537
537
  */
538
- export function tidyReply(text, keepLines = 4) {
538
+ export function tidyReply(text, keepLines = 4, prompt = '') {
539
539
  const MARK = "\u0000CUT\u0000";
540
540
  const FENCE = new RegExp("```([A-Za-z0-9+-]*)\\n([\\s\\S]*?)```", "g");
541
541
 
@@ -545,11 +545,76 @@ export function tidyReply(text, keepLines = 4) {
545
545
  });
546
546
 
547
547
  const leadIn = new RegExp("(?:^|\\n)[^\\n]{0,80}:[ \t]*\\n+" + MARK, "g");
548
- return marked
548
+ const body = marked
549
549
  .replace(leadIn, "\n")
550
550
  .split(MARK).join("")
551
551
  .replace(new RegExp("\\n{3,}", "g"), "\n\n")
552
552
  .trim();
553
+ return withoutRestatement(body, prompt);
554
+ }
555
+
556
+ /**
557
+ * The words of a line worth comparing: lowercase, no punctuation, and nothing
558
+ * short enough to turn up in any sentence at all.
559
+ */
560
+ const significant = (s) => String(s ?? '')
561
+ .toLowerCase()
562
+ .replace(/[^a-z0-9\s]+/g, ' ')
563
+ .split(/\s+/)
564
+ .filter((w) => w.length >= 3);
565
+
566
+ /**
567
+ * Is this line the request handed back?
568
+ *
569
+ * A list of phrases catches the openings a model reaches for out of habit, but
570
+ * the commonest way of repeating a request is simply saying it again in the
571
+ * asker's own words — "A Next.js habit tracker with a clean dashboard, coming
572
+ * right up" — and no list will ever match that. So the line is compared with
573
+ * what was actually typed.
574
+ *
575
+ * Three guards keep it off real answers. A prompt of four significant words or
576
+ * fewer is never matched, because at that length an overlap means nothing. A
577
+ * line much longer than the prompt is saying more than the prompt did, so it
578
+ * is content. And the bar is four fifths of the prompt's words rather than a
579
+ * majority: an answer naturally shares nouns with the request that prompted
580
+ * it, and only something repeating nearly all of it is a repetition.
581
+ */
582
+ export function echoesPrompt(line, prompt) {
583
+ const want = [...new Set(significant(prompt))];
584
+ if (want.length < 4) return false;
585
+
586
+ const text = String(line ?? '');
587
+ if (text.length > String(prompt ?? '').length * 2.5) return false;
588
+
589
+ const have = new Set(significant(text));
590
+ return want.filter((w) => have.has(w)).length / want.length >= 0.8;
591
+ }
592
+
593
+ /**
594
+ * The reply with any opening that reads the request back taken off the front.
595
+ *
596
+ * This ran only on the closing message before, and it belongs on every one. A
597
+ * model that answers "You asked me to add a dark mode toggle — done" has spent
598
+ * its first line telling someone something they typed themselves, and the line
599
+ * directly above it on screen is already their own message, in their own
600
+ * words, against a rail. Two copies of the request and one of the answer is
601
+ * the wrong ratio.
602
+ *
603
+ * It never returns nothing. A reply that is only a restatement is still the
604
+ * whole of the reply, and an empty answer on screen reads as a crash.
605
+ */
606
+ export function withoutRestatement(text, prompt = '') {
607
+ const rows = String(text ?? '').replace(/\r/g, '').split('\n');
608
+
609
+ let start = 0;
610
+ while (start < rows.length) {
611
+ const line = rows[start].trim();
612
+ if (!line) { start++; continue; }
613
+ if (!RESTATED.test(line) && !echoesPrompt(line, prompt)) break;
614
+ start++;
615
+ }
616
+
617
+ return rows.slice(start).join('\n').trim() || String(text ?? '').trim();
553
618
  }
554
619
 
555
620
  /**
@@ -568,7 +633,7 @@ export function tidyReply(text, keepLines = 4) {
568
633
  export const ANSWER_LINES = 8;
569
634
  const ANSWER_ROOM = 600; // eight wrapped lines of prose, for a reply with no line breaks in it
570
635
 
571
- const RESTATED = /^(?:you (?:asked|wanted|requested|said)\b|the (?:request|task|ask)\b|as (?:you )?requested\b|here(?:'s| is) what (?:you asked|i)\b|to (?:summarise|summarize|recap)\b|(?:request|task|summary|recap|overview)\s*:)/i;
636
+ export const RESTATED = /^(?:(?:sure|ok|okay|got it|understood|alright|right)\b[\s,!.—-]*)?(?:you(?:'ve| have)? (?:asked|want|wanted|requested|said|would like|need)\b|the (?:request|task|ask)\b|as (?:you )?requested\b|here(?:'s| is) what (?:you asked|i)\b|i(?:'ll| will|'m going to| am going to) (?:build|create|make|add|write|implement)\b|let(?:'s| us) (?:build|create|make|add|write|implement)\b|to (?:summarise|summarize|recap)\b|(?:request|task|summary|recap|overview)\s*:)/i;
572
637
  const CAVEAT = /\b(?:however|failed|couldn't|could not|cannot|can't|didn't|did not|isn't|is not|doesn't|does not|not (?:yet|wired|working|done|implemented)|missing|unfinished|except)\b/i;
573
638
  const ACTIONABLE = /\b(?:open|run|serve|visit|try|start|npm|npx|node|pnpm|yarn)\b|https?:\/\/|\.(?:html?|css|jsx?|tsx?|md|json|py|rs|go)\b/i;
574
639
  const BULLET = /^\s*(?:[-*•>]|\d+[.)]|[✓✔✅☑])\s+/;