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 +1 -1
- package/src/core/loop.js +30 -2
- package/src/ui/screen.js +89 -12
- package/src/ui/theme.js +62 -37
package/package.json
CHANGED
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
|
-
|
|
1324
|
-
|
|
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
|
-
|
|
309
|
-
this.
|
|
310
|
+
run.targets.push(groupTarget(label));
|
|
311
|
+
this.paintRun();
|
|
310
312
|
} else {
|
|
311
313
|
this.push(`${narrationMark()} ${narration(asLabel(label))}`);
|
|
312
|
-
this.run = {
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
+
}
|