ucode-agent 1.9.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 +75 -2
- package/src/core/updater.js +152 -95
- package/src/ui/screen.js +111 -7
- package/src/ui/theme.js +62 -0
- package/ucode.js +25 -0
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. */
|
|
@@ -408,6 +434,11 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
408
434
|
'',
|
|
409
435
|
'## How to work',
|
|
410
436
|
'',
|
|
437
|
+
'Say what you are about to do, in one short line, before you do it — "now the',
|
|
438
|
+
'tests", "wiring this into the loop". A line like that before a group of actions is',
|
|
439
|
+
'what makes the work readable. Keep it to a sentence: the narration is not the',
|
|
440
|
+
'answer, and three sentences of intent before every step reads as stalling.',
|
|
441
|
+
'',
|
|
411
442
|
'Before you guess at an API, ask: type_of gives the exact signature from the',
|
|
412
443
|
'TypeScript this project has installed, and find_symbol says where something is declared without',
|
|
413
444
|
'reading five files to find it. Rename with rename_symbol rather than edit_file — a',
|
|
@@ -566,6 +597,42 @@ export class Agent {
|
|
|
566
597
|
this.working.push(message);
|
|
567
598
|
}
|
|
568
599
|
|
|
600
|
+
/**
|
|
601
|
+
* Answer every tool call a stopped turn never got to.
|
|
602
|
+
*
|
|
603
|
+
* An assistant message ends by asking for tools, and each of those asks
|
|
604
|
+
* needs an answer. Abandon them and the conversation is left mid-sentence,
|
|
605
|
+
* so the next time the model reads it the only sensible thing to do is
|
|
606
|
+
* carry on where it left off — which is exactly what the user pressed stop
|
|
607
|
+
* to prevent. Saying "this did not happen" for each one ends the sentence,
|
|
608
|
+
* and a line from the user ends the task.
|
|
609
|
+
*/
|
|
610
|
+
closeInterrupted() {
|
|
611
|
+
const answered = new Set(this.working.filter((m) => m.role === 'tool').map((m) => m.toolCallId));
|
|
612
|
+
const missing = [];
|
|
613
|
+
for (const m of this.working) {
|
|
614
|
+
if (m.role !== 'assistant' || !m.toolCalls?.length) continue;
|
|
615
|
+
for (const call of m.toolCalls) {
|
|
616
|
+
if (!answered.has(call.id)) missing.push(call);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
for (const call of missing) {
|
|
620
|
+
this.push({
|
|
621
|
+
role: 'tool',
|
|
622
|
+
toolCallId: call.id,
|
|
623
|
+
name: call.name,
|
|
624
|
+
content: 'The user stopped the turn before this ran. It did not happen, and it must not be retried.',
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
if (missing.length) {
|
|
628
|
+
this.push({
|
|
629
|
+
role: 'user',
|
|
630
|
+
content: 'I stopped that. Drop it and wait for what I ask next — do not pick it back up.',
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return missing.length;
|
|
634
|
+
}
|
|
635
|
+
|
|
569
636
|
async persist() {
|
|
570
637
|
try {
|
|
571
638
|
this.session.model = model();
|
|
@@ -607,6 +674,7 @@ export class Agent {
|
|
|
607
674
|
if (this.busy && this.abort) {
|
|
608
675
|
this.abort.abort();
|
|
609
676
|
this.ui.stopSpinner();
|
|
677
|
+
this.ui.stopTimer?.();
|
|
610
678
|
this.ui.note('interrupted');
|
|
611
679
|
}
|
|
612
680
|
};
|
|
@@ -849,9 +917,12 @@ export class Agent {
|
|
|
849
917
|
trace({ kind: 'turn', ms: Date.now() - turnStarted });
|
|
850
918
|
this.stats.workMs += Date.now() - turnStarted;
|
|
851
919
|
this.stats.turns++;
|
|
920
|
+
if (!finished) this.closeInterrupted();
|
|
852
921
|
this.busy = false;
|
|
853
922
|
this.abort = null;
|
|
854
923
|
this.ui.stopSpinner();
|
|
924
|
+
this.ui.stopTimer?.();
|
|
925
|
+
this.activity = null;
|
|
855
926
|
this.ui.turnEnd?.({ ok: finished });
|
|
856
927
|
await this.persist();
|
|
857
928
|
if (this.full) this.showHeader({ clear: false });
|
|
@@ -1275,8 +1346,10 @@ export class Agent {
|
|
|
1275
1346
|
this.sinceCheck?.clear();
|
|
1276
1347
|
}
|
|
1277
1348
|
if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
|
|
1278
|
-
|
|
1279
|
-
|
|
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));
|
|
1280
1353
|
this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
|
|
1281
1354
|
}
|
|
1282
1355
|
|
package/src/core/updater.js
CHANGED
|
@@ -1,95 +1,152 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* updater.js — staying current without anyone running npm by hand.
|
|
3
|
-
*
|
|
4
|
-
* On every launch ucode asks the registry, in the background, whether a newer
|
|
5
|
-
* version exists. If one does, it installs it globally, detached, while you
|
|
6
|
-
* work. The version you are running carries on untouched; the next launch is
|
|
7
|
-
* the new one. Nothing about starting ucode waits on any of this.
|
|
8
|
-
*
|
|
9
|
-
* It stays out of the way in three cases: a development checkout (updating
|
|
10
|
-
* would overwrite the `npm link` that points at your working copy), when
|
|
11
|
-
* UCODE_NO_UPDATE is set, and when another ucode is already updating.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { spawn } from 'node:child_process';
|
|
15
|
-
import { existsSync, promises as fs } from 'node:fs';
|
|
16
|
-
import os from 'node:os';
|
|
17
|
-
import path from 'node:path';
|
|
18
|
-
import { fileURLToPath } from 'node:url';
|
|
19
|
-
import { VERSION } from './version.js';
|
|
20
|
-
|
|
21
|
-
const PACKAGE = 'ucode-agent';
|
|
22
|
-
const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
23
|
-
const HOME = path.join(os.homedir(), '.ucode');
|
|
24
|
-
const LOCK = path.join(HOME, 'update.lock');
|
|
25
|
-
const LOG = path.join(HOME, 'update.log');
|
|
26
|
-
const LOCK_TTL = 10 * 60_000;
|
|
27
|
-
|
|
28
|
-
/** "1.10.0" > "1.9.3" — numeric, part by part. */
|
|
29
|
-
export function newer(a, b) {
|
|
30
|
-
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
31
|
-
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
|
32
|
-
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
33
|
-
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
34
|
-
}
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function isDevCheckout() {
|
|
39
|
-
return existsSync(path.join(PACKAGE_ROOT, '.git'));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function latestVersion() {
|
|
43
|
-
const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
|
|
44
|
-
signal: AbortSignal.timeout(5_000),
|
|
45
|
-
headers: { accept: 'application/json' },
|
|
46
|
-
});
|
|
47
|
-
if (!res.ok) return null;
|
|
48
|
-
return (await res.json())?.version ?? null;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
1
|
+
/**
|
|
2
|
+
* updater.js — staying current without anyone running npm by hand.
|
|
3
|
+
*
|
|
4
|
+
* On every launch ucode asks the registry, in the background, whether a newer
|
|
5
|
+
* version exists. If one does, it installs it globally, detached, while you
|
|
6
|
+
* work. The version you are running carries on untouched; the next launch is
|
|
7
|
+
* the new one. Nothing about starting ucode waits on any of this.
|
|
8
|
+
*
|
|
9
|
+
* It stays out of the way in three cases: a development checkout (updating
|
|
10
|
+
* would overwrite the `npm link` that points at your working copy), when
|
|
11
|
+
* UCODE_NO_UPDATE is set, and when another ucode is already updating.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { existsSync, rmSync, promises as fs } from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { VERSION } from './version.js';
|
|
20
|
+
|
|
21
|
+
const PACKAGE = 'ucode-agent';
|
|
22
|
+
const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
23
|
+
const HOME = path.join(os.homedir(), '.ucode');
|
|
24
|
+
const LOCK = path.join(HOME, 'update.lock');
|
|
25
|
+
const LOG = path.join(HOME, 'update.log');
|
|
26
|
+
const LOCK_TTL = 10 * 60_000;
|
|
27
|
+
|
|
28
|
+
/** "1.10.0" > "1.9.3" — numeric, part by part. */
|
|
29
|
+
export function newer(a, b) {
|
|
30
|
+
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
31
|
+
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
|
32
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
33
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isDevCheckout() {
|
|
39
|
+
return existsSync(path.join(PACKAGE_ROOT, '.git'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function latestVersion() {
|
|
43
|
+
const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
|
|
44
|
+
signal: AbortSignal.timeout(5_000),
|
|
45
|
+
headers: { accept: 'application/json' },
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) return null;
|
|
48
|
+
return (await res.json())?.version ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Is that process still running? A lock held by a dead one is not a lock. */
|
|
52
|
+
function alive(pid) {
|
|
53
|
+
if (!pid) return false;
|
|
54
|
+
try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Take the update lock, unless a live process holds it.
|
|
59
|
+
*
|
|
60
|
+
* The lock used to be dropped by the parent watching the installer exit — but
|
|
61
|
+
* the installer is detached and ucode usually leaves first, so the handler
|
|
62
|
+
* never ran and the lock sat there blocking every update until it aged out.
|
|
63
|
+
* Now the holder is checked for a pulse, so a lock left by a process that has
|
|
64
|
+
* gone is simply taken over.
|
|
65
|
+
*/
|
|
66
|
+
async function takeLock() {
|
|
67
|
+
try {
|
|
68
|
+
const stat = await fs.stat(LOCK);
|
|
69
|
+
const holder = parseInt(await fs.readFile(LOCK, 'utf8').catch(() => ''), 10);
|
|
70
|
+
if (alive(holder) && Date.now() - stat.mtimeMs < LOCK_TTL) return false;
|
|
71
|
+
} catch { /* no lock — good */ }
|
|
72
|
+
await fs.mkdir(HOME, { recursive: true });
|
|
73
|
+
await fs.writeFile(LOCK, String(process.pid));
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function releaseLock() {
|
|
78
|
+
await fs.rm(LOCK, { force: true }).catch(() => {});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Install the newer version and return true once it is on disk.
|
|
83
|
+
*
|
|
84
|
+
* This one waits. It is used at startup, where waiting a few seconds once per
|
|
85
|
+
* release is the whole point: the alternative is being told an update exists
|
|
86
|
+
* and running the old one anyway.
|
|
87
|
+
*/
|
|
88
|
+
export async function installNow(version) {
|
|
89
|
+
if (!(await takeLock())) return false;
|
|
90
|
+
try {
|
|
91
|
+
const done = await new Promise((resolve) => {
|
|
92
|
+
const child = spawn(`npm install -g ${PACKAGE}@${version} --no-audit --no-fund --silent`, {
|
|
93
|
+
shell: true, windowsHide: true, stdio: 'ignore',
|
|
94
|
+
});
|
|
95
|
+
child.on('exit', (code) => resolve(code === 0));
|
|
96
|
+
child.on('error', () => resolve(false));
|
|
97
|
+
setTimeout(() => resolve(false), 90_000);
|
|
98
|
+
});
|
|
99
|
+
return done;
|
|
100
|
+
} finally {
|
|
101
|
+
await releaseLock();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The newest version on the registry, if it is newer than this one. */
|
|
106
|
+
export async function pendingUpdate() {
|
|
107
|
+
try {
|
|
108
|
+
if (process.env.UCODE_NO_UPDATE || isDevCheckout() || !VERSION) return null;
|
|
109
|
+
const latest = await latestVersion();
|
|
110
|
+
return latest && newer(latest, VERSION) ? latest : null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check, and install if there is something newer.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} o
|
|
120
|
+
* @param {(v: string) => void} [o.onUpdated] called when the install finishes
|
|
121
|
+
*/
|
|
122
|
+
export async function autoUpdate({ onUpdated } = {}) {
|
|
123
|
+
try {
|
|
124
|
+
if (process.env.UCODE_NO_UPDATE || isDevCheckout() || !VERSION) return;
|
|
125
|
+
const latest = await latestVersion();
|
|
126
|
+
if (!latest || !newer(latest, VERSION)) return;
|
|
127
|
+
if (!(await takeLock())) return;
|
|
128
|
+
|
|
129
|
+
const log = await fs.open(LOG, 'w');
|
|
130
|
+
const child = spawn(`npm install -g ${PACKAGE}@${latest} --no-audit --no-fund`, {
|
|
131
|
+
shell: true,
|
|
132
|
+
detached: true,
|
|
133
|
+
windowsHide: true,
|
|
134
|
+
stdio: ['ignore', log.fd, log.fd],
|
|
135
|
+
});
|
|
136
|
+
await log.close();
|
|
137
|
+
child.unref();
|
|
138
|
+
|
|
139
|
+
// The lock is cleared by whoever took it. This process may well be gone
|
|
140
|
+
// before npm finishes, so it cannot be left to an exit handler here.
|
|
141
|
+
child.on('exit', async (code) => {
|
|
142
|
+
await releaseLock();
|
|
143
|
+
if (code === 0) onUpdated?.(latest);
|
|
144
|
+
});
|
|
145
|
+
child.on('error', async () => { await releaseLock(); });
|
|
146
|
+
// And if this process leaves first, release it on the way out.
|
|
147
|
+
process.once('exit', () => { try { rmSync(LOCK, { force: true }); } catch {} });
|
|
148
|
+
} catch {
|
|
149
|
+
// An update check must never be the reason ucode misbehaves. Offline,
|
|
150
|
+
// registry down, no permission to install globally: all silently skipped.
|
|
151
|
+
}
|
|
152
|
+
}
|
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 } 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() {
|
|
@@ -246,6 +248,7 @@ export class Screen {
|
|
|
246
248
|
|
|
247
249
|
assistant(text) {
|
|
248
250
|
if (!text?.trim()) return;
|
|
251
|
+
this.endRun();
|
|
249
252
|
this.add('');
|
|
250
253
|
this.add(render(this.md, text));
|
|
251
254
|
this.add('');
|
|
@@ -291,21 +294,74 @@ export class Screen {
|
|
|
291
294
|
toolCall(label) {
|
|
292
295
|
// U+25CF, not U+23FA: the latter carries emoji presentation, which Windows
|
|
293
296
|
// Terminal draws as a white circle on a blue tile.
|
|
294
|
-
|
|
297
|
+
const kind = groupKind(label);
|
|
298
|
+
const run = this.run;
|
|
299
|
+
// The run's own line is either the last one, or the last but one with its
|
|
300
|
+
// result underneath. Anything further down means something else was said
|
|
301
|
+
// in between, and the run is over.
|
|
302
|
+
const gap = run ? this.lines.length - 1 - run.at : Infinity;
|
|
303
|
+
|
|
304
|
+
// A second step of the same kind rewrites the line the first one wrote,
|
|
305
|
+
// rather than adding another almost-identical one beneath it.
|
|
306
|
+
if (run && run.kind === kind && gap <= 1) {
|
|
307
|
+
if (gap === 1) this.lines.pop(); // its single result line, now counted
|
|
308
|
+
run.count++;
|
|
309
|
+
run.label = label;
|
|
310
|
+
run.targets.push(groupTarget(label));
|
|
311
|
+
this.paintRun();
|
|
312
|
+
} else {
|
|
313
|
+
this.push(`${narrationMark()} ${narration(asLabel(label))}`);
|
|
314
|
+
this.run = {
|
|
315
|
+
kind, count: 1, at: this.lines.length - 1, label,
|
|
316
|
+
targets: [groupTarget(label)], added: 0, removed: 0,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
295
319
|
this.updateSpinner(label);
|
|
296
320
|
}
|
|
297
321
|
|
|
322
|
+
/** Anything that is not another step of the same kind ends the run. */
|
|
323
|
+
endRun() { this.run = null; }
|
|
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
|
+
|
|
298
346
|
/** The checklist, when the model updates it. One line, wrapped if it must. */
|
|
299
347
|
plan(items) {
|
|
300
348
|
const line = planLine(items);
|
|
301
349
|
if (line) this.push(line);
|
|
302
350
|
}
|
|
303
351
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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() {}
|
|
307
361
|
|
|
308
362
|
toolFailed(summary) {
|
|
363
|
+
// A failure is never folded away.
|
|
364
|
+
this.endRun();
|
|
309
365
|
this.push(`${dim(' └ ')}${theme.error(summary)}`);
|
|
310
366
|
}
|
|
311
367
|
|
|
@@ -926,7 +982,55 @@ export class Screen {
|
|
|
926
982
|
this.render();
|
|
927
983
|
}
|
|
928
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
|
+
|
|
929
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
|
+
|
|
930
1034
|
// UCODE_DEBUG_KEYS=1 logs every byte the terminal sends to
|
|
931
1035
|
// ~/.ucode/keys.log. Whether mouse reporting works at all depends on the
|
|
932
1036
|
// terminal forwarding it; this is how to find out.
|
package/src/ui/theme.js
CHANGED
|
@@ -291,3 +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
|
+
}
|
|
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
|
+
}
|
package/ucode.js
CHANGED
|
@@ -81,6 +81,31 @@ async function main() {
|
|
|
81
81
|
return;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// Update before the session starts, not after it ends.
|
|
85
|
+
//
|
|
86
|
+
// Installing in the background and taking effect "next time" means the run
|
|
87
|
+
// you are doing now — the one that might be a demo — is the old one, and you
|
|
88
|
+
// have no way to know. So if there is something newer, it goes on now and
|
|
89
|
+
// this process hands over to it. The wait is a few seconds, once per
|
|
90
|
+
// release; every other launch pays one quick question to the registry.
|
|
91
|
+
if (!args.version && process.argv[2] !== 'login' && process.argv[2] !== 'doctor') {
|
|
92
|
+
const { pendingUpdate, installNow } = await import('./src/core/updater.js');
|
|
93
|
+
const waiting = await pendingUpdate();
|
|
94
|
+
if (waiting) {
|
|
95
|
+
process.stdout.write(` updating to v${waiting}…\n`);
|
|
96
|
+
if (await installNow(waiting)) {
|
|
97
|
+
const { spawnSync } = await import('node:child_process');
|
|
98
|
+
const run = spawnSync(process.execPath, [process.argv[1], ...process.argv.slice(2)], {
|
|
99
|
+
stdio: 'inherit',
|
|
100
|
+
env: { ...process.env, UCODE_NO_UPDATE: '1' }, // the new one must not check again
|
|
101
|
+
});
|
|
102
|
+
process.exit(run.status ?? 0);
|
|
103
|
+
}
|
|
104
|
+
// It did not take. Carry on with the version already here rather than
|
|
105
|
+
// making a failed update the reason ucode will not start.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
84
109
|
if (args.model) {
|
|
85
110
|
try {
|
|
86
111
|
setModel(args.model);
|