bunmicro 0.9.1 → 0.9.9
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/CHANGELOG.md +13 -0
- package/package.json +1 -1
- package/src/buffer/fixed3-codec.js +140 -0
- package/src/index.js +225 -34
- package/src/screen/vt100.js +112 -21
- package/todo.txt +15 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.9] - 2026-06-04
|
|
4
|
+
- Added long line protection for softwrap
|
|
5
|
+
* That means binary edits available
|
|
6
|
+
* you can now open libc.so.6
|
|
7
|
+
* Ctrl+E reopen hex3 to edit & save
|
|
8
|
+
- Fixed softwrap search match cross line
|
|
9
|
+
|
|
10
|
+
## [0.9.5] - 2026-06-03
|
|
11
|
+
- Added encoding hex3 for binary edit
|
|
12
|
+
- term better supports fish
|
|
13
|
+
- enter to close term
|
|
14
|
+
- bat-like highlighting supports URL
|
|
15
|
+
|
|
3
16
|
## [0.9.1] - 2026-06-03
|
|
4
17
|
- Upgrade method explanation
|
|
5
18
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const HEX = Array.from({ length: 256 }, (_, i) =>
|
|
2
|
+
i.toString(16).padStart(2, "0"),
|
|
3
|
+
);
|
|
4
|
+
const ENCODE_TABLE = new Uint8Array(256 * 3);
|
|
5
|
+
const DECODE_HEX = new Int16Array(128);
|
|
6
|
+
|
|
7
|
+
DECODE_HEX.fill(-1);
|
|
8
|
+
|
|
9
|
+
for (let i = 0; i < 10; i++) {
|
|
10
|
+
DECODE_HEX[0x30 + i] = i;
|
|
11
|
+
}
|
|
12
|
+
for (let i = 0; i < 6; i++) {
|
|
13
|
+
DECODE_HEX[0x41 + i] = 10 + i;
|
|
14
|
+
DECODE_HEX[0x61 + i] = 10 + i;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (let byte = 0; byte < 256; byte++) {
|
|
18
|
+
const offset = byte * 3;
|
|
19
|
+
|
|
20
|
+
if (byte >= 0x20 && byte <= 0x7e) {
|
|
21
|
+
ENCODE_TABLE[offset] = byte;
|
|
22
|
+
ENCODE_TABLE[offset + 1] = 0x2e;
|
|
23
|
+
ENCODE_TABLE[offset + 2] = 0x2e;
|
|
24
|
+
} else {
|
|
25
|
+
ENCODE_TABLE[offset] = 0x5c;
|
|
26
|
+
ENCODE_TABLE[offset + 1] = HEX[byte].charCodeAt(0);
|
|
27
|
+
ENCODE_TABLE[offset + 2] = HEX[byte].charCodeAt(1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class Fixed3DecodeError extends SyntaxError {
|
|
32
|
+
constructor(message, position) {
|
|
33
|
+
super(`${message} at text offset ${position}`);
|
|
34
|
+
this.name = "Fixed3DecodeError";
|
|
35
|
+
this.position = position;
|
|
36
|
+
this.byteOffset = Math.floor(position / 3);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function encodeBinary(input) {
|
|
41
|
+
return encodeBinaryToBuffer(input).toString("latin1");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function encodeBinaryToBuffer(input) {
|
|
45
|
+
const bytes = input instanceof Uint8Array ? input : Buffer.from(input);
|
|
46
|
+
const out = Buffer.allocUnsafe(bytes.byteLength * 3);
|
|
47
|
+
let j = 0;
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
50
|
+
const offset = bytes[i] * 3;
|
|
51
|
+
out[j++] = ENCODE_TABLE[offset];
|
|
52
|
+
out[j++] = ENCODE_TABLE[offset + 1];
|
|
53
|
+
out[j++] = ENCODE_TABLE[offset + 2];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function decodeBinary(text) {
|
|
60
|
+
if (typeof text !== "string") {
|
|
61
|
+
throw new TypeError("decodeBinary() expects a string");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return decodeBinaryBytes(Buffer.from(text, "latin1"));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function decodeBinaryBytes(input) {
|
|
68
|
+
const bytes = input instanceof Uint8Array ? input : Buffer.from(input);
|
|
69
|
+
const out = Buffer.allocUnsafe(Math.floor(bytes.byteLength / 3));
|
|
70
|
+
let j = 0;
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < bytes.byteLength; i += 3) {
|
|
73
|
+
const a = bytes[i];
|
|
74
|
+
const b = bytes[i + 1];
|
|
75
|
+
const c = bytes[i + 2];
|
|
76
|
+
|
|
77
|
+
if (b === 0x2e && c === 0x2e) {
|
|
78
|
+
out[j++] = a;
|
|
79
|
+
} else {
|
|
80
|
+
out[j++] = (DECODE_HEX[b] << 4) | DECODE_HEX[c];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function decodeBinaryStrict(text) {
|
|
88
|
+
if (typeof text !== "string") {
|
|
89
|
+
throw new TypeError("decodeBinary() expects a string");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (text.length % 3 !== 0) {
|
|
93
|
+
throw new Fixed3DecodeError("input length is not a multiple of 3", text.length);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const out = Buffer.allocUnsafe(text.length / 3);
|
|
97
|
+
let j = 0;
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < text.length; i += 3) {
|
|
100
|
+
const a = text.charCodeAt(i);
|
|
101
|
+
const b = text.charCodeAt(i + 1);
|
|
102
|
+
const c = text.charCodeAt(i + 2);
|
|
103
|
+
|
|
104
|
+
if (b === 0x2e && c === 0x2e) {
|
|
105
|
+
if (a < 0x20 || a > 0x7e) {
|
|
106
|
+
throw new Fixed3DecodeError("printable cell has non-printable byte", i);
|
|
107
|
+
}
|
|
108
|
+
out[j++] = a;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (a !== 0x5c) {
|
|
113
|
+
throw new Fixed3DecodeError("escaped cell must start with backslash", i);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const hi = b < 128 ? DECODE_HEX[b] : -1;
|
|
117
|
+
const lo = c < 128 ? DECODE_HEX[c] : -1;
|
|
118
|
+
if (hi < 0 || lo < 0) {
|
|
119
|
+
throw new Fixed3DecodeError("escaped cell must contain two hex digits", i);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const byte = (hi << 4) | lo;
|
|
123
|
+
if (byte >= 0x20 && byte <= 0x7e) {
|
|
124
|
+
throw new Fixed3DecodeError("printable byte must use c.. form", i);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
out[j++] = byte;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export default {
|
|
134
|
+
encodeBinary,
|
|
135
|
+
encodeBinaryToBuffer,
|
|
136
|
+
decodeBinary,
|
|
137
|
+
decodeBinaryBytes,
|
|
138
|
+
decodeBinaryStrict,
|
|
139
|
+
Fixed3DecodeError,
|
|
140
|
+
};
|
package/src/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { ClipboardManager } from "./platform/clipboard.js";
|
|
|
21
21
|
import { platformId, run as runCommand, runSync, fetchHttp, fetchHttpBytes, detectHttpBackend } from "./platform/commands.js";
|
|
22
22
|
import { shellSplit } from "./shell/shell.js";
|
|
23
23
|
import { styleToAnsi } from "./display/ansi-style.js";
|
|
24
|
+
import { encodeBinaryToBuffer, decodeBinaryBytes } from "./buffer/fixed3-codec.js";
|
|
24
25
|
|
|
25
26
|
import pkg from "../package.json" with { type: "json" };
|
|
26
27
|
|
|
@@ -100,6 +101,8 @@ const DEFAULT_SETTINGS = {
|
|
|
100
101
|
};
|
|
101
102
|
|
|
102
103
|
const LONG_LINE_REHIGHLIGHT_LIMIT = 300;
|
|
104
|
+
// Lines exceeding this are never highlighted interactively; stored as default and deferred to Esc.
|
|
105
|
+
const LONG_LINE_INITIAL_HIGHLIGHT_LIMIT = 10_000;
|
|
103
106
|
|
|
104
107
|
const promptHistory = new Map();
|
|
105
108
|
let startupHighlightProgress = null;
|
|
@@ -134,18 +137,27 @@ function isHttpUrl(value) {
|
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
async function readTextFileWithEncoding(path, encoding = "utf-8") {
|
|
137
|
-
const decoder = new TextDecoder(normalizeEncodingLabel(encoding));
|
|
138
140
|
const bytes = new Uint8Array(await Bun.file(path).arrayBuffer());
|
|
141
|
+
if (normalizeEncodingLabel(encoding) === "hex3") {
|
|
142
|
+
return { text: encodeBinaryToBuffer(bytes).toString("latin1"), encoding: "hex3" };
|
|
143
|
+
}
|
|
144
|
+
const decoder = new TextDecoder(normalizeEncodingLabel(encoding));
|
|
139
145
|
return { text: decoder.decode(bytes), encoding: decoder.encoding };
|
|
140
146
|
}
|
|
141
147
|
|
|
142
148
|
async function fetchTextWithEncoding(url, encoding = "utf-8") {
|
|
149
|
+
const bytes = await fetchHttpBytes(url);
|
|
150
|
+
if (normalizeEncodingLabel(encoding) === "hex3") {
|
|
151
|
+
return { text: encodeBinaryToBuffer(new Uint8Array(bytes)).toString("latin1"), encoding: "hex3" };
|
|
152
|
+
}
|
|
143
153
|
const decoder = new TextDecoder(normalizeEncodingLabel(encoding));
|
|
144
|
-
return { text: decoder.decode(
|
|
154
|
+
return { text: decoder.decode(bytes), encoding: decoder.encoding };
|
|
145
155
|
}
|
|
146
156
|
|
|
147
157
|
function normalizeEncodingLabel(encoding = "utf-8") {
|
|
148
|
-
|
|
158
|
+
const s = String(encoding || "utf-8");
|
|
159
|
+
if (s === "hex3") return "hex3";
|
|
160
|
+
return new TextDecoder(s).encoding;
|
|
149
161
|
}
|
|
150
162
|
|
|
151
163
|
function isReadonlyBuffer(buf) {
|
|
@@ -330,8 +342,11 @@ function normalizeCharBoundary(line, idx) {
|
|
|
330
342
|
// breaks[0] === 0 always. breaks[k] is the start of visual row k within `line`.
|
|
331
343
|
// Tabs are treated as `tabsize` columns wide (consistent with the renderer).
|
|
332
344
|
// With wordwrap=true, breaks at word boundaries; with wordwrap=false, hard-wraps at bufWidth.
|
|
345
|
+
let _swCacheLine = null, _swCacheBufWidth = 0, _swCacheWordwrap = false, _swCacheTabsize = 4, _swCacheBreaks = null;
|
|
333
346
|
function softwrapBreaks(line, bufWidth, wordwrap, tabsize) {
|
|
334
347
|
if (bufWidth <= 0) return [0];
|
|
348
|
+
if (line === _swCacheLine && bufWidth === _swCacheBufWidth && wordwrap === _swCacheWordwrap && tabsize === _swCacheTabsize)
|
|
349
|
+
return _swCacheBreaks;
|
|
335
350
|
const breaks = [0];
|
|
336
351
|
let visualX = 0; // display col within current visual row
|
|
337
352
|
let wordStart = 0; // code-unit index of current word start
|
|
@@ -371,6 +386,7 @@ function softwrapBreaks(line, bufWidth, wordwrap, tabsize) {
|
|
|
371
386
|
}
|
|
372
387
|
}
|
|
373
388
|
|
|
389
|
+
_swCacheLine = line; _swCacheBufWidth = bufWidth; _swCacheWordwrap = wordwrap; _swCacheTabsize = tabsize; _swCacheBreaks = breaks;
|
|
374
390
|
return breaks;
|
|
375
391
|
}
|
|
376
392
|
|
|
@@ -559,6 +575,9 @@ function parseInput(args) {
|
|
|
559
575
|
}
|
|
560
576
|
|
|
561
577
|
class BufferModel {
|
|
578
|
+
get searchPattern() { return this._searchPattern ?? ""; }
|
|
579
|
+
set searchPattern(v) { this._searchPattern = v ?? ""; this.searchMatches?.clear(); }
|
|
580
|
+
|
|
562
581
|
constructor({ path = "", text = "", command = {}, type = "default", readonly = false, modTimeMs = null, encoding = DEFAULT_SETTINGS.encoding } = {}) {
|
|
563
582
|
this.path = path;
|
|
564
583
|
this.type = type;
|
|
@@ -579,6 +598,7 @@ class BufferModel {
|
|
|
579
598
|
this.acSuggestions = [];
|
|
580
599
|
this.acCompletions = [];
|
|
581
600
|
this.acCurIdx = -1;
|
|
601
|
+
this.searchMatches = new Map();
|
|
582
602
|
this.searchPattern = "";
|
|
583
603
|
this.command = command;
|
|
584
604
|
this.filetype = "unknown";
|
|
@@ -650,6 +670,8 @@ class BufferModel {
|
|
|
650
670
|
|
|
651
671
|
invalidateHighlightFrom(lineNo = 0, options = {}) {
|
|
652
672
|
this._editRev = (this._editRev ?? 0) + 1;
|
|
673
|
+
if (options.force) this.searchMatches?.clear();
|
|
674
|
+
else this.searchMatches?.delete(lineNo);
|
|
653
675
|
invalidateHighlightFrom(this, lineNo, options);
|
|
654
676
|
}
|
|
655
677
|
|
|
@@ -1043,6 +1065,21 @@ class BufferModel {
|
|
|
1043
1065
|
async save(path = this.path) {
|
|
1044
1066
|
if (!path) throw new Error("No filename");
|
|
1045
1067
|
let text = this.lines.join("\n");
|
|
1068
|
+
if (this.encoding === "hex3") {
|
|
1069
|
+
await Bun.write(path, decodeBinaryBytes(Buffer.from(text, "latin1")));
|
|
1070
|
+
this.path = path;
|
|
1071
|
+
this.Path = path;
|
|
1072
|
+
this.AbsPath = path;
|
|
1073
|
+
this.name = basename(path);
|
|
1074
|
+
this.updateModTime();
|
|
1075
|
+
this.readonly = !canWritePath(path);
|
|
1076
|
+
this.Settings.readonly = this.readonly;
|
|
1077
|
+
this.Type.Readonly = this.readonly;
|
|
1078
|
+
this._savedSerial = this._undoSerial ?? 0;
|
|
1079
|
+
this.modified = false;
|
|
1080
|
+
this.message = `Saved ${path}`;
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1046
1083
|
if (DEFAULT_SETTINGS.eofnewline && !text.endsWith("\n")) text += "\n";
|
|
1047
1084
|
await Bun.write(path, text);
|
|
1048
1085
|
this.encoding = "utf-8";
|
|
@@ -1324,6 +1361,7 @@ class TerminalPane {
|
|
|
1324
1361
|
this.proc = null;
|
|
1325
1362
|
this.vt = null;
|
|
1326
1363
|
this.decoder = new TextDecoder();
|
|
1364
|
+
this.exited = false;
|
|
1327
1365
|
}
|
|
1328
1366
|
|
|
1329
1367
|
open(cols, rows) {
|
|
@@ -1335,6 +1373,7 @@ class TerminalPane {
|
|
|
1335
1373
|
cols = Math.max(10, cols ?? this.app.cols);
|
|
1336
1374
|
rows = Math.max(4, rows ?? Math.floor(this.app.rows / 2));
|
|
1337
1375
|
this.vt = new VT100(cols, rows);
|
|
1376
|
+
this.exited = false;
|
|
1338
1377
|
this.proc = Bun.spawn([shell], {
|
|
1339
1378
|
env: { ...process.env, TERM: "xterm-256color", COLUMNS: String(cols), LINES: String(rows) },
|
|
1340
1379
|
terminal: {
|
|
@@ -1348,7 +1387,8 @@ class TerminalPane {
|
|
|
1348
1387
|
this.app.render();
|
|
1349
1388
|
},
|
|
1350
1389
|
exit: () => {
|
|
1351
|
-
this.
|
|
1390
|
+
this.exited = true;
|
|
1391
|
+
this.vt.feed("\r\n[process exited]\r\nPress enter to close\r\n");
|
|
1352
1392
|
this.app.render();
|
|
1353
1393
|
},
|
|
1354
1394
|
},
|
|
@@ -1356,9 +1396,14 @@ class TerminalPane {
|
|
|
1356
1396
|
}
|
|
1357
1397
|
|
|
1358
1398
|
write(data) {
|
|
1399
|
+
if (this.exited) return;
|
|
1359
1400
|
this.proc?.terminal?.write(data);
|
|
1360
1401
|
}
|
|
1361
1402
|
|
|
1403
|
+
writeInput(data) {
|
|
1404
|
+
this.write(encodeTerminalInput(data, this.vt));
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1362
1407
|
resize(cols, rows) {
|
|
1363
1408
|
rows = Math.max(4, rows);
|
|
1364
1409
|
this.vt?.resize(cols, rows);
|
|
@@ -1367,13 +1412,102 @@ class TerminalPane {
|
|
|
1367
1412
|
|
|
1368
1413
|
close() {
|
|
1369
1414
|
try {
|
|
1370
|
-
this.proc?.kill();
|
|
1415
|
+
if (!this.exited) this.proc?.kill();
|
|
1371
1416
|
this.proc?.terminal?.close();
|
|
1372
1417
|
} catch {
|
|
1373
1418
|
// PTY may already be closed.
|
|
1374
1419
|
}
|
|
1375
1420
|
this.proc = null;
|
|
1421
|
+
this.exited = true;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function encodeTerminalInput(data, vt) {
|
|
1426
|
+
if (!vt) return data;
|
|
1427
|
+
const flags = vt.keyboardProtocolFlags ?? 0;
|
|
1428
|
+
const wantsKitty = flags !== 0;
|
|
1429
|
+
const wantsXterm = (vt.modifyOtherKeys ?? 0) > 0 || (vt.formatOtherKeys ?? 0) > 0;
|
|
1430
|
+
if (!wantsKitty && !wantsXterm) return data;
|
|
1431
|
+
|
|
1432
|
+
const text = data instanceof Uint8Array ? decoder.decode(data) : String(data);
|
|
1433
|
+
const events = parseInputEvents(text);
|
|
1434
|
+
if (events.length === 0 || events.some(e => e.type !== "key")) return data;
|
|
1435
|
+
|
|
1436
|
+
const encoded = [];
|
|
1437
|
+
for (const event of events) {
|
|
1438
|
+
const seq = encodeKeyEventForTerminal(event, flags, wantsXterm);
|
|
1439
|
+
if (!seq) return data;
|
|
1440
|
+
encoded.push(seq);
|
|
1441
|
+
}
|
|
1442
|
+
return encoded.join("");
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function encodeKeyEventForTerminal(event, flags, wantsXterm) {
|
|
1446
|
+
const key = event.key;
|
|
1447
|
+
const raw = event.raw ?? "";
|
|
1448
|
+
const reportAll = (flags & 8) !== 0;
|
|
1449
|
+
const disambiguate = (flags & 1) !== 0 || reportAll || wantsXterm;
|
|
1450
|
+
|
|
1451
|
+
if (!reportAll && isPlainTextKey(raw, key)) return raw;
|
|
1452
|
+
const parsed = keyToKittyCode(key, raw);
|
|
1453
|
+
if (!parsed) return raw;
|
|
1454
|
+
const { code, modifiers } = parsed;
|
|
1455
|
+
if (!reportAll && !disambiguate && modifiers <= 1) return raw;
|
|
1456
|
+
if (!reportAll && modifiers <= 1 && !needsDisambiguation(key)) return raw;
|
|
1457
|
+
return `\x1b[${code};${modifiers}u`;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function isPlainTextKey(raw, key) {
|
|
1461
|
+
return raw && raw === key && !raw.includes("\x1b") && !/^(?:ctrl|alt|shift)-/.test(key) && !KEY_CODEPOINTS[key];
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function needsDisambiguation(key) {
|
|
1465
|
+
return key === "escape" || key.startsWith("ctrl-") || key.startsWith("alt-") || key.includes("shift-");
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const NAMED_KEY_CODEPOINTS = {
|
|
1469
|
+
escape: 27,
|
|
1470
|
+
enter: 13,
|
|
1471
|
+
tab: 9,
|
|
1472
|
+
backspace: 127,
|
|
1473
|
+
delete: 57362,
|
|
1474
|
+
insert: 57363,
|
|
1475
|
+
left: 57364,
|
|
1476
|
+
right: 57365,
|
|
1477
|
+
up: 57366,
|
|
1478
|
+
down: 57367,
|
|
1479
|
+
pageup: 57368,
|
|
1480
|
+
pagedown: 57369,
|
|
1481
|
+
home: 57370,
|
|
1482
|
+
end: 57371,
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
const KEY_CODEPOINTS = NAMED_KEY_CODEPOINTS;
|
|
1486
|
+
|
|
1487
|
+
function keyToKittyCode(key, raw) {
|
|
1488
|
+
let rest = key;
|
|
1489
|
+
let mods = 1;
|
|
1490
|
+
let changed = true;
|
|
1491
|
+
while (changed) {
|
|
1492
|
+
changed = false;
|
|
1493
|
+
for (const [prefix, bit] of [["shift-", 1], ["alt-", 2], ["ctrl-", 4]]) {
|
|
1494
|
+
if (rest.startsWith(prefix)) {
|
|
1495
|
+
mods += bit;
|
|
1496
|
+
rest = rest.slice(prefix.length);
|
|
1497
|
+
changed = true;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
if (rest === "space") return { code: 32, modifiers: mods };
|
|
1503
|
+
if (KEY_CODEPOINTS[rest]) return { code: KEY_CODEPOINTS[rest], modifiers: mods };
|
|
1504
|
+
if (rest.length === 1) return { code: rest.toLowerCase().codePointAt(0), modifiers: mods };
|
|
1505
|
+
|
|
1506
|
+
if (raw.length === 1 && raw >= " " && raw !== "\x7f") return { code: raw.toLowerCase().codePointAt(0), modifiers: mods };
|
|
1507
|
+
if (raw.length === 1 && raw.charCodeAt(0) >= 1 && raw.charCodeAt(0) <= 26) {
|
|
1508
|
+
return { code: raw.charCodeAt(0) + 96, modifiers: mods | 4 };
|
|
1376
1509
|
}
|
|
1510
|
+
return null;
|
|
1377
1511
|
}
|
|
1378
1512
|
|
|
1379
1513
|
// ─── Pane / split layout ────────────────────────────────────────────────────
|
|
@@ -1944,12 +2078,14 @@ class App {
|
|
|
1944
2078
|
const isCL = clBg && lineNo === buf.cursor.y && !pane.selection;
|
|
1945
2079
|
if (gutterW > 0) renderGutter(lineNo, row, screenRow);
|
|
1946
2080
|
if (lineNo < buf.lines.length) {
|
|
1947
|
-
const cells = renderHighlightedCells(buf, lineNo, buf.scroll.x, maxW, this.context.colorscheme, pane.selection, buf
|
|
2081
|
+
const cells = renderHighlightedCells(buf, lineNo, buf.scroll.x, maxW, this.context.colorscheme, pane.selection, getLineSearchRanges(buf, lineNo), braceMatches, isCL ? clBg : null);
|
|
1948
2082
|
putCells(this.screen, pane.x + gutterW, screenRow, cells, maxW);
|
|
1949
2083
|
}
|
|
1950
2084
|
}
|
|
1951
2085
|
} else {
|
|
1952
2086
|
let sloc = { line: buf.scroll.y, row: buf.scroll.row ?? 0 };
|
|
2087
|
+
let _swBreaksLineNo = -1, _swBreaks = null;
|
|
2088
|
+
let _swSearchLineNo = -1, _swSearchRanges = [];
|
|
1953
2089
|
for (let screenY = 0; screenY < pane.h; screenY++) {
|
|
1954
2090
|
const screenRow = pane.y + screenY;
|
|
1955
2091
|
const { line: lineNo, row: subRow } = sloc;
|
|
@@ -1957,13 +2093,15 @@ class App {
|
|
|
1957
2093
|
if (lineNo >= buf.lines.length) break;
|
|
1958
2094
|
|
|
1959
2095
|
const lineStr = buf.lines[lineNo] ?? "";
|
|
1960
|
-
|
|
2096
|
+
if (lineNo !== _swBreaksLineNo) { _swBreaks = softwrapBreaks(lineStr, maxW, wordwrap, tabsize); _swBreaksLineNo = lineNo; }
|
|
2097
|
+
if (lineNo !== _swSearchLineNo) { _swSearchRanges = getLineSearchRanges(buf, lineNo); _swSearchLineNo = lineNo; }
|
|
2098
|
+
const breaks = _swBreaks;
|
|
1961
2099
|
const segStart = breaks[subRow] ?? 0;
|
|
1962
2100
|
const isCL = clBg && lineNo === buf.cursor.y && !pane.selection;
|
|
1963
2101
|
|
|
1964
2102
|
if (gutterW > 0) renderGutter(lineNo, screenY, screenRow, subRow);
|
|
1965
2103
|
|
|
1966
|
-
const cells = renderHighlightedCells(buf, lineNo, segStart, maxW, this.context.colorscheme, pane.selection,
|
|
2104
|
+
const cells = renderHighlightedCells(buf, lineNo, segStart, maxW, this.context.colorscheme, pane.selection, _swSearchRanges, braceMatches, isCL ? clBg : null);
|
|
1967
2105
|
putCells(this.screen, pane.x + gutterW, screenRow, cells, maxW);
|
|
1968
2106
|
|
|
1969
2107
|
if (subRow + 1 < breaks.length) {
|
|
@@ -2325,17 +2463,15 @@ class App {
|
|
|
2325
2463
|
return;
|
|
2326
2464
|
}
|
|
2327
2465
|
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
this.closePane(activePaneObj);
|
|
2338
|
-
}
|
|
2466
|
+
if (activePaneObj.terminal.exited && events.some((event) => event.type === "key" && event.key === "enter")) {
|
|
2467
|
+
this.closeTermPane(activePaneObj);
|
|
2468
|
+
this.render();
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// Escape alone: close pane in legacy mode; protocol-aware shells need it as input.
|
|
2473
|
+
if (text === "\x1b" && !(activePaneObj.terminal?.vt?.keyboardProtocolFlags || activePaneObj.terminal?.vt?.modifyOtherKeys)) {
|
|
2474
|
+
this.closeTermPane(activePaneObj);
|
|
2339
2475
|
this.render();
|
|
2340
2476
|
return;
|
|
2341
2477
|
}
|
|
@@ -2352,7 +2488,7 @@ class App {
|
|
|
2352
2488
|
|
|
2353
2489
|
// Any other key input: reset scroll to live view, then forward
|
|
2354
2490
|
if (activePaneObj.terminal?.vt) activePaneObj.terminal.vt.scrollOffset = 0;
|
|
2355
|
-
activePaneObj.terminal.
|
|
2491
|
+
activePaneObj.terminal.writeInput(data);
|
|
2356
2492
|
return;
|
|
2357
2493
|
}
|
|
2358
2494
|
|
|
@@ -3384,7 +3520,8 @@ class App {
|
|
|
3384
3520
|
async save({ force = false } = {}) {
|
|
3385
3521
|
if (!force && this.buffer?.readonly) { this.message = "Can't save under readonly mode"; return; }
|
|
3386
3522
|
try {
|
|
3387
|
-
|
|
3523
|
+
const enc = normalizeEncodingLabel(this.buffer?.encoding);
|
|
3524
|
+
if (enc !== "utf-8" && enc !== "hex3") {
|
|
3388
3525
|
this.openYNPrompt("Save in UTF-8?(y,n)", async (answer) => {
|
|
3389
3526
|
if (answer === "y") await this.saveUtf8();
|
|
3390
3527
|
});
|
|
@@ -3536,6 +3673,18 @@ class App {
|
|
|
3536
3673
|
this.message = "No previous diff";
|
|
3537
3674
|
}
|
|
3538
3675
|
|
|
3676
|
+
closeTermPane(pane) {
|
|
3677
|
+
pane.terminal?.close();
|
|
3678
|
+
pane.terminal = null;
|
|
3679
|
+
if (pane.prevBuffer) {
|
|
3680
|
+
pane.type = "editor";
|
|
3681
|
+
pane.buffer = pane.prevBuffer;
|
|
3682
|
+
pane.prevBuffer = null;
|
|
3683
|
+
} else {
|
|
3684
|
+
this.closePane(pane);
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3539
3688
|
closePane(pane) {
|
|
3540
3689
|
pane.terminal?.close();
|
|
3541
3690
|
const tab = this.tab;
|
|
@@ -3774,7 +3923,7 @@ class App {
|
|
|
3774
3923
|
const saveArgs = [...cmdArgs];
|
|
3775
3924
|
const saveForce = saveArgs[0] === "-f" && (saveArgs.shift(), true);
|
|
3776
3925
|
if (!saveForce && buf?.readonly) { this.message = "Can't save under readonly mode"; break; }
|
|
3777
|
-
if (saveArgs.length > 0 && normalizeEncodingLabel(buf?.encoding) !== "utf-8") {
|
|
3926
|
+
if (saveArgs.length > 0 && normalizeEncodingLabel(buf?.encoding) !== "utf-8" && normalizeEncodingLabel(buf?.encoding) !== "hex3") {
|
|
3778
3927
|
const target = resolve(expandHome(saveArgs[0]));
|
|
3779
3928
|
this.openYNPrompt("Save in UTF-8?(y,n)", async (answer) => {
|
|
3780
3929
|
if (answer === "y") {
|
|
@@ -4546,12 +4695,14 @@ const COMMAND_NAMES = [
|
|
|
4546
4695
|
];
|
|
4547
4696
|
|
|
4548
4697
|
const SUPPORTED_ENCODING_LABELS = [
|
|
4698
|
+
"hex3",
|
|
4549
4699
|
"utf-8", "utf-16le", "utf-16be",
|
|
4550
4700
|
"windows-1252", "iso-8859-1", "latin1",
|
|
4551
4701
|
"big5", "gbk", "gb18030",
|
|
4552
4702
|
"shift_jis", "sjis", "euc-jp", "iso-2022-jp",
|
|
4553
4703
|
"euc-kr", "ks_c_5601-1987",
|
|
4554
4704
|
].filter((encoding) => {
|
|
4705
|
+
if (encoding === "hex3") return true;
|
|
4555
4706
|
try { new TextDecoder(encoding); return true; }
|
|
4556
4707
|
catch { return false; }
|
|
4557
4708
|
});
|
|
@@ -4860,6 +5011,15 @@ function highlightBufferLine(buf, lineNo) {
|
|
|
4860
5011
|
if (!cache.forceLongLineRehighlight && cache.dirtyLongLines.has(y) && cache.results[y]) {
|
|
4861
5012
|
result = cache.results[y];
|
|
4862
5013
|
state = cache.states[y] ?? null;
|
|
5014
|
+
} else if (!cache.forceLongLineRehighlight && line.length > LONG_LINE_INITIAL_HIGHLIGHT_LIMIT) {
|
|
5015
|
+
// Too long to highlight interactively — store a default result and mark dirty for Esc rehighlight.
|
|
5016
|
+
if (!cache.results[y]) {
|
|
5017
|
+
cache.results[y] = { changes: new Map([[0, "default"], [line.length, "default"]]), state: null };
|
|
5018
|
+
cache.states[y] = null;
|
|
5019
|
+
}
|
|
5020
|
+
result = cache.results[y];
|
|
5021
|
+
state = null;
|
|
5022
|
+
cache.dirtyLongLines.add(y);
|
|
4863
5023
|
} else {
|
|
4864
5024
|
const progress = startupHighlightProgress
|
|
4865
5025
|
? (pos) => startupHighlightProgress.linePosition(pos, y, target)
|
|
@@ -4896,6 +5056,11 @@ function invalidateHighlightFrom(buf, lineNo = 0, { force = false } = {}) {
|
|
|
4896
5056
|
if (!cache) return;
|
|
4897
5057
|
const from = Math.max(0, Math.trunc(Number(lineNo) || 0));
|
|
4898
5058
|
const line = buf.lines[from] ?? "";
|
|
5059
|
+
// Hard limit: never clear cache for very long lines even on force — mark dirty instead.
|
|
5060
|
+
if (line.length > LONG_LINE_INITIAL_HIGHLIGHT_LIMIT && cache.results[from]) {
|
|
5061
|
+
cache.dirtyLongLines.add(from);
|
|
5062
|
+
return;
|
|
5063
|
+
}
|
|
4899
5064
|
if (!force && line.length > LONG_LINE_REHIGHLIGHT_LIMIT && cache.results[from]) {
|
|
4900
5065
|
cache.dirtyLongLines.add(from);
|
|
4901
5066
|
return;
|
|
@@ -5317,6 +5482,7 @@ function findMatchingBracePositions(buf) {
|
|
|
5317
5482
|
|
|
5318
5483
|
function findMatchingBracePair(buf) {
|
|
5319
5484
|
if (!(buf?.Settings?.matchbrace ?? DEFAULT_SETTINGS.matchbrace)) return null;
|
|
5485
|
+
if ((buf.lines[buf.cursor.y] ?? "").length > LONG_LINE_INITIAL_HIGHLIGHT_LIMIT) return null;
|
|
5320
5486
|
const left = braceAt(buf, buf.cursor.x - 1, buf.cursor.y);
|
|
5321
5487
|
const right = braceAt(buf, buf.cursor.x, buf.cursor.y);
|
|
5322
5488
|
let origin = null;
|
|
@@ -5374,7 +5540,7 @@ function braceKey(loc) {
|
|
|
5374
5540
|
return String(loc.y) + ":" + String(loc.x);
|
|
5375
5541
|
}
|
|
5376
5542
|
|
|
5377
|
-
function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, selection = null,
|
|
5543
|
+
function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, selection = null, searchRanges = [], braceMatches = null, cursorLineBg = null) {
|
|
5378
5544
|
const raw = buf.lines[lineNo] ?? "";
|
|
5379
5545
|
const cells = [];
|
|
5380
5546
|
let width = 0;
|
|
@@ -5385,8 +5551,6 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
|
|
|
5385
5551
|
if (changes.length === 0 || changes[0][0] !== 0) changes.unshift([0, "default"]);
|
|
5386
5552
|
changes.push([raw.length, changes.at(-1)?.[1] ?? "default"]);
|
|
5387
5553
|
}
|
|
5388
|
-
|
|
5389
|
-
const searchRanges = searchPattern ? getSearchRanges(raw, searchPattern, buf.Settings?.ignorecase ?? true) : [];
|
|
5390
5554
|
// Go: cursor-line bg is skipped when a syntax style already has a non-default background (preservebg)
|
|
5391
5555
|
const defBg = colorscheme?.defaultStyle?.bg ?? "default";
|
|
5392
5556
|
|
|
@@ -5402,6 +5566,7 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
|
|
|
5402
5566
|
: null;
|
|
5403
5567
|
|
|
5404
5568
|
let changeIndex = 0;
|
|
5569
|
+
let searchIdx = 0;
|
|
5405
5570
|
let i = scrollX;
|
|
5406
5571
|
while (i < raw.length && width < maxWidth) {
|
|
5407
5572
|
const cp = raw.codePointAt(i);
|
|
@@ -5415,7 +5580,8 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
|
|
|
5415
5580
|
const syntaxStyle = colorscheme?.get(group) ?? colorscheme?.defaultStyle ?? {};
|
|
5416
5581
|
const preservebg = cursorLineBg != null && syntaxStyle.bg !== undefined && syntaxStyle.bg !== defBg;
|
|
5417
5582
|
const baseStyle = (cursorLineBg && !preservebg) ? { ...syntaxStyle, bg: cursorLineBg } : syntaxStyle;
|
|
5418
|
-
|
|
5583
|
+
while (searchIdx < searchRanges.length && searchRanges[searchIdx][1] <= i) searchIdx++;
|
|
5584
|
+
const inSearch = searchIdx < searchRanges.length && i >= searchRanges[searchIdx][0] && i < searchRanges[searchIdx][1];
|
|
5419
5585
|
const selected = isSelected(selection, lineNo, i, i + charLen);
|
|
5420
5586
|
const braceMatched = braceMatches?.has(String(lineNo) + ":" + String(i));
|
|
5421
5587
|
let style = (showTrailingWs && i >= trailingWsIdx) ? trailingWsStyle : baseStyle;
|
|
@@ -5522,7 +5688,17 @@ function allMatchPositions(text, re, literal) {
|
|
|
5522
5688
|
return positions;
|
|
5523
5689
|
}
|
|
5524
5690
|
|
|
5525
|
-
function
|
|
5691
|
+
function getLineSearchRanges(buf, lineNo) {
|
|
5692
|
+
if (!buf.searchPattern) return [];
|
|
5693
|
+
if (!buf.searchMatches.has(lineNo)) {
|
|
5694
|
+
const raw = buf.lines[lineNo] ?? "";
|
|
5695
|
+
const ignoreCase = buf.Settings?.ignorecase ?? true;
|
|
5696
|
+
buf.searchMatches.set(lineNo, getSearchRanges(raw, buf.searchPattern, ignoreCase));
|
|
5697
|
+
}
|
|
5698
|
+
return buf.searchMatches.get(lineNo);
|
|
5699
|
+
}
|
|
5700
|
+
|
|
5701
|
+
function getSearchRanges(line, pattern, ignoreCase = false, rangeStart = 0, rangeEnd = line.length) {
|
|
5526
5702
|
if (!pattern) return [];
|
|
5527
5703
|
let re;
|
|
5528
5704
|
try {
|
|
@@ -5532,16 +5708,18 @@ function getSearchRanges(line, pattern, ignoreCase = false) {
|
|
|
5532
5708
|
}
|
|
5533
5709
|
const ranges = [];
|
|
5534
5710
|
if (re) {
|
|
5711
|
+
re.lastIndex = rangeStart;
|
|
5535
5712
|
let m;
|
|
5536
5713
|
while ((m = re.exec(line)) !== null) {
|
|
5714
|
+
if (m.index >= rangeEnd) break;
|
|
5537
5715
|
if (m[0].length === 0) { re.lastIndex++; continue; }
|
|
5538
5716
|
ranges.push([m.index, m.index + m[0].length]);
|
|
5539
5717
|
}
|
|
5540
5718
|
} else {
|
|
5541
|
-
let idx =
|
|
5542
|
-
while (idx <
|
|
5719
|
+
let idx = rangeStart;
|
|
5720
|
+
while (idx < rangeEnd) {
|
|
5543
5721
|
const pos = line.indexOf(pattern, idx);
|
|
5544
|
-
if (pos < 0) break;
|
|
5722
|
+
if (pos < 0 || pos >= rangeEnd) break;
|
|
5545
5723
|
ranges.push([pos, pos + pattern.length]);
|
|
5546
5724
|
idx = pos + pattern.length;
|
|
5547
5725
|
}
|
|
@@ -5562,7 +5740,10 @@ async function loadBuffers(files, command) {
|
|
|
5562
5740
|
} else if (!process.stdin.isTTY) {
|
|
5563
5741
|
const chunks = [];
|
|
5564
5742
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
5565
|
-
|
|
5743
|
+
const stdinText = Buffer.concat(chunks).toString("utf8");
|
|
5744
|
+
const stdinBuf = new BufferModel({ text: stdinText, type: process.stdout.isTTY ? "default" : "stdout", command });
|
|
5745
|
+
if (loadBuffers.context) attachSyntax(stdinBuf, loadBuffers.context, "", stdinText);
|
|
5746
|
+
buffers.push(stdinBuf);
|
|
5566
5747
|
} else {
|
|
5567
5748
|
buffers.push(new BufferModel({ command }));
|
|
5568
5749
|
}
|
|
@@ -6032,8 +6213,18 @@ function syncEditorSettings(config) {
|
|
|
6032
6213
|
async function catFiles(files, colorscheme, syntaxDefinitions) {
|
|
6033
6214
|
const targets = files.length > 0 ? files.map((f) => ({ path: f, stdin: false })) : [{ path: null, stdin: true }];
|
|
6034
6215
|
for (const { path: filePath, stdin } of targets) {
|
|
6035
|
-
|
|
6036
|
-
|
|
6216
|
+
let content;
|
|
6217
|
+
let effectivePath = filePath;
|
|
6218
|
+
if (stdin) {
|
|
6219
|
+
content = await Bun.stdin.text();
|
|
6220
|
+
} else if (isHttpUrl(filePath)) {
|
|
6221
|
+
content = await fetchHttp(filePath);
|
|
6222
|
+
// Use the URL pathname for syntax/md detection (strip query/hash)
|
|
6223
|
+
try { effectivePath = new URL(filePath).pathname; } catch { effectivePath = filePath; }
|
|
6224
|
+
} else {
|
|
6225
|
+
content = await Bun.file(filePath).text();
|
|
6226
|
+
}
|
|
6227
|
+
if (effectivePath && /\.md$/i.test(effectivePath)) {
|
|
6037
6228
|
process.stdout.write(
|
|
6038
6229
|
Bun.markdown.ansi(content,{
|
|
6039
6230
|
hyperlinks:true
|
|
@@ -6043,7 +6234,7 @@ async function catFiles(files, colorscheme, syntaxDefinitions) {
|
|
|
6043
6234
|
}
|
|
6044
6235
|
const lines = content.split("\n");
|
|
6045
6236
|
const def = detectSyntax(syntaxDefinitions, {
|
|
6046
|
-
path:
|
|
6237
|
+
path: effectivePath ?? "",
|
|
6047
6238
|
firstLine: lines[0] ?? "",
|
|
6048
6239
|
lines: lines.slice(0, 50),
|
|
6049
6240
|
});
|
package/src/screen/vt100.js
CHANGED
|
@@ -77,6 +77,7 @@ export class VT100 {
|
|
|
77
77
|
this.cells = [];
|
|
78
78
|
this.cx = 0;
|
|
79
79
|
this.cy = 0;
|
|
80
|
+
this.wrapPending = false;
|
|
80
81
|
this.savedCursor = { x: 0, y: 0 };
|
|
81
82
|
this.scrollTop = 0;
|
|
82
83
|
this.scrollBottom = this.rows - 1;
|
|
@@ -88,6 +89,10 @@ export class VT100 {
|
|
|
88
89
|
this.scrollOffset = 0; // 0 = live view; n = n rows scrolled back into history
|
|
89
90
|
// mouse reporting: set by the application via ?1000h / ?1002h / ?1003h
|
|
90
91
|
this.mouseMode = false;
|
|
92
|
+
this.keyboardProtocolFlags = 0;
|
|
93
|
+
this.keyboardProtocolStack = [];
|
|
94
|
+
this.modifyOtherKeys = 0;
|
|
95
|
+
this.formatOtherKeys = 0;
|
|
91
96
|
this._initCells();
|
|
92
97
|
}
|
|
93
98
|
|
|
@@ -205,6 +210,7 @@ export class VT100 {
|
|
|
205
210
|
}
|
|
206
211
|
|
|
207
212
|
_lineFeed() {
|
|
213
|
+
this.wrapPending = false;
|
|
208
214
|
if (this.cy < this.scrollBottom) {
|
|
209
215
|
this.cy++;
|
|
210
216
|
} else {
|
|
@@ -212,6 +218,12 @@ export class VT100 {
|
|
|
212
218
|
}
|
|
213
219
|
}
|
|
214
220
|
|
|
221
|
+
_moveCursor(x, y) {
|
|
222
|
+
this.cx = Math.min(this.cols - 1, Math.max(0, x));
|
|
223
|
+
this.cy = Math.min(this.rows - 1, Math.max(0, y));
|
|
224
|
+
this.wrapPending = false;
|
|
225
|
+
}
|
|
226
|
+
|
|
215
227
|
// Feed a chunk of terminal output. Returns array of response strings to send back.
|
|
216
228
|
feed(text) {
|
|
217
229
|
const data = this.pending + text;
|
|
@@ -248,17 +260,18 @@ export class VT100 {
|
|
|
248
260
|
} else if (next === "7") {
|
|
249
261
|
this.savedCursor = { x: this.cx, y: this.cy }; i += 2;
|
|
250
262
|
} else if (next === "8") {
|
|
251
|
-
this.
|
|
263
|
+
this._moveCursor(this.savedCursor.x, this.savedCursor.y); i += 2;
|
|
252
264
|
} else if (next === "M") {
|
|
253
265
|
// Reverse index
|
|
254
266
|
if (this.cy === this.scrollTop) this._scrollDown(1);
|
|
255
|
-
else this.
|
|
267
|
+
else this._moveCursor(this.cx, this.cy - 1);
|
|
256
268
|
i += 2;
|
|
257
269
|
} else if (next === "(" || next === ")" || next === "*" || next === "+") {
|
|
258
270
|
i += 3; // charset designation, skip designator
|
|
259
271
|
} else if (next === "c") {
|
|
260
272
|
// Full reset
|
|
261
273
|
this._initCells(); this.cx = this.cy = 0;
|
|
274
|
+
this.wrapPending = false;
|
|
262
275
|
this.sgr = { fg: "default", bg: "default", bold: false, italic: false, underline: false, reverse: false };
|
|
263
276
|
this.scrollTop = 0; this.scrollBottom = this.rows - 1;
|
|
264
277
|
i += 2;
|
|
@@ -271,13 +284,15 @@ export class VT100 {
|
|
|
271
284
|
}
|
|
272
285
|
|
|
273
286
|
} else if (ch === "\r") {
|
|
274
|
-
this.cx = 0; i++;
|
|
287
|
+
this.cx = 0; this.wrapPending = false; i++;
|
|
275
288
|
} else if (ch === "\n" || ch === "\x0b" || ch === "\x0c") {
|
|
276
289
|
this._lineFeed(); i++;
|
|
277
290
|
} else if (ch === "\b") {
|
|
278
|
-
if (this.
|
|
291
|
+
if (this.wrapPending) this.wrapPending = false;
|
|
292
|
+
else if (this.cx > 0) this.cx--;
|
|
293
|
+
i++;
|
|
279
294
|
} else if (ch === "\t") {
|
|
280
|
-
this.cx = Math.min(this.cols - 1, (Math.floor(this.cx / 8) + 1) * 8); i++;
|
|
295
|
+
this.cx = Math.min(this.cols - 1, (Math.floor(this.cx / 8) + 1) * 8); this.wrapPending = false; i++;
|
|
281
296
|
} else if (ch === "\x07") {
|
|
282
297
|
i++; // Bell: ignore
|
|
283
298
|
} else if (ch === "\x0e" || ch === "\x0f") {
|
|
@@ -287,10 +302,20 @@ export class VT100 {
|
|
|
287
302
|
const cp = data.codePointAt(i);
|
|
288
303
|
const rune = String.fromCodePoint(cp);
|
|
289
304
|
const width = charWidth(rune);
|
|
305
|
+
if (width > 0 && this.wrapPending) {
|
|
306
|
+
this.cx = 0;
|
|
307
|
+
this._lineFeed();
|
|
308
|
+
}
|
|
290
309
|
this._setCell(this.cx, this.cy, rune, width);
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
310
|
+
if (width > 0) {
|
|
311
|
+
const nextX = this.cx + width;
|
|
312
|
+
if (nextX >= this.cols) {
|
|
313
|
+
this.cx = this.cols - 1;
|
|
314
|
+
this.wrapPending = true;
|
|
315
|
+
} else {
|
|
316
|
+
this.cx = nextX;
|
|
317
|
+
this.wrapPending = false;
|
|
318
|
+
}
|
|
294
319
|
}
|
|
295
320
|
i += cp > 0xFFFF ? 2 : 1;
|
|
296
321
|
} else {
|
|
@@ -302,6 +327,10 @@ export class VT100 {
|
|
|
302
327
|
}
|
|
303
328
|
|
|
304
329
|
_handleCSI(params, final) {
|
|
330
|
+
if (final === "u" && /^(?:[?<>]=?|=)/.test(params)) {
|
|
331
|
+
return this._handleKeyboardProtocol(params);
|
|
332
|
+
}
|
|
333
|
+
|
|
305
334
|
// Check for private mode prefix
|
|
306
335
|
const isPrivate = params.startsWith("?");
|
|
307
336
|
const raw = isPrivate ? params.slice(1) : params;
|
|
@@ -310,17 +339,15 @@ export class VT100 {
|
|
|
310
339
|
const p2 = parts[1] ?? 0;
|
|
311
340
|
|
|
312
341
|
switch (final) {
|
|
313
|
-
case "A": this.
|
|
314
|
-
case "B": this.
|
|
315
|
-
case "C": this.
|
|
316
|
-
case "D": this.
|
|
317
|
-
case "E": this.
|
|
318
|
-
case "F": this.
|
|
319
|
-
case "G": this.
|
|
342
|
+
case "A": this._moveCursor(this.cx, Math.max(this.scrollTop, this.cy - Math.max(1, p1))); break;
|
|
343
|
+
case "B": this._moveCursor(this.cx, Math.min(this.scrollBottom, this.cy + Math.max(1, p1))); break;
|
|
344
|
+
case "C": this._moveCursor(Math.min(this.cols - 1, this.cx + Math.max(1, p1)), this.cy); break;
|
|
345
|
+
case "D": this._moveCursor(Math.max(0, this.cx - Math.max(1, p1)), this.cy); break;
|
|
346
|
+
case "E": this._moveCursor(0, Math.min(this.rows - 1, this.cy + Math.max(1, p1))); break;
|
|
347
|
+
case "F": this._moveCursor(0, Math.max(0, this.cy - Math.max(1, p1))); break;
|
|
348
|
+
case "G": this._moveCursor(Math.min(this.cols - 1, Math.max(0, Math.max(1, p1) - 1)), this.cy); break;
|
|
320
349
|
case "H":
|
|
321
|
-
|
|
322
|
-
this.cy = Math.min(this.rows - 1, Math.max(0, Math.max(1, p1) - 1));
|
|
323
|
-
this.cx = Math.min(this.cols - 1, Math.max(0, Math.max(1, p2) - 1));
|
|
350
|
+
this._moveCursor(Math.max(0, Math.max(1, p2) - 1), Math.max(0, Math.max(1, p1) - 1));
|
|
324
351
|
break;
|
|
325
352
|
case "J":
|
|
326
353
|
if (p1 === 0) {
|
|
@@ -365,8 +392,17 @@ export class VT100 {
|
|
|
365
392
|
for (let x = this.cx; x < Math.min(this.cols, this.cx + n); x++) this._clearCell(x, this.cy);
|
|
366
393
|
break;
|
|
367
394
|
}
|
|
368
|
-
case "d": this.
|
|
369
|
-
case "
|
|
395
|
+
case "d": this._moveCursor(this.cx, Math.min(this.rows - 1, Math.max(0, Math.max(1, p1) - 1))); break;
|
|
396
|
+
case "f":
|
|
397
|
+
if (params.startsWith(">")) this._handleXtermKeyFormat(raw);
|
|
398
|
+
else {
|
|
399
|
+
this._moveCursor(Math.max(0, Math.max(1, p2) - 1), Math.max(0, Math.max(1, p1) - 1));
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
case "m":
|
|
403
|
+
if (params.startsWith(">")) this._handleXtermKeyModifier(raw);
|
|
404
|
+
else this._handleSGR(parts);
|
|
405
|
+
break;
|
|
370
406
|
case "n":
|
|
371
407
|
if (p1 === 6) return `\x1b[${this.cy + 1};${this.cx + 1}R`; // CPR
|
|
372
408
|
if (p1 === 5) return "\x1b[0n"; // device status OK
|
|
@@ -377,7 +413,14 @@ export class VT100 {
|
|
|
377
413
|
if (this.scrollTop >= this.scrollBottom) { this.scrollTop = 0; this.scrollBottom = this.rows - 1; }
|
|
378
414
|
break;
|
|
379
415
|
case "s": this.savedCursor = { x: this.cx, y: this.cy }; break;
|
|
380
|
-
case "u":
|
|
416
|
+
case "u":
|
|
417
|
+
if (params === "") {
|
|
418
|
+
this._moveCursor(this.savedCursor.x, this.savedCursor.y);
|
|
419
|
+
}
|
|
420
|
+
break;
|
|
421
|
+
case "c":
|
|
422
|
+
if (params === "" || p1 === 0) return "\x1b[?1;2c"; // primary device attributes
|
|
423
|
+
break;
|
|
381
424
|
case "h":
|
|
382
425
|
if (isPrivate) {
|
|
383
426
|
for (const n of parts) {
|
|
@@ -396,6 +439,53 @@ export class VT100 {
|
|
|
396
439
|
return null;
|
|
397
440
|
}
|
|
398
441
|
|
|
442
|
+
_handleKeyboardProtocol(params) {
|
|
443
|
+
const parseNum = (value, fallback = 0) => {
|
|
444
|
+
const n = Number(String(value ?? "").split(":")[0]);
|
|
445
|
+
return Number.isFinite(n) ? n : fallback;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
if (params === "?") return `\x1b[?${this.keyboardProtocolFlags}u`;
|
|
449
|
+
if (params.startsWith("=")) {
|
|
450
|
+
const parts = params.slice(1).split(";");
|
|
451
|
+
const flags = parseNum(parts[0], 0);
|
|
452
|
+
const mode = parseNum(parts[1], 1);
|
|
453
|
+
if (mode === 2) this.keyboardProtocolFlags |= flags;
|
|
454
|
+
else if (mode === 3) this.keyboardProtocolFlags &= ~flags;
|
|
455
|
+
else this.keyboardProtocolFlags = flags;
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
if (params.startsWith(">")) {
|
|
459
|
+
const flags = parseNum(params.slice(1), 0);
|
|
460
|
+
this.keyboardProtocolStack.push(this.keyboardProtocolFlags);
|
|
461
|
+
if (this.keyboardProtocolStack.length > 32) this.keyboardProtocolStack.shift();
|
|
462
|
+
this.keyboardProtocolFlags = flags;
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
if (params.startsWith("<")) {
|
|
466
|
+
const count = Math.max(1, parseNum(params.slice(1), 1));
|
|
467
|
+
for (let i = 0; i < count; i++) {
|
|
468
|
+
this.keyboardProtocolFlags = this.keyboardProtocolStack.length > 0 ? this.keyboardProtocolStack.pop() : 0;
|
|
469
|
+
}
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
_handleXtermKeyFormat(raw) {
|
|
476
|
+
const parts = raw.slice(1).split(";").map(p => Number(p));
|
|
477
|
+
const id = parts[0];
|
|
478
|
+
const value = parts[1];
|
|
479
|
+
if (id === 4) this.formatOtherKeys = Number.isFinite(value) ? value : 0;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
_handleXtermKeyModifier(raw) {
|
|
483
|
+
const parts = raw.slice(1).split(";").map(p => Number(p));
|
|
484
|
+
const id = parts[0];
|
|
485
|
+
const value = parts[1];
|
|
486
|
+
if (id === 4) this.modifyOtherKeys = Number.isFinite(value) ? value : 0;
|
|
487
|
+
}
|
|
488
|
+
|
|
399
489
|
_handleSGR(parts) {
|
|
400
490
|
if (parts.length === 0 || (parts.length === 1 && parts[0] === 0)) {
|
|
401
491
|
this.sgr = { fg: "default", bg: "default", bold: false, italic: false, underline: false, reverse: false };
|
|
@@ -459,6 +549,7 @@ export class VT100 {
|
|
|
459
549
|
this.cells = newCells;
|
|
460
550
|
this.cx = Math.min(this.cx, cols - 1);
|
|
461
551
|
this.cy = Math.min(this.cy, rows - 1);
|
|
552
|
+
this.wrapPending = false;
|
|
462
553
|
this.scrollTop = 0;
|
|
463
554
|
this.scrollBottom = rows - 1;
|
|
464
555
|
}
|
package/todo.txt
CHANGED
|
@@ -68,6 +68,12 @@ Current handoff notes
|
|
|
68
68
|
- Lines over 300 chars keep their old cached highlight/state when dirtied, mark the row dirty, and defer full rehighlight until Esc.
|
|
69
69
|
- Dirty long lines render red in the gutter and in the statusline row field.
|
|
70
70
|
- Startup and Esc rehighlight show colorful bottom progress by highlighted character count, wrapped with Bun.wrapAnsi.
|
|
71
|
+
[x] Recent 0.9.x updates:
|
|
72
|
+
- Added hex3 encoding for binary edit/open/save paths and encoding completion.
|
|
73
|
+
- --cat/--bat-style highlighting supports HTTP/HTTPS URLs.
|
|
74
|
+
- Terminal pane close behavior improved: exited terminals show "Press enter to close" and Enter closes/restores the pane.
|
|
75
|
+
- Added Alt-s selection mode plus more Alt key help/defaultkey documentation.
|
|
76
|
+
- Added Dedent/Unindent action aliases for outdent actions.
|
|
71
77
|
[!] Known follow-up: syntax detection still needs Go parity. todo.txt can be misdetected as filetype B because signatures are considered globally instead of only disambiguating filename/header matches.
|
|
72
78
|
[!] Known tool note: apply_patch is currently unreliable in this environment; use Bun scripts for small file edits.
|
|
73
79
|
|
|
@@ -76,7 +82,9 @@ Screen / tcell parity
|
|
|
76
82
|
[x] Replace current whole-string renderer with CellBuffer-backed rendering and diff flush.
|
|
77
83
|
[x] Implement Screen.SetContent/GetContent/Fill/Show equivalents over CellBuffer.
|
|
78
84
|
[x] Preserve per-cell style and wide-char handling: Cell.filler for double-width right-half, Screen.Show skips filler cells, putText/putCells advance col by visual width, setFillerContent added to Screen/CellBuffer.
|
|
79
|
-
[
|
|
85
|
+
[~] Preserve combining chars (zero-width combining marks stored alongside base char).
|
|
86
|
+
Done: CellBuffer/Screen/VT100 store combining marks alongside the base cell and terminal pane rendering emits them through Screen.SetContent.
|
|
87
|
+
Remaining: editor text rendering still needs grapheme-cluster-level behavior for combining marks and ZWJ sequences.
|
|
80
88
|
[ ] Implement fake cursor and multi-cursor reverse styling behavior.
|
|
81
89
|
[ ] Add raw escape registration/unregistration equivalent to tcell RegisterRawSeq.
|
|
82
90
|
[~] Add complete bracketed paste event handling across split input chunks.
|
|
@@ -105,6 +113,7 @@ Buffer / editing model
|
|
|
105
113
|
[~] Implement save options: fileformat, encoding, eofnewline, rmtrailingws, mkparents, autosu/sucmd behavior.
|
|
106
114
|
Done: eofnewline save behavior exists; fileformat status/toggle exists; encoding decode/reopen supports Bun TextDecoder labels including common CJK encodings, and statusline encoding click pre-fills reopen with encoding completion.
|
|
107
115
|
Done: saving a non-UTF-8-decoded buffer prompts "Save in UTF-8?(y,n)" before converting the buffer to UTF-8 on disk.
|
|
116
|
+
Done: hex3 encoding supports binary edit/open/save paths without UTF-8 conversion prompt.
|
|
108
117
|
Remaining: non-UTF-8 save/encode, rmtrailingws, mkparents, autosu/sucmd, full fileformat behavior parity.
|
|
109
118
|
[ ] Implement backup recovery and permbackup behavior.
|
|
110
119
|
[~] Implement savecursor and saveundo serialization.
|
|
@@ -194,8 +203,8 @@ Lua plugin parity
|
|
|
194
203
|
Done: minimal Buf/BufPane/Cursor adapters support autoclose-style Line/Insert/Replace/cursor movement and option access.
|
|
195
204
|
Remaining: real BufPane/Cursor/Buffer object parity, selections, multi-cursor, Tab/TabList APIs, InfoPane/Log/Raw buffers.
|
|
196
205
|
[~] Implement all plugin lifecycle hooks: preinit, init, postinit, onRune, preInsertNewline, preBackspace, onSave, onBufferOpen, onBufferOptionChanged, onAnyEvent, etc.
|
|
197
|
-
Done: preinit/init/postinit, onRune, preInsertNewline, preBackspace, onSave, onBufferOpen, onSetActive/onBufferClose are dispatched in current editor paths.
|
|
198
|
-
Remaining:
|
|
206
|
+
Done: preinit/init/postinit, onRune, preInsertNewline, preBackspace, onSave, onBufferOpen, onSetActive/onBufferClose, onBufferOptionChanged are dispatched in current editor paths.
|
|
207
|
+
Remaining: onAnyEvent, deinit/reload hooks, full action hook coverage, exact args/return behavior.
|
|
199
208
|
[~] Ensure Lua return values can cancel operations where Go micro expects bool false.
|
|
200
209
|
Done: PluginManager.runBool treats false as cancellation and is wired for preBackspace/preInsertNewline.
|
|
201
210
|
Remaining: all cancellable actions/hooks need to use the same path and match Go micro semantics.
|
|
@@ -291,7 +300,9 @@ Shell / jobs / terminal pane
|
|
|
291
300
|
[x] Implement Ctrl-B ShellMode prompt and shell command execution with temporary screen fini/start behavior.
|
|
292
301
|
[x] Improve Bun PTY terminal pane rendering using terminal state/cell emulation instead of raw line log.
|
|
293
302
|
VT100 class in src/screen/vt100.js: CSI cursor/erase/SGR/scroll, CPR response, OSC strip.
|
|
294
|
-
[
|
|
303
|
+
[~] Implement terminal selection/copy and close behavior parity.
|
|
304
|
+
Done: exited terminal panes can be closed with Enter and restore the previous editor buffer when available.
|
|
305
|
+
Remaining: terminal selection/copy and full close behavior parity.
|
|
295
306
|
[ ] Verify Bun.spawn stdio usage everywhere; PTY terminal option remains separate.
|
|
296
307
|
[x] Shared HTTP backend in platform/commands.js: fetchHttp(url) and downloadFile(url, outPath).
|
|
297
308
|
Priority: Bun.which detects curl (curl -kL --silent --fail) or wget (wget --no-check-certificate -q -O -),
|