jsmdcui 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -840,31 +840,185 @@ function _tuiHeadingLines(buffer) {
840
840
  }
841
841
 
842
842
  function _headingCheckboxValue(buffer, heading, id) {
843
+ const list = _tuiHeadingTaskList(buffer, heading);
844
+ if (!list) return id.startsWith("select") ? null : [];
845
+ const selected = [];
846
+ for (const item of list.items) {
847
+ const checkbox = String(buffer.lines[item.start] ?? "").match(/^(\s*)([☐☒])(?:\s+|$)(.*)$/);
848
+ if (checkbox?.[2] !== "☒") continue;
849
+ if (id.startsWith("select")) return item.value;
850
+ selected.push(item.value);
851
+ }
852
+ return id.startsWith("select") ? null : selected;
853
+ }
854
+
855
+ function _tuiHeadingTaskList(buffer, heading) {
856
+ const savedAnchor = buffer?._mdcuiHeadingTaskListAnchors?.get(heading.ordinal);
857
+ if (savedAnchor) {
858
+ return {
859
+ first: savedAnchor.index,
860
+ end: savedAnchor.index,
861
+ indent: savedAnchor.indent,
862
+ items: [],
863
+ };
864
+ }
843
865
  const headingLine = _headingTuiLine(buffer, heading);
844
- if (!headingLine || !Array.isArray(buffer?.lines))
845
- return id.startsWith("select") ? null : [];
866
+ if (!headingLine || !Array.isArray(buffer?.lines)) return null;
846
867
 
847
868
  const tuiHeadings = _tuiHeadingLines(buffer);
848
869
  const nextHeading = tuiHeadings[heading.ordinal + 1];
849
870
  const endLine = nextHeading?.line ?? buffer.lines.length + 1;
871
+ let first = -1;
872
+ let indent = "";
850
873
 
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+|$)(.*)$/);
874
+ for (let y = headingLine; y < endLine - 1; y++) {
875
+ const checkbox = String(buffer.lines[y] ?? "").match(/^(\s*)[☐☒](?:\s+|$)/);
856
876
  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);
877
+ first = y;
878
+ indent = checkbox[1];
879
+ break;
863
880
  }
864
- return id.startsWith("select") ? null : selected;
881
+ if (first < 0) return null;
882
+
883
+ let end = endLine - 1;
884
+ let pendingBlank = -1;
885
+ for (let y = first + 1; y < endLine - 1; y++) {
886
+ const line = String(buffer.lines[y] ?? "");
887
+ if (line.trim() === "") {
888
+ if (pendingBlank < 0) pendingBlank = y;
889
+ continue;
890
+ }
891
+ const leading = line.match(/^\s*/)?.[0].length ?? 0;
892
+ const checkbox = line.match(/^(\s*)[☐☒](?:\s+|$)/);
893
+ if (checkbox || leading > indent.length) {
894
+ pendingBlank = -1;
895
+ continue;
896
+ }
897
+ end = pendingBlank >= 0 ? pendingBlank : y;
898
+ break;
899
+ }
900
+ if (end === endLine - 1 && pendingBlank >= 0) end = pendingBlank;
901
+
902
+ const items = [];
903
+ for (let y = first; y < end; y++) {
904
+ const checkbox = String(buffer.lines[y] ?? "").match(/^(\s*)([☐☒])(?:\s+|$)(.*)$/);
905
+ if (checkbox && checkbox[1] === indent) {
906
+ items.push({
907
+ start: y,
908
+ end,
909
+ value: checkbox[3].trim(),
910
+ });
911
+ }
912
+ }
913
+ for (let index = 0; index < items.length - 1; index++)
914
+ items[index].end = items[index + 1].start;
915
+
916
+ return { first, end, indent, items };
917
+ }
918
+
919
+ function _normalizedSpliceRange(length, argumentCount, start, deleteCount) {
920
+ if (argumentCount === 0) return { start: 0, deleteCount: 0 };
921
+ let relativeStart = Number(start);
922
+ if (Number.isNaN(relativeStart)) relativeStart = 0;
923
+ relativeStart = Math.trunc(relativeStart);
924
+ const actualStart = relativeStart < 0
925
+ ? Math.max(length + relativeStart, 0)
926
+ : Math.min(relativeStart, length);
927
+ if (argumentCount === 1) return { start: actualStart, deleteCount: length - actualStart };
928
+ let requestedDelete = Number(deleteCount);
929
+ if (Number.isNaN(requestedDelete)) requestedDelete = 0;
930
+ requestedDelete = Math.max(0, Math.trunc(requestedDelete));
931
+ return {
932
+ start: actualStart,
933
+ deleteCount: Math.min(requestedDelete, length - actualStart),
934
+ };
865
935
  }
