ucode-agent 1.8.1 → 1.10.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 +45 -0
- package/src/core/updater.js +152 -95
- package/src/ui/screen.js +33 -6
- package/src/ui/theme.js +48 -57
- package/ucode.js +25 -0
package/package.json
CHANGED
package/src/core/loop.js
CHANGED
|
@@ -408,6 +408,11 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
408
408
|
'',
|
|
409
409
|
'## How to work',
|
|
410
410
|
'',
|
|
411
|
+
'Say what you are about to do, in one short line, before you do it — "now the',
|
|
412
|
+
'tests", "wiring this into the loop". A line like that before a group of actions is',
|
|
413
|
+
'what makes the work readable. Keep it to a sentence: the narration is not the',
|
|
414
|
+
'answer, and three sentences of intent before every step reads as stalling.',
|
|
415
|
+
'',
|
|
411
416
|
'Before you guess at an API, ask: type_of gives the exact signature from the',
|
|
412
417
|
'TypeScript this project has installed, and find_symbol says where something is declared without',
|
|
413
418
|
'reading five files to find it. Rename with rename_symbol rather than edit_file — a',
|
|
@@ -566,6 +571,42 @@ export class Agent {
|
|
|
566
571
|
this.working.push(message);
|
|
567
572
|
}
|
|
568
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Answer every tool call a stopped turn never got to.
|
|
576
|
+
*
|
|
577
|
+
* An assistant message ends by asking for tools, and each of those asks
|
|
578
|
+
* needs an answer. Abandon them and the conversation is left mid-sentence,
|
|
579
|
+
* so the next time the model reads it the only sensible thing to do is
|
|
580
|
+
* carry on where it left off — which is exactly what the user pressed stop
|
|
581
|
+
* to prevent. Saying "this did not happen" for each one ends the sentence,
|
|
582
|
+
* and a line from the user ends the task.
|
|
583
|
+
*/
|
|
584
|
+
closeInterrupted() {
|
|
585
|
+
const answered = new Set(this.working.filter((m) => m.role === 'tool').map((m) => m.toolCallId));
|
|
586
|
+
const missing = [];
|
|
587
|
+
for (const m of this.working) {
|
|
588
|
+
if (m.role !== 'assistant' || !m.toolCalls?.length) continue;
|
|
589
|
+
for (const call of m.toolCalls) {
|
|
590
|
+
if (!answered.has(call.id)) missing.push(call);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
for (const call of missing) {
|
|
594
|
+
this.push({
|
|
595
|
+
role: 'tool',
|
|
596
|
+
toolCallId: call.id,
|
|
597
|
+
name: call.name,
|
|
598
|
+
content: 'The user stopped the turn before this ran. It did not happen, and it must not be retried.',
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
if (missing.length) {
|
|
602
|
+
this.push({
|
|
603
|
+
role: 'user',
|
|
604
|
+
content: 'I stopped that. Drop it and wait for what I ask next — do not pick it back up.',
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
return missing.length;
|
|
608
|
+
}
|
|
609
|
+
|
|
569
610
|
async persist() {
|
|
570
611
|
try {
|
|
571
612
|
this.session.model = model();
|
|
@@ -607,6 +648,7 @@ export class Agent {
|
|
|
607
648
|
if (this.busy && this.abort) {
|
|
608
649
|
this.abort.abort();
|
|
609
650
|
this.ui.stopSpinner();
|
|
651
|
+
this.ui.stopTimer?.();
|
|
610
652
|
this.ui.note('interrupted');
|
|
611
653
|
}
|
|
612
654
|
};
|
|
@@ -849,9 +891,12 @@ export class Agent {
|
|
|
849
891
|
trace({ kind: 'turn', ms: Date.now() - turnStarted });
|
|
850
892
|
this.stats.workMs += Date.now() - turnStarted;
|
|
851
893
|
this.stats.turns++;
|
|
894
|
+
if (!finished) this.closeInterrupted();
|
|
852
895
|
this.busy = false;
|
|
853
896
|
this.abort = null;
|
|
854
897
|
this.ui.stopSpinner();
|
|
898
|
+
this.ui.stopTimer?.();
|
|
899
|
+
this.activity = null;
|
|
855
900
|
this.ui.turnEnd?.({ ok: finished });
|
|
856
901
|
await this.persist();
|
|
857
902
|
if (this.full) this.showHeader({ clear: false });
|
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,
|
|
42
|
+
shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel } 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';
|
|
@@ -149,7 +149,7 @@ export class Screen {
|
|
|
149
149
|
|
|
150
150
|
async start() {
|
|
151
151
|
ensureColour(this.output);
|
|
152
|
-
this.output.write(ALT_ON + MOUSE_ON + HIDE +
|
|
152
|
+
this.output.write(ALT_ON + MOUSE_ON + HIDE + title(`ucode — ${path.basename(this.cwd)}`));
|
|
153
153
|
this.input.setRawMode?.(true);
|
|
154
154
|
this.input.resume();
|
|
155
155
|
this.input.setEncoding('utf8');
|
|
@@ -173,7 +173,7 @@ export class Screen {
|
|
|
173
173
|
this.output.off?.('resize', this.onResize);
|
|
174
174
|
this.input.setRawMode?.(false);
|
|
175
175
|
this.input.pause();
|
|
176
|
-
this.output.write(
|
|
176
|
+
this.output.write(MOUSE_OFF + ALT_OFF + SHOW);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
close() {
|
|
@@ -246,6 +246,7 @@ export class Screen {
|
|
|
246
246
|
|
|
247
247
|
assistant(text) {
|
|
248
248
|
if (!text?.trim()) return;
|
|
249
|
+
this.endRun();
|
|
249
250
|
this.add('');
|
|
250
251
|
this.add(render(this.md, text));
|
|
251
252
|
this.add('');
|
|
@@ -291,10 +292,31 @@ export class Screen {
|
|
|
291
292
|
toolCall(label) {
|
|
292
293
|
// U+25CF, not U+23FA: the latter carries emoji presentation, which Windows
|
|
293
294
|
// Terminal draws as a white circle on a blue tile.
|
|
294
|
-
|
|
295
|
+
const kind = groupKind(label);
|
|
296
|
+
const run = this.run;
|
|
297
|
+
// The run's own line is either the last one, or the last but one with its
|
|
298
|
+
// result underneath. Anything further down means something else was said
|
|
299
|
+
// in between, and the run is over.
|
|
300
|
+
const gap = run ? this.lines.length - 1 - run.at : Infinity;
|
|
301
|
+
|
|
302
|
+
// A second step of the same kind rewrites the line the first one wrote,
|
|
303
|
+
// rather than adding another almost-identical one beneath it.
|
|
304
|
+
if (run && run.kind === kind && gap <= 1) {
|
|
305
|
+
if (gap === 1) this.lines.pop(); // its single result line, now counted
|
|
306
|
+
run.count++;
|
|
307
|
+
run.label = label;
|
|
308
|
+
this.lines[run.at] = `${narrationMark()} ${narration(asLabel(groupLabel(label, run.count)))}`;
|
|
309
|
+
this.render();
|
|
310
|
+
} else {
|
|
311
|
+
this.push(`${narrationMark()} ${narration(asLabel(label))}`);
|
|
312
|
+
this.run = { kind, count: 1, at: this.lines.length - 1, label };
|
|
313
|
+
}
|
|
295
314
|
this.updateSpinner(label);
|
|
296
315
|
}
|
|
297
316
|
|
|
317
|
+
/** Anything that is not another step of the same kind ends the run. */
|
|
318
|
+
endRun() { this.run = null; }
|
|
319
|
+
|
|
298
320
|
/** The checklist, when the model updates it. One line, wrapped if it must. */
|
|
299
321
|
plan(items) {
|
|
300
322
|
const line = planLine(items);
|
|
@@ -302,10 +324,15 @@ export class Screen {
|
|
|
302
324
|
}
|
|
303
325
|
|
|
304
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;
|
|
305
330
|
this.push(dim(` └ ${summary}`));
|
|
306
331
|
}
|
|
307
332
|
|
|
308
333
|
toolFailed(summary) {
|
|
334
|
+
// A failure is never folded away.
|
|
335
|
+
this.endRun();
|
|
309
336
|
this.push(`${dim(' └ ')}${theme.error(summary)}`);
|
|
310
337
|
}
|
|
311
338
|
|
|
@@ -1127,7 +1154,7 @@ export class Screen {
|
|
|
1127
1154
|
// dot flickering above the input box on every keystroke.
|
|
1128
1155
|
const out = [HIDE, HOME];
|
|
1129
1156
|
for (let i = 0; i < this.rows; i++) {
|
|
1130
|
-
out.push(
|
|
1157
|
+
out.push(CLEAR_LINE + padVis(frame[i] ?? '', width) + (i === this.rows - 1 ? '' : '\n'));
|
|
1131
1158
|
}
|
|
1132
1159
|
|
|
1133
1160
|
const [row, col] = this.caret();
|
|
@@ -1214,7 +1241,7 @@ export class Screen {
|
|
|
1214
1241
|
|
|
1215
1242
|
const out = [HIDE, HOME];
|
|
1216
1243
|
for (let i = 0; i < this.rows; i++) {
|
|
1217
|
-
out.push(
|
|
1244
|
+
out.push(CLEAR_LINE + padVis(frame[i], g.cols) + (i === this.rows - 1 ? '' : '\n'));
|
|
1218
1245
|
}
|
|
1219
1246
|
const [row, col] = this.caret();
|
|
1220
1247
|
out.push(at(row, col) + SHOW);
|
package/src/ui/theme.js
CHANGED
|
@@ -278,62 +278,53 @@ export function planLine(items) {
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
/**
|
|
281
|
-
*
|
|
281
|
+
* Narration: what the agent is doing, as opposed to what it has to say.
|
|
282
282
|
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
* Two things are needed, not one. Painting each row covers the rows ucode
|
|
290
|
-
* draws; it cannot reach the margin a terminal keeps below the last line or
|
|
291
|
-
* beside the last column, which stays the old colour and shows as a border of
|
|
292
|
-
* the wrong shade. So the terminal is also told, once, what its own background
|
|
293
|
-
* is — and told to put it back on the way out.
|
|
294
|
-
*/
|
|
295
|
-
export const BACKGROUND = process.env.UCODE_BG || '#000000';
|
|
296
|
-
|
|
297
|
-
const rgb = (hex) => {
|
|
298
|
-
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
|
|
299
|
-
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [19, 19, 22];
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
/** Turn the background on. Everything painted after this sits on it. */
|
|
303
|
-
export const BG_ON = (() => {
|
|
304
|
-
if (process.env.NO_COLOR || process.env.UCODE_BG === 'off') return '';
|
|
305
|
-
const [r, g, b] = rgb(BACKGROUND);
|
|
306
|
-
return `\x1b[48;2;${r};${g};${b}m`;
|
|
307
|
-
})();
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Tell the terminal what its own background is.
|
|
311
|
-
*
|
|
312
|
-
* Painting each row covers the rows ucode draws, and nothing else. It cannot
|
|
313
|
-
* reach the margin a terminal keeps below the last line or beside the last
|
|
314
|
-
* column, which stays the old colour and reads as a border in the wrong
|
|
315
|
-
* shade. OSC 11 sets the window's background itself, which does reach those
|
|
316
|
-
* edges. A terminal that does not know the sequence ignores it in silence,
|
|
317
|
-
* and the per-row painting still covers everything ucode draws.
|
|
283
|
+
* These lines are scaffolding — "Reading screen.js", "Checking types". They
|
|
284
|
+
* are worth seeing and not worth reading, and at full strength they compete
|
|
285
|
+
* with the answer, which is the thing the user is actually here for. A
|
|
286
|
+
* terminal has no smaller size to set, so the only axis available is weight:
|
|
287
|
+
* faint, and a step down in colour. The answer stays at full strength and
|
|
288
|
+
* wins the page by contrast rather than by shouting.
|
|
318
289
|
*/
|
|
319
|
-
export const
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
export const
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
290
|
+
export const narration = (text) => chalk.dim(text);
|
|
291
|
+
|
|
292
|
+
/** The bullet beside a narration line: present, not loud. */
|
|
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
|
+
}
|
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);
|