jsmdcui 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -195,7 +195,7 @@ const DEFAULT_SETTINGS = {
195
195
  backup: true,
196
196
  backupdir: "",
197
197
  permbackup: false,
198
- softwrap: false,
198
+ softwrap: true,
199
199
  wordwrap: false,
200
200
  pageoverlap: 2,
201
201
  scrollmargin: 3,
@@ -304,14 +304,14 @@ function encodeHex3Text(text) {
304
304
  return decodeBinaryBytes(Buffer.from(text, "latin1"));
305
305
  }
306
306
 
307
- async function readTextFileWithEncoding(path, encoding = "utf-8") {
307
+ async function readTextFileWithEncoding(path, encoding = "utf-8", inferMdcui = true) {
308
308
  const bytes = new Uint8Array(await Bun.file(path).arrayBuffer());
309
- return decodeAndRenderTextBytes(bytes, encodingForPath(path, encoding), process.stdout.columns || 80, path);
309
+ return decodeAndRenderTextBytes(bytes, encodingForPath(path, encoding, inferMdcui), process.stdout.columns || 80, path);
310
310
  }
311
311
 
312
- async function fetchTextWithEncoding(url, encoding = "utf-8") {
312
+ async function fetchTextWithEncoding(url, encoding = "utf-8", inferMdcui = true) {
313
313
  const bytes = await fetchHttpBytes(url);
314
- return decodeAndRenderTextBytes(new Uint8Array(bytes), encodingForPath(url, encoding));
314
+ return decodeAndRenderTextBytes(new Uint8Array(bytes), encodingForPath(url, encoding, inferMdcui));
315
315
  }
316
316
 
317
317
  function normalizeEncodingLabel(encoding = "utf-8") {
@@ -321,9 +321,9 @@ function normalizeEncodingLabel(encoding = "utf-8") {
321
321
  return new TextDecoder(s).encoding;
322
322
  }
323
323
 
324
- function encodingForPath(pathOrUrl, encoding = DEFAULT_SETTINGS.encoding) {
324
+ function encodingForPath(pathOrUrl, encoding = DEFAULT_SETTINGS.encoding, inferMdcui = true) {
325
325
  const normalized = normalizeEncodingLabel(encoding);
326
- if (normalized !== "utf-8") return normalized;
326
+ if (normalized !== "utf-8" || !inferMdcui) return normalized;
327
327
  let pathname = String(pathOrUrl ?? "").replace(/[?#].*$/, "");
328
328
  try {
329
329
  if (isHttpUrl(pathname)) pathname = new URL(pathname).pathname;
@@ -538,6 +538,70 @@ function mdcuiCellPayload(buf, y, x, trigger = "unknown") {
538
538
  };
539
539
  }
540
540
 
541
+ function resizeMdcuiTextBlock(buf, y, x) {
542
+ if (!buf || !isMdcuiEncoding(buf.encoding) || x !== 0) return null;
543
+ const line = String(buf.lines?.[y] ?? "");
544
+ const top = line.match(/^(\s*)(┌─|╭─|\+-)\s*text(?:[#.][A-Za-z_][\w:.-]*)*\s*$/);
545
+ const bottom = line.match(/^(\s*)(└─|╰─|\+-)\s*$/);
546
+ if (!top && !bottom) return null;
547
+
548
+ let insertAt = -1;
549
+ let removeAt = -1;
550
+ let bodyLine = "";
551
+
552
+ if (bottom) {
553
+ for (let row = y - 1; row >= 0; row--) {
554
+ const header = String(buf.lines[row] ?? "").match(/^(\s*)(┌─|╭─|\+-)\s*text(?:[#.][A-Za-z_][\w:.-]*)*\s*$/);
555
+ if (!header || header[1] !== bottom[1]) continue;
556
+ const marker = header[2] === "+-" ? "|" : "│";
557
+ insertAt = y;
558
+ bodyLine = header[1] + marker + " ";
559
+ break;
560
+ }
561
+ } else if (top) {
562
+ const marker = top[2] === "+-" ? "|" : "│";
563
+ for (let row = y + 1; row < buf.lines.length; row++) {
564
+ const rest = String(buf.lines[row] ?? "").slice(top[1].length);
565
+ if (/^(?:└─|╰─|\+-)\s*$/.test(rest)) {
566
+ const candidate = row - 1;
567
+ if (candidate > y && String(buf.lines[candidate] ?? "") === top[1] + marker + " ")
568
+ removeAt = candidate;
569
+ break;
570
+ }
571
+ }
572
+ }
573
+
574
+ if (insertAt < 0 && removeAt < 0) return "unchanged";
575
+ buf.pushUndo?.(true);
576
+
577
+ const row = insertAt >= 0 ? insertAt : removeAt;
578
+ const deleteCount = removeAt >= 0 ? 1 : 0;
579
+ const replacement = insertAt >= 0 ? [bodyLine] : [];
580
+ buf.lines.splice(row, deleteCount, ...replacement);
581
+
582
+ if (Array.isArray(buf._ansiStyleLines)) {
583
+ const template = buf._ansiStyleLines[Math.max(0, row - 1)] ?? null;
584
+ buf._ansiStyleLines.splice(row, deleteCount, ...replacement.map(() => template));
585
+ }
586
+ if (typeof buf._mdcuiAnsiText === "string") {
587
+ const ansiLines = buf._mdcuiAnsiText.split("\n");
588
+ ansiLines.splice(row, deleteCount, ...replacement);
589
+ buf._mdcuiAnsiText = ansiLines.join("\n");
590
+ }
591
+
592
+ if (insertAt >= 0) {
593
+ if (buf.cursor.y >= insertAt) buf.cursor.y++;
594
+ } else if (buf.cursor.y > removeAt) {
595
+ buf.cursor.y--;
596
+ } else if (buf.cursor.y === removeAt) {
597
+ buf.cursor.y = Math.max(0, removeAt - 1);
598
+ }
599
+ buf.invalidateHighlightFrom?.(row, { force: true });
600
+ buf.modified = true;
601
+ buf.ensureCursor?.();
602
+ return insertAt >= 0 ? "added" : "removed";
603
+ }
604
+
541
605
  function detectFileFormat(text, fallback = DEFAULT_SETTINGS.fileformat) {
542
606
  if (text.length === 0) return fallback === "dos" ? "dos" : "unix";
543
607
  const newlineIdx = text.indexOf("\n");
@@ -557,6 +621,24 @@ function isReadonlyBuffer(buf) {
557
621
  return Boolean(buf?.readonly || buf?.Settings?.readonly || buf?.Type?.Readonly);
558
622
  }
559
623
 
624
+ function mdcuiEditablePrefixLength(buf, y = buf?.cursor?.y ?? 0) {
625
+ if (!isMdcuiEncoding(buf?.encoding ?? buf?.Settings?.encoding)) return 0;
626
+ const line = String(buf?.lines?.[y] ?? "");
627
+ return /^(?:│|\|) /.test(line) ? 2 : 0;
628
+ }
629
+
630
+ function canEditMdcuiAtCursor(buf) {
631
+ const prefixLength = mdcuiEditablePrefixLength(buf);
632
+ return prefixLength > 0 && (buf?.cursor?.x ?? 0) >= prefixLength;
633
+ }
634
+
635
+ function canEditMdcuiSelection(buf, selection) {
636
+ if (!selection) return true;
637
+ const { first, last } = selectionBounds(selection);
638
+ const prefixLength = mdcuiEditablePrefixLength(buf, first.y);
639
+ return first.y === last.y && prefixLength > 0 && first.x >= prefixLength;
640
+ }
641
+
560
642
  function isEditLockedBuffer(buf) {
561
643
  return isMdcuiEncoding(buf?.encoding ?? buf?.Settings?.encoding);
562
644
  }
@@ -869,6 +951,8 @@ function parseArgs(argv) {
869
951
  docs: false,
870
952
  changelog: false,
871
953
  testapp: false,
954
+ demo: false,
955
+ allowUrl: false,
872
956
  buildExe: false,
873
957
  buildFor: "",
874
958
  configDir: "",
@@ -901,9 +985,14 @@ function parseArgs(argv) {
901
985
  else if (arg === "--hex3zst") {
902
986
  flags.settings.set("encoding", "hex3zst");
903
987
  }
988
+ else if (arg === "--edit") {
989
+ flags.settings.set("encoding", "utf-8");
990
+ }
904
991
  else if (arg === "--docs" || arg === "--readme") flags.docs = true;
905
992
  else if (arg === "--changelog") flags.changelog = true;
906
993
  else if (arg === "--testapp.md") flags.testapp = true;
994
+ else if (arg === "--demo") flags.demo = true;
995
+ else if (arg === "--allow-url") flags.allowUrl = true;
907
996
  else if (arg === "--build-exe") flags.buildExe = true;
908
997
  else if (arg === "--build-for") flags.buildFor = argv[++i] ?? "";
909
998
  else if (arg === "-debug") flags.debug = true;
@@ -946,6 +1035,8 @@ Modes:
946
1035
  --hex3, --hex3gz, --hex3zst
947
1036
  Set -encoding hex3, hex3gz, or hex3zst for this session
948
1037
  hex3 shows raw bytes; gz/zst variants compress the same hex3 view
1038
+ --edit
1039
+ Open files as editable UTF-8 text, overriding .md mdcui detection
949
1040
  -encoding mdcui
950
1041
  Render Markdown through runmd.mjs#createTui; .md files use this automatically
951
1042
  Writes .front.js, .back.js, .html, -rpc.js, and -server.js beside the .md file
@@ -971,11 +1062,19 @@ Information:
971
1062
  Show ${pkg.name}'s README.md & exit
972
1063
  --changelog
973
1064
  Show CHANGELOG.md & exit
974
- --testapp.md
975
- Write testapp.md to stdout & exit
976
1065
  -profile, --profile
977
1066
  Print startup performance profile and exit
978
1067
 
1068
+ Demo:
1069
+ --testapp.md
1070
+ Write the bundled testapp.md to stdout & exit
1071
+ --demo
1072
+ Outputs & overwrites ./testapp.md, opens it in the TUI, and writes 5 generated files
1073
+
1074
+ Remote Markdown:
1075
+ --allow-url
1076
+ Download HTTP(S) Markdown to cwd, write 5 generated files, and allow its code to run
1077
+
979
1078
  Experimental:
980
1079
  --build-exe Build a Bun single-file executable and exit
981
1080
  --build-for <target> Build a Bun single-file executable for target`
@@ -1129,7 +1228,7 @@ class BufferModel {
1129
1228
  let text = "";
1130
1229
  let readonly = false;
1131
1230
  let modTimeMs = null;
1132
- let encoding = context.config?.globalSettings?.encoding ?? DEFAULT_SETTINGS.encoding;
1231
+ let encoding = context.inputEncoding ?? context.config?.globalSettings?.encoding ?? DEFAULT_SETTINGS.encoding;
1133
1232
  let ansiStyleLines = null;
1134
1233
  let ansiText = null;
1135
1234
  let sourceText = null;
@@ -1140,7 +1239,7 @@ class BufferModel {
1140
1239
  if (info.isDirectory()) throw new Error(`${path} is a directory`);
1141
1240
  readonly = !canWritePath(path);
1142
1241
  modTimeMs = info.mtimeMs;
1143
- const decoded = await readTextFileWithEncoding(path, encoding);
1242
+ const decoded = await readTextFileWithEncoding(path, encoding, !context.encodingExplicit);
1144
1243
  text = decoded.text;
1145
1244
  encoding = decoded.encoding;
1146
1245
  ansiStyleLines = decoded.ansiStyleLines ?? null;
@@ -1181,7 +1280,8 @@ class BufferModel {
1181
1280
  }
1182
1281
 
1183
1282
  insert(text) {
1184
- if (this.isEditLocked()) return false;
1283
+ if (this.isEditLocked() && !canEditMdcuiAtCursor(this)) return false;
1284
+ if (isMdcuiEncoding(this.encoding) && /[\r\n]/.test(String(text))) return false;
1185
1285
  for (const ch of text) {
1186
1286
  if (ch === "\r" || ch === "\n") this.newline(false);
1187
1287
  else if (ch >= " " || ch === "\t") this.insertChar(ch);
@@ -1190,7 +1290,7 @@ class BufferModel {
1190
1290
  }
1191
1291
 
1192
1292
  insertChar(ch) {
1193
- if (this.isEditLocked()) return false;
1293
+ if (this.isEditLocked() && !canEditMdcuiAtCursor(this)) return false;
1194
1294
  if (ch === "\t" && DEFAULT_SETTINGS.tabstospaces) ch = " ".repeat(DEFAULT_SETTINGS.tabsize);
1195
1295
  const line = this.line();
1196
1296
  this.lines[this.cursor.y] = line.slice(0, this.cursor.x) + ch + line.slice(this.cursor.x);
@@ -1201,6 +1301,7 @@ class BufferModel {
1201
1301
  }
1202
1302
 
1203
1303
  newline(autoindent = true) {
1304
+ if (isMdcuiEncoding(this.encoding)) return false;
1204
1305
  if (this.isEditLocked()) return false;
1205
1306
  const line = this.line();
1206
1307
  const left = line.slice(0, this.cursor.x);
@@ -1219,7 +1320,9 @@ class BufferModel {
1219
1320
  }
1220
1321
 
1221
1322
  backspace() {
1222
- if (this.isEditLocked()) return false;
1323
+ if (this.isEditLocked() && !canEditMdcuiAtCursor(this)) return false;
1324
+ const mdcuiPrefixLength = mdcuiEditablePrefixLength(this);
1325
+ if (mdcuiPrefixLength > 0 && this.cursor.x <= mdcuiPrefixLength) return false;
1223
1326
  if (this.cursor.x > 0) {
1224
1327
  const line = this.line();
1225
1328
  let start = this.cursor.x - 1;
@@ -1246,7 +1349,7 @@ class BufferModel {
1246
1349
  }
1247
1350
 
1248
1351
  deleteForward() {
1249
- if (this.isEditLocked()) return false;
1352
+ if (this.isEditLocked() && !canEditMdcuiAtCursor(this)) return false;
1250
1353
  const line = this.line();
1251
1354
  if (this.cursor.x < line.length) {
1252
1355
  const cp = line.codePointAt(this.cursor.x);
@@ -1255,6 +1358,8 @@ class BufferModel {
1255
1358
  this.invalidateHighlightFrom(this.cursor.y);
1256
1359
  this.modified = true;
1257
1360
  return true;
1361
+ } else if (isMdcuiEncoding(this.encoding)) {
1362
+ return false;
1258
1363
  } else if (this.cursor.y < this.lines.length - 1) {
1259
1364
  this.lines[this.cursor.y] += this.lines[this.cursor.y + 1];
1260
1365
  this.lines.splice(this.cursor.y + 1, 1);
@@ -1384,8 +1489,8 @@ class BufferModel {
1384
1489
  this.cursor.x = x;
1385
1490
  }
1386
1491
 
1387
- pushUndo() {
1388
- if (this.isEditLocked()) return;
1492
+ pushUndo(force = false) {
1493
+ if (!force && this.isEditLocked() && !canEditMdcuiAtCursor(this)) return;
1389
1494
  this.undoStack.push({ lines: this.lines.slice(), cursor: { ...this.cursor }, serial: this._undoSerial });
1390
1495
  this._undoSerial = (this._undoSerial ?? 0) + 1;
1391
1496
  if (this.undoStack.length > 500) this.undoStack.shift();
@@ -1393,7 +1498,7 @@ class BufferModel {
1393
1498
  }
1394
1499
 
1395
1500
  undo() {
1396
- if (this.isEditLocked()) return false;
1501
+ if (this.isEditLocked() && !isMdcuiEncoding(this.encoding)) return false;
1397
1502
  if (!this.undoStack.length) return false;
1398
1503
  this.redoStack.push({ lines: this.lines.slice(), cursor: { ...this.cursor }, serial: this._undoSerial });
1399
1504
  const s = this.undoStack.pop();
@@ -1406,7 +1511,7 @@ class BufferModel {
1406
1511
  }
1407
1512
 
1408
1513
  redo() {
1409
- if (this.isEditLocked()) return false;
1514
+ if (this.isEditLocked() && !isMdcuiEncoding(this.encoding)) return false;
1410
1515
  if (!this.redoStack.length) return false;
1411
1516
  this.undoStack.push({ lines: this.lines.slice(), cursor: { ...this.cursor }, serial: this._undoSerial });
1412
1517
  const s = this.redoStack.pop();
@@ -1536,7 +1641,7 @@ class BufferModel {
1536
1641
  async reopen(context = {}) {
1537
1642
  if (!this.path) return;
1538
1643
  if (isHttpUrl(this.path)) {
1539
- const decoded = await fetchTextWithEncoding(this.path, this.Settings.encoding ?? this.encoding);
1644
+ const decoded = await fetchTextWithEncoding(this.path, this.Settings.encoding ?? this.encoding, false);
1540
1645
  const text = decoded.text;
1541
1646
  this.encoding = decoded.encoding;
1542
1647
  this.Settings.encoding = decoded.encoding;
@@ -1568,7 +1673,7 @@ class BufferModel {
1568
1673
  }
1569
1674
  const info = statSync(this.path);
1570
1675
  if (info.isDirectory()) throw new Error(`${this.path} is a directory`);
1571
- const decoded = await readTextFileWithEncoding(this.path, this.Settings.encoding ?? this.encoding);
1676
+ const decoded = await readTextFileWithEncoding(this.path, this.Settings.encoding ?? this.encoding, false);
1572
1677
  const text = decoded.text;
1573
1678
  this.encoding = decoded.encoding;
1574
1679
  this.Settings.encoding = decoded.encoding;
@@ -2285,6 +2390,7 @@ class App {
2285
2390
  this._messageRowY = null;
2286
2391
  this._messageRowClickZone = null;
2287
2392
  this._resizeRenderSeq = 0;
2393
+ this._mdcuiExitNotified = new WeakSet();
2288
2394
  }
2289
2395
 
2290
2396
  get tab() { return this.tabs[this.activeTabIdx]; }
@@ -2428,6 +2534,8 @@ class App {
2428
2534
  }
2429
2535
 
2430
2536
  async stop(code = 0) {
2537
+ for (const buf of this.buffers)
2538
+ await this.notifyMdcuiExit(buf, "exit");
2431
2539
  this.running = false;
2432
2540
  if (this._backupTimer) { clearInterval(this._backupTimer); this._backupTimer = null; }
2433
2541
  await Promise.allSettled(this.buffers.map((buf) => buf._backupWritePromise).filter(Boolean));
@@ -3471,7 +3579,15 @@ class App {
3471
3579
  }
3472
3580
 
3473
3581
  if (event.type === "paste") {
3474
- if (isEditLockedBuffer(buf)) {
3582
+ if (
3583
+ (isEditLockedBuffer(buf) && !isMdcuiEncoding(buf?.encoding))
3584
+ || (isMdcuiEncoding(buf?.encoding) && (
3585
+ !canEditMdcuiAtCursor(buf)
3586
+ ||
3587
+ /[\r\n]/.test(event.text)
3588
+ || !canEditMdcuiSelection(buf, this.pane?.selection)
3589
+ ))
3590
+ ) {
3475
3591
  this.message = "Buffer is read-only";
3476
3592
  this.render();
3477
3593
  return;
@@ -3849,13 +3965,17 @@ class App {
3849
3965
  break;
3850
3966
  default:
3851
3967
  if (text >= " " || text.includes("\n")) {
3852
- if (isMdcuiEncoding(buf?.encoding) && text === " ") {
3968
+ if (isMdcuiEncoding(buf?.encoding) && text === " " && !canEditMdcuiAtCursor(buf)) {
3853
3969
  await this.handleMdcuiCellCallback(buf, buf.cursor.y, buf.cursor.x, "space");
3854
3970
  break;
3855
3971
  }
3856
3972
  if (!this._undoInsertChain || this.pane?.selection) {
3857
3973
  buf.pushUndo();
3858
3974
  }
3975
+ if (isMdcuiEncoding(buf?.encoding) && !canEditMdcuiSelection(buf, this.pane?.selection)) {
3976
+ this.message = "Protected mdcui prefix";
3977
+ break;
3978
+ }
3859
3979
  this._undoInsertChain = true;
3860
3980
  if (this.pane?.selection) deleteSelection(buf, this.pane);
3861
3981
  await this.insertTextWithHooks(text);
@@ -3868,6 +3988,12 @@ class App {
3868
3988
  async handleMdcuiCellCallback(buf, y = buf?.cursor?.y ?? 0, x = buf?.cursor?.x ?? 0, trigger = "unknown") {
3869
3989
  const payload = mdcuiCellPayload(buf, y, x, trigger);
3870
3990
  if (!payload) return false;
3991
+ const resizeResult = resizeMdcuiTextBlock(buf, y, x);
3992
+ if (resizeResult) {
3993
+ if (resizeResult === "added") this.message = "Added text row";
3994
+ else if (resizeResult === "removed") this.message = "Removed empty text row";
3995
+ return true;
3996
+ }
3871
3997
  const callback = this.context?.mdcuiCallback ?? globalThis.mdcuiCallback;
3872
3998
  if (typeof callback === "function") {
3873
3999
  try {
@@ -4624,7 +4750,7 @@ class App {
4624
4750
  async openInPane(path) {
4625
4751
  try {
4626
4752
  const previous = this.pane.buffer;
4627
- const buffer = await loadBufferForPath(path, this.context);
4753
+ const buffer = await loadBufferForPath(path, this.context, {}, { interactive: true });
4628
4754
  this.pane.buffer = buffer;
4629
4755
  this.pane.selection = null;
4630
4756
  if (previous !== buffer) this._closeBufferIfUnused(previous);
@@ -4637,7 +4763,7 @@ class App {
4637
4763
 
4638
4764
  async openFile(path) {
4639
4765
  try {
4640
- const buffer = await loadBufferForPath(path, this.context);
4766
+ const buffer = await loadBufferForPath(path, this.context, {}, { interactive: true });
4641
4767
  if (isEmptyUntitledBuffer(this.buffer)) {
4642
4768
  const previous = this.pane.buffer;
4643
4769
  this.pane.buffer = buffer;
@@ -4655,6 +4781,34 @@ class App {
4655
4781
  }
4656
4782
  }
4657
4783
 
4784
+ reconcileReopenedBuffer(buffer) {
4785
+ if (!buffer || isHttpUrl(buffer.path)) return buffer;
4786
+ const map = this.context?._openBuffers;
4787
+ if (!map) return buffer;
4788
+ const absPath = resolve(buffer.AbsPath || buffer.path);
4789
+
4790
+ if (isMdcuiEncoding(buffer.encoding)) {
4791
+ if (map.get(absPath) === buffer) map.delete(absPath);
4792
+ buffer._openBufferMap = null;
4793
+ return buffer;
4794
+ }
4795
+
4796
+ const existing = map.get(absPath);
4797
+ if (existing && existing !== buffer && !isMdcuiEncoding(existing.encoding)) {
4798
+ for (const tab of this.tabs) {
4799
+ for (const pane of tab.panes()) {
4800
+ if (pane.buffer === buffer) pane.buffer = existing;
4801
+ if (pane.prevBuffer === buffer) pane.prevBuffer = existing;
4802
+ }
4803
+ }
4804
+ return existing;
4805
+ }
4806
+
4807
+ buffer._openBufferMap = map;
4808
+ map.set(absPath, buffer);
4809
+ return buffer;
4810
+ }
4811
+
4658
4812
  async save({ force = false } = {}) {
4659
4813
  if (isEditLockedBuffer(this.buffer)) { this.message = "Can't save under readonly mode"; return; }
4660
4814
  if (!force && this.buffer?.readonly) { this.message = "Can't save under readonly mode"; return; }
@@ -4706,6 +4860,11 @@ class App {
4706
4860
 
4707
4861
  async quit() {
4708
4862
  const buffer = this.buffer;
4863
+ if (isMdcuiEncoding(buffer?.encoding)) {
4864
+ await this.notifyMdcuiExit(buffer, "quit");
4865
+ await this._doCloseCurrentPane();
4866
+ return;
4867
+ }
4709
4868
  if (buffer.modified) {
4710
4869
  if (!this.prompt) {
4711
4870
  this.openYNPrompt(
@@ -4726,6 +4885,7 @@ class App {
4726
4885
  // Close only the current pane; if it's the last pane in the tab, close the tab.
4727
4886
  async _doCloseCurrentPane() {
4728
4887
  if (this.tab.panes().length > 1) {
4888
+ await this.notifyMdcuiExit(this.buffer, "close-pane");
4729
4889
  this.closePane(this.pane);
4730
4890
  this.render();
4731
4891
  } else {
@@ -4743,6 +4903,32 @@ class App {
4743
4903
  if (!this.prompt && !this.buffer.modified) await this.closeCurrentTab({ force: true });
4744
4904
  }
4745
4905
 
4906
+ async notifyMdcuiExit(buffer, reason = "exit") {
4907
+ if (
4908
+ !buffer
4909
+ || !isMdcuiEncoding(buffer.encoding)
4910
+ || this._mdcuiExitNotified.has(buffer)
4911
+ ) return;
4912
+ this._mdcuiExitNotified.add(buffer);
4913
+
4914
+ const frontPath = buffer.path && !isHttpUrl(buffer.path)
4915
+ ? `${buffer.path}.front.js`
4916
+ : "";
4917
+ if (!frontPath || !existsSync(frontPath)) return;
4918
+
4919
+ try {
4920
+ const frontMod = await import(localModuleUrl(frontPath));
4921
+ if (typeof frontMod.onMdcuiExit !== "function") return;
4922
+ await frontMod.onMdcuiExit({
4923
+ reason,
4924
+ path: buffer.path,
4925
+ $: globalThis.$,
4926
+ });
4927
+ } catch (error) {
4928
+ console.error(`[mdcui] onMdcuiExit: ${error?.message || error}`);
4929
+ }
4930
+ }
4931
+
4746
4932
  async toggleHelp() {
4747
4933
  const cur = this.pane;
4748
4934
  if (cur?.isHelp) {
@@ -4841,11 +5027,14 @@ class App {
4841
5027
  async closeCurrentTab({ force = false } = {}) {
4842
5028
  if (!force && this.buffer?.modified) return await this.quit();
4843
5029
  if (this.tabs.length <= 1) {
5030
+ await this.notifyMdcuiExit(this.buffer, "close-tab");
4844
5031
  await this.stop(0);
4845
5032
  return;
4846
5033
  }
4847
5034
  const closingBuffers = [...new Set(this.tab.panes().flatMap((pane) => [pane.buffer, pane.prevBuffer]).filter(Boolean))];
4848
5035
  const closing = this.buffer;
5036
+ for (const buffer of closingBuffers)
5037
+ await this.notifyMdcuiExit(buffer, "close-tab");
4849
5038
  this.tabs.splice(this.activeTabIdx, 1);
4850
5039
  this.activeTabIdx = Math.min(this.activeTabIdx, this.tabs.length - 1);
4851
5040
  this.message = "";
@@ -5110,9 +5299,10 @@ class App {
5110
5299
  }
5111
5300
  case "reopen": {
5112
5301
  if (!buf?.path) { this.message = "No file to reopen"; break; }
5302
+ let requestedEncoding = null;
5113
5303
  if (cmdArgs[0]) {
5114
5304
  try {
5115
- buf.SetOption("encoding", cmdArgs[0]);
5305
+ requestedEncoding = normalizeEncodingLabel(cmdArgs[0]);
5116
5306
  } catch (error) {
5117
5307
  this.message = String(error.message || error);
5118
5308
  break;
@@ -5120,9 +5310,24 @@ class App {
5120
5310
  }
5121
5311
  const doReopen = async () => {
5122
5312
  try {
5313
+ if (isMdcuiEncoding(requestedEncoding)) {
5314
+ const detachedContext = { ...this.context, inputEncoding: "mdcui", encodingExplicit: true };
5315
+ const reopened = await loadBufferForPath(buf.path, detachedContext);
5316
+ const previous = this.pane.buffer;
5317
+ this.pane.buffer = reopened;
5318
+ this.pane.selection = null;
5319
+ if (previous !== reopened) this._closeBufferIfUnused(previous);
5320
+ await this.context.plugins?.run("onBufferOpen", reopened);
5321
+ await this.context.jsPlugins?.run("onBufferOpen", reopened);
5322
+ this.message = `Reopened ${reopened.name} as ${reopened.encoding}`;
5323
+ this.render();
5324
+ return;
5325
+ }
5326
+ if (requestedEncoding) buf.SetOption("encoding", requestedEncoding);
5123
5327
  await buf.reopen(this.context);
5124
- if (this.pane?.buffer === buf) this.pane.selection = null;
5125
- this.message = `Reopened ${buf.name} as ${buf.encoding}`;
5328
+ const reopened = this.reconcileReopenedBuffer(buf);
5329
+ if (this.pane?.buffer === reopened) this.pane.selection = null;
5330
+ this.message = `Reopened ${reopened.name} as ${reopened.encoding}`;
5126
5331
  } catch (error) {
5127
5332
  this.message = String(error.message || error);
5128
5333
  }
@@ -5228,7 +5433,7 @@ class App {
5228
5433
  case "vsplit": {
5229
5434
  let newBuf;
5230
5435
  if (cmdArgs.length > 0) {
5231
- try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context); }
5436
+ try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context, {}, { interactive: true }); }
5232
5437
  catch (err) { this.message = err.message; break; }
5233
5438
  } else {
5234
5439
  newBuf = new BufferModel({ command: {} });
@@ -5241,7 +5446,7 @@ class App {
5241
5446
  case "hsplit": {
5242
5447
  let newBuf;
5243
5448
  if (cmdArgs.length > 0) {
5244
- try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context); }
5449
+ try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context, {}, { interactive: true }); }
5245
5450
  catch (err) { this.message = err.message; break; }
5246
5451
  } else {
5247
5452
  newBuf = new BufferModel({ command: {} });
@@ -5613,6 +5818,14 @@ class App {
5613
5818
  this._freshClip = false;
5614
5819
  const pasted = this.clipboard.read();
5615
5820
  if (pasted) {
5821
+ if (isMdcuiEncoding(buf?.encoding) && (
5822
+ /[\r\n]/.test(pasted)
5823
+ || !canEditMdcuiAtCursor(buf)
5824
+ || !canEditMdcuiSelection(buf, this.pane?.selection)
5825
+ )) {
5826
+ this.message = "Protected mdcui content";
5827
+ break;
5828
+ }
5616
5829
  buf.pushUndo();
5617
5830
  if (this.pane?.selection) deleteSelection(buf, this.pane);
5618
5831
  buf.insert(pasted);
@@ -6528,11 +6741,25 @@ function commandHasStartupJump(command = {}) {
6528
6741
  return commandHasStartCursor(command) || Boolean(command.searchRegex);
6529
6742
  }
6530
6743
 
6531
- async function loadBufferForPath(pathOrUrl, context, command = {}) {
6744
+ async function loadBufferForPath(pathOrUrl, context, command = {}, { interactive = false } = {}) {
6745
+ const loadContext = interactive
6746
+ ? { ...context, inputEncoding: defaultAllSettings().encoding, encodingExplicit: false }
6747
+ : context;
6748
+ if (isHttpUrl(pathOrUrl) && loadContext.allowUrl) {
6749
+ const url = new URL(pathOrUrl);
6750
+ let name;
6751
+ try { name = decodeURIComponent(basename(url.pathname)); }
6752
+ catch { name = basename(url.pathname); }
6753
+ if (!name) name = "remote.md";
6754
+ if (!name.toLowerCase().endsWith(".md")) name += ".md";
6755
+ const localPath = resolve(name);
6756
+ await Bun.write(localPath, await fetchHttpBytes(pathOrUrl));
6757
+ return await loadBufferForPath(localPath, loadContext, command, { interactive });
6758
+ }
6532
6759
  let buffer;
6533
6760
  if (isHttpUrl(pathOrUrl)) {
6534
- let encoding = context.config?.globalSettings?.encoding ?? DEFAULT_SETTINGS.encoding;
6535
- const decoded = await fetchTextWithEncoding(pathOrUrl, encoding);
6761
+ let encoding = loadContext.inputEncoding ?? loadContext.config?.globalSettings?.encoding ?? DEFAULT_SETTINGS.encoding;
6762
+ const decoded = await fetchTextWithEncoding(pathOrUrl, encoding, !loadContext.encodingExplicit);
6536
6763
  const text = decoded.text;
6537
6764
  encoding = decoded.encoding;
6538
6765
  const urlPath = pathOrUrl.replace(/[?#].*$/, "");
@@ -6548,13 +6775,18 @@ async function loadBufferForPath(pathOrUrl, context, command = {}) {
6548
6775
  mdcuiRenderWidth: decoded.mdcuiRenderWidth ?? 0,
6549
6776
  });
6550
6777
  buffer._configDir = context?.config?.configDir ?? null;
6551
- attachSyntax(buffer, context, urlPath, text);
6778
+ attachSyntax(buffer, loadContext, urlPath, text);
6552
6779
  } else {
6553
6780
  if (!context._openBuffers) context._openBuffers = new Map();
6554
6781
  const absPath = resolve(pathOrUrl);
6555
6782
  const existing = context._openBuffers.get(absPath);
6556
- if (existing) return existing;
6557
- buffer = await BufferModel.fromFile(absPath, command, context);
6783
+ const requestedEncoding = encodingForPath(
6784
+ absPath,
6785
+ loadContext.inputEncoding ?? loadContext.config?.globalSettings?.encoding ?? DEFAULT_SETTINGS.encoding,
6786
+ !loadContext.encodingExplicit,
6787
+ );
6788
+ if (existing && !isMdcuiEncoding(existing.encoding) && !isMdcuiEncoding(requestedEncoding)) return existing;
6789
+ buffer = await BufferModel.fromFile(absPath, command, loadContext);
6558
6790
  // Check for crash-recovery backup before returning the buffer.
6559
6791
  const promptFn = context._termPrompt;
6560
6792
  if (promptFn && buffer._configDir) {
@@ -6565,8 +6797,12 @@ async function loadBufferForPath(pathOrUrl, context, command = {}) {
6565
6797
  attachSyntax(buffer, context, absPath, buffer.lines.join("\n"));
6566
6798
  }
6567
6799
  }
6568
- buffer._openBufferMap = context._openBuffers;
6569
- context._openBuffers.set(absPath, buffer);
6800
+ // mdcui is a derived, read-only view. Keep every view independent and out
6801
+ // of the same-path cache used by normal editable buffers.
6802
+ if (!isMdcuiEncoding(buffer.encoding)) {
6803
+ buffer._openBufferMap = context._openBuffers;
6804
+ context._openBuffers.set(absPath, buffer);
6805
+ }
6570
6806
  }
6571
6807
  if (DEFAULT_SETTINGS.savecursor && !commandHasStartupJump(command) && context?.cursorStates?.[pathOrUrl]) {
6572
6808
  const saved = context.cursorStates[pathOrUrl];
@@ -7272,8 +7508,11 @@ async function printChangelogDocs() {
7272
7508
  }
7273
7509
 
7274
7510
  async function printTestappSource() {
7275
- const testapp = readInternalAssetText("testapp.md") ?? await Bun.file(join(REPO_ROOT, "testapp.md")).text();
7276
- process.stdout.write(testapp);
7511
+ process.stdout.write(await bundledTestappSource());
7512
+ }
7513
+
7514
+ async function bundledTestappSource() {
7515
+ return readInternalAssetText("testapp.md") ?? await Bun.file(join(REPO_ROOT, "testapp.md")).text();
7277
7516
  }
7278
7517
 
7279
7518
  async function main() {
@@ -7331,6 +7570,11 @@ async function main() {
7331
7570
  await printTestappSource();
7332
7571
  return;
7333
7572
  }
7573
+ if (flags.demo) {
7574
+ const demoPath = resolve("testapp.md");
7575
+ await Bun.write(demoPath, await bundledTestappSource());
7576
+ rawFiles.splice(0, rawFiles.length, demoPath);
7577
+ }
7334
7578
  if (flags.options) {
7335
7579
  for (const [key, value] of Object.entries(defaultAllSettings()).sort(([a], [b]) => a.localeCompare(b))) {
7336
7580
  console.log(`-${key} value`);
@@ -7341,6 +7585,7 @@ async function main() {
7341
7585
  addCheckpoint("Config Initialization");
7342
7586
  const config = await new Config({ configDir: flags.configDir }).init();
7343
7587
  config.applyCliSettings(flags.settings);
7588
+ const encodingExplicit = flags.settings.has("encoding") || Object.hasOwn(config.parsedSettings, "encoding");
7344
7589
  syncEditorSettings(config);
7345
7590
 
7346
7591
  addCheckpoint("Runtime Registry Init");
@@ -7352,7 +7597,7 @@ async function main() {
7352
7597
  const syntaxDefinitions = await loadSyntaxDefinitions(runtime);
7353
7598
 
7354
7599
  if (flags.cat) {
7355
- await catFiles(rawFiles, colorscheme, syntaxDefinitions, config.getGlobalOption("encoding"));
7600
+ await catFiles(rawFiles, colorscheme, syntaxDefinitions, config.getGlobalOption("encoding"), !encodingExplicit);
7356
7601
  return;
7357
7602
  }
7358
7603
 
@@ -7410,7 +7655,7 @@ async function main() {
7410
7655
 
7411
7656
  const { files, command } = parseInput(rawFiles);
7412
7657
  const jsPlugins = new JsPluginManager();
7413
- const context = { colorscheme, syntaxDefinitions, config, runtime, jsPlugins };
7658
+ const context = { colorscheme, syntaxDefinitions, config, runtime, jsPlugins, encodingExplicit, allowUrl: flags.allowUrl };
7414
7659
  jsPlugins.setContext(context);
7415
7660
  buildMicroGlobal(jsPlugins); // sets globalThis.micro
7416
7661
 
@@ -7809,11 +8054,16 @@ function outdentLine(buf, _ctx) {
7809
8054
  }
7810
8055
 
7811
8056
  function deleteSelection(buf, pane) {
7812
- if (isEditLockedBuffer(buf)) return "";
7813
8057
  const selection = pane?.selection;
7814
8058
  if (!selection || !buf) return "";
7815
8059
  const text = getSelectionText(buf, selection);
7816
8060
  const { first, last } = selectionBounds(selection);
8061
+ if (isMdcuiEncoding(buf?.encoding)) {
8062
+ const prefixLength = mdcuiEditablePrefixLength(buf, first.y);
8063
+ if (first.y !== last.y || prefixLength === 0 || first.x < prefixLength) return "";
8064
+ } else if (isEditLockedBuffer(buf)) {
8065
+ return "";
8066
+ }
7817
8067
  if (first.y === last.y) {
7818
8068
  const line = buf.lines[first.y] ?? "";
7819
8069
  buf.lines[first.y] = line.slice(0, first.x) + line.slice(last.x);
@@ -7922,30 +8172,34 @@ function syncEditorSettings(config) {
7922
8172
  }
7923
8173
  }
7924
8174
 
7925
- async function catFiles(files, colorscheme, syntaxDefinitions, encoding = DEFAULT_SETTINGS.encoding) {
8175
+ async function catFiles(files, colorscheme, syntaxDefinitions, encoding = DEFAULT_SETTINGS.encoding, inferMdcui = true) {
7926
8176
  const targets = files.length > 0 ? files.map((f) => ({ path: f, stdin: false })) : [{ path: null, stdin: true }];
7927
8177
  for (const { path: filePath, stdin } of targets) {
7928
8178
  let content;
7929
8179
  let ansiContent = null;
7930
8180
  let effectivePath = filePath;
8181
+ let effectiveEncoding = normalizeEncodingLabel(encoding);
7931
8182
  if (stdin) {
7932
8183
  const chunks = [];
7933
8184
  for await (const chunk of process.stdin) chunks.push(chunk);
7934
8185
  const decoded = await decodeAndRenderTextBytes(Buffer.concat(chunks), encoding);
7935
8186
  content = decoded.text;
7936
8187
  ansiContent = decoded.ansiText ?? null;
8188
+ effectiveEncoding = decoded.encoding;
7937
8189
  } else if (isHttpUrl(filePath)) {
7938
- const decoded = await fetchTextWithEncoding(filePath, encoding);
8190
+ const decoded = await fetchTextWithEncoding(filePath, encoding, inferMdcui);
7939
8191
  content = decoded.text;
7940
8192
  ansiContent = decoded.ansiText ?? null;
8193
+ effectiveEncoding = decoded.encoding;
7941
8194
  // Use the URL pathname for syntax/md detection (strip query/hash)
7942
8195
  try { effectivePath = new URL(filePath).pathname; } catch { effectivePath = filePath; }
7943
8196
  } else {
7944
- const decoded = await readTextFileWithEncoding(filePath, encoding);
8197
+ const decoded = await readTextFileWithEncoding(filePath, encoding, inferMdcui);
7945
8198
  content = decoded.text;
7946
8199
  ansiContent = decoded.ansiText ?? null;
8200
+ effectiveEncoding = decoded.encoding;
7947
8201
  }
7948
- if (isMdcuiEncoding(encoding)) {
8202
+ if (isMdcuiEncoding(effectiveEncoding)) {
7949
8203
  process.stdout.write(ansiContent ?? content);
7950
8204
  if (!(ansiContent ?? content).endsWith("\n")) process.stdout.write("\n");
7951
8205
  continue;