866
936
 
867
- function _spliceBufferLines(buffer, start, deleteCount, replacement) {
937
+ function _tuiTaskItemReplacement(indent, inputs) {
938
+ const normalized = inputs.map((input) => {
939
+ const item = input && typeof input === "object"
940
+ ? { value: input.value ?? input.label ?? "", checked: Boolean(input.checked) }
941
+ : { value: input ?? "", checked: false };
942
+ return {
943
+ value: String(item.value).replace(/\r?\n/g, " "),
944
+ checked: item.checked,
945
+ };
946
+ });
947
+ return {
948
+ lines: normalized.map((item) =>
949
+ `${indent}${item.checked ? "☒" : "☐"} ${item.value}`,
950
+ ),
951
+ styles: normalized.map((item) => {
952
+ const styles = [];
953
+ if (item.checked) {
954
+ styles[indent.length] = { fg: "green" };
955
+ styles[indent.length + 1] = { fg: "green" };
956
+ }
957
+ return styles;
958
+ }),
959
+ ansi: normalized.map((item) =>
960
+ `${indent}\x1b[${item.checked ? "32" : "2"}m${item.checked ? "☒" : "☐"} \x1b[0m${item.value}`,
961
+ ),
962
+ };
963
+ }
964
+
965
+ function _mutateTuiHeadingList(buffer, heading, method, args) {
966
+ const list = _tuiHeadingTaskList(buffer, heading);
967
+ if (!list) {
968
+ if (method === "push" || method === "unshift") return 0;
969
+ if (method === "splice") return [];
970
+ return undefined;
971
+ }
972
+
973
+ if (method === "splice") {
974
+ const range = _normalizedSpliceRange(list.items.length, args.length, args[0], args[1]);
975
+ const removedItems = list.items.slice(range.start, range.start + range.deleteCount);
976
+ const removed = removedItems.map((item) => item.value);
977
+ const replacement = _tuiTaskItemReplacement(list.indent, args.slice(2));
978
+ if (range.deleteCount === 0 && replacement.lines.length === 0) return [];
979
+ const start = list.items[range.start]?.start ?? list.end;
980
+ const end = removedItems.at(-1)?.end ?? start;
981
+ if (range.deleteCount === list.items.length && replacement.lines.length === 0 && list.items.length > 0) {
982
+ if (!(buffer._mdcuiHeadingTaskListAnchors instanceof Map))
983
+ buffer._mdcuiHeadingTaskListAnchors = new Map();
984
+ buffer._mdcuiHeadingTaskListAnchors.set(heading.ordinal, { index: start, indent: list.indent });
985
+ }
986
+ _spliceBufferLines(buffer, start, end - start, replacement.lines, {
987
+ styles: replacement.styles,
988
+ ansi: replacement.ansi,
989
+ });
990
+ if (replacement.lines.length > 0)
991
+ buffer._mdcuiHeadingTaskListAnchors?.delete(heading.ordinal);
992
+ return removed;
993
+ }
994
+
995
+ if (method === "pop" || method === "shift") {
996
+ const item = method === "pop" ? list.items.at(-1) : list.items[0];
997
+ if (!item) return undefined;
998
+ if (list.items.length === 1) {
999
+ if (!(buffer._mdcuiHeadingTaskListAnchors instanceof Map))
1000
+ buffer._mdcuiHeadingTaskListAnchors = new Map();
1001
+ buffer._mdcuiHeadingTaskListAnchors.set(heading.ordinal, {
1002
+ index: item.start,
1003
+ indent: list.indent,
1004
+ });
1005
+ }
1006
+ _spliceBufferLines(buffer, item.start, item.end - item.start, []);
1007
+ return item.value;
1008
+ }
1009
+
1010
+ const replacement = _tuiTaskItemReplacement(list.indent, args);
1011
+ if (replacement.lines.length === 0) return list.items.length;
1012
+ const start = method === "push" ? list.end : list.first;
1013
+ _spliceBufferLines(buffer, start, 0, replacement.lines, {
1014
+ styles: replacement.styles,
1015
+ ansi: replacement.ansi,
1016
+ });
1017
+ if (replacement.lines.length > 0) buffer._mdcuiHeadingTaskListAnchors?.delete(heading.ordinal);
1018
+ return list.items.length + replacement.lines.length;
1019
+ }
1020
+
1021
+ function _spliceBufferLines(buffer, start, deleteCount, replacement, replacementMeta = {}) {
868
1022
  const oldCursor = buffer.cursor ? { ...buffer.cursor } : null;
869
1023
  buffer.lines.splice(start, deleteCount, ...replacement);
870
1024
 
@@ -873,15 +1027,29 @@ function _spliceBufferLines(buffer, start, deleteCount, replacement) {
873
1027
  buffer._ansiStyleLines.splice(
874
1028
  start,
875
1029
  deleteCount,
876
- ...replacement.map(() => template),
1030
+ ...(replacementMeta.styles ?? replacement.map(() => template)),
877
1031
  );
878
1032
  }
879
1033
 
880
1034
  if (typeof buffer._mdcuiAnsiText === "string") {
881
1035
  const ansiLines = buffer._mdcuiAnsiText.split("\n");
882
- ansiLines.splice(start, deleteCount, ...replacement);
1036
+ ansiLines.splice(start, deleteCount, ...(replacementMeta.ansi ?? replacement));
883
1037
  buffer._mdcuiAnsiText = ansiLines.join("\n");
884
1038
  }
1039
+ if (Array.isArray(buffer._mdcuiImages)) {
1040
+ const delta = replacement.length - deleteCount;
1041
+ buffer._mdcuiImages = buffer._mdcuiImages
1042
+ .filter((image) => image.line < start || image.line >= start + deleteCount)
1043
+ .map((image) => image.line >= start + deleteCount ? { ...image, line: image.line + delta } : image);
1044
+ }
1045
+ if (buffer._mdcuiHeadingTaskListAnchors instanceof Map) {
1046
+ const oldEnd = start + deleteCount;
1047
+ const delta = replacement.length - deleteCount;
1048
+ for (const anchor of buffer._mdcuiHeadingTaskListAnchors.values()) {
1049
+ if (anchor.index >= oldEnd) anchor.index += delta;
1050
+ else if (anchor.index >= start) anchor.index = start;
1051
+ }
1052
+ }
885
1053
 
886
1054
  buffer.invalidateHighlightFrom?.(start, { force: replacement.length !== deleteCount });
887
1055
  if (oldCursor) {
@@ -961,6 +1129,65 @@ export function createTuiSelector(getBuffer) {
961
1129
  return args.length > 0 ? selection : "";
962
1130
  }
963
1131
  },
1132
+ push(...items) {
1133
+ try {
1134
+ const buffer = getBuffer?.();
1135
+ const heading = _findHeading(buffer, selector);
1136
+ return heading ? _mutateTuiHeadingList(buffer, heading, "push", items) : 0;
1137
+ } catch {
1138
+ return 0;
1139
+ }
1140
+ },
1141
+ pop() {
1142
+ try {
1143
+ const buffer = getBuffer?.();
1144
+ const heading = _findHeading(buffer, selector);
1145
+ return heading ? _mutateTuiHeadingList(buffer, heading, "pop", []) : undefined;
1146
+ } catch {
1147
+ return undefined;
1148
+ }
1149
+ },
1150
+ shift() {
1151
+ try {
1152
+ const buffer = getBuffer?.();
1153
+ const heading = _findHeading(buffer, selector);
1154
+ return heading ? _mutateTuiHeadingList(buffer, heading, "shift", []) : undefined;
1155
+ } catch {
1156
+ return undefined;
1157
+ }
1158
+ },
1159
+ unshift(...items) {
1160
+ try {
1161
+ const buffer = getBuffer?.();
1162
+ const heading = _findHeading(buffer, selector);
1163
+ return heading ? _mutateTuiHeadingList(buffer, heading, "unshift", items) : 0;
1164
+ } catch {
1165
+ return 0;
1166
+ }
1167
+ },
1168
+ splice(...args) {
1169
+ try {
1170
+ const buffer = getBuffer?.();
1171
+ const heading = _findHeading(buffer, selector);
1172
+ return heading ? _mutateTuiHeadingList(buffer, heading, "splice", args) : [];
1173
+ } catch {
1174
+ return [];
1175
+ }
1176
+ },
1177
+ slice(...args) {
1178
+ try {
1179
+ const buffer = getBuffer?.();
1180
+ const heading = _findHeading(buffer, selector);
1181
+ const list = heading ? _tuiHeadingTaskList(buffer, heading) : null;
1182
+ if (!list) return [];
1183
+ return list.items.map((item) => ({
1184
+ value: item.value,
1185
+ checked: String(buffer.lines[item.start] ?? "").match(/^(\s*)([☐☒])(?:\s+|$)/)?.[2] === "☒",
1186
+ })).slice(...args);
1187
+ } catch {
1188
+ return [];
1189
+ }
1190
+ },
964
1191
  };
965
1192
  return selection;
966
1193
  };
@@ -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",
@@ -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",