jsmdcui 0.7.0 → 0.9.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 (44) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +189 -19
  3. package/cdp-maze.js +141 -0
  4. package/demos/maze.md +149 -0
  5. package/demos/todo-zh.md +98 -0
  6. package/demos/todo.md +98 -0
  7. package/edit +9 -0
  8. package/llm-maze.txt +94 -0
  9. package/package.json +1 -1
  10. package/runmd.mjs +44 -2
  11. package/runtime/help/cdp.md +5 -0
  12. package/runtime/help/help.md +189 -19
  13. package/runtime/jsplugins/cdp/cdp-server.js +26 -2
  14. package/runtime/jsplugins/cdp/cdp.js +60 -51
  15. package/runtime/jsplugins/chapter/chapter.js +6 -3
  16. package/runtime/jsplugins/example/example.js +12 -7
  17. package/runtime/syntax/markdown.yaml +1 -0
  18. package/single-exe/README.md +223 -45
  19. package/single-exe/compiled.js +6 -3
  20. package/single-exe/entry.mjs +4 -3
  21. package/single-exe/packAssets.sh +1 -1
  22. package/src/cui/fence-events.mjs +106 -0
  23. package/src/cui/id-collision.mjs +10 -28
  24. package/src/cui/kitty-debug.mjs +1 -1
  25. package/src/cui/kitty-images.mjs +11 -1
  26. package/src/cui/rpc.mjs +6 -3
  27. package/src/cui/server.mjs +13 -2
  28. package/src/index.js +425 -72
  29. package/src/lua/engine.js +1 -4
  30. package/src/platform/terminal.js +5 -0
  31. package/src/plugins/js-bridge.js +157 -8
  32. package/tests/compiled-runtime.test.js +8 -0
  33. package/tests/demo.test.js +40 -0
  34. package/tests/fence-events.test.js +405 -0
  35. package/tests/id-collision.test.js +11 -0
  36. package/tests/js-plugin-prompts.test.js +46 -0
  37. package/tests/key-event.md +10 -0
  38. package/tests/kitty-demo.md +1 -1
  39. package/tests/kitty-images.test.js +22 -0
  40. package/tests/platform-terminal.test.js +8 -0
  41. package/tests/textarea.md +8 -0
  42. package/tests/wui.test.js +16 -1
  43. package/demo.resized.jpg +0 -0
  44. package/src/runtime/compiled.js +0 -25
package/src/lua/engine.js CHANGED
@@ -2,7 +2,6 @@ import { existsSync } from "node:fs";
2
2
  import { readInternalAssetBytes } from "../runtime/assets.js";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { isCompiledBinary, resolveCompiledBaseDir } from "../runtime/compiled.js";
6
5
  import { REPO_ROOT } from "../../single-exe/compiled.js";
7
6
 
8
7
  export async function createLuaEngine() {
@@ -44,9 +43,7 @@ async function resolveLuaWasmLocation() {
44
43
  }
45
44
  }
46
45
 
47
- const fallbackPath = isCompiledBinary(process.argv)
48
- ? join(resolveCompiledBaseDir({ argv: process.argv }), wasmAssetPath)
49
- : join(REPO_ROOT, wasmAssetPath);
46
+ const fallbackPath = join(REPO_ROOT, wasmAssetPath);
50
47
 
51
48
  return existsSync(fallbackPath) ? fallbackPath : undefined;
52
49
  }
@@ -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
+ }
@@ -4,6 +4,7 @@ import { dirname, join, basename, extname } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { tmpdir } from "node:os";
6
6
  import { assetPath, hasInternalAssets, listInternalAssetDirs, listInternalAssetPaths, readInternalAssetBytes } from "../runtime/assets.js";
7
+ import { isMdcuiEncoding } from "../runtime/encodings.js";
7
8
  import { newMessage, newMessageAtLine, MTError, MTWarning, MTInfo } from "../buffer/message.js";
8
9
  import { Loc } from "../buffer/loc.js";
9
10
 
@@ -752,6 +753,56 @@ function _findBlock(lines, selector) {
752
753
  return null;
753
754
  }
754
755
 
