jsmdcui 0.6.3 → 0.8.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +216 -33
  3. package/clean.sh +9 -0
  4. package/demo.resized.jpg +0 -0
  5. package/{image-processor.md → demos/image-processor.md} +1 -0
  6. package/{image-processor.zh-TW.md → demos/image-processor.zh-TW.md} +1 -0
  7. package/demos/maze.md +144 -0
  8. package/{select.md → demos/select.md} +2 -0
  9. package/demos/todo-zh.md +190 -0
  10. package/demos/todo.md +191 -0
  11. package/edit +9 -0
  12. package/package.json +1 -1
  13. package/runmd.mjs +77 -16
  14. package/runtime/help/help.md +216 -33
  15. package/runtime/jsplugins/cdp/cdp.js +5 -2
  16. package/runtime/jsplugins/chapter/chapter.js +4 -2
  17. package/runtime/jsplugins/example/example.js +10 -7
  18. package/runtime/syntax/markdown.yaml +1 -0
  19. package/single-exe/packAssets.sh +1 -1
  20. package/src/cui/fence-events.mjs +106 -0
  21. package/src/cui/id-collision.mjs +10 -28
  22. package/src/cui/kitty-debug.mjs +25 -0
  23. package/src/cui/kitty-images.mjs +200 -0
  24. package/src/cui/rpc.mjs +169 -10
  25. package/src/cui/server.mjs +14 -3
  26. package/src/cui/task-checkbox.mjs +44 -0
  27. package/src/index.js +615 -101
  28. package/src/platform/terminal.js +5 -0
  29. package/src/plugins/js-bridge.js +354 -21
  30. package/src/screen/screen.js +108 -1
  31. package/tests/cat-markdown.test.js +6 -5
  32. package/tests/demo.test.js +99 -9
  33. package/tests/fence-events.test.js +405 -0
  34. package/tests/heading-list-selector.test.js +346 -0
  35. package/tests/id-collision.test.js +14 -2
  36. package/tests/js-plugin-prompts.test.js +46 -0
  37. package/tests/key-event.md +10 -0
  38. package/tests/kitty-demo.md +184 -0
  39. package/tests/kitty-images.test.js +169 -0
  40. package/tests/platform-terminal.test.js +8 -0
  41. package/tests/task-checkbox.test.js +33 -0
  42. package/tests/textarea.md +8 -0
  43. package/tests/wui-responsive-images.test.js +35 -0
  44. package/tests/wui.test.js +16 -1
  45. package/tui +4 -2
  46. package/wui +6 -2
package/src/index.js CHANGED
@@ -93,20 +93,23 @@ function printProfileReport() {
93
93
  }
94
94
 
95
95
  import child_process from "node:child_process"
