bunmicro 0.9.1 → 0.9.5

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.5] - 2026-06-03
4
+ - Added encoding hex3 for binary edit
5
+ - term better supports fish
6
+ - enter to close term
7
+ - bat-like highlighting supports URL
8
+
3
9
  ## [0.9.1] - 2026-06-03
4
10
  - Upgrade method explanation
5
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunmicro",
3
- "version": "0.9.1",
3
+ "version": "0.9.5",
4
4
  "description": "Bun JavaScript rewrite of the micro editor originally in Golang",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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
 
@@ -134,18 +135,27 @@ function isHttpUrl(value) {
134
135
  }
135
136
 
136
137
  async function readTextFileWithEncoding(path, encoding = "utf-8") {
137
- const decoder = new TextDecoder(normalizeEncodingLabel(encoding));
138
138
  const bytes = new Uint8Array(await Bun.file(path).arrayBuffer());
139
+ if (normalizeEncodingLabel(encoding) === "hex3") {
140
+ return { text: encodeBinaryToBuffer(bytes).toString("latin1"), encoding: "hex3" };
141
+ }
142
+ const decoder = new TextDecoder(normalizeEncodingLabel(encoding));
139
143
  return { text: decoder.decode(bytes), encoding: decoder.encoding };
140
144
  }
141
145
 
142
146
  async function fetchTextWithEncoding(url, encoding = "utf-8") {
147
+ const bytes = await fetchHttpBytes(url);
148
+ if (normalizeEncodingLabel(encoding) === "hex3") {
149
+ return { text: encodeBinaryToBuffer(new Uint8Array(bytes)).toString("latin1"), encoding: "hex3" };
150
+ }
143
151
  const decoder = new TextDecoder(normalizeEncodingLabel(encoding));
144
- return { text: decoder.decode(await fetchHttpBytes(url)), encoding: decoder.encoding };
152
+ return { text: decoder.decode(bytes), encoding: decoder.encoding };
145
153
  }
146
154
 
147
155
  function normalizeEncodingLabel(encoding = "utf-8") {
148
- return new TextDecoder(String(encoding || "utf-8")).encoding;
156
+ const s = String(encoding || "utf-8");
157
+ if (s === "hex3") return "hex3";
158
+ return new TextDecoder(s).encoding;
149
159
  }
150
160
 
