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
@@ -0,0 +1,5 @@
1
+ import process from "node:process";
2
+
3
+ export function controllingTerminalInputPath(platform = process.platform) {
4
+ return platform === "win32" ? "CONIN$" : "/dev/tty";
5
+ }
@@ -752,6 +752,56 @@ function _findBlock(lines, selector) {
752
752
  return null;
753
753
  }
754
754
 
755
+ export function findTuiBlockAtLine(lines, lineIndex) {
756
+ return findTuiBlockInIndex(buildTuiBlockIndex(lines), lineIndex);
757
+ }
758
+
759
+ export function buildTuiBlockIndex(lines, declarations = null) {
760
+ const blocks = [];
761
+ for (let start = 0; start < lines.length; start++) {
762
+ const header = _blockHeader(lines[start]);
763
+ if (!header) continue;
764
+ let end = lines.length;
765
+ for (let y = start + 1; y < lines.length; y++) {
766
+ const line = String(lines[y] ?? "");
767
+ const rest = line.startsWith(header.indent)
768
+ ? line.slice(header.indent.length)
769
+ : line;
770
+ if (header.kind === "fenced") {
771
+ const closing = rest.match(/^(`{3,})\s*$/);
772
+ if (!closing || closing[1].length < header.fenceLength) continue;
773
+ } else if (!/^(?:└─|╰─|\+-)\s*$/.test(rest)) {
774
+ continue;
775
+ }
776
+ end = y;
777
+ break;
778
+ }
779
+ const declaration = header.id ? declarations?.get(header.id) : null;
780
+ if (
781
+ declarations == null
782
+ || (
783
+ declaration?.tag === header.tag
784
+ && declaration.events?.size > 0
785
+ )
786
+ ) blocks.push({ start, end, header });
787
+ start = Math.max(start, end);
788
+ }
789
+ return blocks;
790
+ }
791
+
792
+ export function findTuiBlockInIndex(blocks, lineIndex) {
793
+ let low = 0;
794
+ let high = blocks.length - 1;
795
+ while (low <= high) {
796
+ const middle = (low + high) >> 1;
797
+ const block = blocks[middle];
798
+ if (lineIndex <= block.start) high = middle - 1;
799
+ else if (lineIndex >= block.end) low = middle + 1;
800
+ else return block;
801
+ }
802
+ return null;
803
+ }
804
+
755
805
  function _blockValue(lines, selector) {
756
806
  const block = _findBlock(lines, selector);
757
807
  if (!block) return undefined;
@@ -840,32 +890,188 @@ function _tuiHeadingLines(buffer) {
840
890
  }
841
891
 
842
892
  function _headingCheckboxValue(buffer, heading, id) {
893
+ const list = _tuiHeadingTaskList(buffer, heading);
894
+ if (!list) return id.startsWith("select") ? null : [];
895
+ const selected = [];
896
+ for (const item of list.items) {
897
+ const checkbox = String(buffer.lines[item.start] ?? "").match(/^(\s*)([☐☒])(?:\s+|$)(.*)$/);
898
+ if (checkbox?.[2] !== "☒") continue;
899
+ if (id.startsWith("select")) return item.value;
900
+ selected.push(item.value);
901
+ }
902
+ return id.startsWith("select") ? null : selected;
903
+ }
904
+
905
+ function _tuiHeadingTaskList(buffer, heading) {
906
+ const savedAnchor = buffer?._mdcuiHeadingTaskListAnchors?.get(heading.ordinal);
907
+ if (savedAnchor) {
908
+ return {
909
+ first: savedAnchor.index,
910
+ end: savedAnchor.index,
911
+ indent: savedAnchor.indent,
912
+ items: [],
913
+ };
914
+ }
843
915
  const headingLine = _headingTuiLine(buffer, heading);
844
- if (!headingLine || !Array.isArray(buffer?.lines))
845
- return id.startsWith("select") ? null : [];
916
+ if (!headingLine || !Array.isArray(buffer?.lines)) return null;
846
917
 
847
918
  const tuiHeadings = _tuiHeadingLines(buffer);
848
919
  const nextHeading = tuiHeadings[heading.ordinal + 1];
849
920
  const endLine = nextHeading?.line ?? buffer.lines.length + 1;
921
+ let first = -1;
922
+ let indent = "";
850
923
 
851
- let optionIndent = null;
852
- const selected = [];
853
- for (let lineNumber = headingLine + 1; lineNumber < endLine; lineNumber++) {
854
- const line = String(buffer.lines[lineNumber - 1] ?? "");
855
- const checkbox = line.match(/^(\s*)([☐☒])(?:\s+|$)(.*)$/);
924
+ for (let y = headingLine; y < endLine - 1; y++) {
925
+ const checkbox = String(buffer.lines[y] ?? "").match(/^(\s*)[☐☒](?:\s+|$)/);
856
926
  if (!checkbox) continue;
857
- const indent = checkbox[1].length;
858
- if (optionIndent == null) optionIndent = indent;
859
- if (indent !== optionIndent || checkbox[2] !== "☒") continue;
860
- const value = checkbox[3].trim();
861
- if (id.startsWith("select")) return value;
862
- selected.push(value);
927
+ first = y;
928
+ indent = checkbox[1];
929
+ break;
863
930
  }
864
- return id.startsWith("select") ? null : selected;
931
+ if (first < 0) return null;
932
+
933
+ let end = endLine - 1;
934
+ let pendingBlank = -1;
935
+ for (let y = first + 1; y < endLine - 1; y++) {
936
+ const line = String(buffer.lines[y] ?? "");
937
+ if (line.trim() === "") {
938
+ if (pendingBlank < 0) pendingBlank = y;
939
+ continue;
940
+ }
941
+ const leading = line.match(/^\s*/)?.[0].length ?? 0;
942
+ const checkbox = line.match(/^(\s*)[☐☒](?:\s+|$)/);
943
+ if (checkbox || leading > indent.length) {
944
+ pendingBlank = -1;
945
+ continue;
946
+ }
947
+ end = pendingBlank >= 0 ? pendingBlank : y;
948
+ break;
949
+ }
950
+ if (end === endLine - 1 && pendingBlank >= 0) end = pendingBlank;
951
+
952
+ const items = [];
953
+ for (let y = first; y < end; y++) {
954
+ const checkbox = String(buffer.lines[y] ?? "").match(/^(\s*)([☐☒])(?:\s+|$)(.*)$/);
955
+ if (checkbox && checkbox[1] === indent) {
956
+ items.push({
957
+ start: y,
958
+ end,
959
+ value: checkbox[3].trim(),
960
+ });
961
+ }
962
+ }
963
+ for (let index = 0; index < items.length - 1; index++)
964
+ items[index].end = items[index + 1].start;
965
+
966
+ return { first, end, indent, items };
967
+ }
968
+
969
+ function _normalizedSpliceRange(length, argumentCount, start, deleteCount) {
970
+ if (argumentCount === 0) return { start: 0, deleteCount: 0 };
971
+ let relativeStart = Number(start);
972
+ if (Number.isNaN(relativeStart)) relativeStart = 0;
973
+ relativeStart = Math.trunc(relativeStart);
974
+ const actualStart = relativeStart < 0
975
+ ? Math.max(length + relativeStart, 0)
976
+ : Math.min(relativeStart, length);
977
+ if (argumentCount === 1) return { start: actualStart, deleteCount: length - actualStart };
978
+ let requestedDelete = Number(deleteCount);
979
+ if (Number.isNaN(requestedDelete)) requestedDelete = 0;
980
+ requestedDelete = Math.max(0, Math.trunc(requestedDelete));
981
+ return {
982
+ start: actualStart,
983
+ deleteCount: Math.min(requestedDelete, length - actualStart),
984
+ };
985
+ }
986
+
987
+ function _tuiTaskItemReplacement(indent, inputs) {
988
+ const normalized = inputs.map((input) => {
989
+ const item = input && typeof input === "object"
990
+ ? { value: input.value ?? input.label ?? "", checked: Boolean(input.checked) }
991
+ : { value: input ?? "", checked: false };
992
+ return {
993
+ value: String(item.value).replace(/\r?\n/g, " "),
994
+ checked: item.checked,
995
+ };
996
+ });
997
+ return {
998
+ lines: normalized.map((item) =>
999
+ `${indent}${item.checked ? "☒" : "☐"} ${item.value}`,
1000
+ ),
1001
+ styles: normalized.map((item) => {
1002
+ const styles = [];
1003
+ if (item.checked) {
1004
+ styles[indent.length] = { fg: "green" };
1005
+ styles[indent.length + 1] = { fg: "green" };
1006
+ }
1007
+ return styles;
1008
+ }),
1009
+ ansi: normalized.map((item) =>
1010
+ `${indent}\x1b[${item.checked ? "32" : "2"}m${item.checked ? "☒" : "☐"} \x1b[0m${item.value}`,
1011
+ ),
1012
+ };
1013
+ }
1014
+
1015
+ function _mutateTuiHeadingList(buffer, heading, method, args) {
1016
+ const list = _tuiHeadingTaskList(buffer, heading);
1017
+ if (!list) {
1018
+ if (method === "push" || method === "unshift") return 0;
1019
+ if (method === "splice") return [];
1020
+ return undefined;
1021
+ }
1022
+
1023
+ if (method === "splice") {
1024
+ const range = _normalizedSpliceRange(list.items.length, args.length, args[0], args[1]);
1025
+ const removedItems = list.items.slice(range.start, range.start + range.deleteCount);
1026
+ const removed = removedItems.map((item) => item.value);
1027
+ const replacement = _tuiTaskItemReplacement(list.indent, args.slice(2));
1028
+ if (range.deleteCount === 0 && replacement.lines.length === 0) return [];
1029
+ const start = list.items[range.start]?.start ?? list.end;
1030
+ const end = removedItems.at(-1)?.end ?? start;
1031
+ if (range.deleteCount === list.items.length && replacement.lines.length === 0 && list.items.length > 0) {
1032
+ if (!(buffer._mdcuiHeadingTaskListAnchors instanceof Map))
1033
+ buffer._mdcuiHeadingTaskListAnchors = new Map();
1034
+ buffer._mdcuiHeadingTaskListAnchors.set(heading.ordinal, { index: start, indent: list.indent });
1035
+ }
1036
+ spliceTuiBufferLines(buffer, start, end - start, replacement.lines, {
1037
+ styles: replacement.styles,
1038
+ ansi: replacement.ansi,
1039
+ });
1040
+ if (replacement.lines.length > 0)
1041
+ buffer._mdcuiHeadingTaskListAnchors?.delete(heading.ordinal);
1042
+ return removed;
1043
+ }
1044
+
1045
+ if (method === "pop" || method === "shift") {
1046
+ const item = method === "pop" ? list.items.at(-1) : list.items[0];
1047
+ if (!item) return undefined;
1048
+ if (list.items.length === 1) {
1049
+ if (!(buffer._mdcuiHeadingTaskListAnchors instanceof Map))
1050
+ buffer._mdcuiHeadingTaskListAnchors = new Map();
1051
+ buffer._mdcuiHeadingTaskListAnchors.set(heading.ordinal, {
1052
+ index: item.start,
1053
+ indent: list.indent,
1054
+ });
1055
+ }
1056
+ spliceTuiBufferLines(buffer, item.start, item.end - item.start, []);
1057
+ return item.value;
1058
+ }
1059
+
1060
+ const replacement = _tuiTaskItemReplacement(list.indent, args);
1061
+ if (replacement.lines.length === 0) return list.items.length;
1062
+ const start = method === "push" ? list.end : list.first;
1063
+ spliceTuiBufferLines(buffer, start, 0, replacement.lines, {
1064
+ styles: replacement.styles,
1065
+ ansi: replacement.ansi,
1066
+ });
1067
+ if (replacement.lines.length > 0) buffer._mdcuiHeadingTaskListAnchors?.delete(heading.ordinal);
1068
+ return list.items.length + replacement.lines.length;
865
1069
  }
866
1070
 
867
- function _spliceBufferLines(buffer, start, deleteCount, replacement) {
1071
+ export function spliceTuiBufferLines(buffer, start, deleteCount, replacement, replacementMeta = {}) {
868
1072
  const oldCursor = buffer.cursor ? { ...buffer.cursor } : null;
1073
+ buffer._mdcuiFenceBlockIndex = null;
1074
+ buffer._mdcuiControlBlockIndex = null;
869
1075
  buffer.lines.splice(start, deleteCount, ...replacement);
870
1076
 
871
1077
  if (Array.isArray(buffer._ansiStyleLines)) {
@@ -873,15 +1079,29 @@ function _spliceBufferLines(buffer, start, deleteCount, replacement) {
873
1079
  buffer._ansiStyleLines.splice(
874
1080
  start,
875
1081
  deleteCount,
876
- ...replacement.map(() => template),
1082
+ ...(replacementMeta.styles ?? replacement.map(() => template)),
877
1083
  );
878
1084
  }
879
1085
 
880
1086
  if (typeof buffer._mdcuiAnsiText === "string") {
881
1087
  const ansiLines = buffer._mdcuiAnsiText.split("\n");
882
- ansiLines.splice(start, deleteCount, ...replacement);
1088
+ ansiLines.splice(start, deleteCount, ...(replacementMeta.ansi ?? replacement));
883
1089
  buffer._mdcuiAnsiText = ansiLines.join("\n");
884
1090
  }
1091
+ if (Array.isArray(buffer._mdcuiImages)) {
1092
+ const delta = replacement.length - deleteCount;
1093
+ buffer._mdcuiImages = buffer._mdcuiImages
1094
+ .filter((image) => image.line < start || image.line >= start + deleteCount)
1095
+ .map((image) => image.line >= start + deleteCount ? { ...image, line: image.line + delta } : image);
1096
+ }
1097
+ if (buffer._mdcuiHeadingTaskListAnchors instanceof Map) {
1098
+ const oldEnd = start + deleteCount;
1099
+ const delta = replacement.length - deleteCount;
1100
+ for (const anchor of buffer._mdcuiHeadingTaskListAnchors.values()) {
1101
+ if (anchor.index >= oldEnd) anchor.index += delta;
1102
+ else if (anchor.index >= start) anchor.index = start;
1103
+ }
1104
+ }
885
1105
 
886
1106
  buffer.invalidateHighlightFrom?.(start, { force: replacement.length !== deleteCount });
887
1107
  if (oldCursor) {
@@ -897,24 +1117,70 @@ function _spliceBufferLines(buffer, start, deleteCount, replacement) {
897
1117
  buffer.ensureCursor?.();
898
1118
  }
899
1119
 
1120
+ export function insertTuiTextareaNewline(buffer, block) {
1121
+ if (!buffer || block?.header?.tag !== "textarea") return false;
1122
+ const prefix = block.header.indent + block.header.bodyMarker + " ";
1123
+ const line = String(buffer.lines?.[buffer.cursor.y] ?? "");
1124
+ const left = line.slice(0, buffer.cursor.x);
1125
+ const right = line.slice(buffer.cursor.x);
1126
+ buffer.lines[buffer.cursor.y] = left;
1127
+ spliceTuiBufferLines(buffer, buffer.cursor.y + 1, 0, [prefix + right]);
1128
+ buffer.cursor.y++;
1129
+ buffer.cursor.x = prefix.length;
1130
+ buffer.ensureCursor?.();
1131
+ return true;
1132
+ }
1133
+
1134
+ export function mergeTuiTextareaBackward(buffer, block) {
1135
+ if (!buffer || block?.header?.tag !== "textarea") return false;
1136
+ const prefix = block.header.indent + block.header.bodyMarker + " ";
1137
+ if (buffer.cursor.x > prefix.length || buffer.cursor.y <= block.start + 1) return false;
1138
+ const previousY = buffer.cursor.y - 1;
1139
+ const previousLength = String(buffer.lines?.[previousY] ?? "").length;
1140
+ const line = String(buffer.lines?.[buffer.cursor.y] ?? "");
1141
+ const body = line.startsWith(prefix) ? line.slice(prefix.length) : line;
1142
+ buffer.lines[previousY] += body;
1143
+ spliceTuiBufferLines(buffer, buffer.cursor.y, 1, []);
1144
+ buffer.cursor.y = previousY;
1145
+ buffer.cursor.x = previousLength;
1146
+ buffer.ensureCursor?.();
1147
+ return true;
1148
+ }
1149
+
1150
+ export function mergeTuiTextareaForward(buffer, block) {
1151
+ if (!buffer || block?.header?.tag !== "textarea") return false;
1152
+ const line = String(buffer.lines?.[buffer.cursor.y] ?? "");
1153
+ if (buffer.cursor.x < line.length || buffer.cursor.y >= block.end - 1) return false;
1154
+ const prefix = block.header.indent + block.header.bodyMarker + " ";
1155
+ const nextLine = String(buffer.lines?.[buffer.cursor.y + 1] ?? "");
1156
+ const body = nextLine.startsWith(prefix) ? nextLine.slice(prefix.length) : nextLine;
1157
+ buffer.lines[buffer.cursor.y] += body;
1158
+ spliceTuiBufferLines(buffer, buffer.cursor.y + 1, 1, []);
1159
+ buffer.ensureCursor?.();
1160
+ return true;
1161
+ }
1162
+
900
1163
  function _setBlockValue(buffer, selector, value) {
901
1164
  const lines = buffer.lines;
902
1165
  const block = _findBlock(lines, selector);
903
1166
  if (!block) return false;
904
1167
 
905
- const values = String(value ?? "").replace(/\r\n?/g, "\n").split("\n");
1168
+ const normalizedValue = String(value ?? "").replace(/\r\n?/g, "\n");
1169
+ if ((_blockValue(lines, selector) ?? "") === normalizedValue) return true;
1170
+ buffer.pushUndo?.(true);
1171
+ const values = normalizedValue.split("\n");
906
1172
  const contentStart = block.start + 1;
907
1173
  const capacity = Math.max(0, block.end - contentStart);
908
1174
 
909
1175
  if (block.header.kind === "fenced") {
910
1176
  const replacement = values.map((line) => block.header.indent + line);
911
- _spliceBufferLines(buffer, contentStart, capacity, replacement);
1177
+ spliceTuiBufferLines(buffer, contentStart, capacity, replacement);
912
1178
  return true;
913
1179
  }
914
1180
 
915
1181
  const rowPrefix = block.header.indent + block.header.bodyMarker + " ";
916
1182
  const replacement = values.map((line) => rowPrefix + line);
917
- _spliceBufferLines(buffer, contentStart, capacity, replacement);
1183
+ spliceTuiBufferLines(buffer, contentStart, capacity, replacement);
918
1184
  return true;
919
1185
  }
920
1186
 
@@ -961,6 +1227,65 @@ export function createTuiSelector(getBuffer) {
961
1227
  return args.length > 0 ? selection : "";
962
1228
  }
963
1229
  },
1230
+ push(...items) {
1231
+ try {
1232
+ const buffer = getBuffer?.();
1233
+ const heading = _findHeading(buffer, selector);
1234
+ return heading ? _mutateTuiHeadingList(buffer, heading, "push", items) : 0;
1235
+ } catch {
1236
+ return 0;
1237
+ }
1238
+ },
1239
+ pop() {
1240
+ try {
1241
+ const buffer = getBuffer?.();
1242
+ const heading = _findHeading(buffer, selector);
1243
+ return heading ? _mutateTuiHeadingList(buffer, heading, "pop", []) : undefined;
1244
+ } catch {
1245
+ return undefined;
1246
+ }
1247
+ },
1248
+ shift() {
1249
+ try {
1250
+ const buffer = getBuffer?.();
1251
+ const heading = _findHeading(buffer, selector);
1252
+ return heading ? _mutateTuiHeadingList(buffer, heading, "shift", []) : undefined;
1253
+ } catch {
1254
+ return undefined;
1255
+ }
1256
+ },
1257
+ unshift(...items) {
1258
+ try {
1259
+ const buffer = getBuffer?.();
1260
+ const heading = _findHeading(buffer, selector);
1261
+ return heading ? _mutateTuiHeadingList(buffer, heading, "unshift", items) : 0;
1262
+ } catch {
1263
+ return 0;
1264
+ }
1265
+ },
1266
+ splice(...args) {
1267
+ try {
1268
+ const buffer = getBuffer?.();
1269
+ const heading = _findHeading(buffer, selector);
1270
+ return heading ? _mutateTuiHeadingList(buffer, heading, "splice", args) : [];
1271
+ } catch {
1272
+ return [];
1273
+ }
1274
+ },
1275
+ slice(...args) {
1276
+ try {
1277
+ const buffer = getBuffer?.();
1278
+ const heading = _findHeading(buffer, selector);
1279
+ const list = heading ? _tuiHeadingTaskList(buffer, heading) : null;
1280
+ if (!list) return [];
1281
+ return list.items.map((item) => ({
1282
+ value: item.value,
1283
+ checked: String(buffer.lines[item.start] ?? "").match(/^(\s*)([☐☒])(?:\s+|$)/)?.[2] === "☒",
1284
+ })).slice(...args);
1285
+ } catch {
1286
+ return [];
1287
+ }
1288
+ },
964
1289
  };
965
1290
  return selection;
966
1291
  };
@@ -1002,7 +1327,12 @@ export function buildMicroGlobal(jsManager) {
1002
1327
  // ── Messaging ─────────────────────────────────────────────────
1003
1328
  Log: (...args) => console.log(...args),
1004
1329
  TermMessage: (msg) => { const app = getApp(); if (app) { app.message = String(msg); if (app._started) app.render?.(); } },
1005
- alert: async (msg) => { const app = getApp(); if (app) await app.runAlert(msg); else console.log(String(msg)); },
1330
+ alert: (msg) => { const app = getApp(); return app ? app.protectedAlert(msg) : console.log(String(msg)); },
1331
+ confirm: (msg) => { const app = getApp(); return app ? app.protectedConfirm(msg) : false; },
1332
+ prompt: (msg, defaultValue = "") => {
1333
+ const app = getApp();
1334
+ return app ? app.protectedPrompt(msg, defaultValue) : defaultValue;
1335
+ },
1006
1336
 
1007
1337
  // ── Buffer line access (1-based line numbers; omit → cursor line) ─
1008
1338
 
@@ -1023,6 +1353,7 @@ export function buildMicroGlobal(jsManager) {
1023
1353
  const y = lineNumber != null ? Number(lineNumber) - 1 : buf.cursor.y;
1024
1354
  if (y < 0 || y >= buf.lines.length) return;
1025
1355
  buf.pushUndo?.();
1356
+ buf._mdcuiFenceBlockIndex = null;
1026
1357
  const parts = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
1027
1358
  buf.lines.splice(y, 1, ...parts);
1028
1359
  buf.invalidateHighlightFrom?.(y, { force: parts.length > 1 });
@@ -1039,6 +1370,7 @@ export function buildMicroGlobal(jsManager) {
1039
1370
  const y = lineNumber != null ? Number(lineNumber) - 1 : buf.cursor.y;
1040
1371
  if (y < 0 || y >= buf.lines.length) return;
1041
1372
  buf.pushUndo?.();
1373
+ buf._mdcuiFenceBlockIndex = null;
1042
1374
  if (buf.lines.length === 1) {
1043
1375
  buf.lines[0] = "";
1044
1376
  } else {
@@ -1079,6 +1411,7 @@ export function buildMicroGlobal(jsManager) {
1079
1411
  if (!app?.buffer) return;
1080
1412
  const buf = app.buffer;
1081
1413
  buf.pushUndo?.();
1414
+ buf._mdcuiFenceBlockIndex = null;
1082
1415
  buf.lines = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
1083
1416
  if (buf.lines.length === 0) buf.lines = [""];
1084
1417
  buf.invalidateHighlightFrom?.(0, { force: true });
@@ -2,6 +2,7 @@ import process from "node:process";
2
2
  import { styleToAnsi } from "../display/ansi-style.js";
3
3
  import { CellBuffer } from "./cell-buffer.js";
4
4
  import { DISABLE_MOUSE, DISABLE_PASTE, ENABLE_MOUSE, ENABLE_PASTE, ResizeEvent } from "./events.js";
5
+ import { logKittyPlacement } from "../cui/kitty-debug.mjs";
5
6
 
6
7
  const CURSOR_SHAPE_SEQUENCE = {
7
8
  default: "\x1b[0 q",
@@ -13,28 +14,45 @@ const CURSOR_SHAPE_SEQUENCE = {
13
14
  bar: "\x1b[6 q",
14
15
  };
15
16
 
17
+ const KITTY_APC = "\x1b_G";
18
+ const KITTY_ST = "\x1b\\";
19
+ const KITTY_CHUNK_SIZE = 4096;
20
+
16
21
  export function cursorShapeSequence(shape) {
17
22
  return CURSOR_SHAPE_SEQUENCE[shape] ?? CURSOR_SHAPE_SEQUENCE.block;
18
23
  }
19
24
 
20
25
  export class Screen {
21
- constructor({ mouse = true } = {}) {
26
+ constructor({ mouse = true, kittyMode = "off" } = {}) {
22
27
  this.mouse = mouse;
28
+ this.kittyMode = kittyMode;
23
29
  this.cols = process.stdout.columns || 80;
24
30
  this.rows = process.stdout.rows || 24;
25
31
  this.cells = new CellBuffer(this.cols, this.rows);
26
32
  this.previous = null;
27
33
  this.cursor = null;
28
34
  this.cursorVisible = false;
35
+ this.kittyImages = [];
36
+ this._shownKittySignature = "";
37
+ this._shownKittyIds = [];
38
+ this._transmittedKittyIds = new Set();
29
39
  }
30
40
 
31
41
  init() {
42
+ this._shownKittySignature = "";
43
+ this._shownKittyIds = [];
44
+ this._transmittedKittyIds.clear();
32
45
  this.write("\x1b[?1049h\x1b[?25l");
33
46
  if (this.mouse) this.write(ENABLE_MOUSE);
34
47
  this.write(ENABLE_PASTE);
35
48
  }
36
49
 
37
50
  fini() {
51
+ for (const id of this._transmittedKittyIds)
52
+ this.write(`${KITTY_APC}a=d,d=I,i=${id},q=2;${KITTY_ST}`);
53
+ this._shownKittySignature = "";
54
+ this._shownKittyIds = [];
55
+ this._transmittedKittyIds.clear();
38
56
  if (this.mouse) this.write(DISABLE_MOUSE);
39
57
  this.write(DISABLE_PASTE);
40
58
  this.write("\x1b[0 q\x1b[?25h\x1b[?1049l\x1b[0m");
@@ -97,6 +115,74 @@ export class Screen {
97
115
  if (this.cursor && this.cursorVisible) {
98
116
  out += cursorShapeSequence(this.cursor.shape) + this.move(this.cursor.y + 1, this.cursor.x + 1) + "\x1b[?25h";
99
117
  } else out += "\x1b[?25l";
118
+ const kittySignature = this.kittyImages.map((image) =>
119
+ `${image.id}:${image.placementId ?? image.id}:${image.x}:${image.y}:${image.cols}:${image.rows}:${image.sourceX ?? 0}:${image.sourceY ?? 0}:${image.sourceWidth ?? 0}:${image.sourceHeight ?? 0}`
120
+ ).join("|");
121
+ if (kittySignature !== this._shownKittySignature) {
122
+ logKittyPlacement("screen-overlay-change", {
123
+ previousSignature: this._shownKittySignature,
124
+ nextSignature: kittySignature,
125
+ deletingImageIds: this._shownKittyIds,
126
+ terminalCols: this.cols,
127
+ terminalRows: this.rows,
128
+ textCursor: this.cursor,
129
+ textCursorVisible: this.cursorVisible,
130
+ });
131
+ for (const id of this._shownKittyIds)
132
+ out += `${KITTY_APC}a=d,d=i,i=${id},q=2;${KITTY_ST}`;
133
+ for (const image of this.kittyImages) {
134
+ const transmitted = this._transmittedKittyIds.has(image.id);
135
+ logKittyPlacement("screen-placement-packet", {
136
+ imageId: image.id,
137
+ placementId: image.placementId ?? image.id,
138
+ zeroBasedX: image.x,
139
+ zeroBasedY: image.y,
140
+ csiCol: image.x + 1,
141
+ csiRow: image.y + 1,
142
+ cols: image.cols,
143
+ rows: image.rows,
144
+ sourceRect: image.sourceWidth && image.sourceHeight ? {
145
+ x: image.sourceX ?? 0,
146
+ y: image.sourceY ?? 0,
147
+ width: image.sourceWidth,
148
+ height: image.sourceHeight,
149
+ } : null,
150
+ C: 1,
151
+ mime: image.mime,
152
+ bytes: image.data?.length ?? 0,
153
+ action: transmitted ? "p" : "T",
154
+ });
155
+ out += this.move(image.y + 1, image.x + 1);
156
+ const placementFields = [
157
+ `i=${image.id}`, `p=${image.placementId ?? image.id}`, "q=2",
158
+ ...(image.sourceWidth && image.sourceHeight ? [
159
+ `x=${image.sourceX ?? 0}`,
160
+ `y=${image.sourceY ?? 0}`,
161
+ `w=${image.sourceWidth}`,
162
+ `h=${image.sourceHeight}`,
163
+ ] : []),
164
+ `c=${image.cols}`, `r=${image.rows}`, "C=1",
165
+ ...(this.kittyMode === "extended" && image.mime ? [`U=${image.mime}`] : []),
166
+ ];
167
+ if (transmitted) {
168
+ out += `${KITTY_APC}a=p,${placementFields.join(",")};${KITTY_ST}`;
169
+ continue;
170
+ }
171
+ const base64 = Buffer.from(image.data).toString("base64");
172
+ for (let offset = 0; offset < base64.length; offset += KITTY_CHUNK_SIZE) {
173
+ const payload = base64.slice(offset, offset + KITTY_CHUNK_SIZE);
174
+ const more = offset + KITTY_CHUNK_SIZE < base64.length;
175
+ const fields = [
176
+ "a=T", "f=100", "t=d", ...placementFields, `m=${more ? 1 : 0}`,
177
+ ];
178
+ out += `${KITTY_APC}${fields.join(",")};${payload}${KITTY_ST}`;
179
+ }
180
+ this._transmittedKittyIds.add(image.id);
181
+ }
182
+ if (this.cursor && this.cursorVisible) out += this.move(this.cursor.y + 1, this.cursor.x + 1);
183
+ this._shownKittySignature = kittySignature;
184
+ this._shownKittyIds = [...new Set(this.kittyImages.map((image) => image.id))];
185
+ }
100
186
  this.write(out);
101
187
  this.previous = this.cells.clone();
102
188
  }
@@ -110,6 +196,26 @@ export class Screen {
110
196
  this.cursorVisible = visible;
111
197
  }
112
198
 
199
+ setKittyImages(images = []) {
200
+ this.kittyImages = this.kittyMode === "off" ? [] : images;
201
+ logKittyPlacement("screen-frame-images", {
202
+ count: this.kittyImages.length,
203
+ kittyMode: this.kittyMode,
204
+ images: this.kittyImages.map((image) => ({
205
+ imageId: image.id,
206
+ placementId: image.placementId ?? image.id,
207
+ x: image.x,
208
+ y: image.y,
209
+ cols: image.cols,
210
+ rows: image.rows,
211
+ sourceX: image.sourceX ?? null,
212
+ sourceY: image.sourceY ?? null,
213
+ sourceWidth: image.sourceWidth ?? null,
214
+ sourceHeight: image.sourceHeight ?? null,
215
+ })),
216
+ });
217
+ }
218
+
113
219
  hideCursor() {
114
220
  this.cursorVisible = false;
115
221
  this.write("\x1b[?25l");
@@ -133,6 +239,7 @@ export class Screen {
133
239
  this.rows = process.stdout.rows || this.rows;
134
240
  this.cells.resize(this.cols, this.rows);
135
241
  this.previous = null;
242
+ this._shownKittySignature = "";
136
243
  return new ResizeEvent(this.cols, this.rows);
137
244
  }
138
245
 
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
 
7
7
  const tui = join(import.meta.dir, "..", "tui");
8
+ const bunBin = Bun.which("bun") || process.argv0;
8
9
 
9
10
  test("cat renders .md files once with the implicit mdcui encoding", async () => {
10
11
  const dir = await mkdtemp(join(tmpdir(), "jsmdcui-cat-"));
@@ -12,12 +13,12 @@ test("cat renders .md files once with the implicit mdcui encoding", async () =>
12
13
  await writeFile(markdownPath, "# Heading\n\n- one\n- two\n");
13
14
 
14
15
  try {
15
- const implicit = Bun.spawnSync([tui, "-cat", markdownPath], {
16
+ const implicit = Bun.spawnSync([bunBin, tui, "-cat", markdownPath], {
16
17
  cwd: dir,
17
18
  stdout: "pipe",
18
19
  stderr: "pipe",
19
20
  });
20
- const explicit = Bun.spawnSync([tui, "-cat", "-encoding", "mdcui", markdownPath], {
21
+ const explicit = Bun.spawnSync([bunBin, tui, "-cat", "-encoding", "mdcui", markdownPath], {
21
22
  cwd: dir,
22
23
  stdout: "pipe",
23
24
  stderr: "pipe",
@@ -37,7 +38,7 @@ test("an explicit utf8 encoding overrides the .md mdcui default", async () => {
37
38
  await writeFile(markdownPath, "# Heading\n\n```js front\nalert('kept')\n```\n");
38
39
 
39
40
  try {
40
- const result = Bun.spawnSync([tui, "-cat", "-encoding", "utf8", markdownPath], {
41
+ const result = Bun.spawnSync([bunBin, tui, "-cat", "-encoding", "utf8", markdownPath], {
41
42
  cwd: dir,
42
43
  stdout: "pipe",
43
44
  stderr: "pipe",
@@ -58,12 +59,12 @@ test("--edit overrides the .md mdcui default with utf8", async () => {
58
59
  await writeFile(markdownPath, "# Heading\n\n```js front\nalert('editable')\n```\n");
59
60
 
60
61
  try {
61
- const edit = Bun.spawnSync([tui, "-cat", "--edit", markdownPath], {
62
+ const edit = Bun.spawnSync([bunBin, tui, "-cat", "--edit", markdownPath], {
62
63
  cwd: dir,
63
64
  stdout: "pipe",
64
65
  stderr: "pipe",
65
66
  });
66
- const utf8 = Bun.spawnSync([tui, "-cat", "-encoding", "utf8", markdownPath], {
67
+ const utf8 = Bun.spawnSync([bunBin, tui, "-cat", "-encoding", "utf8", markdownPath], {
67
68
  cwd: dir,
68
69
  stdout: "pipe",
69
70
  stderr: "pipe",