jsmdcui 0.7.0 → 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.
@@ -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;
@@ -983,7 +1033,7 @@ function _mutateTuiHeadingList(buffer, heading, method, args) {
983
1033
  buffer._mdcuiHeadingTaskListAnchors = new Map();
984
1034
  buffer._mdcuiHeadingTaskListAnchors.set(heading.ordinal, { index: start, indent: list.indent });
985
1035
  }
986
- _spliceBufferLines(buffer, start, end - start, replacement.lines, {
1036
+ spliceTuiBufferLines(buffer, start, end - start, replacement.lines, {
987
1037
  styles: replacement.styles,
988
1038
  ansi: replacement.ansi,
989
1039
  });
@@ -1003,14 +1053,14 @@ function _mutateTuiHeadingList(buffer, heading, method, args) {
1003
1053
  indent: list.indent,
1004
1054
  });
1005
1055
  }
1006
- _spliceBufferLines(buffer, item.start, item.end - item.start, []);
1056
+ spliceTuiBufferLines(buffer, item.start, item.end - item.start, []);
1007
1057
  return item.value;
1008
1058
  }
1009
1059
 
1010
1060
  const replacement = _tuiTaskItemReplacement(list.indent, args);
1011
1061
  if (replacement.lines.length === 0) return list.items.length;
1012
1062
  const start = method === "push" ? list.end : list.first;