756
+ export function findTuiBlockAtLine(lines, lineIndex) {
757
+ return findTuiBlockInIndex(buildTuiBlockIndex(lines), lineIndex);
758
+ }
759
+
760
+ export function buildTuiBlockIndex(lines, declarations = null) {
761
+ const blocks = [];
762
+ for (let start = 0; start < lines.length; start++) {
763
+ const header = _blockHeader(lines[start]);
764
+ if (!header) continue;
765
+ let end = lines.length;
766
+ for (let y = start + 1; y < lines.length; y++) {
767
+ const line = String(lines[y] ?? "");
768
+ const rest = line.startsWith(header.indent)
769
+ ? line.slice(header.indent.length)
770
+ : line;
771
+ if (header.kind === "fenced") {
772
+ const closing = rest.match(/^(`{3,})\s*$/);
773
+ if (!closing || closing[1].length < header.fenceLength) continue;
774
+ } else if (!/^(?:└─|╰─|\+-)\s*$/.test(rest)) {
775
+ continue;
776
+ }
777
+ end = y;
778
+ break;
779
+ }
780
+ const declaration = header.id ? declarations?.get(header.id) : null;
781
+ if (
782
+ declarations == null
783
+ || (
784
+ declaration?.tag === header.tag
785
+ && declaration.events?.size > 0
786
+ )
787
+ ) blocks.push({ start, end, header });
788
+ start = Math.max(start, end);
789
+ }
790
+ return blocks;
791
+ }
792
+
793
+ export function findTuiBlockInIndex(blocks, lineIndex) {
794
+ let low = 0;
795
+ let high = blocks.length - 1;
796
+ while (low <= high) {
797
+ const middle = (low + high) >> 1;
798
+ const block = blocks[middle];
799
+ if (lineIndex <= block.start) high = middle - 1;
800
+ else if (lineIndex >= block.end) low = middle + 1;
801
+ else return block;
802
+ }
803
+ return null;
804
+ }
805
+
755
806
  function _blockValue(lines, selector) {
756
807
  const block = _findBlock(lines, selector);
757
808
  if (!block) return undefined;
@@ -983,7 +1034,7 @@ function _mutateTuiHeadingList(buffer, heading, method, args) {
983
1034
  buffer._mdcuiHeadingTaskListAnchors = new Map();
984
1035
  buffer._mdcuiHeadingTaskListAnchors.set(heading.ordinal, { index: start, indent: list.indent });
985
1036
  }
986
- _spliceBufferLines(buffer, start, end - start, replacement.lines, {
1037
+ spliceTuiBufferLines(buffer, start, end - start, replacement.lines, {
987
1038
  styles: replacement.styles,
988
1039
  ansi: replacement.ansi,
989
1040
  });
@@ -1003,14 +1054,14 @@ function _mutateTuiHeadingList(buffer, heading, method, args) {
1003
1054
  indent: list.indent,
1004
1055
  });
1005
1056
  }
1006
- _spliceBufferLines(buffer, item.start, item.end - item.start, []);
1057
+ spliceTuiBufferLines(buffer, item.start, item.end - item.start, []);
1007
1058
  return item.value;
1008
1059
  }
1009
1060
 
1010
1061
  const replacement = _tuiTaskItemReplacement(list.indent, args);
1011
1062
  if (replacement.lines.length === 0) return list.items.length;
1012
1063
  const start = method === "push" ? list.end : list.first;
1013
- _spliceBufferLines(buffer, start, 0, replacement.lines, {
1064
+ spliceTuiBufferLines(buffer, start, 0, replacement.lines, {
1014
1065
  styles: replacement.styles,
1015
1066
  ansi: replacement.ansi,
1016
1067
  });
@@ -1018,8 +1069,10 @@ function _mutateTuiHeadingList(buffer, heading, method, args) {
1018
1069
  return list.items.length + replacement.lines.length;
1019
1070
  }
1020
1071
 
1021
- function _spliceBufferLines(buffer, start, deleteCount, replacement, replacementMeta = {}) {
1072
+ export function spliceTuiBufferLines(buffer, start, deleteCount, replacement, replacementMeta = {}) {
1022
1073
  const oldCursor = buffer.cursor ? { ...buffer.cursor } : null;
1074
+ buffer._mdcuiFenceBlockIndex = null;
1075
+ buffer._mdcuiControlBlockIndex = null;
1023
1076
  buffer.lines.splice(start, deleteCount, ...replacement);
1024
1077
 
1025
1078
  if (Array.isArray(buffer._ansiStyleLines)) {
@@ -1065,24 +1118,70 @@ function _spliceBufferLines(buffer, start, deleteCount, replacement, replacement
1065
1118
  buffer.ensureCursor?.();
1066
1119
  }
1067
1120
 
1121
+ export function insertTuiTextareaNewline(buffer, block) {
1122
+ if (!buffer || block?.header?.tag !== "textarea") return false;
1123
+ const prefix = block.header.indent + block.header.bodyMarker + " ";
1124
+ const line = String(buffer.lines?.[buffer.cursor.y] ?? "");
1125
+ const left = line.slice(0, buffer.cursor.x);
1126
+ const right = line.slice(buffer.cursor.x);
1127
+ buffer.lines[buffer.cursor.y] = left;
1128
+ spliceTuiBufferLines(buffer, buffer.cursor.y + 1, 0, [prefix + right]);
1129
+ buffer.cursor.y++;
1130
+ buffer.cursor.x = prefix.length;
1131
+ buffer.ensureCursor?.();
1132
+ return true;
1133
+ }
1134
+
1135
+ export function mergeTuiTextareaBackward(buffer, block) {
1136
+ if (!buffer || block?.header?.tag !== "textarea") return false;
1137
+ const prefix = block.header.indent + block.header.bodyMarker + " ";
1138
+ if (buffer.cursor.x > prefix.length || buffer.cursor.y <= block.start + 1) return false;
1139
+ const previousY = buffer.cursor.y - 1;
1140
+ const previousLength = String(buffer.lines?.[previousY] ?? "").length;
1141
+ const line = String(buffer.lines?.[buffer.cursor.y] ?? "");
1142
+ const body = line.startsWith(prefix) ? line.slice(prefix.length) : line;
1143
+ buffer.lines[previousY] += body;
1144
+ spliceTuiBufferLines(buffer, buffer.cursor.y, 1, []);
1145
+ buffer.cursor.y = previousY;
1146
+ buffer.cursor.x = previousLength;
1147
+ buffer.ensureCursor?.();
1148
+ return true;
1149
+ }
1150
+
1151
+ export function mergeTuiTextareaForward(buffer, block) {
1152
+ if (!buffer || block?.header?.tag !== "textarea") return false;
1153
+ const line = String(buffer.lines?.[buffer.cursor.y] ?? "");
1154
+ if (buffer.cursor.x < line.length || buffer.cursor.y >= block.end - 1) return false;
1155
+ const prefix = block.header.indent + block.header.bodyMarker + " ";
1156
+ const nextLine = String(buffer.lines?.[buffer.cursor.y + 1] ?? "");
1157
+ const body = nextLine.startsWith(prefix) ? nextLine.slice(prefix.length) : nextLine;
1158
+ buffer.lines[buffer.cursor.y] += body;
1159
+ spliceTuiBufferLines(buffer, buffer.cursor.y + 1, 1, []);
1160
+ buffer.ensureCursor?.();
1161
+ return true;
1162
+ }
1163
+
1068
1164
  function _setBlockValue(buffer, selector, value) {
1069
1165
  const lines = buffer.lines;
1070
1166
  const block = _findBlock(lines, selector);
1071
1167
  if (!block) return false;
1072
1168
 
1073
- const values = String(value ?? "").replace(/\r\n?/g, "\n").split("\n");
1169
+ const normalizedValue = String(value ?? "").replace(/\r\n?/g, "\n");
1170
+ if ((_blockValue(lines, selector) ?? "") === normalizedValue) return true;
1171
+ buffer.pushUndo?.(true);
1172
+ const values = normalizedValue.split("\n");
1074
1173
  const contentStart = block.start + 1;
1075
1174
  const capacity = Math.max(0, block.end - contentStart);
1076
1175
 
1077
1176
  if (block.header.kind === "fenced") {
1078
1177
  const replacement = values.map((line) => block.header.indent + line);
1079
- _spliceBufferLines(buffer, contentStart, capacity, replacement);
1178
+ spliceTuiBufferLines(buffer, contentStart, capacity, replacement);
1080
1179
  return true;
1081
1180
  }
1082
1181
 
1083
1182
  const rowPrefix = block.header.indent + block.header.bodyMarker + " ";
1084
1183
  const replacement = values.map((line) => rowPrefix + line);
1085
- _spliceBufferLines(buffer, contentStart, capacity, replacement);
1184
+ spliceTuiBufferLines(buffer, contentStart, capacity, replacement);
1086
1185
  return true;
1087
1186
  }
1088
1187
 
@@ -1229,7 +1328,12 @@ export function buildMicroGlobal(jsManager) {
1229
1328
  // ── Messaging ─────────────────────────────────────────────────
1230
1329
  Log: (...args) => console.log(...args),
1231
1330
  TermMessage: (msg) => { const app = getApp(); if (app) { app.message = String(msg); if (app._started) app.render?.(); } },
1232
- alert: async (msg) => { const app = getApp(); if (app) await app.runAlert(msg); else console.log(String(msg)); },
1331
+ alert: (msg) => { const app = getApp(); return app ? app.protectedAlert(msg) : console.log(String(msg)); },
1332
+ confirm: (msg) => { const app = getApp(); return app ? app.protectedConfirm(msg) : false; },
1333
+ prompt: (msg, defaultValue = "") => {
1334
+ const app = getApp();
1335
+ return app ? app.protectedPrompt(msg, defaultValue) : defaultValue;
1336
+ },
1233
1337
 
1234
1338
  // ── Buffer line access (1-based line numbers; omit → cursor line) ─
1235
1339
 
@@ -1250,6 +1354,7 @@ export function buildMicroGlobal(jsManager) {
1250
1354
  const y = lineNumber != null ? Number(lineNumber) - 1 : buf.cursor.y;
1251
1355
  if (y < 0 || y >= buf.lines.length) return;
1252
1356
  buf.pushUndo?.();
1357
+ buf._mdcuiFenceBlockIndex = null;
1253
1358
  const parts = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
1254
1359
  buf.lines.splice(y, 1, ...parts);
1255
1360
  buf.invalidateHighlightFrom?.(y, { force: parts.length > 1 });
@@ -1266,6 +1371,7 @@ export function buildMicroGlobal(jsManager) {
1266
1371
  const y = lineNumber != null ? Number(lineNumber) - 1 : buf.cursor.y;
1267
1372
  if (y < 0 || y >= buf.lines.length) return;
1268
1373
  buf.pushUndo?.();
1374
+ buf._mdcuiFenceBlockIndex = null;
1269
1375
  if (buf.lines.length === 1) {
1270
1376
  buf.lines[0] = "";
1271
1377
  } else {
@@ -1300,12 +1406,55 @@ export function buildMicroGlobal(jsManager) {
1300
1406
  return app?.buffer?.lines.join("\n") ?? "";
1301
1407
  },
1302
1408
 
1409
+ // Returns the rendered ANSI document when available, otherwise plain text.
1410
+ getAllAnsiText() {
1411
+ const buffer = getApp()?.buffer;
1412
+ return typeof buffer?._mdcuiAnsiText === "string"
1413
+ ? buffer._mdcuiAnsiText
1414
+ : buffer?.lines.join("\n") ?? "";
1415
+ },
1416
+
1417
+ // Activates a 1-based rendered-buffer cell in MDCUI; otherwise falls back to goto.
1418
+ async clickBufferCell(column = 1, line = 1) {
1419
+ const app = getApp();
1420
+ const buffer = app?.buffer;
1421
+ if (!app || !buffer) return false;
1422
+
1423
+ const targetLine = Math.max(1, Math.trunc(Number(line)) || 1);
1424
+ const targetColumn = Math.max(1, Math.trunc(Number(column)) || 1);
1425
+ if (!isMdcuiEncoding(buffer.encoding ?? buffer.Settings?.encoding)) {
1426
+ await app.handleCommand(`goto ${targetLine}:${targetColumn}`);
1427
+ app.render?.();
1428
+ return true;
1429
+ }
1430
+
1431
+ const y = Math.min(targetLine - 1, Math.max(0, buffer.lines.length - 1));
1432
+ const x = Math.min(targetColumn - 1, String(buffer.lines[y] ?? "").length);
1433
+ buffer.cursor = { x, y };
1434
+ const handled = await app.handleMdcuiCellCallback(buffer, y, x, "mouse");
1435
+ app.render?.();
1436
+ return handled;
1437
+ },
1438
+
1439
+ // Sends terminal input through the App's normal parser and event pipeline.
1440
+ async _dispatchRawInput(raw) {
1441
+ const app = getApp();
1442
+ if (!app) return false;
1443
+ const bytes = typeof raw === "string"
1444
+ ? new TextEncoder().encode(raw)
1445
+ : raw;
1446
+ await app._dispatchInput(bytes);
1447
+ app.render?.();
1448
+ return true;
1449
+ },
1450
+
1303
1451
  // Replaces the entire buffer content with text (may contain newlines).
1304
1452
  putAllText(text) {
1305
1453
  const app = getApp();
1306
1454
  if (!app?.buffer) return;
1307
1455
  const buf = app.buffer;
1308
1456
  buf.pushUndo?.();
1457
+ buf._mdcuiFenceBlockIndex = null;
1309
1458
  buf.lines = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
1310
1459
  if (buf.lines.length === 0) buf.lines = [""];
1311
1460
  buf.invalidateHighlightFrom?.(0, { force: true });
@@ -0,0 +1,8 @@
1
+ import { expect, test } from "bun:test";
2
+ import { isCompiledBinary as isSingleExeCompiled } from "../single-exe/compiled.js";
3
+
4
+ test("single-exe recognizes Bun compiled virtual paths", () => {
5
+ expect(isSingleExeCompiled(["bun", "/$bunfs/root/app.js"])).toBe(true);
6
+ expect(isSingleExeCompiled(["bun", "B:/~BUN/root/app.js"])).toBe(true);
7
+ expect(isSingleExeCompiled(["bun", "/project/src/index.js"])).toBe(false);
8
+ });
@@ -21,6 +21,7 @@ test("--help describes the non-overwriting demo behavior", () => {
21
21
  expect(output.match(/Open it in the TUI and write 5 generated files beside it/g)?.length).toBe(2);
22
22
  expect(output).toContain("--demo-imgtool");
23
23
  expect(output).toContain("--demo-imgtool-zh");
24
+ expect(output).toContain("--cdp-maze");
24
25
  });
25
26
 
26
27
  test("--demo-list lists root and automatically discovered demos", () => {
@@ -38,6 +39,27 @@ test("--demo-list lists root and automatically discovered demos", () => {
38
39
  expect(output).toContain("--demo-imgtool --demo-image-processor");
39
40
  });
40
41
 
42
+ test("--export-cdp-maze writes and overwrites the bundled solver", async () => {
43
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-export-cdp-maze-"));
44
+ const outputPath = join(dir, "cdp-maze.js");
45
+ try {
46
+ await writeFile(outputPath, "old solver\n");
47
+ const result = Bun.spawnSync([bunBin, tui, "--export-cdp-maze"], {
48
+ cwd: dir,
49
+ stdout: "pipe",
50
+ stderr: "pipe",
51
+ });
52
+
53
+ expect(result.exitCode).toBe(0);
54
+ expect(result.stdout.toString()).toContain(`Wrote ${outputPath}`);
55
+ const exported = await readFile(outputPath, "utf8");
56
+ expect(exported).toContain("export async function runCdpMaze");
57
+ expect(exported).not.toContain("old solver");
58
+ } finally {
59
+ await rm(dir, { recursive: true, force: true });
60
+ }
61
+ });
62
+
41
63
  test("--demo writes bundled testapp.md to cwd before opening it", async () => {
42
64
  const dir = await mkdtemp(join(tmpdir(), "jsmdcui-demo-"));
43
65
  try {
@@ -94,6 +116,24 @@ test("--demo-<filename> automatically loads a matching demos file", async () =>
94
116
  }
95
117
  });
96
118
 
119
+ test("--cdp-maze loads the maze demo", async () => {
120
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-cdp-maze-"));
121
+ try {
122
+ const result = Bun.spawnSync([bunBin, tui, "--cdp-maze", "-cat", "-encoding", "utf8"], {
123
+ cwd: dir,
124
+ stdout: "pipe",
125
+ stderr: "pipe",
126
+ });
127
+
128
+ expect(result.exitCode).toBe(0);
129
+ const written = await readFile(join(dir, "maze.md"), "utf8");
130
+ expect(written).toContain("Put the cursor here");
131
+ expect(Bun.stripANSI(result.stdout.toString())).toContain("Put the cursor here");
132
+ } finally {
133
+ await rm(dir, { recursive: true, force: true });
134
+ }
135
+ });
136
+
97
137
  test("--demo-<filename> preserves an existing local copy", async () => {
98
138
  const dir = await mkdtemp(join(tmpdir(), "jsmdcui-demo-generic-existing-"));
99
139
  const existing = "# Keep my selector demo\n";