ucode-agent 1.10.0 → 1.11.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.10.0",
3
+ "version": "1.11.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
@@ -65,6 +65,32 @@ const MAX_ARG_RETRIES = 2;
65
65
  const MAX_CONTINUATIONS = 3;
66
66
 
67
67
  /** Read-only tools whose result line adds nothing — the user saw the output. */
68
+ /**
69
+ * How many rows a diff adds and removes.
70
+ *
71
+ * The rows come through as "+12| text" and "-12| text", with a "~" heading
72
+ * for each file in a multi-file write and an undecorated note counting what
73
+ * was elided. Only the signs are counted.
74
+ */
75
+ export function countDiff(rows = []) {
76
+ let added = 0;
77
+ let removed = 0;
78
+ for (const row of rows) {
79
+ const line = String(row ?? '');
80
+ if (line.startsWith('~')) continue;
81
+ // "… 218 more removed" / "… 508 more added" stand for rows not shown.
82
+ const more = /^\s*[….]+\s*(\d+)\s+more\s+(added|removed)/.exec(line);
83
+ if (more) {
84
+ if (more[2] === 'added') added += Number(more[1]);
85
+ else removed += Number(more[1]);
86
+ continue;
87
+ }
88
+ if (line.startsWith('+')) added++;
89
+ else if (line.startsWith('-')) removed++;
90
+ }
91
+ return { added, removed };
92
+ }
93
+
68
94
  const QUIET = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search', 'update_plan']);
69
95
 
70
96
  /** Tools that draw their own line, so they get no "● Doing X" line of their own. */
@@ -1320,8 +1346,10 @@ export class Agent {
1320
1346
  this.sinceCheck?.clear();
1321
1347
  }
1322
1348
  if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
1323
- if (out.diff?.length) this.ui.diff(out.diff);
1324
- if (out.output?.length) this.ui.commandOutput(out.output);
1349
+ // The change as its two numbers, not as a copy of the file. The diff rows
1350
+ // are still built by the tool — the model reads them in the result — they
1351
+ // simply do not go on screen.
1352
+ if (out.diff?.length) this.ui.diffStat?.(countDiff(out.diff));
1325
1353
  this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
1326
1354
  }
1327
1355
 
package/src/ui/screen.js CHANGED
@@ -39,7 +39,7 @@ import chalk from 'chalk';
39
39
  import {
40
40
  theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
41
41
  boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
42
- shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel } from './theme.js';
42
+ shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine } from './theme.js';
43
43
  import { FRAME_MS, fitActivity, shimmer, spinnerGlyph, formatDuration, doneLine, stepPaint } from './activity.js';
44
44
  import { renderer, render, polish } from './markdown.js';
45
45
  import { VERSION } from '../core/version.js';
@@ -84,6 +84,8 @@ const TRACK = process.platform === 'win32'
84
84
  const UNTRACK = process.platform === 'win32'
85
85
  ? '' : `${ESC}[?1006l${ESC}[?1015l${ESC}[?1002l${ESC}[?1000l`;
86
86
 
87
+ const PASTE_ON = `${ESC}[?2004h`;
88
+ const PASTE_OFF = `${ESC}[?2004l`;
87
89
  const MOUSE_ON = `${ESC}[?1007h${TRACK}`;
88
90
  const MOUSE_OFF = `${UNTRACK}${ESC}[?1007l`;
89
91
  const HIDE = `${ESC}[?25l`;
