jsmdcui 0.5.0 → 0.7.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
@@ -98,11 +98,15 @@ 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, updateAnsiTaskCheckbox } from "./cui/task-checkbox.mjs";
102
+ import { checkMarkdownIdCollisions, formatMarkdownIdCheckAnsi } from "./cui/id-collision.mjs";
103
+ import { fitKittyImageToWidth, prepareKittyImages } from "./cui/kitty-images.mjs";
104
+ import { logKittyPlacement } from "./cui/kitty-debug.mjs";
101
105
  import { Config } from "./config/config.js";
102
106
  import { defaultAllSettings, OPTION_CHOICES, LOCAL_SETTINGS } from "./config/defaults.js";
103
107
  import { cleanConfig } from "./config/clean.js";
104
108
  import { RuntimeRegistry, RTColorscheme, RTHelp } from "./runtime/registry.js";
105
- import { assetPath, hasInternalAssets, listInternalAssetDirs, readInternalAssetText } from "./runtime/assets.js";
109
+ import { assetPath, hasInternalAssets, listInternalAssetDirs, listInternalAssetPaths, readInternalAssetText } from "./runtime/assets.js";
106
110
  //import { PluginManager } from "./plugins/manager.js";
107
111
  import { JsPluginManager, buildMicroGlobal, runAction, listActions } from "./plugins/js-bridge.js";
108
112
  import { Colorscheme } from "./config/colorscheme.js";
@@ -117,6 +121,10 @@ import { shellSplit } from "./shell/shell.js";
117
121
  import { styleToAnsi } from "./display/ansi-style.js";
118
122
  import { encodeBinaryToBuffer, decodeBinaryBytes } from "./buffer/fixed3-codec.js";
119
123
  import { writeBackup, removeBackup, applyBackup } from "./buffer/backup.js";
124
+
125
+ let kittyImageMode = "off";
126
+ let allowRemoteKittyImages = false;
127
+ const remoteMarkdownSources = new Map();
120
128
  import { isHex3Encoding, isMdcuiEncoding } from "./runtime/encodings.js";
121
129
  import { createInterface } from "node:readline/promises";
122
130
 