151
161
  function isReadonlyBuffer(buf) {
@@ -1043,6 +1053,21 @@ class BufferModel {
1043
1053
  async save(path = this.path) {
1044
1054
  if (!path) throw new Error("No filename");
1045
1055
  let text = this.lines.join("\n");
1056
+ if (this.encoding === "hex3") {
1057
+ await Bun.write(path, decodeBinaryBytes(Buffer.from(text, "latin1")));
1058
+ this.path = path;
1059
+ this.Path = path;
1060
+ this.AbsPath = path;
1061
+ this.name = basename(path);
1062
+ this.updateModTime();
1063
+ this.readonly = !canWritePath(path);
1064
+ this.Settings.readonly = this.readonly;
1065
+ this.Type.Readonly = this.readonly;
1066
+ this._savedSerial = this._undoSerial ?? 0;
1067
+ this.modified = false;
1068
+ this.message = `Saved ${path}`;
1069
+ return;
1070
+ }
1046
1071
  if (DEFAULT_SETTINGS.eofnewline && !text.endsWith("\n")) text += "\n";
1047
1072
  await Bun.write(path, text);
1048
1073
  this.encoding = "utf-8";
@@ -1324,6 +1349,7 @@ class TerminalPane {
1324
1349
  this.proc = null;
1325
1350
  this.vt = null;
1326
1351
  this.decoder = new TextDecoder();
1352
+ this.exited = false;
1327
1353
  }
1328
1354
 
1329
1355
  open(cols, rows) {
@@ -1335,6 +1361,7 @@ class TerminalPane {
1335
1361
  cols = Math.max(10, cols ?? this.app.cols);
1336
1362
  rows = Math.max(4, rows ?? Math.floor(this.app.rows / 2));
1337
1363
  this.vt = new VT100(cols, rows);
1364
+ this.exited = false;
1338
1365
  this.proc = Bun.spawn([shell], {
1339
1366
  env: { ...process.env, TERM: "xterm-256color", COLUMNS: String(cols), LINES: String(rows) },
1340
1367
  terminal: {
@@ -1348,7 +1375,8 @@ class TerminalPane {
1348
1375
  this.app.render();
1349
1376
  },
1350
1377
  exit: () => {
1351
- this.vt.feed("\r\n[process exited]\r\n");
1378
+ this.exited = true;
1379
+ this.vt.feed("\r\n[process exited]\r\nPress enter to close\r\n");
1352
1380
  this.app.render();
1353
1381
  },
1354
1382
  },
@@ -1356,9 +1384,14 @@ class TerminalPane {
1356
1384
  }
1357
1385
 
1358
1386
  write(data) {
1387
+ if (this.exited) return;
1359
1388
  this.proc?.terminal?.write(data);
1360
1389
  }
1361
1390
 
1391
+ writeInput(data) {
1392
+ this.write(encodeTerminalInput(data, this.vt));
1393
+ }
1394
+
1362
1395
  resize(cols, rows) {
1363
1396
  rows = Math.max(4, rows);
1364
1397
  this.vt?.resize(cols, rows);
@@ -1367,13 +1400,102 @@ class TerminalPane {
1367
1400
 
1368
1401
  close() {
1369
1402
  try {
1370
- this.proc?.kill();
1403
+ if (!this.exited) this.proc?.kill();
1371
1404
  this.proc?.terminal?.close();
1372
1405
  } catch {
1373
1406
  // PTY may already be closed.
1374
1407
  }
1375
1408
  this.proc = null;
1409
+ this.exited = true;
1410
+ }
1411
+ }
1412
+
1413
+ function encodeTerminalInput(data, vt) {
1414
+ if (!vt) return data;
1415
+ const flags = vt.keyboardProtocolFlags ?? 0;
1416
+ const wantsKitty = flags !== 0;
1417
+ const wantsXterm = (vt.modifyOtherKeys ?? 0) > 0 || (vt.formatOtherKeys ?? 0) > 0;
1418
+ if (!wantsKitty && !wantsXterm) return data;
1419
+
1420
+ const text = data instanceof Uint8Array ? decoder.decode(data) : String(data);
1421
+ const events = parseInputEvents(text);
1422
+ if (events.length === 0 || events.some(e => e.type !== "key")) return data;
1423
+
1424
+ const encoded = [];
1425
+ for (const event of events) {
1426
+ const seq = encodeKeyEventForTerminal(event, flags, wantsXterm);
1427
+ if (!seq) return data;
1428
+ encoded.push(seq);
1429
+ }
1430
+ return encoded.join("");
1431
+ }
1432
+
1433
+ function encodeKeyEventForTerminal(event, flags, wantsXterm) {
1434
+ const key = event.key;
1435
+ const raw = event.raw ?? "";
1436
+ const reportAll = (flags & 8) !== 0;
1437
+ const disambiguate = (flags & 1) !== 0 || reportAll || wantsXterm;
1438
+
1439
+ if (!reportAll && isPlainTextKey(raw, key)) return raw;
1440
+ const parsed = keyToKittyCode(key, raw);
1441
+ if (!parsed) return raw;
1442
+ const { code, modifiers } = parsed;
1443
+ if (!reportAll && !disambiguate && modifiers <= 1) return raw;
1444
+ if (!reportAll && modifiers <= 1 && !needsDisambiguation(key)) return raw;
1445
+ return `\x1b[${code};${modifiers}u`;
1446
+ }
1447
+
1448
+ function isPlainTextKey(raw, key) {
1449
+ return raw && raw === key && !raw.includes("\x1b") && !/^(?:ctrl|alt|shift)-/.test(key) && !KEY_CODEPOINTS[key];
1450
+ }
1451
+
1452
+ function needsDisambiguation(key) {
1453
+ return key === "escape" || key.startsWith("ctrl-") || key.startsWith("alt-") || key.includes("shift-");
1454
+ }
1455
+
1456
+ const NAMED_KEY_CODEPOINTS = {
1457
+ escape: 27,
1458
+ enter: 13,
1459
+ tab: 9,
1460
+ backspace: 127,
1461
+ delete: 57362,
1462
+ insert: 57363,
1463
+ left: 57364,
1464
+ right: 57365,
1465
+ up: 57366,
1466
+ down: 57367,
1467
+ pageup: 57368,
1468
+ pagedown: 57369,
1469
+ home: 57370,
1470
+ end: 57371,
1471
+ };
1472
+
1473
+ const KEY_CODEPOINTS = NAMED_KEY_CODEPOINTS;
1474
+
1475
+ function keyToKittyCode(key, raw) {
1476
+ let rest = key;
1477
+ let mods = 1;
1478
+ let changed = true;
1479
+ while (changed) {
1480
+ changed = false;
1481
+ for (const [prefix, bit] of [["shift-", 1], ["alt-", 2], ["ctrl-", 4]]) {
1482
+ if (rest.startsWith(prefix)) {
1483
+ mods += bit;
1484
+ rest = rest.slice(prefix.length);
1485
+ changed = true;
1486
+ }
1487
+ }
1376
1488
  }
1489
+
1490
+ if (rest === "space") return { code: 32, modifiers: mods };
1491
+ if (KEY_CODEPOINTS[rest]) return { code: KEY_CODEPOINTS[rest], modifiers: mods };
1492
+ if (rest.length === 1) return { code: rest.toLowerCase().codePointAt(0), modifiers: mods };
1493
+
1494
+ if (raw.length === 1 && raw >= " " && raw !== "\x7f") return { code: raw.toLowerCase().codePointAt(0), modifiers: mods };
1495
+ if (raw.length === 1 && raw.charCodeAt(0) >= 1 && raw.charCodeAt(0) <= 26) {
1496
+ return { code: raw.charCodeAt(0) + 96, modifiers: mods | 4 };
1497
+ }
1498
+ return null;
1377
1499
  }
1378
1500
 
1379
1501
  // ─── Pane / split layout ────────────────────────────────────────────────────
@@ -2325,17 +2447,15 @@ class App {
2325
2447
  return;
2326
2448
  }
2327
2449
 
2328
- // Escape alone: restore previous buffer if available, otherwise close pane
2329
- if (text === "\x1b") {
2330
- activePaneObj.terminal?.close();
2331
- activePaneObj.terminal = null;
2332
- if (activePaneObj.prevBuffer) {
2333
- activePaneObj.type = "editor";
2334
- activePaneObj.buffer = activePaneObj.prevBuffer;
2335
- activePaneObj.prevBuffer = null;
2336
- } else {
2337
- this.closePane(activePaneObj);
2338
- }
2450
+ if (activePaneObj.terminal.exited && events.some((event) => event.type === "key" && event.key === "enter")) {
2451
+ this.closeTermPane(activePaneObj);
2452
+ this.render();
2453
+ return;
2454
+ }
2455
+
2456
+ // Escape alone: close pane in legacy mode; protocol-aware shells need it as input.
2457
+ if (text === "\x1b" && !(activePaneObj.terminal?.vt?.keyboardProtocolFlags || activePaneObj.terminal?.vt?.modifyOtherKeys)) {
2458
+ this.closeTermPane(activePaneObj);
2339
2459
  this.render();
2340
2460
  return;
2341
2461
  }
@@ -2352,7 +2472,7 @@ class App {
2352
2472
 
2353
2473
  // Any other key input: reset scroll to live view, then forward
2354
2474
  if (activePaneObj.terminal?.vt) activePaneObj.terminal.vt.scrollOffset = 0;
2355
- activePaneObj.terminal.write(data);
2475
+ activePaneObj.terminal.writeInput(data);
2356
2476
  return;
2357
2477
  }
2358
2478
 
@@ -3384,7 +3504,8 @@ class App {
3384
3504
  async save({ force = false } = {}) {
3385
3505
  if (!force && this.buffer?.readonly) { this.message = "Can't save under readonly mode"; return; }
3386
3506
  try {
3387
- if (normalizeEncodingLabel(this.buffer?.encoding) !== "utf-8") {
3507
+ const enc = normalizeEncodingLabel(this.buffer?.encoding);
3508
+ if (enc !== "utf-8" && enc !== "hex3") {
3388
3509
  this.openYNPrompt("Save in UTF-8?(y,n)", async (answer) => {
3389
3510
  if (answer === "y") await this.saveUtf8();
3390
3511
  });
@@ -3536,6 +3657,18 @@ class App {
3536
3657
  this.message = "No previous diff";
3537
3658
  }
3538
3659
 
3660
+ closeTermPane(pane) {
3661
+ pane.terminal?.close();
3662
+ pane.terminal = null;
3663
+ if (pane.prevBuffer) {
3664
+ pane.type = "editor";
3665
+ pane.buffer = pane.prevBuffer;
3666
+ pane.prevBuffer = null;
3667
+ } else {
3668
+ this.closePane(pane);
3669
+ }
3670
+ }
3671
+
3539
3672
  closePane(pane) {
3540
3673
  pane.terminal?.close();
3541
3674
  const tab = this.tab;
@@ -3774,7 +3907,7 @@ class App {
3774
3907
  const saveArgs = [...cmdArgs];
3775
3908
  const saveForce = saveArgs[0] === "-f" && (saveArgs.shift(), true);
3776
3909
  if (!saveForce && buf?.readonly) { this.message = "Can't save under readonly mode"; break; }
3777
- if (saveArgs.length > 0 && normalizeEncodingLabel(buf?.encoding) !== "utf-8") {
3910
+ if (saveArgs.length > 0 && normalizeEncodingLabel(buf?.encoding) !== "utf-8" && normalizeEncodingLabel(buf?.encoding) !== "hex3") {
3778
3911
  const target = resolve(expandHome(saveArgs[0]));
3779
3912
  this.openYNPrompt("Save in UTF-8?(y,n)", async (answer) => {
3780
3913
  if (answer === "y") {
@@ -4546,12 +4679,14 @@ const COMMAND_NAMES = [
4546
4679
  ];
4547
4680
 
4548
4681
  const SUPPORTED_ENCODING_LABELS = [
4682
+ "hex3",
4549
4683
  "utf-8", "utf-16le", "utf-16be",
4550
4684
  "windows-1252", "iso-8859-1", "latin1",
4551
4685
  "big5", "gbk", "gb18030",
4552
4686
  "shift_jis", "sjis", "euc-jp", "iso-2022-jp",
4553
4687
  "euc-kr", "ks_c_5601-1987",
4554
4688
  ].filter((encoding) => {
4689
+ if (encoding === "hex3") return true;
4555
4690
  try { new TextDecoder(encoding); return true; }
4556
4691
  catch { return false; }
4557
4692
  });
@@ -5562,7 +5697,10 @@ async function loadBuffers(files, command) {
5562
5697
  } else if (!process.stdin.isTTY) {
5563
5698
  const chunks = [];
5564
5699
  for await (const chunk of process.stdin) chunks.push(chunk);
5565
- buffers.push(new BufferModel({ text: Buffer.concat(chunks).toString("utf8"), type: process.stdout.isTTY ? "default" : "stdout", command }));
5700
+ const stdinText = Buffer.concat(chunks).toString("utf8");
5701
+ const stdinBuf = new BufferModel({ text: stdinText, type: process.stdout.isTTY ? "default" : "stdout", command });
5702
+ if (loadBuffers.context) attachSyntax(stdinBuf, loadBuffers.context, "", stdinText);
5703
+ buffers.push(stdinBuf);
5566
5704
  } else {
5567
5705
  buffers.push(new BufferModel({ command }));
5568
5706
  }
@@ -6032,8 +6170,18 @@ function syncEditorSettings(config) {
6032
6170
  async function catFiles(files, colorscheme, syntaxDefinitions) {
6033
6171
  const targets = files.length > 0 ? files.map((f) => ({ path: f, stdin: false })) : [{ path: null, stdin: true }];
6034
6172
  for (const { path: filePath, stdin } of targets) {
6035
- const content = stdin ? await Bun.stdin.text() : await Bun.file(filePath).text();
6036
- if (filePath && /\.md$/i.test(filePath)) {
6173
+ let content;
6174
+ let effectivePath = filePath;
6175
+ if (stdin) {
6176
+ content = await Bun.stdin.text();
6177
+ } else if (isHttpUrl(filePath)) {
6178
+ content = await fetchHttp(filePath);
6179
+ // Use the URL pathname for syntax/md detection (strip query/hash)
6180
+ try { effectivePath = new URL(filePath).pathname; } catch { effectivePath = filePath; }
6181
+ } else {
6182
+ content = await Bun.file(filePath).text();
6183
+ }
6184
+ if (effectivePath && /\.md$/i.test(effectivePath)) {
6037
6185
  process.stdout.write(
6038
6186
  Bun.markdown.ansi(content,{
6039
6187
  hyperlinks:true
@@ -6043,7 +6191,7 @@ async function catFiles(files, colorscheme, syntaxDefinitions) {
6043
6191
  }
6044
6192
  const lines = content.split("\n");
6045
6193
  const def = detectSyntax(syntaxDefinitions, {
6046
- path: filePath ?? "",
6194
+ path: effectivePath ?? "",
6047
6195
  firstLine: lines[0] ?? "",
6048
6196
  lines: lines.slice(0, 50),
6049
6197
  });
@@ -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.cx = this.savedCursor.x; this.cy = this.savedCursor.y; i += 2;
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.cy = Math.max(0, this.cy - 1);
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.cx > 0) this.cx--; i++;
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
- this.cx += width;
292
- if (this.cx >= this.cols) {
293
- this.cx = 0; this._lineFeed();
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.cy = Math.max(this.scrollTop, this.cy - Math.max(1, p1)); break;
314
- case "B": this.cy = Math.min(this.scrollBottom, this.cy + Math.max(1, p1)); break;
315
- case "C": this.cx = Math.min(this.cols - 1, this.cx + Math.max(1, p1)); break;
316
- case "D": this.cx = Math.max(0, this.cx - Math.max(1, p1)); break;
317
- case "E": this.cy = Math.min(this.rows - 1, this.cy + Math.max(1, p1)); this.cx = 0; break;
318
- case "F": this.cy = Math.max(0, this.cy - Math.max(1, p1)); this.cx = 0; break;
319
- case "G": this.cx = Math.min(this.cols - 1, Math.max(0, Math.max(1, p1) - 1)); break;
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
- case "f":
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.cy = Math.min(this.rows - 1, Math.max(0, Math.max(1, p1) - 1)); break;
369
- case "m": this._handleSGR(parts); break;
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": this.cx = this.savedCursor.x; this.cy = this.savedCursor.y; break;
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
  }