ucode-agent 1.54.0 → 1.57.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/README.md +17 -6
- package/THIRD_PARTY_NOTICES.md +34 -0
- package/package.json +64 -65
- package/src/core/loop.js +3112 -3240
- package/src/core/provider.js +1149 -1099
- package/src/core/skills.js +1 -1
- package/src/core/window.js +84 -11
- package/src/tools/files.js +113 -20
- package/src/tools/fuzzy.js +295 -0
- package/src/tools/index.js +6 -45
- package/src/tools/scaffold.js +502 -528
- package/src/tools/shared.js +12 -5
- package/src/tools/shell.js +52 -2
- package/src/ui/plain.js +8 -2
- package/src/ui/screen.js +66 -16
- package/src/ui/theme.js +22 -2
package/src/tools/shared.js
CHANGED
|
@@ -408,12 +408,19 @@ export const WALK_WIDTH = 32;
|
|
|
408
408
|
*/
|
|
409
409
|
const known = new Map();
|
|
410
410
|
|
|
411
|
+
/**
|
|
412
|
+
* One key per file. Windows and macOS do not care about case, so "App.js" and
|
|
413
|
+
* "app.js" are the same file there — and keyed apart, ucode's own write under
|
|
414
|
+
* one spelling read as somebody else's edit under the other.
|
|
415
|
+
*/
|
|
416
|
+
const stampKey = (abs) => (process.platform === 'win32' || process.platform === 'darwin' ? abs.toLowerCase() : abs);
|
|
417
|
+
|
|
411
418
|
/** Record a file as ucode now knows it. Never throws: a missing stamp only costs the check. */
|
|
412
419
|
export async function noteFile(abs) {
|
|
413
420
|
try {
|
|
414
|
-
known.set(abs, (await fs.stat(abs)).mtimeMs);
|
|
421
|
+
known.set(stampKey(abs), (await fs.stat(abs)).mtimeMs);
|
|
415
422
|
} catch {
|
|
416
|
-
known.delete(abs);
|
|
423
|
+
known.delete(stampKey(abs));
|
|
417
424
|
}
|
|
418
425
|
}
|
|
419
426
|
|
|
@@ -431,7 +438,7 @@ export async function writeTracked(abs, data, encoding = 'utf8') {
|
|
|
431
438
|
|
|
432
439
|
/** Forget a file, so the next write to it goes through unchallenged. */
|
|
433
440
|
export function forgetFile(abs) {
|
|
434
|
-
known.delete(abs);
|
|
441
|
+
known.delete(stampKey(abs));
|
|
435
442
|
}
|
|
436
443
|
|
|
437
444
|
/**
|
|
@@ -444,7 +451,7 @@ export function forgetFile(abs) {
|
|
|
444
451
|
* clobber.
|
|
445
452
|
*/
|
|
446
453
|
export async function assertUnchanged(abs, show) {
|
|
447
|
-
const seen = known.get(abs);
|
|
454
|
+
const seen = known.get(stampKey(abs));
|
|
448
455
|
if (seen === undefined) return;
|
|
449
456
|
|
|
450
457
|
let now;
|
|
@@ -457,7 +464,7 @@ export async function assertUnchanged(abs, show) {
|
|
|
457
464
|
// costs nothing and stops a same-second write reading as someone else's.
|
|
458
465
|
if (Math.abs(now - seen) < 1) return;
|
|
459
466
|
|
|
460
|
-
known.delete(abs);
|
|
467
|
+
known.delete(stampKey(abs));
|
|
461
468
|
throw new ToolFailure({
|
|
462
469
|
kind: 'changed_on_disk',
|
|
463
470
|
attempted: `overwriting ${show}`,
|
package/src/tools/shell.js
CHANGED
|
@@ -14,7 +14,7 @@ import { openSync, closeSync, readFileSync, mkdirSync, statSync } from 'node:fs'
|
|
|
14
14
|
import os from 'node:os';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { ToolFailure } from '../core/failure.js';
|
|
17
|
-
import { resolveIn, guard, result, getRoot, MAX_OUTPUT } from './shared.js';
|
|
17
|
+
import { resolveIn, guard, confirm, result, getRoot, MAX_OUTPUT } from './shared.js';
|
|
18
18
|
|
|
19
19
|
const DEFAULT_TIMEOUT = 120_000;
|
|
20
20
|
const MAX_TIMEOUT = 600_000;
|
|
@@ -28,6 +28,19 @@ const LIVE_LINES = 200;
|
|
|
28
28
|
const LOOKS_LIKE_SERVER =
|
|
29
29
|
/(\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview|watch)\b|\b(?:vite|next dev|next start|nuxt dev|astro dev|webpack serve|svelte-kit dev|serve)\b|\buvicorn\b|\bgunicorn\b|\bflask run\b|\bdjango[\w-]* runserver\b|\bmanage\.py runserver\b|\brails server\b|\bhttp\.server\b|\bhttp-server\b|\blive-server\b)/i;
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Does some part of this command actually run a server? A segment that only
|
|
33
|
+
* searches for or lists one does not: `ps aux | grep http-server` was
|
|
34
|
+
* backgrounded as a server and reported as having died before it was ready.
|
|
35
|
+
*/
|
|
36
|
+
const INSPECTS = /^(?:grep|egrep|findstr|find|ps|echo|cat|type|tasklist|netstat|curl|wget|ls|dir|which|where|head|tail|less|more)\b/i;
|
|
37
|
+
export function startsServer(command) {
|
|
38
|
+
return String(command).replace(/"[^"]*"|'[^']*'/g, '""').split(/&&|\|\||[;|&]/).some((part) => {
|
|
39
|
+
const segment = part.trim();
|
|
40
|
+
return !INSPECTS.test(segment) && LOOKS_LIKE_SERVER.test(segment);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
/** A foreground server that was explicitly asked for still gets a short leash. */
|
|
32
45
|
const SERVER_TIMEOUT = 30_000;
|
|
33
46
|
|
|
@@ -41,6 +54,17 @@ const INSTALL_TIMEOUT = 600_000;
|
|
|
41
54
|
const LOOKS_LIKE_INSTALL =
|
|
42
55
|
/(\b(?:npm|pnpm|yarn|bun)\s+(?:i|install|add|ci|create)\b|\bnpx\s+(?:create-|degit\b|shadcn)|\bpip3?\s+install\b|\bpoetry\s+(?:install|add)\b|\bcargo\s+(?:build|install|fetch)\b|\bgo\s+(?:mod\s+download|get)\b|\bbundle\s+install\b|\bcomposer\s+(?:install|require)\b|\bgit\s+clone\b)/i;
|
|
43
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Any kill by program name rather than by process number.
|
|
59
|
+
*
|
|
60
|
+
* `taskkill /F /IM python.exe` in a live build ended six Python processes on
|
|
61
|
+
* the machine, most of them nothing to do with the app. Every process ucode
|
|
62
|
+
* starts comes back with its PID, so the narrow kill is always available;
|
|
63
|
+
* the broad one is the user's call, and is asked about.
|
|
64
|
+
*/
|
|
65
|
+
const KILLS_BY_NAME =
|
|
66
|
+
/(\btaskkill\b[^|;&]*\/IM\b|\b(?:killall|pkill)\b|\b(?:Stop-Process|spps)\b[^|;&]*-(?:N(?:a(?:me?)?)?|ProcessName)\b|\bGet-Process\b[^;&]*\|\s*(?:Stop-Process|spps|kill)\b|\bwmic\b[^|;&]*\bprocess\b[^|;&]*\b(?:delete|call\s+terminate)\b|\bkill\b[^;&]*\$\(\s*pgrep\b|\bpgrep\b[^;&]*\|\s*xargs\s+(?:-\S+\s+)*kill\b)/i;
|
|
67
|
+
|
|
44
68
|
/**
|
|
45
69
|
* Commands that kill a whole class of process rather than one process.
|
|
46
70
|
*
|
|
@@ -73,6 +97,10 @@ const KILLS_EVERYTHING =
|
|
|
73
97
|
* NEXT_TELEMETRY Next skips its telemetry notice and ping
|
|
74
98
|
* NO_COLOR output comes back as text rather than escape codes,
|
|
75
99
|
* which the model would otherwise have to read past
|
|
100
|
+
* PYTHONUNBUFFERED Python writes as it goes instead of holding output
|
|
101
|
+
* until a buffer fills: `python -m http.server` said
|
|
102
|
+
* "Serving HTTP" to a log that stayed empty, and each
|
|
103
|
+
* start waited out the full 45 seconds as "still starting"
|
|
76
104
|
*/
|
|
77
105
|
export function childEnv(base = process.env) {
|
|
78
106
|
return {
|
|
@@ -89,6 +117,7 @@ export function childEnv(base = process.env) {
|
|
|
89
117
|
NEXT_TELEMETRY_DISABLED: '1',
|
|
90
118
|
NO_COLOR: '1',
|
|
91
119
|
FORCE_COLOR: '0',
|
|
120
|
+
PYTHONUNBUFFERED: '1',
|
|
92
121
|
};
|
|
93
122
|
}
|
|
94
123
|
|
|
@@ -630,13 +659,34 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
|
|
|
630
659
|
});
|
|
631
660
|
}
|
|
632
661
|
|
|
662
|
+
if (KILLS_BY_NAME.test(command)) {
|
|
663
|
+
const attempted = `running \`${command.trim().slice(0, 80)}\``;
|
|
664
|
+
try {
|
|
665
|
+
await confirm(
|
|
666
|
+
`stop every matching process: ${command.trim().slice(0, 120)}`,
|
|
667
|
+
'That kills every process with that name on this computer, not only the ones ucode started.',
|
|
668
|
+
'command',
|
|
669
|
+
);
|
|
670
|
+
} catch (err) {
|
|
671
|
+
if (err?.kind !== 'cannot_ask') throw err;
|
|
672
|
+
throw new ToolFailure({
|
|
673
|
+
kind: 'broad_kill',
|
|
674
|
+
attempted,
|
|
675
|
+
failed: 'That kills every process with that name on this computer, not only the ones ucode ' +
|
|
676
|
+
'started, and there is nobody here to approve it.',
|
|
677
|
+
fix: 'Stop the one process by its PID: every server ucode starts reports its PID and the exact ' +
|
|
678
|
+
'command to stop it.',
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
633
683
|
const workdir = cwd
|
|
634
684
|
? resolveIn(cwd, 'run_command', 'cwd')
|
|
635
685
|
: { abs: getRoot(), inside: true, show: '.' };
|
|
636
686
|
await guard(workdir, `run a command in ${workdir.abs}`);
|
|
637
687
|
|
|
638
688
|
const env = childEnv();
|
|
639
|
-
const server =
|
|
689
|
+
const server = startsServer(command);
|
|
640
690
|
|
|
641
691
|
// Anything run where a background install is still going waits for it —
|
|
642
692
|
// two installs in one folder corrupt node_modules, and a build before the
|
package/src/ui/plain.js
CHANGED
|
@@ -186,14 +186,20 @@ export class Plain {
|
|
|
186
186
|
for (const line of lines) this.output.write(` ${dim(line)}\n`);
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
assistant(text, { closing = false } = {}) {
|
|
190
|
-
const body = closing ? trimAnswer(tidyReply(text)) : tidyReply(text);
|
|
189
|
+
assistant(text, { closing = false, replay = false } = {}) {
|
|
190
|
+
const body = replay ? String(text) : (closing ? trimAnswer(tidyReply(text)) : tidyReply(text));
|
|
191
191
|
const out = render(this.md, body);
|
|
192
192
|
if (!out) return;
|
|
193
193
|
this.stopSpinner();
|
|
194
194
|
this.output.write(`\n${out}\n\n`);
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
userMessage(text) {
|
|
198
|
+
this.stopSpinner();
|
|
199
|
+
const first = String(text ?? '').split('\n')[0] ?? '';
|
|
200
|
+
if (first.trim()) this.output.write(`\n${blue('›')} ${first}\n`);
|
|
201
|
+
}
|
|
202
|
+
|
|
197
203
|
narrate(text) {
|
|
198
204
|
const line = asLabel(text);
|
|
199
205
|
if (!line) return;
|
package/src/ui/screen.js
CHANGED
|
@@ -99,6 +99,34 @@ const NEWLINE = String.fromCharCode(10);
|
|
|
99
99
|
const at = (row, col) => `${ESC}[${row};${col}H`;
|
|
100
100
|
const title = (t) => `${ESC}]0;${t}\x07`;
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Every paint is wrapped in these. Autowrap off means a row that is one cell
|
|
104
|
+
* wider than we counted loses its last cell instead of wrapping onto the next
|
|
105
|
+
* row and scrolling the whole frame up — that scroll was the glitch. The
|
|
106
|
+
* synchronized-update pair makes terminals that support it show the frame in
|
|
107
|
+
* one go; the rest ignore it.
|
|
108
|
+
*/
|
|
109
|
+
const PAINT_BEGIN = `${ESC}[?2026h${ESC}[?7l`;
|
|
110
|
+
const PAINT_END = `${ESC}[?7h${ESC}[?2026l`;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Text as it may be drawn: colour codes kept, everything that moves the cursor
|
|
114
|
+
* gone. A carriage return from a CRLF file sent the padding back over the
|
|
115
|
+
* line, a tab took eight cells while it was counted as one, and a clear-screen
|
|
116
|
+
* from a tool's output wiped the frame mid-paint.
|
|
117
|
+
*
|
|
118
|
+
* The SGR colour codes (\x1b[..m) are matched first and kept. An earlier
|
|
119
|
+
* version only excluded them from the CSI branch, so the bare-\x1b fallback
|
|
120
|
+
* stripped the ESC off every colour code and left "[36m" littered across
|
|
121
|
+
* coloured lines — visible only in a real terminal, never in tests, which is
|
|
122
|
+
* why the glitch survived the suite.
|
|
123
|
+
*/
|
|
124
|
+
function printable(text) {
|
|
125
|
+
return String(text)
|
|
126
|
+
.replace(/\t/g, ' ')
|
|
127
|
+
.replace(/\x1b\[[0-9;]*m|(\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][0-9A-Za-z]|[\x30-\x7e])?|[\x00-\x09\x0b-\x1a\x1c-\x1f\x7f])/g, (m, bad) => (bad ? '' : m));
|
|
128
|
+
}
|
|
129
|
+
|
|
102
130
|
/**
|
|
103
131
|
* Fixed rows below the header: the gap under it, the gap above the input box,
|
|
104
132
|
* the input box's two borders, the blank row inside it, and the status row.
|
|
@@ -181,6 +209,7 @@ export class Screen {
|
|
|
181
209
|
this.onModeChange = null;
|
|
182
210
|
this.spinTimer = null;
|
|
183
211
|
this.paintedBusy = false; // whose turn the frame on screen was drawn for
|
|
212
|
+
this.paintedLines = null; // transcript length at the last paint; growth since then belongs underneath a scrolled-back view
|
|
184
213
|
this.activity = null; // the turn in flight: when it began, how many steps
|
|
185
214
|
this.tick = 0; // animation frames painted, for the spinner
|
|
186
215
|
this.intro = 0; // when the launch sweep began, 0 once it is over
|
|
@@ -292,11 +321,10 @@ export class Screen {
|
|
|
292
321
|
*/
|
|
293
322
|
add(text = '') {
|
|
294
323
|
const width = this.width();
|
|
295
|
-
for (const raw of
|
|
324
|
+
for (const raw of printable(text).split('\n')) {
|
|
296
325
|
if (visLen(raw) <= width) this.lines.push(raw);
|
|
297
326
|
else for (const wrapped of wrapAnsi(raw, width)) this.lines.push(wrapped);
|
|
298
327
|
}
|
|
299
|
-
this.scroll = 0; // new output snaps back to the bottom
|
|
300
328
|
}
|
|
301
329
|
|
|
302
330
|
push(text = '') {
|
|
@@ -329,6 +357,7 @@ export class Screen {
|
|
|
329
357
|
clearScreen() {
|
|
330
358
|
this.lines = [];
|
|
331
359
|
this.scroll = 0;
|
|
360
|
+
this.paintedLines = null;
|
|
332
361
|
this.render();
|
|
333
362
|
}
|
|
334
363
|
|
|
@@ -339,10 +368,14 @@ export class Screen {
|
|
|
339
368
|
* the last thing left on screen, and what the whole session reads like
|
|
340
369
|
* afterwards, so it is cut to eight lines — see trimAnswer.
|
|
341
370
|
*/
|
|
342
|
-
assistant(text, { closing = false } = {}) {
|
|
371
|
+
assistant(text, { closing = false, replay = false, silent = false } = {}) {
|
|
343
372
|
if (!text?.trim()) return;
|
|
373
|
+
// A replayed answer goes back exactly as it was first shown: no tidying,
|
|
374
|
+
// no trimming, no re-read of the request. Tidy-up exists to keep a live
|
|
375
|
+
// answer short; on a resumed one it rewrites history, which is why a
|
|
376
|
+
// resumed session read like only fragments had survived.
|
|
344
377
|
const tidy = tidyReply(text, 4, this.lastPrompt);
|
|
345
|
-
const body = closing ? trimAnswer(tidy) : tidy;
|
|
378
|
+
const body = replay ? String(text) : (closing ? trimAnswer(tidy) : tidy);
|
|
346
379
|
if (!body.trim()) return;
|
|
347
380
|
this.endRun();
|
|
348
381
|
this.add('');
|
|
@@ -355,7 +388,7 @@ export class Screen {
|
|
|
355
388
|
this.add(render(this.md, body));
|
|
356
389
|
|
|
357
390
|
this.add('');
|
|
358
|
-
this.render();
|
|
391
|
+
if (!silent) this.render();
|
|
359
392
|
}
|
|
360
393
|
|
|
361
394
|
/**
|
|
@@ -368,10 +401,11 @@ export class Screen {
|
|
|
368
401
|
* ladder of rules across the page: two horizontal lines per message, each as
|
|
369
402
|
* loud as the input box, none of them saying anything the rail does not.
|
|
370
403
|
*/
|
|
371
|
-
userMessage(text) {
|
|
404
|
+
userMessage(text, { silent = false } = {}) {
|
|
372
405
|
// Kept so the reply can be checked against it: an answer that opens by
|
|
373
406
|
// saying the request back is repeating the line directly above it.
|
|
374
407
|
this.lastPrompt = String(text ?? '');
|
|
408
|
+
this.scroll = 0; // sending something is the one thing that jumps to the bottom
|
|
375
409
|
const room = Math.max(8, this.width() - 2); // the rail and the space after it
|
|
376
410
|
|
|
377
411
|
const rows = [];
|
|
@@ -385,7 +419,7 @@ export class Screen {
|
|
|
385
419
|
for (const row of rows) this.add(`${blue(RAIL)} ${chalk.white(row)}`);
|
|
386
420
|
this.add('');
|
|
387
421
|
this.add('');
|
|
388
|
-
this.render();
|
|
422
|
+
if (!silent) this.render();
|
|
389
423
|
}
|
|
390
424
|
|
|
391
425
|
/**
|
|
@@ -1019,10 +1053,10 @@ export class Screen {
|
|
|
1019
1053
|
const width = this.width();
|
|
1020
1054
|
const liveAt = this.activityRowAt();
|
|
1021
1055
|
this.output.write(
|
|
1022
|
-
HIDE +
|
|
1056
|
+
PAINT_BEGIN + HIDE +
|
|
1023
1057
|
(liveAt ? at(liveAt, 1) + CLEAR_LINE + padVis(this.activityLine(width), width) : '') +
|
|
1024
1058
|
at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(width), width, this.borderPaint()) +
|
|
1025
|
-
at(row, col) + SHOW
|
|
1059
|
+
at(row, col) + SHOW + PAINT_END
|
|
1026
1060
|
);
|
|
1027
1061
|
}
|
|
1028
1062
|
|
|
@@ -1149,6 +1183,7 @@ export class Screen {
|
|
|
1149
1183
|
}
|
|
1150
1184
|
|
|
1151
1185
|
this.pendingPrompt = 'go ahead? [y/N]';
|
|
1186
|
+
this.scroll = 0; // the question has to be on screen to be answered
|
|
1152
1187
|
this.render();
|
|
1153
1188
|
|
|
1154
1189
|
return this.nextLine().then((answer) => {
|
|
@@ -1273,7 +1308,8 @@ export class Screen {
|
|
|
1273
1308
|
const before = this.scroll;
|
|
1274
1309
|
this.scroll = Math.min(Math.max(0, this.scroll + delta), max);
|
|
1275
1310
|
if (this.scroll === before && delta > 0) this.flash('already at the top');
|
|
1276
|
-
|
|
1311
|
+
// One wheel notch arrives as three arrow keys in one chunk; one frame, not three.
|
|
1312
|
+
this.soon();
|
|
1277
1313
|
}
|
|
1278
1314
|
|
|
1279
1315
|
/**
|
|
@@ -1511,6 +1547,20 @@ export class Screen {
|
|
|
1511
1547
|
const width = this.width();
|
|
1512
1548
|
const height = this.viewportHeight();
|
|
1513
1549
|
|
|
1550
|
+
// Scrolled back, the view stays on what is being read while output grows
|
|
1551
|
+
// underneath: every line added since the last paint extends the scroll by
|
|
1552
|
+
// the same amount, so the same rows stay on screen. Snapping to the
|
|
1553
|
+
// bottom on every streamed line fought the wheel sixteen times a second.
|
|
1554
|
+
// A streamed reply truncates its tail and rebuilds it on every delta, so
|
|
1555
|
+
// the growth since the last paint is net — but the tail sits below a
|
|
1556
|
+
// scrolled-back viewport, and net growth still moves the scroll by exactly
|
|
1557
|
+
// the amount that keeps the visible rows put.
|
|
1558
|
+
if (this.scroll > 0 && this.paintedLines != null) {
|
|
1559
|
+
const grown = this.lines.length - this.paintedLines;
|
|
1560
|
+
this.scroll = Math.min(Math.max(0, this.scroll + grown), Math.max(0, this.lines.length - height));
|
|
1561
|
+
}
|
|
1562
|
+
this.paintedLines = this.lines.length;
|
|
1563
|
+
|
|
1514
1564
|
// The live line is the last line of the conversation, not a fixture above
|
|
1515
1565
|
// the input box. Pinned down there it sat at the bottom of the screen while
|
|
1516
1566
|
// the message that started it was at the top, with the empty middle of the
|
|
@@ -1539,13 +1589,13 @@ export class Screen {
|
|
|
1539
1589
|
// The cursor is hidden for the duration of the paint. Without this it is
|
|
1540
1590
|
// dragged through every line as the frame is written, which shows up as a
|
|
1541
1591
|
// dot flickering above the input box on every keystroke.
|
|
1542
|
-
const out = [
|
|
1592
|
+
const out = [PAINT_BEGIN, HIDE];
|
|
1543
1593
|
for (let i = 0; i < this.rows; i++) {
|
|
1544
|
-
out.push(CLEAR_LINE + padVis(frame[i] ?? '', width)
|
|
1594
|
+
out.push(at(i + 1, 1) + CLEAR_LINE + padVis(frame[i] ?? '', width));
|
|
1545
1595
|
}
|
|
1546
1596
|
|
|
1547
1597
|
const [row, col] = this.caret();
|
|
1548
|
-
out.push(at(row, col) + SHOW);
|
|
1598
|
+
out.push(at(row, col) + SHOW + PAINT_END);
|
|
1549
1599
|
this.paintedBusy = this.busy();
|
|
1550
1600
|
this.output.write(out.join(''));
|
|
1551
1601
|
}
|
|
@@ -1637,12 +1687,12 @@ export class Screen {
|
|
|
1637
1687
|
frame[this.rows - 1] = ' '.repeat(Math.max(0, g.cols - visLen(tag) - 2)) + tag;
|
|
1638
1688
|
}
|
|
1639
1689
|
|
|
1640
|
-
const out = [
|
|
1690
|
+
const out = [PAINT_BEGIN, HIDE];
|
|
1641
1691
|
for (let i = 0; i < this.rows; i++) {
|
|
1642
|
-
out.push(CLEAR_LINE + padVis(frame[i], g.cols)
|
|
1692
|
+
out.push(at(i + 1, 1) + CLEAR_LINE + padVis(frame[i], g.cols));
|
|
1643
1693
|
}
|
|
1644
1694
|
const [row, col] = this.caret();
|
|
1645
|
-
out.push(at(row, col) + SHOW);
|
|
1695
|
+
out.push(at(row, col) + SHOW + PAINT_END);
|
|
1646
1696
|
this.paintedBusy = this.busy();
|
|
1647
1697
|
this.output.write(out.join(''));
|
|
1648
1698
|
}
|
package/src/ui/theme.js
CHANGED
|
@@ -174,6 +174,21 @@ export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
|
174
174
|
* and a table of every Unicode width would be a megabyte to get the last
|
|
175
175
|
* fraction of a percent right.
|
|
176
176
|
*/
|
|
177
|
+
/**
|
|
178
|
+
* The emoji among the older symbol blocks — ✅ ❌ ⚡ ⭐ ⌛ and friends. They sit
|
|
179
|
+
* between box-drawing characters that are one cell, so they are listed one by
|
|
180
|
+
* one rather than as a range. Counted as one cell, a model's "✅ Done" line
|
|
181
|
+
* ran a column past the edge, wrapped, and scrolled the whole frame.
|
|
182
|
+
*/
|
|
183
|
+
const WIDE_SYMBOLS = new Set([
|
|
184
|
+
0x231a, 0x231b, 0x23e9, 0x23ea, 0x23eb, 0x23ec, 0x23f0, 0x23f3, 0x25fd, 0x25fe,
|
|
185
|
+
0x2614, 0x2615, 0x2648, 0x2649, 0x264a, 0x264b, 0x264c, 0x264d, 0x264e, 0x264f,
|
|
186
|
+
0x2650, 0x2651, 0x2652, 0x2653, 0x267f, 0x2693, 0x26a1, 0x26aa, 0x26ab, 0x26bd,
|
|
187
|
+
0x26be, 0x26c4, 0x26c5, 0x26ce, 0x26d4, 0x26ea, 0x26f2, 0x26f3, 0x26f5, 0x26fa,
|
|
188
|
+
0x26fd, 0x2705, 0x270a, 0x270b, 0x2728, 0x274c, 0x274e, 0x2753, 0x2754, 0x2755,
|
|
189
|
+
0x2757, 0x2795, 0x2796, 0x2797, 0x27b0, 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b55,
|
|
190
|
+
]);
|
|
191
|
+
|
|
177
192
|
export function charWidth(code) {
|
|
178
193
|
// Zero: combining marks, joiners, variation selectors.
|
|
179
194
|
if ((code >= 0x0300 && code <= 0x036f)
|
|
@@ -194,10 +209,15 @@ export function charWidth(code) {
|
|
|
194
209
|
|| (code >= 0xfe30 && code <= 0xfe6f)
|
|
195
210
|
|| (code >= 0xff00 && code <= 0xff60)
|
|
196
211
|
|| (code >= 0xffe0 && code <= 0xffe6)
|
|
212
|
+
|| (code >= 0x1f000 && code <= 0x1f02f)
|
|
213
|
+
|| code === 0x1f0cf
|
|
214
|
+
|| (code >= 0x1f18e && code <= 0x1f2ff)
|
|
197
215
|
|| (code >= 0x1f300 && code <= 0x1f64f)
|
|
198
216
|
|| (code >= 0x1f680 && code <= 0x1f6ff)
|
|
199
|
-
|| (code >=
|
|
200
|
-
|| (code >=
|
|
217
|
+
|| (code >= 0x1f7e0 && code <= 0x1f7eb)
|
|
218
|
+
|| (code >= 0x1f900 && code <= 0x1faff)
|
|
219
|
+
|| (code >= 0x20000 && code <= 0x3fffd)
|
|
220
|
+
|| WIDE_SYMBOLS.has(code)) return 2;
|
|
201
221
|
|
|
202
222
|
return 1;
|
|
203
223
|
}
|