jsmdcui 0.5.0 → 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.
@@ -770,7 +770,255 @@ function _blockValue(lines, selector) {
770
770
  return value.join("\n");
771
771
  }
772
772
 
773
- function _spliceBufferLines(buffer, start, deleteCount, replacement) {
773
+ function _headingSelectorId(input) {
774
+ const match = String(input ?? "").trim().match(/^#([^\s]+)$/);
775
+ return match?.[1] ?? null;
776
+ }
777
+
778
+ function _findHeading(buffer, selector) {
779
+ const id = _headingSelectorId(selector);
780
+ const markdown = buffer?._mdcuiTuiSourceText;
781
+ if (!id || markdown == null || typeof Bun?.markdown?.html !== "function") return undefined;
782
+
783
+ const html = String(Bun.markdown.html(markdown, { headings: { ids: true } }));
784
+ const headings = /<h([1-6])\b([^>]*)>([^]*?)<\/h\1\s*>/gi;
785
+ let match;
786
+ let ordinal = 0;
787
+ while ((match = headings.exec(html)) !== null) {
788
+ const headingId = match[2].match(/\bid="([^"]*)"/i)?.[1];
789
+ if (headingId === id) {
790
+ return {
791
+ html: match[3],
792
+ level: Number(match[1]),
793
+ ordinal,
794
+ };
795
+ }
796
+ ordinal++;
797
+ }
798
+ return undefined;
799
+ }
800
+
801
+ let _headingAnsiPrefixes;
802
+ function _tuiHeadingAnsiPrefixes() {
803
+ if (_headingAnsiPrefixes) return _headingAnsiPrefixes;
804
+ const marker = "MDCUI_HEADING_LINE_PROBE";
805
+ _headingAnsiPrefixes = new Map();
806
+ for (let level = 1; level <= 6; level++) {
807
+ const rendered = String(Bun.markdown.ansi(
808
+ `${"#".repeat(level)} ${marker}`,
809
+ { hyperlinks: true, columns: 80 },
810
+ ));
811
+ const line = rendered.split("\n").find((item) => item.includes(marker));
812
+ if (line) _headingAnsiPrefixes.set(level, line.slice(0, line.indexOf(marker)));
813
+ }
814
+ return _headingAnsiPrefixes;
815
+ }
816
+
817
+ function _headingTuiLine(buffer, heading) {
818
+ const ansiText = buffer?._mdcuiAnsiText;
819
+ if (!heading || typeof ansiText !== "string") return 0;
820
+ const tuiHeadings = _tuiHeadingLines(buffer);
821
+ const exact = tuiHeadings[heading.ordinal];
822
+ if (exact?.level === heading.level) return exact.line;
823
+ return 0;
824
+ }
825
+
826
+ function _tuiHeadingLines(buffer) {
827
+ const ansiText = buffer?._mdcuiAnsiText;
828
+ if (typeof ansiText !== "string") return [];
829
+ const prefixes = _tuiHeadingAnsiPrefixes();
830
+ const tuiHeadings = [];
831
+ for (const [lineIndex, line] of ansiText.split("\n").entries()) {
832
+ for (const [level, prefix] of prefixes) {
833
+ if (line.startsWith(prefix)) {
834
+ tuiHeadings.push({ level, line: lineIndex + 1 });
835
+ break;
836
+ }
837
+ }
838
+ }
839
+ return tuiHeadings;
840
+ }
841
+
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
+ }
865
+ const headingLine = _headingTuiLine(buffer, heading);
866
+ if (!headingLine || !Array.isArray(buffer?.lines)) return null;
867
+
868
+ const tuiHeadings = _tuiHeadingLines(buffer);
869
+ const nextHeading = tuiHeadings[heading.ordinal + 1];
870
+ const endLine = nextHeading?.line ?? buffer.lines.length + 1;
871
+ let first = -1;
872
+ let indent = "";
873
+
874
+ for (let y = headingLine; y < endLine - 1; y++) {
875
+ const checkbox = String(buffer.lines[y] ?? "").match(/^(\s*)[☐☒](?:\s+|$)/);
876
+ if (!checkbox) continue;
877
+ first = y;
878
+ indent = checkbox[1];
879
+ break;
880
+ }
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
+ };
935
+ }
936
+
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 = {}) {
774
1022
  const oldCursor = buffer.cursor ? { ...buffer.cursor } : null;
775
1023
  buffer.lines.splice(start, deleteCount, ...replacement);
776
1024
 
@@ -779,15 +1027,29 @@ function _spliceBufferLines(buffer, start, deleteCount, replacement) {
779
1027
  buffer._ansiStyleLines.splice(
780
1028
  start,
781
1029
  deleteCount,
782
- ...replacement.map(() => template),
1030
+ ...(replacementMeta.styles ?? replacement.map(() => template)),
783
1031
  );
784
1032
  }
785
1033
 
786
1034
  if (typeof buffer._mdcuiAnsiText === "string") {
787
1035
  const ansiLines = buffer._mdcuiAnsiText.split("\n");
788
- ansiLines.splice(start, deleteCount, ...replacement);
1036
+ ansiLines.splice(start, deleteCount, ...(replacementMeta.ansi ?? replacement));
789
1037
  buffer._mdcuiAnsiText = ansiLines.join("\n");
790
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
+ }
791
1053
 
792
1054
  buffer.invalidateHighlightFrom?.(start, { force: replacement.length !== deleteCount });
793
1055
  if (oldCursor) {
@@ -828,11 +1090,32 @@ export function createTuiSelector(getBuffer) {
828
1090
  return function $(selector) {
829
1091
  const parsedSelector = _parseBlockIdentity(selector, { selector: true });
830
1092
  const selection = {
1093
+ html() {
1094
+ try {
1095
+ return _findHeading(getBuffer?.(), selector)?.html ?? "";
1096
+ } catch {
1097
+ return "";
1098
+ }
1099
+ },
1100
+ line() {
1101
+ try {
1102
+ const buffer = getBuffer?.();
1103
+ return _headingTuiLine(buffer, _findHeading(buffer, selector));
1104
+ } catch {
1105
+ return 0;
1106
+ }
1107
+ },
831
1108
  val(...args) {
832
1109
  try {
833
- if (!parsedSelector) return args.length > 0 ? selection : "";
834
1110
  const buffer = getBuffer?.();
835
1111
  if (!buffer) return args.length > 0 ? selection : "";
1112
+ const heading = _findHeading(buffer, selector);
1113
+ const headingId = _headingSelectorId(selector);
1114
+ if (heading && headingId) {
1115
+ if (args.length > 0) return selection;
1116
+ return _headingCheckboxValue(buffer, heading, headingId);
1117
+ }
1118
+ if (!parsedSelector) return args.length > 0 ? selection : "";
836
1119
  const lines = Array.isArray(buffer.lines)
837
1120
  ? buffer.lines
838
1121
  : String(buffer).replace(/\r\n?/g, "\n").split("\n");
@@ -846,6 +1129,65 @@ export function createTuiSelector(getBuffer) {
846
1129
  return args.length > 0 ? selection : "";
847
1130
  }
848
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
+ },
849
1191
  };
850
1192
  return selection;
851
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",