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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunmicro",
3
- "version": "0.9.1",
3
+ "version": "0.9.9",
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
 
@@ -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(await fetchHttpBytes(url)), encoding: decoder.encoding };
154
+ return { text: decoder.decode(bytes), encoding: decoder.encoding };
145
155
  }
146
156
 
147
157
  function normalizeEncodingLabel(encoding = "utf-8") {
148
- return new TextDecoder(String(encoding || "utf-8")).encoding;
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.vt.feed("\r\n[process exited]\r\n");
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.searchPattern, braceMatches, isCL ? clBg : null);
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
- const breaks = softwrapBreaks(lineStr, maxW, wordwrap, tabsize);
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, buf.searchPattern, braceMatches, isCL ? clBg : null);
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
- // 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
- }
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.write(data);
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
- if (normalizeEncodingLabel(this.buffer?.encoding) !== "utf-8") {
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, searchPattern = "", braceMatches = null, cursorLineBg = 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
- const inSearch = searchRanges.some(([from, to]) => i >= from && i < to);
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 getSearchRanges(line, pattern, ignoreCase = false) {
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 = 0;
5542
- while (idx < line.length) {
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
- buffers.push(new BufferModel({ text: Buffer.concat(chunks).toString("utf8"), type: process.stdout.isTTY ? "default" : "stdout", command }));
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
- const content = stdin ? await Bun.stdin.text() : await Bun.file(filePath).text();
6036
- if (filePath && /\.md$/i.test(filePath)) {
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: filePath ?? "",
6237
+ path: effectivePath ?? "",
6047
6238
  firstLine: lines[0] ?? "",
6048
6239
  lines: lines.slice(0, 50),
6049
6240
  });
@@ -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
  }
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
- [ ] Preserve combining chars (zero-width combining marks stored alongside base char).
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: onBufferOptionChanged, onAnyEvent, deinit/reload hooks, full action hook coverage, exact args/return behavior.
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
- [ ] Implement terminal selection/copy and close behavior parity.
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 -),