@@ -149,7 +151,7 @@ export class Screen {
149
151
 
150
152
  async start() {
151
153
  ensureColour(this.output);
152
- this.output.write(ALT_ON + MOUSE_ON + HIDE + title(`ucode — ${path.basename(this.cwd)}`));
154
+ this.output.write(ALT_ON + MOUSE_ON + PASTE_ON + HIDE + title(`ucode — ${path.basename(this.cwd)}`));
153
155
  this.input.setRawMode?.(true);
154
156
  this.input.resume();
155
157
  this.input.setEncoding('utf8');
@@ -173,7 +175,7 @@ export class Screen {
173
175
  this.output.off?.('resize', this.onResize);
174
176
  this.input.setRawMode?.(false);
175
177
  this.input.pause();
176
- this.output.write(MOUSE_OFF + ALT_OFF + SHOW);
178
+ this.output.write(PASTE_OFF + MOUSE_OFF + ALT_OFF + SHOW);
177
179
  }
178
180
 
179
181
  close() {
@@ -305,11 +307,14 @@ export class Screen {
305
307
  if (gap === 1) this.lines.pop(); // its single result line, now counted
306
308
  run.count++;
307
309
  run.label = label;
308
- this.lines[run.at] = `${narrationMark()} ${narration(asLabel(groupLabel(label, run.count)))}`;
309
- this.render();
310
+ run.targets.push(groupTarget(label));
311
+ this.paintRun();
310
312
  } else {
311
313
  this.push(`${narrationMark()} ${narration(asLabel(label))}`);
312
- this.run = { kind, count: 1, at: this.lines.length - 1, label };
314
+ this.run = {
315
+ kind, count: 1, at: this.lines.length - 1, label,
316
+ targets: [groupTarget(label)], added: 0, removed: 0,
317
+ };
313
318
  }
314
319
  this.updateSpinner(label);
315
320
  }
@@ -317,18 +322,42 @@ export class Screen {
317
322
  /** Anything that is not another step of the same kind ends the run. */
318
323
  endRun() { this.run = null; }
319
324
 
325
+ /** Redraw the run's single line from what it has accumulated. */
326
+ paintRun() {
327
+ if (!this.run) return;
328
+ this.lines[this.run.at] = `${narrationMark()} ${narration(asLabel(runLine(this.run)))}`;
329
+ this.render();
330
+ }
331
+
332
+ /**
333
+ * A change, as its two numbers.
334
+ *
335
+ * The diff itself used to go into the transcript. A 539-line file printed
336
+ * there buries the answer under a copy of something already on disk, so
337
+ * what is kept is the shape of the change: how much arrived, how much left.
338
+ */
339
+ diffStat({ added = 0, removed = 0 } = {}) {
340
+ if (!this.run) return;
341
+ this.run.added += added;
342
+ this.run.removed += removed;
343
+ this.paintRun();
344
+ }
345
+
320
346
  /** The checklist, when the model updates it. One line, wrapped if it must. */
321
347
  plan(items) {
322
348
  const line = planLine(items);
323
349
  if (line) this.push(line);
324
350
  }
325
351
 
326
- toolResult(summary) {
327
- // Inside a run, the count on the line above already says what happened;
328
- // a result line per step is the noise this is meant to remove.
329
- if (this.run && this.run.count > 1) return;
330
- this.push(dim(` └ ${summary}`));
331
- }
352
+ /**
353
+ * What came of a step.
354
+ *
355
+ * Nothing goes underneath the bullet any more: a line of its own for every
356
+ * result doubles the height of the transcript to say "ok". The bullet
357
+ * already names the step, and a change adds its numbers to that same line.
358
+ * Only a failure earns a line of its own.
359
+ */
360
+ toolResult() {}
332
361
 
333
362
  toolFailed(summary) {
334
363
  // A failure is never folded away.
@@ -953,7 +982,55 @@ export class Screen {
953
982
  this.render();
954
983
  }
955
984
 
985
+ /**
986
+ * Text arriving as a paste rather than as typing.
987
+ *
988
+ * A terminal in bracketed-paste mode wraps pasted text in markers, which is
989
+ * the only way to tell forty lines pasted at once from forty lines typed
990
+ * very fast. Without it every newline in the paste reads as Enter, so a
991
+ * pasted block submits itself a line at a time and arrives as forty
992
+ * messages. Inside the markers a newline is just a character.
993
+ */
994
+ onPaste(text) {
995
+ const clean = String(text).replace(/\r\n?/g, '\n');
996
+ this.buffer = this.buffer.slice(0, this.cursor) + clean + this.buffer.slice(this.cursor);
997
+ this.cursor += clean.length;
998
+ this.render();
999
+ }
1000
+
956
1001
  onData(chunk) {
1002
+ // Pasted text first: it is wrapped in markers and must not be read as
1003
+ // keys, or its newlines submit it in pieces.
1004
+ const paste = /\[200~([\s\S]*?)\[201~/g;
1005
+ if (paste.test(chunk)) {
1006
+ paste.lastIndex = 0;
1007
+ let at = 0;
1008
+ let m;
1009
+ while ((m = paste.exec(chunk))) {
1010
+ if (m.index > at) this.onData(chunk.slice(at, m.index));
1011
+ this.onPaste(m[1]);
1012
+ at = m.index + m[0].length;
1013
+ }
1014
+ if (at < chunk.length) this.onData(chunk.slice(at));
1015
+ return;
1016
+ }
1017
+ // An unterminated paste: hold what has arrived and wait for the rest.
1018
+ const open = chunk.indexOf('[200~');
1019
+ if (open !== -1) {
1020
+ if (open > 0) this.onData(chunk.slice(0, open));
1021
+ this.pasting = chunk.slice(open + 6);
1022
+ return;
1023
+ }
1024
+ if (this.pasting !== undefined && this.pasting !== null) {
1025
+ const close = chunk.indexOf('[201~');
1026
+ if (close === -1) { this.pasting += chunk; return; }
1027
+ this.onPaste(this.pasting + chunk.slice(0, close));
1028
+ this.pasting = null;
1029
+ const after = chunk.slice(close + 6);
1030
+ if (after) this.onData(after);
1031
+ return;
1032
+ }
1033
+
957
1034
  // UCODE_DEBUG_KEYS=1 logs every byte the terminal sends to
958
1035
  // ~/.ucode/keys.log. Whether mouse reporting works at all depends on the
959
1036
  // terminal forwarding it; this is how to find out.
package/src/ui/theme.js CHANGED
@@ -291,40 +291,65 @@ export const narration = (text) => chalk.dim(text);
291
291
 
292
292
  /** The bullet beside a narration line: present, not loud. */
293
293
  export const narrationMark = () => chalk.dim(deep('●'));
294
-
295
- /**
296
- * How a run of the same kind of step reads once it is over.
297
- *
298
- * While it happens, "Running npm test" is the useful thing to show. Once
299
- * three of them have happened, three near-identical lines are just noise
300
- * between the reader and the answer, so they fold into one: "Ran 3 commands".
301
- * The present tense belongs to the thing happening now; the past tense to the
302
- * summary of what did.
303
- */
304
- const GROUPS = {
305
- Running: ['Ran', 'command', 'commands'],
306
- Reading: ['Read', 'file', 'files'],
307
- Searching: ['Searched', 'time', 'times'],
308
- Finding: ['Found', 'pattern', 'patterns'],
309
- Listing: ['Listed', 'directory', 'directories'],
310
- Writing: ['Wrote', 'file', 'files'],
311
- Editing: ['Edited', 'file', 'files'],
312
- Checking: ['Checked', 'thing', 'things'],
313
- Looking: ['Looked up', 'name', 'names'],
314
- Asking: ['Asked about', 'name', 'names'],
315
- Mapping: ['Mapped', 'folder', 'folders'],
316
- Adding: ['Added', 'block', 'blocks'],
317
- Renaming: ['Renamed', 'name', 'names'],
318
- };
319
-
320
- /** The first word of a label, which is what decides whether two steps match. */
321
- export const groupKind = (label) => String(label ?? '').trim().split(/\s+/)[0] ?? '';
322
-
323
- /** One line standing in for `count` steps that all began with the same word. */
324
- export function groupLabel(label, count) {
325
- if (count <= 1) return String(label ?? '');
326
- const g = GROUPS[groupKind(label)];
327
- if (!g) return `${label} (+${count - 1} more)`;
328
- const [past, one, many] = g;
329
- return `${past} ${count} ${count === 1 ? one : many}`;
330
- }
294
+
295
+ /**
296
+ * How a run of the same kind of step reads once it is over.
297
+ *
298
+ * While it happens, "Running npm test" is the useful thing to show. Once
299
+ * three of them have happened, three near-identical lines are just noise
300
+ * between the reader and the answer, so they fold into one: "Ran 3 commands".
301
+ * The present tense belongs to the thing happening now; the past tense to the
302
+ * summary of what did.
303
+ */
304
+ const GROUPS = {
305
+ Running: ['Ran', 'command', 'commands'],
306
+ Reading: ['Read', 'file', 'files'],
307
+ Searching: ['Searched', 'time', 'times'],
308
+ Finding: ['Found', 'pattern', 'patterns'],
309
+ Listing: ['Listed', 'directory', 'directories'],
310
+ Writing: ['Wrote', 'file', 'files'],
311
+ Editing: ['Edited', 'file', 'files'],
312
+ Checking: ['Checked', 'thing', 'things'],
313
+ Looking: ['Looked up', 'name', 'names'],
314
+ Asking: ['Asked about', 'name', 'names'],
315
+ Mapping: ['Mapped', 'folder', 'folders'],
316
+ Adding: ['Added', 'block', 'blocks'],
317
+ Renaming: ['Renamed', 'name', 'names'],
318
+ };
319
+
320
+ /** The first word of a label, which is what decides whether two steps match. */
321
+ export const groupKind = (label) => String(label ?? '').trim().split(/\s+/)[0] ?? '';
322
+
323
+ /** One line standing in for `count` steps that all began with the same word. */
324
+ export function groupLabel(label, count) {
325
+ if (count <= 1) return String(label ?? '');
326
+ const g = GROUPS[groupKind(label)];
327
+ if (!g) return `${label} (+${count - 1} more)`;
328
+ const [past, one, many] = g;
329
+ return `${past} ${count} ${count === 1 ? one : many}`;
330
+ }
331
+
332
+ /** The part of a label after its opening word: the file or command it is about. */
333
+ export const groupTarget = (label) => String(label ?? '').trim().split(/\s+/).slice(1).join(' ');
334
+
335
+ /**
336
+ * One narration line, standing for everything that happened under it.
337
+ *
338
+ * The transcript is a record of what was done, not a copy of what was
339
+ * written. A 539-line file printed into it buries the answer and tells the
340
+ * reader nothing they could not get from the file itself, so a change is its
341
+ * two numbers. Several steps on one file stay one line naming that file;
342
+ * several files become a count.
343
+ */
344
+ export function runLine({ label, count = 1, targets = [], added = 0, removed = 0 }) {
345
+ const counts = added || removed
346
+ ? ` ${chalk.hex('#3fb950')(`+${added}`)} ${chalk.hex('#f2939c')(`-${removed}`)}`
347
+ : '';
348
+ if (count <= 1) return `${label}${counts}`;
349
+
350
+ const g = GROUPS[groupKind(label)];
351
+ const unique = [...new Set(targets.filter(Boolean))];
352
+ if (g && unique.length === 1) return `${g[0]} ${unique[0]}${counts}`;
353
+ if (!g) return `${label} (+${count - 1} more)${counts}`;
354
+ return `${g[0]} ${count} ${count === 1 ? g[1] : g[2]}${counts}`;
355
+ }