96
- import { accessSync, constants, existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
96
+ import { accessSync, closeSync, constants, existsSync, openSync, readSync, readdirSync, statSync, unlinkSync } from "node:fs";
97
97
  import { mkdir } from "node:fs/promises";
98
98
  import { dirname, basename, join, resolve, sep } from "node:path";
99
99
  import { fileURLToPath, pathToFileURL } from "node:url";
100
100
  import process from "node:process";
101
- import { toggleTaskCheckboxBeforeColumn } from "./cui/task-checkbox.mjs";
101
+ import { toggleTaskCheckboxBeforeColumn, updateAnsiTaskCheckbox } from "./cui/task-checkbox.mjs";
102
+ import { fenceEventMap, inlineFenceEventCode } from "./cui/fence-events.mjs";
102
103
  import { checkMarkdownIdCollisions, formatMarkdownIdCheckAnsi } from "./cui/id-collision.mjs";
104
+ import { fitKittyImageToWidth, prepareKittyImages } from "./cui/kitty-images.mjs";
105
+ import { logKittyPlacement } from "./cui/kitty-debug.mjs";
103
106
  import { Config } from "./config/config.js";
104
107
  import { defaultAllSettings, OPTION_CHOICES, LOCAL_SETTINGS } from "./config/defaults.js";
105
108
  import { cleanConfig } from "./config/clean.js";
106
109
  import { RuntimeRegistry, RTColorscheme, RTHelp } from "./runtime/registry.js";
107
- import { assetPath, hasInternalAssets, listInternalAssetDirs, readInternalAssetText } from "./runtime/assets.js";
110
+ import { assetPath, hasInternalAssets, listInternalAssetDirs, listInternalAssetPaths, readInternalAssetText } from "./runtime/assets.js";
108
111
  //import { PluginManager } from "./plugins/manager.js";
109
- import { JsPluginManager, buildMicroGlobal, runAction, listActions } from "./plugins/js-bridge.js";
112
+ import { JsPluginManager, buildMicroGlobal, buildTuiBlockIndex, findTuiBlockInIndex, insertTuiTextareaNewline, mergeTuiTextareaBackward, mergeTuiTextareaForward, runAction, listActions } from "./plugins/js-bridge.js";
110
113
  import { Colorscheme } from "./config/colorscheme.js";
111
114
  import { detectSyntax, loadSyntaxDefinitions } from "./highlight/parser.js";
112
115
  import { Highlighter } from "./highlight/highlighter.js";
@@ -115,10 +118,15 @@ import { Screen } from "./screen/screen.js";
115
118
  import { VT100 } from "./screen/vt100.js";
116
119
  import { ClipboardManager, probeOSC52, osc52Clipboard } from "./platform/clipboard.js";
117
120
  import { platformId, run as runCommand, runSync, fetchHttpBytes, detectHttpBackend } from "./platform/commands.js";
121
+ import { controllingTerminalInputPath } from "./platform/terminal.js";
118
122
  import { shellSplit } from "./shell/shell.js";
119
123
  import { styleToAnsi } from "./display/ansi-style.js";
120
124
  import { encodeBinaryToBuffer, decodeBinaryBytes } from "./buffer/fixed3-codec.js";
121
125
  import { writeBackup, removeBackup, applyBackup } from "./buffer/backup.js";
126
+
127
+ let kittyImageMode = "off";
128
+ let allowRemoteKittyImages = false;
129
+ const remoteMarkdownSources = new Map();
122
130
  import { isHex3Encoding, isMdcuiEncoding } from "./runtime/encodings.js";
123
131
  import { createInterface } from "node:readline/promises";
124
132
 
@@ -313,7 +321,12 @@ async function readTextFileWithEncoding(path, encoding = "utf-8", inferMdcui = t
313
321
 
314
322
  async function fetchTextWithEncoding(url, encoding = "utf-8", inferMdcui = true) {
315
323
  const bytes = await fetchHttpBytes(url);
316
- return decodeAndRenderTextBytes(new Uint8Array(bytes), encodingForPath(url, encoding, inferMdcui));
324
+ return decodeAndRenderTextBytes(
325
+ new Uint8Array(bytes),
326
+ encodingForPath(url, encoding, inferMdcui),
327
+ process.stdout.columns || 80,
328
+ url,
329
+ );
317
330
  }
318
331
 
319
332
  function normalizeEncodingLabel(encoding = "utf-8") {
@@ -337,20 +350,31 @@ async function decodeAndRenderTextBytes(bytes, encoding = "utf-8", width = proce
337
350
  const decoded = decodeTextBytesWithEncoding(bytes, encoding);
338
351
  if (!isMdcuiEncoding(decoded.encoding)) return decoded;
339
352
  const renderWidth = Math.max(1, Math.trunc(Number(width) || 80));
340
- const { rendered, tuiSourceText } = await renderMdcui(decoded.text, renderWidth, mdpath);
353
+ const { rendered, tuiSourceText, images } = await renderMdcui(decoded.text, renderWidth, mdpath, mdpath);
341
354
  const styled = parseAnsiStyledText(rendered);
342
- return { text: styled.text, encoding: "mdcui", sourceText: decoded.text, tuiSourceText, ansiText: rendered, ansiStyleLines: styled.styleLines, mdcuiRenderWidth: renderWidth };
355
+ return { text: styled.text, encoding: "mdcui", sourceText: decoded.text, tuiSourceText, ansiText: rendered, ansiStyleLines: styled.styleLines, mdcuiImages: images, mdcuiRenderWidth: renderWidth };
343
356
  }
344
357
 
345
- async function renderMdcui(markdown, width = process.stdout.columns || 80, mdpath = null) {
358
+ async function renderMdcui(markdown, width = process.stdout.columns || 80, mdpath = null, imageBasePath = mdpath) {
346
359
  const runmd = await import("../runmd.mjs");
347
360
  let md = String(markdown);
348
361
  if (mdpath && !isHttpUrl(mdpath)) {
349
362
  md = await runmd.extractJs(md, mdpath);
350
363
  await runmd.createWui(md, mdpath);
351
364
  }
365
+ const tui = runmd.createTui(md, Math.max(1, Math.trunc(Number(width) || 80)));
366
+ const resolvedImageBasePath = mdpath && !isHttpUrl(mdpath)
367
+ ? remoteMarkdownSources.get(resolve(mdpath)) ?? imageBasePath
368
+ : imageBasePath;
369
+ const prepared = kittyImageMode === "off"
370
+ ? { rendered: tui, images: [] }
371
+ : await prepareKittyImages(tui, resolvedImageBasePath, width, {
372
+ allowUrl: allowRemoteKittyImages,
373
+ kittyMode: kittyImageMode,
374
+ });
352
375
  return {
353
- rendered: runmd.createTui(md, Math.max(1, Math.trunc(Number(width) || 80))),
376
+ rendered: prepared.rendered,
377
+ images: prepared.images,
354
378
  tuiSourceText: md,
355
379
  };
356
380
  }
@@ -590,6 +614,12 @@ function resizeMdcuiTextBlock(buf, y, x) {
590
614
  ansiLines.splice(row, deleteCount, ...replacement);
591
615
  buf._mdcuiAnsiText = ansiLines.join("\n");
592
616
  }
617
+ if (Array.isArray(buf._mdcuiImages)) {
618
+ const delta = replacement.length - deleteCount;
619
+ buf._mdcuiImages = buf._mdcuiImages
620
+ .filter((image) => image.line < row || image.line >= row + deleteCount)
621
+ .map((image) => image.line >= row + deleteCount ? { ...image, line: image.line + delta } : image);
622
+ }
593
623
 
594
624
  if (insertAt >= 0) {
595
625
  if (buf.cursor.y >= insertAt) buf.cursor.y++;
@@ -641,6 +671,21 @@ function canEditMdcuiSelection(buf, selection) {
641
671
  return first.y === last.y && prefixLength > 0 && first.x >= prefixLength;
642
672
  }
643
673
 
674
+ function mdcuiTextareaAtCursor(buf) {
675
+ if (!isMdcuiEncoding(buf?.encoding)) return null;
676
+ let cache = buf._mdcuiControlBlockIndex;
677
+ if (!cache || cache.lines !== buf.lines || cache.lineCount !== buf.lines.length) {
678
+ cache = {
679
+ lines: buf.lines,
680
+ lineCount: buf.lines.length,
681
+ blocks: buildTuiBlockIndex(buf.lines),
682
+ };
683
+ buf._mdcuiControlBlockIndex = cache;
684
+ }
685
+ const block = findTuiBlockInIndex(cache.blocks, buf.cursor.y);
686
+ return block?.header?.tag === "textarea" ? block : null;
687
+ }
688
+
644
689
  function isEditLockedBuffer(buf) {
645
690
  return isMdcuiEncoding(buf?.encoding ?? buf?.Settings?.encoding);
646
691
  }
@@ -943,6 +988,100 @@ function takeDisplay(text, maxWidth) {
943
988
  return out;
944
989
  }
945
990
 
991
+ function keyboardEventJson(event) {
992
+ const target = event?.target;
993
+ return {
994
+ type: String(event?.type ?? ""),
995
+ key: String(event?.key ?? ""),
996
+ code: String(event?.code ?? ""),
997
+ raw: String(event?.raw ?? ""),
998
+ ctrlKey: Boolean(event?.ctrlKey),
999
+ shiftKey: Boolean(event?.shiftKey),
1000
+ altKey: Boolean(event?.altKey),
1001
+ metaKey: Boolean(event?.metaKey),
1002
+ repeat: Boolean(event?.repeat),
1003
+ defaultPrevented: Boolean(event?.defaultPrevented),
1004
+ target: {
1005
+ id: String(target?.id ?? ""),
1006
+ tagName: String(target?.tagName ?? ""),
1007
+ className: String(target?.className ?? ""),
1008
+ value: String(target?.value ?? ""),
1009
+ },
1010
+ };
1011
+ }
1012
+
1013
+ function tuiKeyEventForFront(event, target, type) {
1014
+ const sequence = String(event?.key ?? "");
1015
+ const parts = sequence.split("-");
1016
+ const aliases = {
1017
+ enter: "Enter",
1018
+ escape: "Escape",
1019
+ tab: "Tab",
1020
+ backtab: "Tab",
1021
+ backspace: "Backspace",
1022
+ delete: "Delete",
1023
+ left: "ArrowLeft",
1024
+ right: "ArrowRight",
1025
+ up: "ArrowUp",
1026
+ down: "ArrowDown",
1027
+ home: "Home",
1028
+ end: "End",
1029
+ pageup: "PageUp",
1030
+ pagedown: "PageDown",
1031
+ space: " ",
1032
+ };
1033
+ let base = parts.at(-1) || sequence;
1034
+ if (sequence === "backtab") base = "tab";
1035
+ const raw = String(event?.raw ?? "");
1036
+ const key = aliases[base] ?? (sequence === raw && [...raw].length === 1 ? raw : base);
1037
+ let defaultPrevented = false;
1038
+ let propagationStopped = false;
1039
+ const result = {
1040
+ type,
1041
+ key,
1042
+ raw,
1043
+ ctrlKey: parts.includes("ctrl"),
1044
+ altKey: parts.includes("alt"),
1045
+ shiftKey: sequence === "backtab" || parts.includes("shift"),
1046
+ metaKey: parts.includes("meta"),
1047
+ repeat: false,
1048
+ target,
1049
+ currentTarget: target,
1050
+ get defaultPrevented() { return defaultPrevented; },
1051
+ get propagationStopped() { return propagationStopped; },
1052
+ preventDefault() { defaultPrevented = true; },
1053
+ stopPropagation() { propagationStopped = true; },
1054
+ };
1055
+ Object.defineProperty(result, "toJSON", {
1056
+ configurable: true,
1057
+ value() { return keyboardEventJson(this); },
1058
+ });
1059
+ return result;
1060
+ }
1061
+
1062
+ function indexedMdcuiFenceBlockAtLine(buffer, lineIndex) {
1063
+ const declarations = buffer?._mdcuiFenceEvents;
1064
+ if (!isMdcuiEncoding(buffer?.encoding) || declarations?.size === 0) return null;
1065
+ let cache = buffer._mdcuiFenceBlockIndex;
1066
+ // Render/reopen replaces the lines array; structural selector edits explicitly
1067
+ // clear this cache. Ordinary character edits leave block boundaries unchanged.
1068
+ if (
1069
+ !cache
1070
+ || cache.lines !== buffer.lines
1071
+ || cache.lineCount !== buffer.lines.length
1072
+ || cache.declarations !== declarations
1073
+ ) {
1074
+ cache = {
1075
+ lines: buffer.lines,
1076
+ lineCount: buffer.lines.length,
1077
+ declarations,
1078
+ blocks: buildTuiBlockIndex(buffer.lines, declarations),
1079
+ };
1080
+ buffer._mdcuiFenceBlockIndex = cache;
1081
+ }
1082
+ return findTuiBlockInIndex(cache.blocks, lineIndex);
1083
+ }
1084
+
946
1085
  function parseArgs(argv) {
947
1086
  const flags = {
948
1087
  version: false,
@@ -955,10 +1094,8 @@ function parseArgs(argv) {
955
1094
  exportReadme: false,
956
1095
  changelog: false,
957
1096
  testapp: false,
958
- demo: false,
959
- demoSelect: false,
960
- demoImgtool: false,
961
- demoImgtoolZh: false,
1097
+ demoList: false,
1098
+ demo: null,
962
1099
  allowUrl: false,
963
1100
  buildExe: false,
964
1101
  buildFor: "",
@@ -968,6 +1105,7 @@ function parseArgs(argv) {
968
1105
  plugin: "",
969
1106
  cdpPort: 0,
970
1107
  cdpAddress: "",
1108
+ kittyMode: "off",
971
1109
  settings: new Map(),
972
1110
  };
973
1111
  const files = [];
@@ -1000,11 +1138,25 @@ function parseArgs(argv) {
1000
1138
  else if (arg === "--export-readme") flags.exportReadme = true;
1001
1139
  else if (arg === "--changelog") flags.changelog = true;
1002
1140
  else if (arg === "--testapp.md") flags.testapp = true;
1003
- else if (arg === "--demo") flags.demo = true;
1004
- else if (arg === "--demo-select") flags.demoSelect = true;
1005
- else if (arg === "--demo-imgtool") flags.demoImgtool = true;
1006
- else if (arg === "--demo-imgtool-zh") flags.demoImgtoolZh = true;
1141
+ else if (arg === "--demo-list") flags.demoList = true;
1142
+ else if (arg === "--demo") {
1143
+ flags.demo = { option: arg, filename: "testapp.md", asset: "testapp.md" };
1144
+ }
1145
+ else if (arg === "--demo-imgtool") {
1146
+ flags.demo = { option: arg, filename: "image-processor.md", asset: "demos/image-processor.md" };
1147
+ }
1148
+ else if (arg === "--demo-imgtool-zh") {
1149
+ flags.demo = { option: arg, filename: "image-processor.zh-TW.md", asset: "demos/image-processor.zh-TW.md" };
1150
+ }
1151
+ else if (arg.startsWith("--demo-")) {
1152
+ const name = arg.slice("--demo-".length);
1153
+ flags.demo = /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)
1154
+ ? { option: arg, filename: `${name}.md`, asset: `demos/${name}.md` }
1155
+ : { option: arg, error: "demo filename must contain only letters, numbers, dots, underscores, and hyphens" };
1156
+ }
1007
1157
  else if (arg === "--allow-url") flags.allowUrl = true;
1158
+ else if (arg === "--kitty") flags.kittyMode = "extended";
1159
+ else if (arg === "--kitty-compat") flags.kittyMode = "compat";
1008
1160
  else if (arg === "--build-exe") flags.buildExe = true;
1009
1161
  else if (arg === "--build-for") flags.buildFor = argv[++i] ?? "";
1010
1162
  else if (arg === "-debug") flags.debug = true;
@@ -1056,6 +1208,10 @@ Modes:
1056
1208
  -encoding mdcui
1057
1209
  Render Markdown through runmd.mjs#createTui; .md files use this automatically
1058
1210
  Writes .front.js, .back.js, .html, -rpc.js, and -server.js beside the .md file
1211
+ --kitty
1212
+ Display Markdown images with Kitty graphics and the jsgotty MIME extension
1213
+ --kitty-compat
1214
+ Display Markdown images with Kitty graphics without the non-standard MIME U field
1059
1215
 
1060
1216
  Settings:
1061
1217
  -SETTING VALUE
@@ -1086,22 +1242,23 @@ Information:
1086
1242
  Demo:
1087
1243
  --testapp.md
1088
1244
  Write the bundled testapp.md to stdout & exit
1245
+ --demo-list
1246
+ List the bundled demos and their command-line options, then exit
1089
1247
  --demo
1090
1248
  Use the existing ./testapp.md without overwriting it, or write the bundled demo if missing
1091
1249
  Open it in the TUI and write 5 generated files beside it
1092
- --demo-select
1093
- Use the existing ./select.md without overwriting it, or write the bundled demo if missing
1250
+ --demo-<filename>
1251
+ Load demos/<filename>.md, preserving an existing ./<filename>.md or writing the bundled copy
1094
1252
  Open it in the TUI and write 5 generated files beside it
1253
+ For example: --demo-select, --demo-todo, or --demo-todo-zh
1095
1254
  --demo-imgtool
1096
- Use the existing ./image-processor.md without overwriting it, or write the bundled demo if missing
1097
- Open the Bun.Image processor in the TUI and write 5 generated files beside it
1255
+ Alias for --demo-image-processor
1098
1256
  --demo-imgtool-zh
1099
- Use the existing ./image-processor.zh-TW.md without overwriting it, or write the bundled demo if missing
1100
- Open the Traditional Chinese Bun.Image processor in the TUI and write 5 generated files beside it
1257
+ Alias for --demo-image-processor.zh-TW
1101
1258
 
1102
1259
  Remote Markdown:
1103
1260
  --allow-url
1104
- Download HTTP(S) Markdown to cwd, write 5 generated files, and allow its code to run
1261
+ Download HTTP(S) Markdown and, with Kitty mode, its HTTP(S) images; allow its code to run
1105
1262
 
1106
1263
  Experimental:
1107
1264
  --build-exe Build a Bun single-file executable and exit
@@ -1149,7 +1306,7 @@ class BufferModel {
1149
1306
  get searchPattern() { return this._searchPattern ?? ""; }
1150
1307
  set searchPattern(v) { this._searchPattern = v ?? ""; this.searchMatches?.clear(); }
1151
1308
 
1152
- constructor({ path = "", text = "", command = {}, type = "default", readonly = false, modTimeMs = null, encoding = DEFAULT_SETTINGS.encoding, ansiStyleLines = null, ansiText = null, sourceText = null, tuiSourceText = null, mdcuiRenderWidth = 0 } = {}) {
1309
+ constructor({ path = "", text = "", command = {}, type = "default", readonly = false, modTimeMs = null, encoding = DEFAULT_SETTINGS.encoding, ansiStyleLines = null, ansiText = null, sourceText = null, tuiSourceText = null, mdcuiImages = null, mdcuiRenderWidth = 0 } = {}) {
1153
1310
  this.path = path;
1154
1311
  this.type = type;
1155
1312
  this.name = path ? basename(path) : "No name";
@@ -1162,6 +1319,8 @@ class BufferModel {
1162
1319
  this._mdcuiAnsiText = isMdcuiEncoding(this.encoding) ? String(ansiText ?? "") : null;
1163
1320
  this._mdcuiSourceText = isMdcuiEncoding(this.encoding) ? String(sourceText ?? text) : null;
1164
1321
  this._mdcuiTuiSourceText = isMdcuiEncoding(this.encoding) ? String(tuiSourceText ?? sourceText ?? text) : null;
1322
+ this._mdcuiFenceEvents = isMdcuiEncoding(this.encoding) ? fenceEventMap(sourceText ?? tuiSourceText ?? text) : new Map();
1323
+ this._mdcuiImages = isMdcuiEncoding(this.encoding) ? (mdcuiImages ?? []) : [];
1165
1324
  this._mdcuiRenderWidth = Math.trunc(Number(mdcuiRenderWidth) || 0);
1166
1325
  this.cursor = { x: 0, y: 0 };
1167
1326
  this.scroll = { x: 0, y: 0, row: 0 };
@@ -1262,6 +1421,7 @@ class BufferModel {
1262
1421
  let sourceText = null;
1263
1422
  let tuiSourceText = null;
1264
1423
  let mdcuiRenderWidth = 0;
1424
+ let mdcuiImages = null;
1265
1425
  if (existsSync(path)) {
1266
1426
  const info = statSync(path);
1267
1427
  if (info.isDirectory()) throw new Error(`${path} is a directory`);
@@ -1275,9 +1435,10 @@ class BufferModel {
1275
1435
  sourceText = decoded.sourceText ?? null;
1276
1436
  tuiSourceText = decoded.tuiSourceText ?? null;
1277
1437
  mdcuiRenderWidth = decoded.mdcuiRenderWidth ?? 0;
1438
+ mdcuiImages = decoded.mdcuiImages ?? null;
1278
1439
  if (isMdcuiEncoding(encoding)) readonly = true;
1279
1440
  }
1280
- const buffer = new BufferModel({ path, text, command, readonly, modTimeMs, encoding, ansiStyleLines, ansiText, sourceText, tuiSourceText, mdcuiRenderWidth });
1441
+ const buffer = new BufferModel({ path, text, command, readonly, modTimeMs, encoding, ansiStyleLines, ansiText, sourceText, tuiSourceText, mdcuiImages, mdcuiRenderWidth });
1281
1442
  buffer._configDir = context?.config?.configDir ?? null;
1282
1443
  attachSyntax(buffer, context, path, text);
1283
1444
  return buffer;
@@ -1517,9 +1678,39 @@ class BufferModel {
1517
1678
  this.cursor.x = x;
1518
1679
  }
1519
1680
 
1681
+ _historyState() {
1682
+ return {
1683
+ lines: this.lines.slice(),
1684
+ cursor: { ...this.cursor },
1685
+ serial: this._undoSerial,
1686
+ ansiStyleLines: Array.isArray(this._ansiStyleLines)
1687
+ ? this._ansiStyleLines.map((line) => Array.isArray(line) ? line.slice() : line)
1688
+ : this._ansiStyleLines,
1689
+ mdcuiAnsiText: this._mdcuiAnsiText,
1690
+ mdcuiImages: Array.isArray(this._mdcuiImages)
1691
+ ? this._mdcuiImages.map((image) => ({ ...image }))
1692
+ : this._mdcuiImages,
1693
+ headingTaskListAnchors: this._mdcuiHeadingTaskListAnchors instanceof Map
1694
+ ? new Map([...this._mdcuiHeadingTaskListAnchors].map(([key, value]) => [key, { ...value }]))
1695
+ : this._mdcuiHeadingTaskListAnchors,
1696
+ };
1697
+ }
1698
+
1699
+ _restoreHistoryState(state) {
1700
+ this.lines = state.lines;
1701
+ this.cursor = { ...state.cursor };
1702
+ this._undoSerial = state.serial ?? 0;
1703
+ this._ansiStyleLines = state.ansiStyleLines;
1704
+ this._mdcuiAnsiText = state.mdcuiAnsiText;
1705
+ this._mdcuiImages = state.mdcuiImages;
1706
+ this._mdcuiHeadingTaskListAnchors = state.headingTaskListAnchors;
1707
+ this._mdcuiFenceBlockIndex = null;
1708
+ this._mdcuiControlBlockIndex = null;
1709
+ }
1710
+
1520
1711
  pushUndo(force = false) {
1521
1712
  if (!force && this.isEditLocked() && !canEditMdcuiAtCursor(this)) return;
1522
- this.undoStack.push({ lines: this.lines.slice(), cursor: { ...this.cursor }, serial: this._undoSerial });
1713
+ this.undoStack.push(this._historyState());
1523
1714
  this._undoSerial = (this._undoSerial ?? 0) + 1;
1524
1715
  if (this.undoStack.length > 500) this.undoStack.shift();
1525
1716
  this.redoStack = [];
@@ -1528,12 +1719,10 @@ class BufferModel {
1528
1719
  undo() {
1529
1720
  if (this.isEditLocked() && !isMdcuiEncoding(this.encoding)) return false;
1530
1721
  if (!this.undoStack.length) return false;
1531
- this.redoStack.push({ lines: this.lines.slice(), cursor: { ...this.cursor }, serial: this._undoSerial });
1722
+ this.redoStack.push(this._historyState());
1532
1723
  const s = this.undoStack.pop();
1533
- this.lines = s.lines;
1724
+ this._restoreHistoryState(s);
1534
1725
  this.invalidateHighlightFrom(0, { force: true });
1535
- this.cursor = { ...s.cursor };
1536
- this._undoSerial = s.serial ?? 0;
1537
1726
  this.modified = this._undoSerial !== this._savedSerial;
1538
1727
  return true;
1539
1728
  }
@@ -1541,12 +1730,10 @@ class BufferModel {
1541
1730
  redo() {
1542
1731
  if (this.isEditLocked() && !isMdcuiEncoding(this.encoding)) return false;
1543
1732
  if (!this.redoStack.length) return false;
1544
- this.undoStack.push({ lines: this.lines.slice(), cursor: { ...this.cursor }, serial: this._undoSerial });
1733
+ this.undoStack.push(this._historyState());
1545
1734
  const s = this.redoStack.pop();
1546
- this.lines = s.lines;
1735
+ this._restoreHistoryState(s);
1547
1736
  this.invalidateHighlightFrom(0, { force: true });
1548
- this.cursor = { ...s.cursor };
1549
- this._undoSerial = s.serial ?? 0;
1550
1737
  this.modified = this._undoSerial !== this._savedSerial;
1551
1738
  return true;
1552
1739
  }
@@ -1677,7 +1864,9 @@ class BufferModel {
1677
1864
  this._mdcuiAnsiText = isMdcuiEncoding(this.encoding) ? String(decoded.ansiText ?? "") : null;
1678
1865
  this._mdcuiSourceText = isMdcuiEncoding(this.encoding) ? String(decoded.sourceText ?? text) : null;
1679
1866
  this._mdcuiTuiSourceText = isMdcuiEncoding(this.encoding) ? String(decoded.tuiSourceText ?? decoded.sourceText ?? text) : null;
1867
+ this._mdcuiFenceEvents = isMdcuiEncoding(this.encoding) ? fenceEventMap(decoded.sourceText ?? decoded.tuiSourceText ?? text) : new Map();
1680
1868
  this._mdcuiRenderWidth = decoded.mdcuiRenderWidth ?? 0;
1869
+ this._mdcuiImages = decoded.mdcuiImages ?? [];
1681
1870
  const readonly = isMdcuiEncoding(this.encoding);
1682
1871
  this.fileformat = detectFileFormat(text, this.Settings.fileformat ?? DEFAULT_SETTINGS.fileformat);
1683
1872
  this.Settings.fileformat = this.fileformat;
@@ -1709,7 +1898,9 @@ class BufferModel {
1709
1898
  this._mdcuiAnsiText = isMdcuiEncoding(this.encoding) ? String(decoded.ansiText ?? "") : null;
1710
1899
  this._mdcuiSourceText = isMdcuiEncoding(this.encoding) ? String(decoded.sourceText ?? text) : null;
1711
1900
  this._mdcuiTuiSourceText = isMdcuiEncoding(this.encoding) ? String(decoded.tuiSourceText ?? decoded.sourceText ?? text) : null;
1901
+ this._mdcuiFenceEvents = isMdcuiEncoding(this.encoding) ? fenceEventMap(decoded.sourceText ?? decoded.tuiSourceText ?? text) : new Map();
1712
1902
  this._mdcuiRenderWidth = decoded.mdcuiRenderWidth ?? 0;
1903
+ this._mdcuiImages = decoded.mdcuiImages ?? [];
1713
1904
  this.fileformat = detectFileFormat(text, this.Settings.fileformat ?? DEFAULT_SETTINGS.fileformat);
1714
1905
  this.Settings.fileformat = this.fileformat;
1715
1906
  this.lines = normalizeBufferText(text).split("\n");
@@ -1733,12 +1924,13 @@ class BufferModel {
1733
1924
  if (!isMdcuiEncoding(this.encoding) || this._mdcuiTuiSourceText == null) return false;
1734
1925
  const renderWidth = Math.max(1, Math.trunc(Number(width) || 80));
1735
1926
  if (renderWidth === this._mdcuiRenderWidth) return false;
1736
- const { rendered } = await renderMdcui(this._mdcuiTuiSourceText, renderWidth);
1927
+ const { rendered, images } = await renderMdcui(this._mdcuiTuiSourceText, renderWidth, null, this.path);
1737
1928
  const styled = parseAnsiStyledText(rendered);
1738
1929
  this.lines = normalizeBufferText(styled.text).split("\n");
1739
1930
  if (this.lines.length === 0) this.lines = [""];
1740
1931
  this._ansiStyleLines = styled.styleLines;
1741
1932
  this._mdcuiAnsiText = rendered;
1933
+ this._mdcuiImages = images;
1742
1934
  this._mdcuiRenderWidth = renderWidth;
1743
1935
  this.fileformat = detectFileFormat(styled.text, this.Settings.fileformat ?? DEFAULT_SETTINGS.fileformat);
1744
1936
  this.Settings.fileformat = this.fileformat;
@@ -2404,7 +2596,10 @@ class App {
2404
2596
  this.clipboard = new ClipboardManager();
2405
2597
  this.context = context;
2406
2598
  this.shellRunning = false;
2407
- this.screen = new Screen({ mouse: DEFAULT_SETTINGS.mouse !== false });
2599
+ this.screen = new Screen({
2600
+ mouse: DEFAULT_SETTINGS.mouse !== false,
2601
+ kittyMode: context.kittyMode ?? "off",
2602
+ });
2408
2603
  this.tabRects = [];
2409
2604
  this._escBuf = null; // pending lone ESC bytes waiting for alt-key combo
2410
2605
  this._escTimer = null;
@@ -2449,16 +2644,14 @@ class App {
2449
2644
  }
2450
2645
 
2451
2646
  async start() {
2452
- this._started = true;
2647
+ this.installProtectedPrompts();
2453
2648
  // When stdin was a pipe (content already consumed in loadBuffers), open the
2454
2649
  // controlling terminal directly so the event loop has a live handle and
2455
- // keyboard input works. Unix: /dev/tty Windows: \\.\CON
2650
+ // keyboard input works. Unix: /dev/tty; Windows: CONIN$.
2456
2651
  if (!process.stdin.isTTY) {
2457
2652
  try {
2458
- const { openSync } = await import("node:fs");
2459
2653
  const { ReadStream } = await import("node:tty");
2460
- const ttyPath = process.platform === "win32" ? "\\\\.\\CON" : "/dev/tty";
2461
- const fd = openSync(ttyPath, "r+");
2654
+ const fd = openSync(controllingTerminalInputPath(), "r");
2462
2655
  this._ttyStream = new ReadStream(fd);
2463
2656
  } catch {
2464
2657
  this._ttyStream = process.stdin;
@@ -2488,6 +2681,7 @@ class App {
2488
2681
  });
2489
2682
  process.on("SIGINT", () => {}); // Ctrl+C is handled as copy in handleEvent
2490
2683
  this.screen.init();
2684
+ this._started = true;
2491
2685
  // Update backup prompt to screen-aware version now that TUI is running.
2492
2686
  if (this.context._termPrompt) {
2493
2687
  this.context._termPrompt = async (msg) => {
@@ -2572,6 +2766,8 @@ class App {
2572
2766
  if (p.type === "term") p.terminal?.close();
2573
2767
  (this._ttyStream ?? process.stdin).setRawMode?.(false);
2574
2768
  this.screen.fini();
2769
+ this._started = false;
2770
+ this.restoreProtectedPrompts();
2575
2771
 
2576
2772
  if (this.context?.config?.getGlobalOption("savehistory") !== false) {
2577
2773
  try { await saveHistory(this.context.config.configDir); } catch {}
@@ -2590,6 +2786,157 @@ class App {
2590
2786
  process.exit(code);
2591
2787
  }
2592
2788
 
2789
+ installProtectedPrompts() {
2790
+ if (this._protectedPrompts) return;
2791
+ const saved = {
2792
+ alert: {
2793
+ had: Object.prototype.hasOwnProperty.call(globalThis, "alert"),
2794
+ value: globalThis.alert,
2795
+ },
2796
+ confirm: {
2797
+ had: Object.prototype.hasOwnProperty.call(globalThis, "confirm"),
2798
+ value: globalThis.confirm,
2799
+ },
2800
+ prompt: {
2801
+ had: Object.prototype.hasOwnProperty.call(globalThis, "prompt"),
2802
+ value: globalThis.prompt,
2803
+ },
2804
+ };
2805
+ this._protectedPrompts = saved;
2806
+ this._protectedPromptGlobals = {
2807
+ alert: this.protectedAlert.bind(this),
2808
+ confirm: this.protectedConfirm.bind(this),
2809
+ prompt: this.protectedPrompt.bind(this),
2810
+ };
2811
+ Object.assign(globalThis, this._protectedPromptGlobals);
2812
+ }
2813
+
2814
+ runProtectedPrompt(name, fallback, args) {
2815
+ const nativeFn = this._protectedPrompts?.[name]?.value;
2816
+ const tty = this._ttyStream ?? process.stdin;
2817
+ const wasRaw = typeof tty?.isRaw === "boolean" ? tty.isRaw : true;
2818
+ if (
2819
+ !this._started
2820
+ || this.shellRunning
2821
+ || this._alertRunning
2822
+ || !wasRaw
2823
+ || typeof nativeFn !== "function"
2824
+ ) {
2825
+ this.message = String(args[0] ?? "");
2826
+ return fallback;
2827
+ }
2828
+
2829
+ const inputHandler = this._inputHandler;
2830
+ const listenerAttached = Boolean(
2831
+ inputHandler && tty.listeners?.("data").includes(inputHandler),
2832
+ );
2833
+ const wasPaused = tty.isPaused?.() ?? false;
2834
+ let value;
2835
+ let failed = false;
2836
+ let failure;
2837
+ const cleanupFailures = [];
2838
+ const cleanup = (fn) => {
2839
+ try { fn(); } catch (error) { cleanupFailures.push(error); }
2840
+ };
2841
+
2842
+ try {
2843
+ this._alertRunning = true;
2844
+ // Do not let the editor's flowing-mode data listener race the blocking
2845
+ // prompt for bytes from the same terminal.
2846
+ if (listenerAttached) tty.removeListener("data", inputHandler);
2847
+ tty.pause?.();
2848
+ tty.setRawMode?.(false);
2849
+ this.screen.fini();
2850
+ this.screen.previous = null;
2851
+ // Bun's native prompts always read fd 0. If Markdown itself came from a
2852
+ // pipe, read synchronously from the controlling terminal instead.
2853
+ value = tty !== process.stdin
2854
+ ? this.runProtectedTtyPrompt(name, fallback, args)
2855
+ : Reflect.apply(nativeFn, globalThis, args);
2856
+ } catch (error) {
2857
+ failed = true;
2858
+ failure = error;
2859
+ } finally {
2860
+ cleanup(() => tty.setRawMode?.(wasRaw));
2861
+ cleanup(() => { this.screen.previous = null; });
2862
+ cleanup(() => this.screen.init());
2863
+ cleanup(() => { this._alertRunning = false; });
2864
+ cleanup(() => {
2865
+ if (listenerAttached) tty.on("data", inputHandler);
2866
+ });
2867
+ cleanup(() => {
2868
+ if (!wasPaused) tty.resume?.();
2869
+ });
2870
+ cleanup(() => this.render());
2871
+ }
2872
+ if (failed) throw failure;
2873
+ if (cleanupFailures.length > 0) throw cleanupFailures[0];
2874
+ return value;
2875
+ }
2876
+
2877
+ runProtectedTtyPrompt(name, fallback, args) {
2878
+ const message = String(args[0] ?? "");
2879
+ const defaultValue = String(args[1] ?? "");
2880
+ const fd = openSync(controllingTerminalInputPath(), "r");
2881
+ try {
2882
+ if (name === "alert") {
2883
+ process.stdout.write(`${message}\n\nPress ENTER to continue...`);
2884
+ this.readProtectedPromptLine(fd);
2885
+ return undefined;
2886
+ }
2887
+ const suffix = name === "confirm"
2888
+ ? " [y/N] "
2889
+ : defaultValue ? ` (${defaultValue}) ` : " ";
2890
+ process.stdout.write(message + suffix);
2891
+ const answer = this.readProtectedPromptLine(fd);
2892
+ if (answer == null) return fallback;
2893
+ if (name === "confirm") return /^(?:y|yes)$/i.test(answer.trim());
2894
+ return answer === "" ? defaultValue : answer;
2895
+ } finally {
2896
+ closeSync(fd);
2897
+ }
2898
+ }
2899
+
2900
+ readProtectedPromptLine(fd) {
2901
+ const chunks = [];
2902
+ const chunk = Buffer.allocUnsafe(1024);
2903
+ while (true) {
2904
+ const length = readSync(fd, chunk, 0, chunk.length, null);
2905
+ if (length === 0) return chunks.length > 0 ? Buffer.concat(chunks).toString() : null;
2906
+ const bytes = chunk.subarray(0, length);
2907
+ const newline = bytes.findIndex((byte) => byte === 0x0a || byte === 0x0d);
2908
+ chunks.push(Buffer.from(newline < 0 ? bytes : bytes.subarray(0, newline)));
2909
+ if (newline >= 0) return Buffer.concat(chunks).toString();
2910
+ }
2911
+ }
2912
+
2913
+ protectedAlert(msg = "") {
2914
+ return this.runProtectedPrompt("alert", undefined, [String(msg)]);
2915
+ }
2916
+
2917
+ protectedConfirm(msg = "") {
2918
+ return this.runProtectedPrompt("confirm", false, [String(msg)]);
2919
+ }
2920
+
2921
+ protectedPrompt(msg = "", defaultValue = "") {
2922
+ return this.runProtectedPrompt(
2923
+ "prompt",
2924
+ defaultValue,
2925
+ [String(msg), String(defaultValue)],
2926
+ );
2927
+ }
2928
+
2929
+ restoreProtectedPrompts() {
2930
+ const saved = this._protectedPrompts;
2931
+ if (!saved) return;
2932
+ for (const name of ["alert", "confirm", "prompt"]) {
2933
+ if (saved[name].had) globalThis[name] = saved[name].value;
2934
+ else delete globalThis[name];
2935
+ }
2936
+ this._protectedPrompts = null;
2937
+ this._protectedPromptGlobals = null;
2938
+ }
2939
+
2593
2940
  layoutEditorArea() {
2594
2941
  const promptHeight = this.prompt ? 1 : 0;
2595
2942
  const tabBarHeight = this.tabs.length > 1 ? 1 : 0;
@@ -2638,6 +2985,7 @@ class App {
2638
2985
 
2639
2986
  const defaultStyle = this.context.colorscheme?.defaultStyle ?? {};
2640
2987
  this.screen.fill(" ", defaultStyle);
2988
+ this._kittyFrameImages = [];
2641
2989
 
2642
2990
  this.tabRects = [];
2643
2991
  if (tabBarHeight) this.renderTabbar(defaultStyle);
@@ -2813,6 +3161,7 @@ class App {
2813
3161
  this.screen.setCursor(0, 0, false);
2814
3162
  }
2815
3163
 
3164
+ this.screen.setKittyImages(this._kittyFrameImages);
2816
3165
  this.screen.show();
2817
3166
  }
2818
3167
 
@@ -2995,6 +3344,59 @@ class App {
2995
3344
  ? (this.context.colorscheme.get("cursor-line")?.fg ?? null)
2996
3345
  : null;
2997
3346
 
3347
+ const addKittyImage = (lineNo, screenRow, subRow = 0) => {
3348
+ if (subRow !== 0 || !Array.isArray(buf._mdcuiImages)) return;
3349
+ for (const image of buf._mdcuiImages) {
3350
+ if (image.line !== lineNo) continue;
3351
+ const { cols, rows } = fitKittyImageToWidth(image, maxW);
3352
+ const visibleTop = Math.max(pane.y, screenRow);
3353
+ const visibleBottom = Math.min(pane.y + pane.h, screenRow + rows);
3354
+ const visibleRows = visibleBottom - visibleTop;
3355
+ if (visibleRows < 1 || cols < 1) continue;
3356
+ const clippedTopRows = visibleTop - screenRow;
3357
+ const pixelHeight = Math.max(1, Math.trunc(Number(image.pixelHeight) || 1));
3358
+ const sourceY = Math.min(pixelHeight - 1, Math.round(pixelHeight * clippedTopRows / rows));
3359
+ const sourceBottom = Math.min(pixelHeight, Math.round(pixelHeight * (clippedTopRows + visibleRows) / rows));
3360
+ const sourceHeight = Math.max(1, sourceBottom - sourceY);
3361
+ const sourceWidth = Math.max(1, Math.trunc(Number(image.pixelWidth) || 1));
3362
+ const x = pane.x + gutterW;
3363
+ const placementId = ((image.id ^ Math.imul(x + 1, 73856093) ^ Math.imul(visibleTop + 1, 19349663) ^ Math.imul(sourceY + 1, 83492791)) >>> 0) % 2147483646 + 1;
3364
+ logKittyPlacement("app-placement", {
3365
+ buffer: buf.path,
3366
+ imagePath: image.path,
3367
+ imageId: image.id,
3368
+ placementId,
3369
+ imageLogicalLine: image.line,
3370
+ renderedLine: lineNo,
3371
+ subRow,
3372
+ pane: { x: pane.x, y: pane.y, width: pane.w, height: pane.h },
3373
+ scroll: { ...buf.scroll },
3374
+ gutterWidth: gutterW,
3375
+ maxWidth: maxW,
3376
+ screenX: x,
3377
+ nominalScreenY: screenRow,
3378
+ screenY: visibleTop,
3379
+ cols,
3380
+ rows: visibleRows,
3381
+ originalCols: image.cols,
3382
+ originalRows: image.rows,
3383
+ sourceRect: { x: 0, y: sourceY, width: sourceWidth, height: sourceHeight },
3384
+ });
3385
+ this._kittyFrameImages.push({
3386
+ ...image,
3387
+ x,
3388
+ y: visibleTop,
3389
+ cols,
3390
+ rows: visibleRows,
3391
+ sourceX: 0,
3392
+ sourceY,
3393
+ sourceWidth,
3394
+ sourceHeight,
3395
+ placementId,
3396
+ });
3397
+ }
3398
+ };
3399
+
2998
3400
  const hasDiff = (buf.Settings?.diffgutter ?? false) && !!buf.diffBase;
2999
3401
  if (hasDiff && !buf._diffOnUpdate) buf._diffOnUpdate = () => this.render();
3000
3402
  const diffMarks = hasDiff ? getDiffMarkers(buf) : null;
@@ -3009,6 +3411,18 @@ class App {
3009
3411
  const msgWarnStyle = cs?.styles?.has("gutter-warning") ? cs.get("gutter-warning") : defaultStyle;
3010
3412
  const msgErrStyle = cs?.styles?.has("gutter-error") ? cs.get("gutter-error") : defaultStyle;
3011
3413
 
3414
+ // When the image anchor has scrolled above the pane, its lower portion can
3415
+ // still intersect the viewport. It will not be encountered by the normal
3416
+ // visible-line loop, so add that clipped placement explicitly.
3417
+ for (const image of buf._mdcuiImages ?? []) {
3418
+ if (image.line >= buf.scroll.y) continue;
3419
+ const nominalScreenRow = pane.y + image.line - buf.scroll.y;
3420
+ const { rows } = fitKittyImageToWidth(image, maxW);
3421
+ if (nominalScreenRow < pane.y && nominalScreenRow + rows > pane.y) {
3422
+ addKittyImage(image.line, nominalScreenRow);
3423
+ }
3424
+ }
3425
+
3012
3426
  const renderGutter = (lineNo, row, screenRow, subRow = 0) => {
3013
3427
  // Message indicator: 2 cols, '> ' with kind-based style (Go: drawGutter)
3014
3428
  if (msgW > 0) {
@@ -3052,6 +3466,7 @@ class App {
3052
3466
  if (lineNo < buf.lines.length) {
3053
3467
  const cells = renderHighlightedCells(buf, lineNo, buf.scroll.x, maxW, this.context.colorscheme, pane.selection, getLineSearchRanges(buf, lineNo), braceMatches, isCL ? clBg : null);
3054
3468
  putCells(this.screen, pane.x + gutterW, screenRow, cells, maxW);
3469
+ addKittyImage(lineNo, screenRow);
3055
3470
  }
3056
3471
  }
3057
3472
  } else {
@@ -3075,6 +3490,7 @@ class App {
3075
3490
 
3076
3491
  const cells = renderHighlightedCells(buf, lineNo, segStart, maxW, this.context.colorscheme, pane.selection, _swSearchRanges, braceMatches, isCL ? clBg : null);
3077
3492
  putCells(this.screen, pane.x + gutterW, screenRow, cells, maxW);
3493
+ addKittyImage(lineNo, screenRow, subRow);
3078
3494
 
3079
3495
  if (subRow + 1 < breaks.length) {
3080
3496
  sloc = { line: lineNo, row: subRow + 1 };
@@ -3631,6 +4047,7 @@ class App {
3631
4047
 
3632
4048
  const text = event.raw;
3633
4049
  const seq = event.key;
4050
+ const keydownBlock = indexedMdcuiFenceBlockAtLine(buf, buf?.cursor.y);
3634
4051
  if (buf) buf.allowCursorOffscreen = false;
3635
4052
 
3636
4053
  if (this._rawMode) {
@@ -3641,6 +4058,12 @@ class App {
3641
4058
  return;
3642
4059
  }
3643
4060
 
4061
+ const keydownEvent = await this.dispatchMdcuiFenceEvent(buf, keydownBlock, event, "keydown");
4062
+ if (keydownEvent?.defaultPrevented && !["ctrl-q", "alt-q", "escape"].includes(seq)) {
4063
+ this.render();
4064
+ return;
4065
+ }
4066
+
3644
4067
  // Reset undo insert chain on any non-printable-char key
3645
4068
  if (!(seq === text && text.length === 1 && text >= " ")) this._undoInsertChain = false;
3646
4069
 
@@ -3829,15 +4252,28 @@ class App {
3829
4252
  case "backspace":
3830
4253
  buf.pushUndo();
3831
4254
  if (this.pane?.selection) deleteSelection(buf, this.pane);
3832
- else if (await this.runPluginBool("preBackspace")) buf.backspace();
4255
+ else if (await this.runPluginBool("preBackspace")) {
4256
+ const textarea = mdcuiTextareaAtCursor(buf);
4257
+ if (!mergeTuiTextareaBackward(buf, textarea)) buf.backspace();
4258
+ }
3833
4259
  break;
3834
4260
  case "delete":
3835
4261
  buf.pushUndo();
3836
4262
  if (this.pane?.selection) deleteSelection(buf, this.pane);
3837
- else buf.deleteForward();
4263
+ else {
4264
+ const textarea = mdcuiTextareaAtCursor(buf);
4265
+ if (!mergeTuiTextareaForward(buf, textarea)) buf.deleteForward();
4266
+ }
3838
4267
  break;
3839
4268
  case "enter":
3840
4269
  if (isMdcuiEncoding(buf?.encoding)) {
4270
+ const textarea = mdcuiTextareaAtCursor(buf);
4271
+ if (textarea && canEditMdcuiAtCursor(buf)) {
4272
+ buf.pushUndo();
4273
+ if (this.pane?.selection) deleteSelection(buf, this.pane);
4274
+ insertTuiTextareaNewline(buf, textarea);
4275
+ break;
4276
+ }
3841
4277
  await this.handleMdcuiCellCallback(buf, buf.cursor.y, buf.cursor.x, "enter");
3842
4278
  break;
3843
4279
  }
@@ -4013,6 +4449,48 @@ class App {
4013
4449
  this.render();
4014
4450
  }
4015
4451
 
4452
+ async dispatchMdcuiFenceEvent(buf, block, inputEvent, eventName) {
4453
+ const id = block?.header?.id;
4454
+ const declaration = id ? buf?._mdcuiFenceEvents?.get(id) : null;
4455
+ const handler = declaration?.events?.get(eventName);
4456
+ const code = inlineFenceEventCode(handler);
4457
+ if (
4458
+ !code
4459
+ || declaration.tag !== block?.header?.tag
4460
+ || !buf?.path
4461
+ || isHttpUrl(buf.path)
4462
+ ) return false;
4463
+
4464
+ const frontPath = `${buf.path}.front.js`;
4465
+ if (!existsSync(frontPath)) return false;
4466
+ try {
4467
+ const selection = globalThis.$?.(`${declaration.tag}#${id}`);
4468
+ const target = {
4469
+ id,
4470
+ tagName: declaration.tag.toUpperCase(),
4471
+ className: declaration.classes.join(" "),
4472
+ };
4473
+ Object.defineProperty(target, "value", {
4474
+ enumerable: true,
4475
+ get: () => selection?.val?.() ?? "",
4476
+ set: (value) => selection?.val?.(value),
4477
+ });
4478
+ const event = tuiKeyEventForFront(inputEvent, target, eventName);
4479
+ const [{ evalFront }, frontMod] = await Promise.all([
4480
+ import("./cui/rpc.mjs"),
4481
+ import(localModuleUrl(frontPath)),
4482
+ ]);
4483
+ const result = await evalFront(frontMod, code, { event });
4484
+ if (result && typeof result === "object" && result.ok === false) {
4485
+ this.message = `mdcui ${eventName}: ${String(result.error ?? "Unknown error")}`;
4486
+ }
4487
+ return event;
4488
+ } catch (error) {
4489
+ this.message = `mdcui ${eventName}: ${String(error?.message || error)}`;
4490
+ return null;
4491
+ }
4492
+ }
4493
+
4016
4494
  async handleMdcuiCellCallback(buf, y = buf?.cursor?.y ?? 0, x = buf?.cursor?.x ?? 0, trigger = "unknown") {
4017
4495
  const payload = mdcuiCellPayload(buf, y, x, trigger);
4018
4496
  if (!payload) return false;
@@ -4039,50 +4517,11 @@ class App {
4039
4517
  import("./cui/rpc.mjs"),
4040
4518
  import(localModuleUrl(`${buf.path}.front.js`)),
4041
4519
  ]);
4042
- const hadAlert = Object.prototype.hasOwnProperty.call(globalThis, "alert");
4043
- const prevAlert = globalThis.alert;
4044
- const hadConfirm = Object.prototype.hasOwnProperty.call(globalThis, "confirm");
4045
- const prevConfirm = globalThis.confirm;
4046
- const hadPrompt = Object.prototype.hasOwnProperty.call(globalThis, "prompt");
4047
- const prevPrompt = globalThis.prompt;
4048
- const nativeAlert = typeof prevAlert === "function" ? prevAlert : null;
4049
- const withNativePrompt = (nativeFn, fallback, args) => {
4050
- if (!this._started || typeof nativeFn !== "function") {
4051
- this.message = String(args[0] ?? "");
4052
- return fallback;
4053
- }
4054
- const tty = this._ttyStream ?? process.stdin;
4055
- this._alertRunning = true;
4056
- tty.setRawMode?.(false);
4057
- this.screen.fini();
4058
- this.screen.previous = null;
4059
- try {
4060
- return nativeFn(...args);
4061
- } finally {
4062
- tty.setRawMode?.(true);
4063
- this.screen.previous = null;
4064
- this.screen.init();
4065
- this._alertRunning = false;
4066
- this.render();
4067
- }
4068
- };
4069
- globalThis.alert = (msg = "") => withNativePrompt(nativeAlert, undefined, [String(msg)]);
4070
- globalThis.confirm = (msg = "") => withNativePrompt(prevConfirm, false, [String(msg)]);
4071
- globalThis.prompt = (msg = "", defaultValue = "") => withNativePrompt(prevPrompt, defaultValue, [String(msg), String(defaultValue)]);
4072
- try {
4073
- const result = await evalFront(frontMod, payload.link);
4074
- if (result && typeof result === "object" && result.ok === false) {
4075
- globalThis.alert(String(result.error ?? "Unknown error"));
4076
- } else if (result != null) {
4077
- this.message = String(result);
4078
- }
4079
- } finally {
4080
- if (hadAlert) globalThis.alert = prevAlert;
4081
- else delete globalThis.alert;
4082
- if (hadConfirm) globalThis.confirm = prevConfirm;
4083
- else delete globalThis.confirm;
4084
- if (hadPrompt) globalThis.prompt = prevPrompt;
4085
- else delete globalThis.prompt;
4520
+ const result = await evalFront(frontMod, payload.link);
4521
+ if (result && typeof result === "object" && result.ok === false) {
4522
+ this.protectedAlert(String(result.error ?? "Unknown error"));
4523
+ } else if (result != null) {
4524
+ this.message = String(result);
4086
4525
  }
4087
4526
  return true;
4088
4527
  } catch (error) {
@@ -4094,6 +4533,22 @@ class App {
4094
4533
  const checkboxResult = toggleTaskCheckboxBeforeColumn(payload.line, x);
4095
4534
  if (checkboxResult.toggled) {
4096
4535
  buf.lines[y] = checkboxResult.line;
4536
+ const checkboxStyle = checkboxResult.checked ? { fg: "green" } : null;
4537
+ const styleLine = buf._ansiStyleLines?.[y];
4538
+ if (Array.isArray(styleLine)) {
4539
+ styleLine[checkboxResult.checkboxAt] = checkboxStyle;
4540
+ if (checkboxResult.line[checkboxResult.checkboxAt + 1] === " ")
4541
+ styleLine[checkboxResult.checkboxAt + 1] = checkboxStyle;
4542
+ }
4543
+ if (typeof buf._mdcuiAnsiText === "string") {
4544
+ const ansiLines = buf._mdcuiAnsiText.split("\n");
4545
+ ansiLines[y] = updateAnsiTaskCheckbox(
4546
+ ansiLines[y],
4547
+ checkboxResult.checkboxAt,
4548
+ checkboxResult.checked,
4549
+ );
4550
+ buf._mdcuiAnsiText = ansiLines.join("\n");
4551
+ }
4097
4552
  buf.modified = true;
4098
4553
  }
4099
4554
 
@@ -6788,6 +7243,7 @@ async function loadBufferForPath(pathOrUrl, context, command = {}, { interactive
6788
7243
  if (!name.toLowerCase().endsWith(".md")) name += ".md";
6789
7244
  const localPath = resolve(name);
6790
7245
  await Bun.write(localPath, await fetchHttpBytes(pathOrUrl));
7246
+ remoteMarkdownSources.set(localPath, new URL(pathOrUrl).href);
6791
7247
  return await loadBufferForPath(localPath, loadContext, command, { interactive });
6792
7248
  }
6793
7249
  let buffer;
@@ -6807,6 +7263,7 @@ async function loadBufferForPath(pathOrUrl, context, command = {}, { interactive
6807
7263
  sourceText: decoded.sourceText ?? null,
6808
7264
  tuiSourceText: decoded.tuiSourceText ?? null,
6809
7265
  mdcuiRenderWidth: decoded.mdcuiRenderWidth ?? 0,
7266
+ mdcuiImages: decoded.mdcuiImages ?? null,
6810
7267
  });
6811
7268
  buffer._configDir = context?.config?.configDir ?? null;
6812
7269
  attachSyntax(buffer, loadContext, urlPath, text);
@@ -7517,6 +7974,7 @@ async function loadBuffers(files, command) {
7517
7974
  sourceText: decoded.sourceText ?? null,
7518
7975
  tuiSourceText: decoded.tuiSourceText ?? null,
7519
7976
  mdcuiRenderWidth: decoded.mdcuiRenderWidth ?? 0,
7977
+ mdcuiImages: decoded.mdcuiImages ?? null,
7520
7978
  });
7521
7979
  if (loadBuffers.context) attachSyntax(stdinBuf, loadBuffers.context, "", stdinText);
7522
7980
  buffers.push(stdinBuf);
@@ -7558,6 +8016,42 @@ async function bundledMarkdownSource(filename) {
7558
8016
  return readInternalAssetText(filename) ?? await Bun.file(join(REPO_ROOT, filename)).text();
7559
8017
  }
7560
8018
 
8019
+ function availableDemoAssets() {
8020
+ let paths;
8021
+ if (hasInternalAssets()) {
8022
+ paths = listInternalAssetPaths("demos");
8023
+ } else {
8024
+ try {
8025
+ paths = readdirSync(join(REPO_ROOT, "demos"), { withFileTypes: true })
8026
+ .filter((entry) => entry.isFile())
8027
+ .map((entry) => assetPath("demos", entry.name));
8028
+ } catch {
8029
+ paths = [];
8030
+ }
8031
+ }
8032
+
8033
+ return [...new Set(paths)]
8034
+ .filter((path) => /^demos\/[^/]+\.md$/i.test(path))
8035
+ .sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
8036
+ }
8037
+
8038
+ function printDemoList() {
8039
+ const demos = availableDemoAssets().map((asset) => ({
8040
+ option: `--demo-${asset.slice("demos/".length, -".md".length)}`,
8041
+ asset,
8042
+ }));
8043
+ const entries = [{ option: "--demo", asset: "testapp.md" }, ...demos];
8044
+ const optionWidth = Math.max(...entries.map(({ option }) => option.length));
8045
+
8046
+ console.log("Available demos:\n");
8047
+ for (const { option, asset } of entries) {
8048
+ console.log(` ${option.padEnd(optionWidth)} ${asset}`);
8049
+ }
8050
+ console.log("\nCompatibility aliases:\n");
8051
+ console.log(" --demo-imgtool --demo-image-processor");
8052
+ console.log(" --demo-imgtool-zh --demo-image-processor.zh-TW");
8053
+ }
8054
+
7561
8055
  async function main() {
7562
8056
  if (process.argv[2] === "--wui") {
7563
8057
  process.argv.splice(2, 1);
@@ -7571,6 +8065,8 @@ async function main() {
7571
8065
  await buildEarlyExit(null,DEFAULT_BUILD_OUTFILE)
7572
8066
 
7573
8067
  const { flags, files: rawFiles } = parseArgs(process.argv.slice(2));
8068
+ kittyImageMode = flags.kittyMode;
8069
+ allowRemoteKittyImages = flags.allowUrl;
7574
8070
 
7575
8071
  if (flags.help) {
7576
8072
  console.log(usage());
@@ -7636,19 +8132,27 @@ async function main() {
7636
8132
  await printTestappSource();
7637
8133
  return;
7638
8134
  }
7639
- const demoFilename = flags.demo
7640
- ? "testapp.md"
7641
- : flags.demoSelect
7642
- ? "select.md"
7643
- : flags.demoImgtool
7644
- ? "image-processor.md"
7645
- : flags.demoImgtoolZh
7646
- ? "image-processor.zh-TW.md"
7647
- : "";
7648
- if (demoFilename) {
7649
- const demoPath = resolve(demoFilename);
8135
+ if (flags.demoList) {
8136
+ printDemoList();
8137
+ return;
8138
+ }
8139
+ if (flags.demo) {
8140
+ if (flags.demo.error) {
8141
+ console.error(`Invalid demo option ${flags.demo.option}: ${flags.demo.error}`);
8142
+ process.exitCode = 2;
8143
+ return;
8144
+ }
8145
+ let demoSource;
8146
+ try {
8147
+ demoSource = await bundledMarkdownSource(flags.demo.asset);
8148
+ } catch {
8149
+ console.error(`Unknown demo ${flags.demo.option}: ${flags.demo.asset} was not found`);
8150
+ process.exitCode = 2;
8151
+ return;
8152
+ }
8153
+ const demoPath = resolve(flags.demo.filename);
7650
8154
  if (!(await Bun.file(demoPath).exists())) {
7651
- await Bun.write(demoPath, await bundledMarkdownSource(demoFilename));
8155
+ await Bun.write(demoPath, demoSource);
7652
8156
  }
7653
8157
  rawFiles.splice(0, rawFiles.length, demoPath);
7654
8158
  }
@@ -7732,7 +8236,16 @@ async function main() {
7732
8236
 
7733
8237
  const { files, command } = parseInput(rawFiles);
7734
8238
  const jsPlugins = new JsPluginManager();
7735
- const context = { colorscheme, syntaxDefinitions, config, runtime, jsPlugins, encodingExplicit, allowUrl: flags.allowUrl };
8239
+ const context = {
8240
+ colorscheme,
8241
+ syntaxDefinitions,
8242
+ config,
8243
+ runtime,
8244
+ jsPlugins,
8245
+ encodingExplicit,
8246
+ allowUrl: flags.allowUrl,
8247
+ kittyMode: flags.kittyMode,
8248
+ };
7736
8249
  jsPlugins.setContext(context);
7737
8250
  buildMicroGlobal(jsPlugins); // sets globalThis.micro
7738
8251
 
@@ -7836,6 +8349,7 @@ async function main() {
7836
8349
  addCheckpoint("App Instantiation");
7837
8350
  const app = new App(buffers, context);
7838
8351
  jsPlugins.setApp(app);
8352
+ app.installProtectedPrompts();
7839
8353
  // if (plugins && !pluginErr && app.buffer) plugins.curPaneAdapter = makePaneAdapter(app.buffer, app);
7840
8354
  // Dispatch all JS plugin lifecycle hooks after setApp so TermMessage,
7841
8355
  // CurPane, cmd/action proxies, and buffer APIs all work correctly.