1013
- _spliceBufferLines(buffer, start, 0, replacement.lines, {
1063
+ spliceTuiBufferLines(buffer, start, 0, replacement.lines, {
1014
1064
  styles: replacement.styles,
1015
1065
  ansi: replacement.ansi,
1016
1066
  });
@@ -1018,8 +1068,10 @@ function _mutateTuiHeadingList(buffer, heading, method, args) {
1018
1068
  return list.items.length + replacement.lines.length;
1019
1069
  }
1020
1070
 
1021
- function _spliceBufferLines(buffer, start, deleteCount, replacement, replacementMeta = {}) {
1071
+ export function spliceTuiBufferLines(buffer, start, deleteCount, replacement, replacementMeta = {}) {
1022
1072
  const oldCursor = buffer.cursor ? { ...buffer.cursor } : null;
1073
+ buffer._mdcuiFenceBlockIndex = null;
1074
+ buffer._mdcuiControlBlockIndex = null;
1023
1075
  buffer.lines.splice(start, deleteCount, ...replacement);
1024
1076
 
1025
1077
  if (Array.isArray(buffer._ansiStyleLines)) {
@@ -1065,24 +1117,70 @@ function _spliceBufferLines(buffer, start, deleteCount, replacement, replacement
1065
1117
  buffer.ensureCursor?.();
1066
1118
  }
1067
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
+
1068
1163
  function _setBlockValue(buffer, selector, value) {
1069
1164
  const lines = buffer.lines;
1070
1165
  const block = _findBlock(lines, selector);
1071
1166
  if (!block) return false;
1072
1167
 
1073
- 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");
1074
1172
  const contentStart = block.start + 1;
1075
1173
  const capacity = Math.max(0, block.end - contentStart);
1076
1174
 
1077
1175
  if (block.header.kind === "fenced") {
1078
1176
  const replacement = values.map((line) => block.header.indent + line);
1079
- _spliceBufferLines(buffer, contentStart, capacity, replacement);
1177
+ spliceTuiBufferLines(buffer, contentStart, capacity, replacement);
1080
1178
  return true;
1081
1179
  }
1082
1180
 
1083
1181
  const rowPrefix = block.header.indent + block.header.bodyMarker + " ";
1084
1182
  const replacement = values.map((line) => rowPrefix + line);
1085
- _spliceBufferLines(buffer, contentStart, capacity, replacement);
1183
+ spliceTuiBufferLines(buffer, contentStart, capacity, replacement);
1086
1184
  return true;
1087
1185
  }
1088
1186
 
@@ -1229,7 +1327,12 @@ export function buildMicroGlobal(jsManager) {
1229
1327
  // ── Messaging ─────────────────────────────────────────────────
1230
1328
  Log: (...args) => console.log(...args),
1231
1329
  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)); },
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
+ },
1233
1336
 
1234
1337
  // ── Buffer line access (1-based line numbers; omit → cursor line) ─
1235
1338
 
@@ -1250,6 +1353,7 @@ export function buildMicroGlobal(jsManager) {
1250
1353
  const y = lineNumber != null ? Number(lineNumber) - 1 : buf.cursor.y;
1251
1354
  if (y < 0 || y >= buf.lines.length) return;
1252
1355
  buf.pushUndo?.();
1356
+ buf._mdcuiFenceBlockIndex = null;
1253
1357
  const parts = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
1254
1358
  buf.lines.splice(y, 1, ...parts);
1255
1359
  buf.invalidateHighlightFrom?.(y, { force: parts.length > 1 });
@@ -1266,6 +1370,7 @@ export function buildMicroGlobal(jsManager) {
1266
1370
  const y = lineNumber != null ? Number(lineNumber) - 1 : buf.cursor.y;
1267
1371
  if (y < 0 || y >= buf.lines.length) return;
1268
1372
  buf.pushUndo?.();
1373
+ buf._mdcuiFenceBlockIndex = null;
1269
1374
  if (buf.lines.length === 1) {
1270
1375
  buf.lines[0] = "";
1271
1376
  } else {
@@ -1306,6 +1411,7 @@ export function buildMicroGlobal(jsManager) {
1306
1411
  if (!app?.buffer) return;
1307
1412
  const buf = app.buffer;
1308
1413
  buf.pushUndo?.();
1414
+ buf._mdcuiFenceBlockIndex = null;
1309
1415
  buf.lines = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
1310
1416
  if (buf.lines.length === 0) buf.lines = [""];
1311
1417
  buf.invalidateHighlightFrom?.(0, { force: true });
@@ -0,0 +1,405 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { fenceEventMap, inlineFenceEventCode, parseFenceDeclarations } from "../src/cui/fence-events.mjs";
6
+ import { evalFront } from "../src/cui/rpc.mjs";
7
+ import { buildTuiBlockIndex, createTuiSelector, findTuiBlockAtLine, findTuiBlockInIndex, insertTuiTextareaNewline, mergeTuiTextareaBackward, mergeTuiTextareaForward } from "../src/plugins/js-bridge.js";
8
+ import { convertWuiTextareas } from "../runmd.mjs";
9
+
10
+ const tui = join(import.meta.dir, "..", "tui");
11
+ const bunBin = Bun.which("bun") || process.argv0;
12
+
13
+ async function waitFor(check, timeout = 5000) {
14
+ const deadline = Date.now() + timeout;
15
+ while (Date.now() < deadline) {
16
+ if (check()) return;
17
+ await Bun.sleep(20);
18
+ }
19
+ throw new Error("timed out waiting for PTY output");
20
+ }
21
+
22
+ test("fenced text controls retain quoted inline keydown code after the identity", () => {
23
+ const markdown = '```text#myid.field @keydown="first(); second(event); show(\\"done\\")"\nvalue\n```\n';
24
+ const declarations = parseFenceDeclarations(markdown);
25
+ expect(declarations).toHaveLength(1);
26
+ expect(declarations[0]).toMatchObject({
27
+ tag: "text",
28
+ id: "myid",
29
+ classes: ["field"],
30
+ });
31
+ expect(declarations[0].events.get("keydown")).toEqual({
32
+ code: 'first(); second(event); show("done")',
33
+ modifiers: [],
34
+ });
35
+ });
36
+
37
+ test("prevent modifier prepends preventDefault to inline event code", () => {
38
+ const markdown = '```text#myid @keydown.prevent="submit(event)"\nvalue\n```\n';
39
+ const handler = fenceEventMap(markdown).get("myid").events.get("keydown");
40
+ expect(handler).toEqual({ code: "submit(event)", modifiers: ["prevent"] });
41
+ expect(inlineFenceEventCode(handler)).toBe("event.preventDefault();submit(event)");
42
+ });
43
+
44
+ test("WUI writes keyboard code as native inline handlers with a mobile beforeinput fallback", () => {
45
+ const markdown = '```text#myid.field @keydown.prevent="guard(event)"\nvalue\n```\n';
46
+ const html = convertWuiTextareas(Bun.markdown.html(markdown), fenceEventMap(markdown));
47
+ expect(html).toContain('id="myid"');
48
+ expect(html).toContain('const __mdcuiKeyCode=Number(event.keyCode||event.which||0);');
49
+ expect(html).toContain('if(event.key!==&quot;Unidentified&quot;){\nObject.defineProperty(event,&quot;toJSON&quot;');
50
+ expect(html).toContain('event.preventDefault();guard(event)\n}"');
51
+ expect(html).toContain('onbeforeinput="if(!this.__mdcuiIdentifiedKeydown&amp;&amp;event.data!=null');
52
+ expect(html).toContain('ctrlKey:{configurable:true,value:!!m.ctrlKey}');
53
+ expect(html).toContain('metaKey:{configurable:true,value:!!m.metaKey}');
54
+ expect(html).not.toContain("onkeyup=");
55
+ expect(html).not.toContain("addEventListener");
56
+ });
57
+
58
+ test("keyup declarations are unsupported in both interfaces", () => {
59
+ const markdown = '```text#myid @keyup="update(event)"\nvalue\n```\n';
60
+ const declarations = parseFenceDeclarations(markdown);
61
+ const html = convertWuiTextareas(Bun.markdown.html(markdown), fenceEventMap(markdown));
62
+ expect(declarations[0].events.size).toBe(0);
63
+ expect(fenceEventMap(markdown).has("myid")).toBeFalse();
64
+ expect(html).not.toContain("onkeyup=");
65
+ expect(html).not.toContain("onkeydown=");
66
+ });
67
+
68
+ test("WUI keeps a trailing line comment inside the generated keydown block", () => {
69
+ const markdown = '```text#myid @keydown="guard(event); // trailing comment"\nvalue\n```\n';
70
+ const html = convertWuiTextareas(Bun.markdown.html(markdown), fenceEventMap(markdown));
71
+ const code = (html.match(/onkeydown="([^"]*)"/)?.[1] ?? "")
72
+ .replaceAll("&quot;", '"')
73
+ .replaceAll("&gt;", ">")
74
+ .replaceAll("&lt;", "<")
75
+ .replaceAll("&amp;", "&");
76
+ expect(() => new Function("event", "guard", code)).not.toThrow();
77
+ });
78
+
79
+ test("WUI restores modifier letters from keyCode or code during keydown", () => {
80
+ const markdown = '```text#myid @keydown.prevent="guard(event)"\nvalue\n```\n';
81
+ const html = convertWuiTextareas(Bun.markdown.html(markdown), fenceEventMap(markdown));
82
+ const decodeAttribute = (value) => value
83
+ .replaceAll("&quot;", '"')
84
+ .replaceAll("&gt;", ">")
85
+ .replaceAll("&lt;", "<")
86
+ .replaceAll("&amp;", "&");
87
+ const keydownCode = decodeAttribute(html.match(/onkeydown="([^"]*)"/)?.[1] ?? "");
88
+ const beforeInputCode = decodeAttribute(html.match(/onbeforeinput="([^"]*)"/)?.[1] ?? "");
89
+ const seen = [];
90
+ const element = {};
91
+ const runKeydown = new Function("event", "guard", keydownCode);
92
+ element.onkeydown = (event) => runKeydown.call(element, event, (current) => seen.push({
93
+ key: current.key,
94
+ ctrlKey: current.ctrlKey,
95
+ shiftKey: current.shiftKey,
96
+ altKey: current.altKey,
97
+ metaKey: current.metaKey,
98
+ json: JSON.parse(JSON.stringify(current)),
99
+ toJSONEnumerable: Object.keys(current).includes("toJSON"),
100
+ }));
101
+
102
+ const unidentified = {
103
+ key: "ß",
104
+ keyCode: 0,
105
+ code: "KeyS",
106
+ ctrlKey: true,
107
+ shiftKey: false,
108
+ altKey: true,
109
+ metaKey: true,
110
+ defaultPrevented: false,
111
+ preventDefault() { this.defaultPrevented = true; },
112
+ };
113
+ element.onkeydown(unidentified);
114
+ expect(unidentified.defaultPrevented).toBeTrue();
115
+ expect(unidentified.key).toBe("s");
116
+
117
+ const beforeInput = {
118
+ data: "β",
119
+ defaultPrevented: false,
120
+ preventDefault() { this.defaultPrevented = true; },
121
+ };
122
+ new Function("event", beforeInputCode).call(element, beforeInput);
123
+ expect(seen).toEqual([{
124
+ key: "s",
125
+ ctrlKey: true,
126
+ shiftKey: false,
127
+ altKey: true,
128
+ metaKey: true,
129
+ json: {
130
+ type: "",
131
+ key: "s",
132
+ code: "KeyS",
133
+ raw: "",
134
+ ctrlKey: true,
135
+ shiftKey: false,
136
+ altKey: true,
137
+ metaKey: true,
138
+ repeat: false,
139
+ defaultPrevented: true,
140
+ target: { id: "", tagName: "", className: "", value: "" },
141
+ },
142
+ toJSONEnumerable: false,
143
+ }]);
144
+ expect(beforeInput.key).toBeUndefined();
145
+ expect(beforeInput.defaultPrevented).toBeFalse();
146
+ clearTimeout(element.__mdcuiKeydownReset);
147
+ });
148
+
149
+ test("WUI beforeinput keeps composed text for AltGraph and unmodified input", () => {
150
+ const markdown = '```text#myid @keydown="guard(event)"\nvalue\n```\n';
151
+ const html = convertWuiTextareas(Bun.markdown.html(markdown), fenceEventMap(markdown));
152
+ const decodeAttribute = (value) => value
153
+ .replaceAll("&quot;", '"')
154
+ .replaceAll("&gt;", ">")
155
+ .replaceAll("&lt;", "<")
156
+ .replaceAll("&amp;", "&");
157
+ const keydownCode = decodeAttribute(html.match(/onkeydown="([^"]*)"/)?.[1] ?? "");
158
+ const beforeInputCode = decodeAttribute(html.match(/onbeforeinput="([^"]*)"/)?.[1] ?? "");
159
+ const seen = [];
160
+ const element = {};
161
+ const runKeydown = new Function("event", "guard", keydownCode);
162
+ element.onkeydown = (event) => runKeydown.call(element, event, current => seen.push(current.key));
163
+ const retry = (keydown, data) => {
164
+ element.onkeydown({ key: "Unidentified", ...keydown });
165
+ new Function("event", beforeInputCode).call(element, { data });
166
+ };
167
+
168
+ retry({ keyCode: 83, altKey: true, getModifierState: name => name === "AltGraph" }, "β");
169
+ retry({ keyCode: 83 }, "絲");
170
+
171
+ expect(seen).toEqual(["β", "絲"]);
172
+ clearTimeout(element.__mdcuiKeydownReset);
173
+ });
174
+
175
+ test("TUI finds the event target only while the cursor is inside the framed body", () => {
176
+ const lines = ["┌─ text#myid.field", "│ value", "└─"];
177
+ expect(findTuiBlockAtLine(lines, 0)).toBeNull();
178
+ expect(findTuiBlockAtLine(lines, 1)?.header).toMatchObject({ tag: "text", id: "myid" });
179
+ expect(findTuiBlockAtLine(lines, 2)).toBeNull();
180
+ });
181
+
182
+ test("TUI textarea val changes participate in undo and redo", () => {
183
+ const buffer = {
184
+ lines: ["┌─ textarea#field", "│ old", "└─"],
185
+ cursor: { x: 2, y: 1 },
186
+ undoStack: [],
187
+ redoStack: [],
188
+ pushUndo(force) {
189
+ expect(force).toBeTrue();
190
+ this.undoStack.push(this.lines.slice());
191
+ this.redoStack = [];
192
+ },
193
+ undo() {
194
+ this.redoStack.push(this.lines.slice());
195
+ this.lines = this.undoStack.pop();
196
+ },
197
+ redo() {
198
+ this.undoStack.push(this.lines.slice());
199
+ this.lines = this.redoStack.pop();
200
+ },
201
+ invalidateHighlightFrom() {},
202
+ ensureCursor() {},
203
+ };
204
+ const $ = createTuiSelector(() => buffer);
205
+
206
+ $("textarea#field").val("new\nrow");
207
+ expect($("textarea#field").val()).toBe("new\nrow");
208
+ buffer.undo();
209
+ expect($("textarea#field").val()).toBe("old");
210
+ buffer.redo();
211
+ expect($("textarea#field").val()).toBe("new\nrow");
212
+ });
213
+
214
+ test("TUI textarea Enter splits a body row and Backspace joins it", () => {
215
+ const buffer = {
216
+ lines: ["┌─ textarea#field", "│ abcd", "└─", "after"],
217
+ cursor: { x: 4, y: 1 },
218
+ _mdcuiFenceBlockIndex: { stale: true },
219
+ invalidateHighlightFrom() {},
220
+ ensureCursor() {},
221
+ };
222
+ const block = findTuiBlockAtLine(buffer.lines, buffer.cursor.y);
223
+
224
+ expect(insertTuiTextareaNewline(buffer, block)).toBeTrue();
225
+ expect(buffer.lines).toEqual([
226
+ "┌─ textarea#field",
227
+ "│ ab",
228
+ "│ cd",
229
+ "└─",
230
+ "after",
231
+ ]);
232
+ expect(buffer.cursor).toEqual({ x: 2, y: 2 });
233
+ expect(buffer._mdcuiFenceBlockIndex).toBeNull();
234
+
235
+ expect(mergeTuiTextareaBackward(buffer, findTuiBlockAtLine(buffer.lines, buffer.cursor.y))).toBeTrue();
236
+ expect(buffer.lines).toEqual(["┌─ textarea#field", "│ abcd", "└─", "after"]);
237
+ expect(buffer.cursor).toEqual({ x: 4, y: 1 });
238
+ });
239
+
240
+ test("TUI textarea Delete at line end joins the next body row", () => {
241
+ const buffer = {
242
+ lines: ["┌─ textarea#field", "│ ab", "│ cd", "└─", "after"],
243
+ cursor: { x: 4, y: 1 },
244
+ _mdcuiFenceBlockIndex: { stale: true },
245
+ _mdcuiControlBlockIndex: { stale: true },
246
+ invalidateHighlightFrom() {},
247
+ ensureCursor() {},
248
+ };
249
+ const block = findTuiBlockAtLine(buffer.lines, buffer.cursor.y);
250
+
251
+ expect(mergeTuiTextareaForward(buffer, block)).toBeTrue();
252
+ expect(buffer.lines).toEqual(["┌─ textarea#field", "│ abcd", "└─", "after"]);
253
+ expect(buffer.cursor).toEqual({ x: 4, y: 1 });
254
+ expect(buffer._mdcuiFenceBlockIndex).toBeNull();
255
+ expect(buffer._mdcuiControlBlockIndex).toBeNull();
256
+ expect(mergeTuiTextareaForward(buffer, findTuiBlockAtLine(buffer.lines, buffer.cursor.y))).toBeFalse();
257
+ });
258
+
259
+ test("TUI keyboard lookup indexes event blocks once and uses binary lookup", () => {
260
+ const lines = [
261
+ "heading",
262
+ "┌─ text#plain",
263
+ "│ no event",
264
+ "└─",
265
+ ...Array.from({ length: 5_000 }, (_, index) => `line ${index}`),
266
+ "┌─ text#target",
267
+ "│ event body",
268
+ "└─",
269
+ ];
270
+ const declarations = new Map([
271
+ ["target", { tag: "text", events: new Map([["keydown", { code: "hit()" }]]) }],
272
+ ]);
273
+ const blocks = buildTuiBlockIndex(lines, declarations);
274
+
275
+ expect(blocks).toHaveLength(1);
276
+ expect(blocks[0].header).toMatchObject({ tag: "text", id: "target" });
277
+ expect(findTuiBlockInIndex(blocks, lines.length - 2)).toBe(blocks[0]);
278
+ expect(findTuiBlockInIndex(blocks, 2)).toBeNull();
279
+ });
280
+
281
+ test("front evaluation exposes the TUI event scope to inline statements", async () => {
282
+ const seen = [];
283
+ const event = { key: "Enter" };
284
+ const result = await evalFront(
285
+ { record(value) { seen.push(value); } },
286
+ "record(event.key)",
287
+ { event },
288
+ );
289
+ expect(result).toBeUndefined();
290
+ expect(seen).toEqual(["Enter"]);
291
+ });
292
+
293
+ test("TUI keydown.prevent runs before editing and blocks text input", async () => {
294
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-keydown-"));
295
+ const markdownPath = join(dir, "app.md");
296
+ const markdown = [
297
+ '```text#field @keydown.prevent="record(event)"',
298
+ "a",
299
+ "```",
300
+ "",
301
+ "```text#status",
302
+ "waiting",
303
+ "```",
304
+ "",
305
+ "```js front",
306
+ "export function record(event) {",
307
+ " const data = JSON.parse(JSON.stringify(event));",
308
+ " $('#status').val(`${data.key}:${data.target.id}:${data.target.value}:${data.defaultPrevented}:${Object.keys(event).includes('toJSON')}`);",
309
+ "}",
310
+ "```",
311
+ "",
312
+ ].join("\n");
313
+ let output = "";
314
+ let proc;
315
+ let terminal;
316
+ try {
317
+ await writeFile(markdownPath, markdown);
318
+ terminal = new Bun.Terminal({
319
+ cols: 60,
320
+ rows: 16,
321
+ data(_terminal, data) {
322
+ output += Buffer.from(data).toString();
323
+ },
324
+ });
325
+ proc = Bun.spawn({
326
+ cmd: [bunBin, tui, "+2:4", markdownPath],
327
+ cwd: dir,
328
+ terminal,
329
+ env: { ...process.env, TERM: "xterm-256color", COLUMNS: "60", LINES: "16" },
330
+ });
331
+ await waitFor(() => Bun.stripANSI(output).includes("waiting"));
332
+ terminal.write("x");
333
+ await waitFor(() => Bun.stripANSI(output).includes("x:field:a:true:false"));
334
+ terminal.write("\x11");
335
+ await Promise.race([proc.exited, Bun.sleep(2000)]);
336
+ expect(Bun.stripANSI(output)).toContain("x:field:a:true:false");
337
+ } finally {
338
+ if (proc && proc.exitCode == null) proc.kill();
339
+ terminal?.close();
340
+ await rm(dir, { recursive: true, force: true });
341
+ }
342
+ }, 10000);
343
+
344
+ test("TUI keydown alert releases and restores terminal input", async () => {
345
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-keydown-alert-"));
346
+ const markdownPath = join(dir, "app.md");
347
+ const markdown = [
348
+ '```text#field @keydown.prevent="celebrate(event)"',
349
+ "focus here",
350
+ "```",
351
+ "",
352
+ "```text#status",
353
+ "waiting",
354
+ "```",
355
+ "",
356
+ "```js front",
357
+ "export function celebrate(event) {",
358
+ " if (event.key === 'x') {",
359
+ " alert('KEYDOWN ALERT');",
360
+ " $('#status').val('alert closed');",
361
+ " return;",
362
+ " }",
363
+ " $('#status').val(`second input: ${event.key}`);",
364
+ "}",
365
+ "```",
366
+ "",
367
+ ].join("\n");
368
+ let output = "";
369
+ let proc;
370
+ let terminal;
371
+ try {
372
+ await writeFile(markdownPath, markdown);
373
+ terminal = new Bun.Terminal({
374
+ cols: 60,
375
+ rows: 16,
376
+ data(_terminal, data) {
377
+ output += Buffer.from(data).toString();
378
+ },
379
+ });
380
+ proc = Bun.spawn({
381
+ cmd: [bunBin, tui, "+2:4", markdownPath],
382
+ cwd: dir,
383
+ terminal,
384
+ env: { ...process.env, TERM: "xterm-256color", COLUMNS: "60", LINES: "16" },
385
+ });
386
+ await waitFor(() => Bun.stripANSI(output).includes("waiting"));
387
+ terminal.write("x");
388
+ await waitFor(() => Bun.stripANSI(output).includes("KEYDOWN ALERT"));
389
+ terminal.write("\r");
390
+ await waitFor(() => Bun.stripANSI(output).includes("alert closed"));
391
+ terminal.write("z");
392
+ await waitFor(() => Bun.stripANSI(output).includes("second input:z"));
393
+ terminal.write("\x11");
394
+ const exitCode = await Promise.race([
395
+ proc.exited,
396
+ Bun.sleep(2000).then(() => null),
397
+ ]);
398
+ expect(exitCode).toBe(0);
399
+ expect(Bun.stripANSI(output)).toContain("second input:z");
400
+ } finally {
401
+ if (proc && proc.exitCode == null) proc.kill();
402
+ terminal?.close();
403
+ await rm(dir, { recursive: true, force: true });
404
+ }
405
+ }, 10000);
@@ -43,6 +43,17 @@ test("--check reports heading/fenced-block collisions with line details", async
43
43
  expect(output.lastIndexOf("FAILED")).toBeGreaterThan(output.lastIndexOf("Suggested fix"));
44
44
  });
45
45
 
46
+ test("--check keeps fenced IDs that are followed by inline event attributes", async () => {
47
+ const result = await runCheck(
48
+ '## Write Status\n\n```text#write-status @keydown="refresh(); validate(event)"\nwaiting\n```\n',
49
+ );
50
+ const output = Bun.stripANSI(result.stdout.toString());
51
+ expect(result.exitCode).toBe(1);
52
+ expect(output).toContain("ID #write-status");
53
+ expect(output).toContain("Type: text fenced block");
54
+ expect(output).toContain('Source: ```text#write-status @keydown="refresh(); validate(event)"');
55
+ });
56
+
46
57
  test("--check reports duplicate fenced-block IDs", async () => {
47
58
  const result = await runCheck("```text#myid\na\n```\n\n```textarea#myid\nb\n```\n");
48
59
  expect(result.exitCode).toBe(1);
@@ -0,0 +1,46 @@
1
+ import { expect, test } from "bun:test";
2
+ import { buildMicroGlobal } from "../src/plugins/js-bridge.js";
3
+
4
+ test("micro prompt APIs delegate and return synchronously", () => {
5
+ const previousMicro = globalThis.micro;
6
+ const previousSelector = globalThis.$;
7
+ const calls = [];
8
+ const app = {
9
+ protectedAlert(message) {
10
+ calls.push(["alert", message]);
11
+ return undefined;
12
+ },
13
+ protectedConfirm(message) {
14
+ calls.push(["confirm", message]);
15
+ return true;
16
+ },
17
+ protectedPrompt(message, defaultValue) {
18
+ calls.push(["prompt", message, defaultValue]);
19
+ return "answer";
20
+ },
21
+ };
22
+
23
+ try {
24
+ const micro = buildMicroGlobal({ _app: app, _ctx: null, on() {} });
25
+ const alertResult = micro.alert("hello");
26
+ const confirmResult = micro.confirm("continue?");
27
+ const promptResult = micro.prompt("name", "Ada");
28
+
29
+ expect(alertResult).toBeUndefined();
30
+ expect(confirmResult).toBe(true);
31
+ expect(promptResult).toBe("answer");
32
+ expect(alertResult?.then).toBeUndefined();
33
+ expect(confirmResult?.then).toBeUndefined();
34
+ expect(promptResult?.then).toBeUndefined();
35
+ expect(calls).toEqual([
36
+ ["alert", "hello"],
37
+ ["confirm", "continue?"],
38
+ ["prompt", "name", "Ada"],
39
+ ]);
40
+ } finally {
41
+ if (previousMicro === undefined) delete globalThis.micro;
42
+ else globalThis.micro = previousMicro;
43
+ if (previousSelector === undefined) delete globalThis.$;
44
+ else globalThis.$ = previousSelector;
45
+ }
46
+ });
@@ -0,0 +1,10 @@
1
+ ```text#myid @keydown.prevent="handle(event)"
2
+ hello
3
+ ```
4
+
5
+ ```js front
6
+ export function handle(e)
7
+ {
8
+ alert(JSON.stringify(e,null,1))
9
+ }
10
+ ```