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
@@ -4,9 +4,10 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  const tui = join(import.meta.dir, "..", "tui");
7
+ const bunBin = Bun.which("bun") || process.argv0;
7
8
 
8
9
  test("--help describes the non-overwriting demo behavior", () => {
9
- const result = Bun.spawnSync([tui, "--help"], {
10
+ const result = Bun.spawnSync([bunBin, tui, "--help"], {
10
11
  stdout: "pipe",
11
12
  stderr: "pipe",
12
13
  });
@@ -15,14 +16,32 @@ test("--help describes the non-overwriting demo behavior", () => {
15
16
  const output = result.stdout.toString();
16
17
  expect(output).toContain("use the existing ./testapp.md without overwriting it");
17
18
  expect(output).toContain("If ./testapp.md is missing, write the bundled demo there first");
19
+ expect(output).toContain("--demo-<filename>");
20
+ expect(output).toContain("demos/<filename>.md");
21
+ expect(output.match(/Open it in the TUI and write 5 generated files beside it/g)?.length).toBe(2);
18
22
  expect(output).toContain("--demo-imgtool");
19
23
  expect(output).toContain("--demo-imgtool-zh");
20
24
  });
21
25
 
26
+ test("--demo-list lists root and automatically discovered demos", () => {
27
+ const result = Bun.spawnSync([bunBin, tui, "--demo-list"], {
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ });
31
+
32
+ expect(result.exitCode).toBe(0);
33
+ const output = result.stdout.toString();
34
+ expect(output).toMatch(/--demo\s+testapp\.md/);
35
+ expect(output).toMatch(/--demo-image-processor\s+demos\/image-processor\.md/);
36
+ expect(output).toMatch(/--demo-select\s+demos\/select\.md/);
37
+ expect(output).toMatch(/--demo-todo-zh\s+demos\/todo-zh\.md/);
38
+ expect(output).toContain("--demo-imgtool --demo-image-processor");
39
+ });
40
+
22
41
  test("--demo writes bundled testapp.md to cwd before opening it", async () => {
23
42
  const dir = await mkdtemp(join(tmpdir(), "jsmdcui-demo-"));
24
43
  try {
25
- const result = Bun.spawnSync([tui, "--demo", "-cat", "-encoding", "utf8"], {
44
+ const result = Bun.spawnSync([bunBin, tui, "--demo", "-cat", "-encoding", "utf8"], {
26
45
  cwd: dir,
27
46
  stdout: "pipe",
28
47
  stderr: "pipe",
@@ -30,8 +49,8 @@ test("--demo writes bundled testapp.md to cwd before opening it", async () => {
30
49
 
31
50
  expect(result.exitCode).toBe(0);
32
51
  const written = await readFile(join(dir, "testapp.md"), "utf8");
33
- expect(written).toContain("# jsmdcui");
34
- expect(Bun.stripANSI(result.stdout.toString())).toContain("jsmdcui");
52
+ expect(written).toContain("計算機");
53
+ expect(Bun.stripANSI(result.stdout.toString())).toContain("計算機");
35
54
  } finally {
36
55
  await rm(dir, { recursive: true, force: true });
37
56
  }
@@ -42,7 +61,7 @@ test("--demo preserves an existing testapp.md", async () => {
42
61
  const existing = "# Keep my demo\n";
43
62
  try {
44
63
  await writeFile(join(dir, "testapp.md"), existing);
45
- const result = Bun.spawnSync([tui, "--demo", "-cat", "-encoding", "utf8"], {
64
+ const result = Bun.spawnSync([bunBin, tui, "--demo", "-cat", "-encoding", "utf8"], {
46
65
  cwd: dir,
47
66
  stdout: "pipe",
48
67
  stderr: "pipe",
@@ -56,10 +75,81 @@ test("--demo preserves an existing testapp.md", async () => {
56
75
  }
57
76
  });
58
77
 
78
+ test("--demo-<filename> automatically loads a matching demos file", async () => {
79
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-demo-generic-"));
80
+ try {
81
+ const result = Bun.spawnSync([bunBin, tui, "--demo-todo", "-cat", "-encoding", "utf8"], {
82
+ cwd: dir,
83
+ stdout: "pipe",
84
+ stderr: "pipe",
85
+ });
86
+
87
+ expect(result.exitCode).toBe(0);
88
+ const written = await readFile(join(dir, "todo.md"), "utf8");
89
+ expect(written).toContain("# Todo List");
90
+ expect(written).toContain("javascript:addTodo()");
91
+ expect(Bun.stripANSI(result.stdout.toString())).toContain("Show Completed");
92
+ } finally {
93
+ await rm(dir, { recursive: true, force: true });
94
+ }
95
+ });
96
+
97
+ test("--demo-<filename> preserves an existing local copy", async () => {
98
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-demo-generic-existing-"));
99
+ const existing = "# Keep my selector demo\n";
100
+ try {
101
+ await writeFile(join(dir, "select.md"), existing);
102
+ const result = Bun.spawnSync([bunBin, tui, "--demo-select", "-cat", "-encoding", "utf8"], {
103
+ cwd: dir,
104
+ stdout: "pipe",
105
+ stderr: "pipe",
106
+ });
107
+
108
+ expect(result.exitCode).toBe(0);
109
+ expect(await readFile(join(dir, "select.md"), "utf8")).toBe(existing);
110
+ expect(Bun.stripANSI(result.stdout.toString())).toContain("Keep my selector demo");
111
+ } finally {
112
+ await rm(dir, { recursive: true, force: true });
113
+ }
114
+ });
115
+
116
+ test("an unknown --demo-<filename> reports an error without creating a file", async () => {
117
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-demo-unknown-"));
118
+ try {
119
+ const result = Bun.spawnSync([bunBin, tui, "--demo-does-not-exist", "-cat", "-encoding", "utf8"], {
120
+ cwd: dir,
121
+ stdout: "pipe",
122
+ stderr: "pipe",
123
+ });
124
+
125
+ expect(result.exitCode).toBe(2);
126
+ expect(result.stderr.toString()).toContain("Unknown demo --demo-does-not-exist");
127
+ expect(await Bun.file(join(dir, "does-not-exist.md")).exists()).toBe(false);
128
+ } finally {
129
+ await rm(dir, { recursive: true, force: true });
130
+ }
131
+ });
132
+
133
+ test("--demo-image-processor works through generic demo discovery", async () => {
134
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-imgtool-generic-"));
135
+ try {
136
+ const result = Bun.spawnSync([bunBin, tui, "--demo-image-processor", "-cat", "-encoding", "utf8"], {
137
+ cwd: dir,
138
+ stdout: "pipe",
139
+ stderr: "pipe",
140
+ });
141
+
142
+ expect(result.exitCode).toBe(0);
143
+ expect(await readFile(join(dir, "image-processor.md"), "utf8")).toContain("# Bun.Image Processor");
144
+ } finally {
145
+ await rm(dir, { recursive: true, force: true });
146
+ }
147
+ });
148
+
59
149
  test("--demo-imgtool writes the bundled image processor to cwd", async () => {
60
150
  const dir = await mkdtemp(join(tmpdir(), "jsmdcui-imgtool-"));
61
151
  try {
62
- const result = Bun.spawnSync([tui, "--demo-imgtool", "-cat", "-encoding", "utf8"], {
152
+ const result = Bun.spawnSync([bunBin, tui, "--demo-imgtool", "-cat", "-encoding", "utf8"], {
63
153
  cwd: dir,
64
154
  stdout: "pipe",
65
155
  stderr: "pipe",
@@ -80,7 +170,7 @@ test("--demo-imgtool preserves an existing image-processor.md", async () => {
80
170
  const existing = "# Keep my image tool\n";
81
171
  try {
82
172
  await writeFile(join(dir, "image-processor.md"), existing);
83
- const result = Bun.spawnSync([tui, "--demo-imgtool", "-cat", "-encoding", "utf8"], {
173
+ const result = Bun.spawnSync([bunBin, tui, "--demo-imgtool", "-cat", "-encoding", "utf8"], {
84
174
  cwd: dir,
85
175
  stdout: "pipe",
86
176
  stderr: "pipe",
@@ -97,7 +187,7 @@ test("--demo-imgtool preserves an existing image-processor.md", async () => {
97
187
  test("--demo-imgtool-zh writes the bundled Traditional Chinese image processor to cwd", async () => {
98
188
  const dir = await mkdtemp(join(tmpdir(), "jsmdcui-imgtool-zh-"));
99
189
  try {
100
- const result = Bun.spawnSync([tui, "--demo-imgtool-zh", "-cat", "-encoding", "utf8"], {
190
+ const result = Bun.spawnSync([bunBin, tui, "--demo-imgtool-zh", "-cat", "-encoding", "utf8"], {
101
191
  cwd: dir,
102
192
  stdout: "pipe",
103
193
  stderr: "pipe",
@@ -118,7 +208,7 @@ test("--demo-imgtool-zh preserves an existing image-processor.zh-TW.md", async (
118
208
  const existing = "# 保留我的圖片工具\n";
119
209
  try {
120
210
  await writeFile(join(dir, "image-processor.zh-TW.md"), existing);
121
- const result = Bun.spawnSync([tui, "--demo-imgtool-zh", "-cat", "-encoding", "utf8"], {
211
+ const result = Bun.spawnSync([bunBin, tui, "--demo-imgtool-zh", "-cat", "-encoding", "utf8"], {
122
212
  cwd: dir,
123
213
  stdout: "pipe",
124
214
  stderr: "pipe",
@@ -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);