@@ -311,7 +319,12 @@ async function readTextFileWithEncoding(path, encoding = "utf-8", inferMdcui = t
311
319
 
312
320
  async function fetchTextWithEncoding(url, encoding = "utf-8", inferMdcui = true) {
313
321
  const bytes = await fetchHttpBytes(url);
314
- return decodeAndRenderTextBytes(new Uint8Array(bytes), encodingForPath(url, encoding, inferMdcui));
322
+ return decodeAndRenderTextBytes(
323
+ new Uint8Array(bytes),
324
+ encodingForPath(url, encoding, inferMdcui),
325
+ process.stdout.columns || 80,
326
+ url,
327
+ );
315
328
  }
316
329
 
317
330
  function normalizeEncodingLabel(encoding = "utf-8") {
@@ -335,20 +348,30 @@ async function decodeAndRenderTextBytes(bytes, encoding = "utf-8", width = proce
335
348
  const decoded = decodeTextBytesWithEncoding(bytes, encoding);
336
349
  if (!isMdcuiEncoding(decoded.encoding)) return decoded;
337
350
  const renderWidth = Math.max(1, Math.trunc(Number(width) || 80));
338
- const { rendered, tuiSourceText } = await renderMdcui(decoded.text, renderWidth, mdpath);
351
+ const { rendered, tuiSourceText, images } = await renderMdcui(decoded.text, renderWidth, mdpath, mdpath);
339
352
  const styled = parseAnsiStyledText(rendered);
340
- return { text: styled.text, encoding: "mdcui", sourceText: decoded.text, tuiSourceText, ansiText: rendered, ansiStyleLines: styled.styleLines, mdcuiRenderWidth: renderWidth };
353
+ return { text: styled.text, encoding: "mdcui", sourceText: decoded.text, tuiSourceText, ansiText: rendered, ansiStyleLines: styled.styleLines, mdcuiImages: images, mdcuiRenderWidth: renderWidth };
341
354
  }
342
355
 
343
- async function renderMdcui(markdown, width = process.stdout.columns || 80, mdpath = null) {
356
+ async function renderMdcui(markdown, width = process.stdout.columns || 80, mdpath = null, imageBasePath = mdpath) {
344
357
  const runmd = await import("../runmd.mjs");
345
358
  let md = String(markdown);
346
359
  if (mdpath && !isHttpUrl(mdpath)) {
347
360
  md = await runmd.extractJs(md, mdpath);
348
361
  await runmd.createWui(md, mdpath);
349
362
  }
363
+ const tui = runmd.createTui(md, Math.max(1, Math.trunc(Number(width) || 80)));
364
+ const resolvedImageBasePath = mdpath && !isHttpUrl(mdpath)
365
+ ? remoteMarkdownSources.get(resolve(mdpath)) ?? imageBasePath
366
+ : imageBasePath;
367
+ const prepared = kittyImageMode === "off"
368
+ ? { rendered: tui, images: [] }
369
+ : await prepareKittyImages(tui, resolvedImageBasePath, width, {
370
+ allowUrl: allowRemoteKittyImages,
371
+ });
350
372
  return {
351
- rendered: runmd.createTui(md, Math.max(1, Math.trunc(Number(width) || 80))),
373
+ rendered: prepared.rendered,
374
+ images: prepared.images,
352
375
  tuiSourceText: md,
353
376
  };
354
377
  }
@@ -588,6 +611,12 @@ function resizeMdcuiTextBlock(buf, y, x) {
588
611
  ansiLines.splice(row, deleteCount, ...replacement);
589
612
  buf._mdcuiAnsiText = ansiLines.join("\n");
590
613
  }
614
+ if (Array.isArray(buf._mdcuiImages)) {
615
+ const delta = replacement.length - deleteCount;
616
+ buf._mdcuiImages = buf._mdcuiImages
617
+ .filter((image) => image.line < row || image.line >= row + deleteCount)
618
+ .map((image) => image.line >= row + deleteCount ? { ...image, line: image.line + delta } : image);
619
+ }
591
620
 
592
621
  if (insertAt >= 0) {
593
622
  if (buf.cursor.y >= insertAt) buf.cursor.y++;
@@ -947,11 +976,14 @@ function parseArgs(argv) {
947
976
  options: false,
948
977
  help: false,
949
978
  clean: false,
979
+ check: false,
950
980
  cat: false,
951
981
  docs: false,
982
+ exportReadme: false,
952
983
  changelog: false,
953
984
  testapp: false,
954
- demo: false,
985
+ demoList: false,
986
+ demo: null,
955
987
  allowUrl: false,
956
988
  buildExe: false,
957
989
  buildFor: "",
@@ -961,6 +993,7 @@ function parseArgs(argv) {
961
993
  plugin: "",
962
994
  cdpPort: 0,
963
995
  cdpAddress: "",
996
+ kittyMode: "off",
964
997
  settings: new Map(),
965
998
  };
966
999
  const files = [];
@@ -971,6 +1004,7 @@ function parseArgs(argv) {
971
1004
  else if (arg === "-options") flags.options = true;
972
1005
  else if (arg === "-help" || arg === "--help" || arg === "-h") flags.help = true;
973
1006
  else if (arg === "-clean") flags.clean = true;
1007
+ else if (arg === "--check") flags.check = true;
974
1008
  else if (arg === "--cat" || arg === "-cat" || arg === "--ccat" || arg === "-ccat" || arg === "--bat" || arg === "-bat" || arg === "--glow" || arg === "-glow") flags.cat = true;
975
1009
  else if (arg === "--xxd" || arg === "--hexdump") {
976
1010
  flags.cat = true;
@@ -989,10 +1023,28 @@ function parseArgs(argv) {
989
1023
  flags.settings.set("encoding", "utf-8");
990
1024
  }
991
1025
  else if (arg === "--docs" || arg === "--readme") flags.docs = true;
1026
+ else if (arg === "--export-readme") flags.exportReadme = true;
992
1027
  else if (arg === "--changelog") flags.changelog = true;
993
1028
  else if (arg === "--testapp.md") flags.testapp = true;
994
- else if (arg === "--demo") flags.demo = true;
1029
+ else if (arg === "--demo-list") flags.demoList = true;
1030
+ else if (arg === "--demo") {
1031
+ flags.demo = { option: arg, filename: "testapp.md", asset: "testapp.md" };
1032
+ }
1033
+ else if (arg === "--demo-imgtool") {
1034
+ flags.demo = { option: arg, filename: "image-processor.md", asset: "demos/image-processor.md" };
1035
+ }
1036
+ else if (arg === "--demo-imgtool-zh") {
1037
+ flags.demo = { option: arg, filename: "image-processor.zh-TW.md", asset: "demos/image-processor.zh-TW.md" };
1038
+ }
1039
+ else if (arg.startsWith("--demo-")) {
1040
+ const name = arg.slice("--demo-".length);
1041
+ flags.demo = /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)
1042
+ ? { option: arg, filename: `${name}.md`, asset: `demos/${name}.md` }
1043
+ : { option: arg, error: "demo filename must contain only letters, numbers, dots, underscores, and hyphens" };
1044
+ }
995
1045
  else if (arg === "--allow-url") flags.allowUrl = true;
1046
+ else if (arg === "--kitty") flags.kittyMode = "extended";
1047
+ else if (arg === "--kitty-compat") flags.kittyMode = "compat";
996
1048
  else if (arg === "--build-exe") flags.buildExe = true;
997
1049
  else if (arg === "--build-for") flags.buildFor = argv[++i] ?? "";
998
1050
  else if (arg === "-debug") flags.debug = true;
@@ -1024,9 +1076,13 @@ function usage() {
1024
1076
  ${pkg.name} --wui [FILE.md]
1025
1077
 
1026
1078
  Modes:
1079
+ --check FILE.md
1080
+ Check heading and fenced-block IDs for collisions, print details, and exit
1081
+ Exits 0 when IDs are unique, 1 on collisions, and 2 on usage/read errors
1027
1082
  --wui [FILE.md]
1028
1083
  Generate or overwrite Markdown UI files beside FILE.md and start the server
1029
- Without FILE.md, use testapp.md and write generated files in the current directory
1084
+ Without FILE.md, use the existing ./testapp.md without overwriting it
1085
+ If ./testapp.md is missing, write the bundled demo there first
1030
1086
  --cat, --ccat, --bat, --glow
1031
1087
  Render file(s) and write to stdout, then exit (.md uses mdcui/createTui)
1032
1088
  A local .md file also writes or overwrites five generated files beside it
@@ -1040,6 +1096,10 @@ Modes:
1040
1096
  -encoding mdcui
1041
1097
  Render Markdown through runmd.mjs#createTui; .md files use this automatically
1042
1098
  Writes .front.js, .back.js, .html, -rpc.js, and -server.js beside the .md file
1099
+ --kitty
1100
+ Display Markdown images with Kitty graphics and the jsgotty MIME extension
1101
+ --kitty-compat
1102
+ Display Markdown images with Kitty graphics without the non-standard MIME U field
1043
1103
 
1044
1104
  Settings:
1045
1105
  -SETTING VALUE
@@ -1060,6 +1120,8 @@ Information:
1060
1120
  Show version+backend info & exit
1061
1121
  --docs, --readme
1062
1122
  Show ${pkg.name}'s README.md & exit
1123
+ --export-readme
1124
+ Write or overwrite ./README.md with the bundled README.md & exit
1063
1125
  --changelog
1064
1126
  Show CHANGELOG.md & exit
1065
1127
  -profile, --profile
@@ -1068,12 +1130,23 @@ Information:
1068
1130
  Demo:
1069
1131
  --testapp.md
1070
1132
  Write the bundled testapp.md to stdout & exit
1133
+ --demo-list
1134
+ List the bundled demos and their command-line options, then exit
1071
1135
  --demo
1072
- Outputs & overwrites ./testapp.md, opens it in the TUI, and writes 5 generated files
1136
+ Use the existing ./testapp.md without overwriting it, or write the bundled demo if missing
1137
+ Open it in the TUI and write 5 generated files beside it
1138
+ --demo-<filename>
1139
+ Load demos/<filename>.md, preserving an existing ./<filename>.md or writing the bundled copy
1140
+ Open it in the TUI and write 5 generated files beside it
1141
+ For example: --demo-select, --demo-todo, or --demo-todo-zh
1142
+ --demo-imgtool
1143
+ Alias for --demo-image-processor
1144
+ --demo-imgtool-zh
1145
+ Alias for --demo-image-processor.zh-TW
1073
1146
 
1074
1147
  Remote Markdown:
1075
1148
  --allow-url
1076
- Download HTTP(S) Markdown to cwd, write 5 generated files, and allow its code to run
1149
+ Download HTTP(S) Markdown and, with Kitty mode, its HTTP(S) images; allow its code to run
1077
1150
 
1078
1151
  Experimental:
1079
1152
  --build-exe Build a Bun single-file executable and exit
@@ -1121,7 +1194,7 @@ class BufferModel {
1121
1194
  get searchPattern() { return this._searchPattern ?? ""; }
1122
1195
  set searchPattern(v) { this._searchPattern = v ?? ""; this.searchMatches?.clear(); }
1123
1196
 
1124
- constructor({ path = "", text = "", command = {}, type = "default", readonly = false, modTimeMs = null, encoding = DEFAULT_SETTINGS.encoding, ansiStyleLines = null, ansiText = null, sourceText = null, tuiSourceText = null, mdcuiRenderWidth = 0 } = {}) {
1197
+ 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 } = {}) {
1125
1198
  this.path = path;
1126
1199
  this.type = type;
1127
1200
  this.name = path ? basename(path) : "No name";
@@ -1134,6 +1207,7 @@ class BufferModel {
1134
1207
  this._mdcuiAnsiText = isMdcuiEncoding(this.encoding) ? String(ansiText ?? "") : null;
1135
1208
  this._mdcuiSourceText = isMdcuiEncoding(this.encoding) ? String(sourceText ?? text) : null;
1136
1209
  this._mdcuiTuiSourceText = isMdcuiEncoding(this.encoding) ? String(tuiSourceText ?? sourceText ?? text) : null;
1210
+ this._mdcuiImages = isMdcuiEncoding(this.encoding) ? (mdcuiImages ?? []) : [];
1137
1211
  this._mdcuiRenderWidth = Math.trunc(Number(mdcuiRenderWidth) || 0);
1138
1212
  this.cursor = { x: 0, y: 0 };
1139
1213
  this.scroll = { x: 0, y: 0, row: 0 };
@@ -1234,6 +1308,7 @@ class BufferModel {
1234
1308
  let sourceText = null;
1235
1309
  let tuiSourceText = null;
1236
1310
  let mdcuiRenderWidth = 0;
1311
+ let mdcuiImages = null;
1237
1312
  if (existsSync(path)) {
1238
1313
  const info = statSync(path);
1239
1314
  if (info.isDirectory()) throw new Error(`${path} is a directory`);
@@ -1247,9 +1322,10 @@ class BufferModel {
1247
1322
  sourceText = decoded.sourceText ?? null;
1248
1323
  tuiSourceText = decoded.tuiSourceText ?? null;
1249
1324
  mdcuiRenderWidth = decoded.mdcuiRenderWidth ?? 0;
1325
+ mdcuiImages = decoded.mdcuiImages ?? null;
1250
1326
  if (isMdcuiEncoding(encoding)) readonly = true;
1251
1327
  }
1252
- const buffer = new BufferModel({ path, text, command, readonly, modTimeMs, encoding, ansiStyleLines, ansiText, sourceText, tuiSourceText, mdcuiRenderWidth });
1328
+ const buffer = new BufferModel({ path, text, command, readonly, modTimeMs, encoding, ansiStyleLines, ansiText, sourceText, tuiSourceText, mdcuiImages, mdcuiRenderWidth });
1253
1329
  buffer._configDir = context?.config?.configDir ?? null;
1254
1330
  attachSyntax(buffer, context, path, text);
1255
1331
  return buffer;
@@ -1650,6 +1726,7 @@ class BufferModel {
1650
1726
  this._mdcuiSourceText = isMdcuiEncoding(this.encoding) ? String(decoded.sourceText ?? text) : null;
1651
1727
  this._mdcuiTuiSourceText = isMdcuiEncoding(this.encoding) ? String(decoded.tuiSourceText ?? decoded.sourceText ?? text) : null;
1652
1728
  this._mdcuiRenderWidth = decoded.mdcuiRenderWidth ?? 0;
1729
+ this._mdcuiImages = decoded.mdcuiImages ?? [];
1653
1730
  const readonly = isMdcuiEncoding(this.encoding);
1654
1731
  this.fileformat = detectFileFormat(text, this.Settings.fileformat ?? DEFAULT_SETTINGS.fileformat);
1655
1732
  this.Settings.fileformat = this.fileformat;
@@ -1682,6 +1759,7 @@ class BufferModel {
1682
1759
  this._mdcuiSourceText = isMdcuiEncoding(this.encoding) ? String(decoded.sourceText ?? text) : null;
1683
1760
  this._mdcuiTuiSourceText = isMdcuiEncoding(this.encoding) ? String(decoded.tuiSourceText ?? decoded.sourceText ?? text) : null;
1684
1761
  this._mdcuiRenderWidth = decoded.mdcuiRenderWidth ?? 0;
1762
+ this._mdcuiImages = decoded.mdcuiImages ?? [];
1685
1763
  this.fileformat = detectFileFormat(text, this.Settings.fileformat ?? DEFAULT_SETTINGS.fileformat);
1686
1764
  this.Settings.fileformat = this.fileformat;
1687
1765
  this.lines = normalizeBufferText(text).split("\n");
@@ -1705,12 +1783,13 @@ class BufferModel {
1705
1783
  if (!isMdcuiEncoding(this.encoding) || this._mdcuiTuiSourceText == null) return false;
1706
1784
  const renderWidth = Math.max(1, Math.trunc(Number(width) || 80));
1707
1785
  if (renderWidth === this._mdcuiRenderWidth) return false;
1708
- const { rendered } = await renderMdcui(this._mdcuiTuiSourceText, renderWidth);
1786
+ const { rendered, images } = await renderMdcui(this._mdcuiTuiSourceText, renderWidth, null, this.path);
1709
1787
  const styled = parseAnsiStyledText(rendered);
1710
1788
  this.lines = normalizeBufferText(styled.text).split("\n");
1711
1789
  if (this.lines.length === 0) this.lines = [""];
1712
1790
  this._ansiStyleLines = styled.styleLines;
1713
1791
  this._mdcuiAnsiText = rendered;
1792
+ this._mdcuiImages = images;
1714
1793
  this._mdcuiRenderWidth = renderWidth;
1715
1794
  this.fileformat = detectFileFormat(styled.text, this.Settings.fileformat ?? DEFAULT_SETTINGS.fileformat);
1716
1795
  this.Settings.fileformat = this.fileformat;
@@ -2376,7 +2455,10 @@ class App {
2376
2455
  this.clipboard = new ClipboardManager();
2377
2456
  this.context = context;
2378
2457
  this.shellRunning = false;
2379
- this.screen = new Screen({ mouse: DEFAULT_SETTINGS.mouse !== false });
2458
+ this.screen = new Screen({
2459
+ mouse: DEFAULT_SETTINGS.mouse !== false,
2460
+ kittyMode: context.kittyMode ?? "off",
2461
+ });
2380
2462
  this.tabRects = [];
2381
2463
  this._escBuf = null; // pending lone ESC bytes waiting for alt-key combo
2382
2464
  this._escTimer = null;
@@ -2610,6 +2692,7 @@ class App {
2610
2692
 
2611
2693
  const defaultStyle = this.context.colorscheme?.defaultStyle ?? {};
2612
2694
  this.screen.fill(" ", defaultStyle);
2695
+ this._kittyFrameImages = [];
2613
2696
 
2614
2697
  this.tabRects = [];
2615
2698
  if (tabBarHeight) this.renderTabbar(defaultStyle);
@@ -2785,6 +2868,7 @@ class App {
2785
2868
  this.screen.setCursor(0, 0, false);
2786
2869
  }
2787
2870
 
2871
+ this.screen.setKittyImages(this._kittyFrameImages);
2788
2872
  this.screen.show();
2789
2873
  }
2790
2874
 
@@ -2967,6 +3051,59 @@ class App {
2967
3051
  ? (this.context.colorscheme.get("cursor-line")?.fg ?? null)
2968
3052
  : null;
2969
3053
 
3054
+ const addKittyImage = (lineNo, screenRow, subRow = 0) => {
3055
+ if (subRow !== 0 || !Array.isArray(buf._mdcuiImages)) return;
3056
+ for (const image of buf._mdcuiImages) {
3057
+ if (image.line !== lineNo) continue;
3058
+ const { cols, rows } = fitKittyImageToWidth(image, maxW);
3059
+ const visibleTop = Math.max(pane.y, screenRow);
3060
+ const visibleBottom = Math.min(pane.y + pane.h, screenRow + rows);
3061
+ const visibleRows = visibleBottom - visibleTop;
3062
+ if (visibleRows < 1 || cols < 1) continue;
3063
+ const clippedTopRows = visibleTop - screenRow;
3064
+ const pixelHeight = Math.max(1, Math.trunc(Number(image.pixelHeight) || 1));
3065
+ const sourceY = Math.min(pixelHeight - 1, Math.round(pixelHeight * clippedTopRows / rows));
3066
+ const sourceBottom = Math.min(pixelHeight, Math.round(pixelHeight * (clippedTopRows + visibleRows) / rows));
3067
+ const sourceHeight = Math.max(1, sourceBottom - sourceY);
3068
+ const sourceWidth = Math.max(1, Math.trunc(Number(image.pixelWidth) || 1));
3069
+ const x = pane.x + gutterW;
3070
+ const placementId = ((image.id ^ Math.imul(x + 1, 73856093) ^ Math.imul(visibleTop + 1, 19349663) ^ Math.imul(sourceY + 1, 83492791)) >>> 0) % 2147483646 + 1;
3071
+ logKittyPlacement("app-placement", {
3072
+ buffer: buf.path,
3073
+ imagePath: image.path,
3074
+ imageId: image.id,
3075
+ placementId,
3076
+ imageLogicalLine: image.line,
3077
+ renderedLine: lineNo,
3078
+ subRow,
3079
+ pane: { x: pane.x, y: pane.y, width: pane.w, height: pane.h },
3080
+ scroll: { ...buf.scroll },
3081
+ gutterWidth: gutterW,
3082
+ maxWidth: maxW,
3083
+ screenX: x,
3084
+ nominalScreenY: screenRow,
3085
+ screenY: visibleTop,
3086
+ cols,
3087
+ rows: visibleRows,
3088
+ originalCols: image.cols,
3089
+ originalRows: image.rows,
3090
+ sourceRect: { x: 0, y: sourceY, width: sourceWidth, height: sourceHeight },
3091
+ });
3092
+ this._kittyFrameImages.push({
3093
+ ...image,
3094
+ x,
3095
+ y: visibleTop,
3096
+ cols,
3097
+ rows: visibleRows,
3098
+ sourceX: 0,
3099
+ sourceY,
3100
+ sourceWidth,
3101
+ sourceHeight,
3102
+ placementId,
3103
+ });
3104
+ }
3105
+ };
3106
+
2970
3107
  const hasDiff = (buf.Settings?.diffgutter ?? false) && !!buf.diffBase;
2971
3108
  if (hasDiff && !buf._diffOnUpdate) buf._diffOnUpdate = () => this.render();
2972
3109
  const diffMarks = hasDiff ? getDiffMarkers(buf) : null;
@@ -2981,6 +3118,18 @@ class App {
2981
3118
  const msgWarnStyle = cs?.styles?.has("gutter-warning") ? cs.get("gutter-warning") : defaultStyle;
2982
3119
  const msgErrStyle = cs?.styles?.has("gutter-error") ? cs.get("gutter-error") : defaultStyle;
2983
3120
 
3121
+ // When the image anchor has scrolled above the pane, its lower portion can
3122
+ // still intersect the viewport. It will not be encountered by the normal
3123
+ // visible-line loop, so add that clipped placement explicitly.
3124
+ for (const image of buf._mdcuiImages ?? []) {
3125
+ if (image.line >= buf.scroll.y) continue;
3126
+ const nominalScreenRow = pane.y + image.line - buf.scroll.y;
3127
+ const { rows } = fitKittyImageToWidth(image, maxW);
3128
+ if (nominalScreenRow < pane.y && nominalScreenRow + rows > pane.y) {
3129
+ addKittyImage(image.line, nominalScreenRow);
3130
+ }
3131
+ }
3132
+
2984
3133
  const renderGutter = (lineNo, row, screenRow, subRow = 0) => {
2985
3134
  // Message indicator: 2 cols, '> ' with kind-based style (Go: drawGutter)
2986
3135
  if (msgW > 0) {
@@ -3024,6 +3173,7 @@ class App {
3024
3173
  if (lineNo < buf.lines.length) {
3025
3174
  const cells = renderHighlightedCells(buf, lineNo, buf.scroll.x, maxW, this.context.colorscheme, pane.selection, getLineSearchRanges(buf, lineNo), braceMatches, isCL ? clBg : null);
3026
3175
  putCells(this.screen, pane.x + gutterW, screenRow, cells, maxW);
3176
+ addKittyImage(lineNo, screenRow);
3027
3177
  }
3028
3178
  }
3029
3179
  } else {
@@ -3047,6 +3197,7 @@ class App {
3047
3197
 
3048
3198
  const cells = renderHighlightedCells(buf, lineNo, segStart, maxW, this.context.colorscheme, pane.selection, _swSearchRanges, braceMatches, isCL ? clBg : null);
3049
3199
  putCells(this.screen, pane.x + gutterW, screenRow, cells, maxW);
3200
+ addKittyImage(lineNo, screenRow, subRow);
3050
3201
 
3051
3202
  if (subRow + 1 < breaks.length) {
3052
3203
  sloc = { line: lineNo, row: subRow + 1 };
@@ -4062,6 +4213,28 @@ class App {
4062
4213
  return true;
4063
4214
  }
4064
4215
  }
4216
+
4217
+ const checkboxResult = toggleTaskCheckboxBeforeColumn(payload.line, x);
4218
+ if (checkboxResult.toggled) {
4219
+ buf.lines[y] = checkboxResult.line;
4220
+ const checkboxStyle = checkboxResult.checked ? { fg: "green" } : null;
4221
+ const styleLine = buf._ansiStyleLines?.[y];
4222
+ if (Array.isArray(styleLine)) {
4223
+ styleLine[checkboxResult.checkboxAt] = checkboxStyle;
4224
+ if (checkboxResult.line[checkboxResult.checkboxAt + 1] === " ")
4225
+ styleLine[checkboxResult.checkboxAt + 1] = checkboxStyle;
4226
+ }
4227
+ if (typeof buf._mdcuiAnsiText === "string") {
4228
+ const ansiLines = buf._mdcuiAnsiText.split("\n");
4229
+ ansiLines[y] = updateAnsiTaskCheckbox(
4230
+ ansiLines[y],
4231
+ checkboxResult.checkboxAt,
4232
+ checkboxResult.checked,
4233
+ );
4234
+ buf._mdcuiAnsiText = ansiLines.join("\n");
4235
+ }
4236
+ buf.modified = true;
4237
+ }
4065
4238
 
4066
4239
  this.message = `event ${payload.trigger}:`+
4067
4240
  `(${payload.row},${payload.col})`+
@@ -6754,6 +6927,7 @@ async function loadBufferForPath(pathOrUrl, context, command = {}, { interactive
6754
6927
  if (!name.toLowerCase().endsWith(".md")) name += ".md";
6755
6928
  const localPath = resolve(name);
6756
6929
  await Bun.write(localPath, await fetchHttpBytes(pathOrUrl));
6930
+ remoteMarkdownSources.set(localPath, new URL(pathOrUrl).href);
6757
6931
  return await loadBufferForPath(localPath, loadContext, command, { interactive });
6758
6932
  }
6759
6933
  let buffer;
@@ -6773,6 +6947,7 @@ async function loadBufferForPath(pathOrUrl, context, command = {}, { interactive
6773
6947
  sourceText: decoded.sourceText ?? null,
6774
6948
  tuiSourceText: decoded.tuiSourceText ?? null,
6775
6949
  mdcuiRenderWidth: decoded.mdcuiRenderWidth ?? 0,
6950
+ mdcuiImages: decoded.mdcuiImages ?? null,
6776
6951
  });
6777
6952
  buffer._configDir = context?.config?.configDir ?? null;
6778
6953
  attachSyntax(buffer, loadContext, urlPath, text);
@@ -7483,6 +7658,7 @@ async function loadBuffers(files, command) {
7483
7658
  sourceText: decoded.sourceText ?? null,
7484
7659
  tuiSourceText: decoded.tuiSourceText ?? null,
7485
7660
  mdcuiRenderWidth: decoded.mdcuiRenderWidth ?? 0,
7661
+ mdcuiImages: decoded.mdcuiImages ?? null,
7486
7662
  });
7487
7663
  if (loadBuffers.context) attachSyntax(stdinBuf, loadBuffers.context, "", stdinText);
7488
7664
  buffers.push(stdinBuf);
@@ -7498,8 +7674,17 @@ async function loadBuffers(files, command) {
7498
7674
  }
7499
7675
 
7500
7676
  async function printReadmeDocs() {
7501
- const readme = readInternalAssetText("README.md") ?? await Bun.file(join(REPO_ROOT, "README.md")).text();
7502
- process.stdout.write(Bun.markdown.ansi(readme, { hyperlinks: true }));
7677
+ process.stdout.write(Bun.markdown.ansi(await bundledReadmeSource(), { hyperlinks: true }));
7678
+ }
7679
+
7680
+ async function bundledReadmeSource() {
7681
+ return readInternalAssetText("README.md") ?? await Bun.file(join(REPO_ROOT, "README.md")).text();
7682
+ }
7683
+
7684
+ async function exportReadme() {
7685
+ const readmePath = resolve("README.md");
7686
+ await Bun.write(readmePath, await bundledReadmeSource());
7687
+ console.log(`Wrote ${readmePath}`);
7503
7688
  }
7504
7689
 
7505
7690
  async function printChangelogDocs() {
@@ -7508,11 +7693,47 @@ async function printChangelogDocs() {
7508
7693
  }
7509
7694
 
7510
7695
  async function printTestappSource() {
7511
- process.stdout.write(await bundledTestappSource());
7696
+ process.stdout.write(await bundledMarkdownSource("testapp.md"));
7697
+ }
7698
+
7699
+ async function bundledMarkdownSource(filename) {
7700
+ return readInternalAssetText(filename) ?? await Bun.file(join(REPO_ROOT, filename)).text();
7701
+ }
7702
+
7703
+ function availableDemoAssets() {
7704
+ let paths;
7705
+ if (hasInternalAssets()) {
7706
+ paths = listInternalAssetPaths("demos");
7707
+ } else {
7708
+ try {
7709
+ paths = readdirSync(join(REPO_ROOT, "demos"), { withFileTypes: true })
7710
+ .filter((entry) => entry.isFile())
7711
+ .map((entry) => assetPath("demos", entry.name));
7712
+ } catch {
7713
+ paths = [];
7714
+ }
7715
+ }
7716
+
7717
+ return [...new Set(paths)]
7718
+ .filter((path) => /^demos\/[^/]+\.md$/i.test(path))
7719
+ .sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
7512
7720
  }
7513
7721
 
7514
- async function bundledTestappSource() {
7515
- return readInternalAssetText("testapp.md") ?? await Bun.file(join(REPO_ROOT, "testapp.md")).text();
7722
+ function printDemoList() {
7723
+ const demos = availableDemoAssets().map((asset) => ({
7724
+ option: `--demo-${asset.slice("demos/".length, -".md".length)}`,
7725
+ asset,
7726
+ }));
7727
+ const entries = [{ option: "--demo", asset: "testapp.md" }, ...demos];
7728
+ const optionWidth = Math.max(...entries.map(({ option }) => option.length));
7729
+
7730
+ console.log("Available demos:\n");
7731
+ for (const { option, asset } of entries) {
7732
+ console.log(` ${option.padEnd(optionWidth)} ${asset}`);
7733
+ }
7734
+ console.log("\nCompatibility aliases:\n");
7735
+ console.log(" --demo-imgtool --demo-image-processor");
7736
+ console.log(" --demo-imgtool-zh --demo-image-processor.zh-TW");
7516
7737
  }
7517
7738
 
7518
7739
  async function main() {
@@ -7528,6 +7749,8 @@ async function main() {
7528
7749
  await buildEarlyExit(null,DEFAULT_BUILD_OUTFILE)
7529
7750
 
7530
7751
  const { flags, files: rawFiles } = parseArgs(process.argv.slice(2));
7752
+ kittyImageMode = flags.kittyMode;
7753
+ allowRemoteKittyImages = flags.allowUrl;
7531
7754
 
7532
7755
  if (flags.help) {
7533
7756
  console.log(usage());
@@ -7558,10 +7781,33 @@ async function main() {
7558
7781
  console.log("Clipboard:", backends);
7559
7782
  return;
7560
7783
  }
7784
+ if (flags.check) {
7785
+ if (rawFiles.length !== 1) {
7786
+ console.error("Usage: jsmdcui --check FILE.md");
7787
+ process.exitCode = 2;
7788
+ return;
7789
+ }
7790
+ const checkPath = resolve(rawFiles[0]);
7791
+ try {
7792
+ const file = Bun.file(checkPath);
7793
+ if (!(await file.exists())) throw new Error("file not found");
7794
+ const result = checkMarkdownIdCollisions(await file.text());
7795
+ process.stdout.write(formatMarkdownIdCheckAnsi(checkPath, result));
7796
+ if (result.collisions.length) process.exitCode = 1;
7797
+ } catch (error) {
7798
+ console.error(`Cannot check ${checkPath}: ${error?.message || error}`);
7799
+ process.exitCode = 2;
7800
+ }
7801
+ return;
7802
+ }
7561
7803
  if (flags.docs) {
7562
7804
  await printReadmeDocs();
7563
7805
  return;
7564
7806
  }
7807
+ if (flags.exportReadme) {
7808
+ await exportReadme();
7809
+ return;
7810
+ }
7565
7811
  if (flags.changelog) {
7566
7812
  await printChangelogDocs();
7567
7813
  return;
@@ -7570,9 +7816,28 @@ async function main() {
7570
7816
  await printTestappSource();
7571
7817
  return;
7572
7818
  }
7819
+ if (flags.demoList) {
7820
+ printDemoList();
7821
+ return;
7822
+ }
7573
7823
  if (flags.demo) {
7574
- const demoPath = resolve("testapp.md");
7575
- await Bun.write(demoPath, await bundledTestappSource());
7824
+ if (flags.demo.error) {
7825
+ console.error(`Invalid demo option ${flags.demo.option}: ${flags.demo.error}`);
7826
+ process.exitCode = 2;
7827
+ return;
7828
+ }
7829
+ let demoSource;
7830
+ try {
7831
+ demoSource = await bundledMarkdownSource(flags.demo.asset);
7832
+ } catch {
7833
+ console.error(`Unknown demo ${flags.demo.option}: ${flags.demo.asset} was not found`);
7834
+ process.exitCode = 2;
7835
+ return;
7836
+ }
7837
+ const demoPath = resolve(flags.demo.filename);
7838
+ if (!(await Bun.file(demoPath).exists())) {
7839
+ await Bun.write(demoPath, demoSource);
7840
+ }
7576
7841
  rawFiles.splice(0, rawFiles.length, demoPath);
7577
7842
  }
7578
7843
  if (flags.options) {
@@ -7655,7 +7920,16 @@ async function main() {
7655
7920
 
7656
7921
  const { files, command } = parseInput(rawFiles);
7657
7922
  const jsPlugins = new JsPluginManager();
7658
- const context = { colorscheme, syntaxDefinitions, config, runtime, jsPlugins, encodingExplicit, allowUrl: flags.allowUrl };
7923
+ const context = {
7924
+ colorscheme,
7925
+ syntaxDefinitions,
7926
+ config,
7927
+ runtime,
7928
+ jsPlugins,
7929
+ encodingExplicit,
7930
+ allowUrl: flags.allowUrl,
7931
+ kittyMode: flags.kittyMode,
7932
+ };
7659
7933
  jsPlugins.setContext(context);
7660
7934
  buildMicroGlobal(jsPlugins); // sets globalThis.micro
7661
7935