@linxiraos/pi-tui 1.0.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 (77) hide show
  1. package/CHANGELOG.md +2219 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +116 -0
  4. package/dist/types/bracketed-paste.d.ts +51 -0
  5. package/dist/types/components/box.d.ts +31 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +162 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +25 -0
  10. package/dist/types/components/loader.d.ts +25 -0
  11. package/dist/types/components/markdown.d.ts +88 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +69 -0
  14. package/dist/types/components/settings-list.d.ts +123 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +89 -0
  17. package/dist/types/components/text.d.ts +27 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/desktop-notify.d.ts +52 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +48 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +197 -0
  25. package/dist/types/keys.d.ts +210 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +76 -0
  28. package/dist/types/latex-block.d.ts +8 -0
  29. package/dist/types/latex-to-unicode.d.ts +50 -0
  30. package/dist/types/loop-watchdog.d.ts +44 -0
  31. package/dist/types/mouse.d.ts +67 -0
  32. package/dist/types/stdin-buffer.d.ts +60 -0
  33. package/dist/types/symbols.d.ts +25 -0
  34. package/dist/types/terminal-capabilities.d.ts +285 -0
  35. package/dist/types/terminal.d.ts +175 -0
  36. package/dist/types/tmux.d.ts +6 -0
  37. package/dist/types/ttyid.d.ts +9 -0
  38. package/dist/types/tui.d.ts +457 -0
  39. package/dist/types/utils.d.ts +100 -0
  40. package/package.json +70 -0
  41. package/src/autocomplete.ts +1079 -0
  42. package/src/bracketed-paste.ts +123 -0
  43. package/src/components/box.ts +236 -0
  44. package/src/components/cancellable-loader.ts +40 -0
  45. package/src/components/editor.ts +3301 -0
  46. package/src/components/image.ts +460 -0
  47. package/src/components/input.ts +482 -0
  48. package/src/components/loader.ts +174 -0
  49. package/src/components/markdown.ts +3119 -0
  50. package/src/components/scroll-view.ts +227 -0
  51. package/src/components/select-list.ts +539 -0
  52. package/src/components/settings-list.ts +793 -0
  53. package/src/components/spacer.ts +32 -0
  54. package/src/components/tab-bar.ts +300 -0
  55. package/src/components/text.ts +173 -0
  56. package/src/components/truncated-text.ts +69 -0
  57. package/src/deccara.ts +314 -0
  58. package/src/desktop-notify.ts +192 -0
  59. package/src/editor-component.ts +74 -0
  60. package/src/fuzzy.ts +384 -0
  61. package/src/index.ts +51 -0
  62. package/src/keybindings.ts +346 -0
  63. package/src/keys.ts +566 -0
  64. package/src/kill-ring.ts +51 -0
  65. package/src/kitty-graphics.ts +171 -0
  66. package/src/latex-block.ts +1338 -0
  67. package/src/latex-to-unicode.ts +2017 -0
  68. package/src/loop-watchdog.ts +115 -0
  69. package/src/mouse.ts +105 -0
  70. package/src/stdin-buffer.ts +781 -0
  71. package/src/symbols.ts +26 -0
  72. package/src/terminal-capabilities.ts +1211 -0
  73. package/src/terminal.ts +1854 -0
  74. package/src/tmux.ts +14 -0
  75. package/src/ttyid.ts +84 -0
  76. package/src/tui.ts +4275 -0
  77. package/src/utils.ts +619 -0
@@ -0,0 +1,460 @@
1
+ import { getKittyGraphics } from "../kitty-graphics";
2
+ import {
3
+ getCellDimensions,
4
+ getImageDimensions,
5
+ type ImageDimensions,
6
+ imageFallback,
7
+ renderImage,
8
+ TERMINAL,
9
+ } from "../terminal-capabilities";
10
+ import type { Component } from "../tui";
11
+
12
+ export interface ImageTheme {
13
+ fallbackColor: (str: string) => string;
14
+ }
15
+
16
+ export interface ImageOptions {
17
+ maxWidthCells?: number;
18
+ maxHeightCells?: number;
19
+ filename?: string;
20
+ /** Shared budget that caps how many inline images render as live graphics. */
21
+ budget?: ImageBudget;
22
+ /**
23
+ * Stable identity for the underlying image (e.g. `toolCallId:index`). Lets the
24
+ * budget hand back the same graphics id across component re-creations so a
25
+ * repaint replaces the placement instead of stacking a duplicate.
26
+ */
27
+ imageKey?: string;
28
+ }
29
+
30
+ const EMPTY_IDS: readonly number[] = [];
31
+ const EMPTY_TRANSMITS: readonly string[] = [];
32
+ const SAVE_CURSOR = "\x1b7";
33
+ const RESTORE_CURSOR = "\x1b8";
34
+ // Direct placements reserve height with leading zero-width rows. Keep them
35
+ // non-plain so transcript blank-edge trimming does not collapse image-only blocks.
36
+ const RESERVED_IMAGE_ROW = "\x1b[0m";
37
+
38
+ /** Default count of inline images kept as live graphics before older ones fall back to text. */
39
+ export const DEFAULT_MAX_INLINE_IMAGES = 8;
40
+
41
+ let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
42
+ function nextImageIdSeed(): number {
43
+ nextImageBudgetSeed = (nextImageBudgetSeed + 0x10000) & 0xffffff;
44
+ return nextImageBudgetSeed || 1;
45
+ }
46
+ /**
47
+ * Bounds how many inline images render as live terminal graphics at once.
48
+ *
49
+ * Terminal graphics protocols — Kitty especially — keep every transmitted image
50
+ * in a per-terminal store and re-draw placements as content scrolls; text-clear
51
+ * escapes (`CSI 2 J` / `CSI 3 J`) do not remove them. Unbounded, a session that
52
+ * shows many images piles up placements plus store memory and leaves ghosts in
53
+ * scrollback.
54
+ *
55
+ * The budget keeps the most recent `cap` images live and demotes older ones to
56
+ * their text fallback. Demotion needs a full redraw (so off-screen rows are
57
+ * rewritten) plus an explicit graphics purge of the demoted ids — {@link Image}
58
+ * reports display order via {@link observe}, and the TUI drives the purge +
59
+ * redraw on the frame after a new image pushes the count past the cap.
60
+ *
61
+ * `cap <= 0` disables budgeting: every image stays a live graphic.
62
+ */
63
+ export class ImageBudget {
64
+ #cap: number;
65
+ #requestRender: () => void;
66
+ #nextId = nextImageIdSeed();
67
+ #keyToId = new Map<string, number>();
68
+ #idToKey = new Map<number, string>();
69
+ /** Display-order image ids observed during the in-flight pass. */
70
+ #passIds: number[] = [];
71
+ /**
72
+ * Suppress threshold reflected in the frame currently on the terminal: images
73
+ * at display indices `[0, #onTerminal)` are shown as text there.
74
+ */
75
+ #onTerminal = 0;
76
+ /** Suppress threshold the current/next render should apply. */
77
+ #planned = 0;
78
+ /**
79
+ * True while the in-flight pass applies a stricter threshold than the terminal
80
+ * shows — the demotion frame that must purge graphics and fully repaint.
81
+ */
82
+ #applyingReset = false;
83
+ #lastTotal = 0;
84
+ #purgeIds: number[] = [];
85
+ /** Image ids whose data is believed to be loaded in the terminal's store. */
86
+ #transmitted = new Set<number>();
87
+ /** Transmit sequences (full base64) to write once, before this frame's placements. */
88
+ #pendingTransmits: string[] = [];
89
+ // True while the in-flight pass is a partial/throwaway pass (the
90
+ // non-multiplexer resize viewport fast path) that walks only the visible
91
+ // tail, bottom-up. Such a pass cannot derive display order from observe()
92
+ // call order, so its suppression decisions replay the committed split below.
93
+ #stablePass = false;
94
+ // Image ids shown as text in the frame currently on the terminal: the
95
+ // display-order prefix [0, #onTerminal) of the last full pass, snapshotted by
96
+ // id so a partial pass reproduces the on-screen live/text split without a
97
+ // full, correctly-ordered walk.
98
+ #suppressedIds = new Set<number>();
99
+
100
+ constructor(cap: number = DEFAULT_MAX_INLINE_IMAGES, requestRender: () => void = () => {}) {
101
+ this.#cap = normalizeCap(cap);
102
+ this.#requestRender = requestRender;
103
+ }
104
+
105
+ get cap(): number {
106
+ return this.#cap;
107
+ }
108
+
109
+ get enabled(): boolean {
110
+ return this.#cap > 0;
111
+ }
112
+
113
+ setRequestRender(requestRender: () => void): void {
114
+ this.#requestRender = requestRender;
115
+ }
116
+
117
+ setCap(cap: number): void {
118
+ const next = normalizeCap(cap);
119
+ if (next === this.#cap) return;
120
+ this.#cap = next;
121
+ this.#reconcile(this.#lastTotal);
122
+ }
123
+
124
+ /**
125
+ * Stable graphics id for a logical image. A non-empty `key` maps to the same
126
+ * id across re-creations (so repaints replace the placement); a missing key
127
+ * gets a fresh id every call.
128
+ */
129
+ acquireId(key?: string): number {
130
+ if (key) {
131
+ const existing = this.#keyToId.get(key);
132
+ if (existing !== undefined) return existing;
133
+ const id = this.#nextId;
134
+ this.#nextId = (this.#nextId + 1) & 0xffffff || 1;
135
+ this.#keyToId.set(key, id);
136
+ this.#idToKey.set(id, key);
137
+ return id;
138
+ }
139
+ const id = this.#nextId;
140
+ this.#nextId = (this.#nextId + 1) & 0xffffff || 1;
141
+ return id;
142
+ }
143
+
144
+ /**
145
+ * Begin a render pass. Called by the renderer before composing the frame.
146
+ * Pass `stable: true` for a partial/throwaway pass that does not walk the
147
+ * whole tree in display order (the resize viewport fast path): {@link observe}
148
+ * then replays the last committed per-id decision instead of one derived from
149
+ * call order, and the pass must NOT be closed with {@link endPass}.
150
+ */
151
+ beginPass(stable = false): void {
152
+ this.#passIds.length = 0;
153
+ this.#stablePass = stable;
154
+ this.#applyingReset = !stable && this.#cap > 0 && this.#planned > this.#onTerminal;
155
+ }
156
+
157
+ /**
158
+ * Record an image in display order and report whether it must render its text
159
+ * fallback this frame. Called by every {@link Image} during render — including
160
+ * on a cache hit, so the image keeps its display-order slot.
161
+ *
162
+ * During a `stable` pass ({@link beginPass}) the call order and visible subset
163
+ * are not authoritative, so the decision is the committed on-terminal split
164
+ * (`#suppressedIds`) keyed by id — order- and partiality-independent.
165
+ */
166
+ observe(imageId: number): boolean {
167
+ if (this.#stablePass) {
168
+ const suppressed = this.#cap > 0 && this.#suppressedIds.has(imageId);
169
+ if (suppressed) this.#forgetKeyForId(imageId);
170
+ return suppressed;
171
+ }
172
+ const index = this.#passIds.length;
173
+ this.#passIds.push(imageId);
174
+ const suppressed = this.#cap > 0 && index < this.#planned;
175
+ if (suppressed) this.#forgetKeyForId(imageId);
176
+ return suppressed;
177
+ }
178
+
179
+ /**
180
+ * End a render pass. Returns true when this frame must purge graphics and
181
+ * fully repaint to apply a stricter budget; read the ids via
182
+ * {@link takePurgeIds}.
183
+ */
184
+ endPass(): boolean {
185
+ const total = this.#passIds.length;
186
+ this.#lastTotal = total;
187
+ let reset = false;
188
+ if (this.#applyingReset) {
189
+ for (let i = this.#onTerminal; i < this.#planned && i < total; i++) {
190
+ const id = this.#passIds[i];
191
+ this.#purgeIds.push(id);
192
+ // d=I frees the data too, so the image must re-transmit if it returns.
193
+ this.#transmitted.delete(id);
194
+ this.#forgetKeyForId(id);
195
+ }
196
+ this.#onTerminal = this.#planned;
197
+ this.#applyingReset = false;
198
+ reset = true;
199
+ }
200
+ this.#reconcile(total);
201
+ // Snapshot the committed display-order suppression by id: the prefix
202
+ // [0, #onTerminal) is what the terminal currently shows as text. Partial
203
+ // passes replay this per id (see #stablePass) instead of re-deriving it
204
+ // from a reversed, tail-only walk.
205
+ this.#suppressedIds = new Set(this.#passIds.slice(0, this.#onTerminal));
206
+ return reset;
207
+ }
208
+
209
+ /** Image ids to delete from the terminal this frame; clears the pending set. */
210
+ takePurgeIds(): readonly number[] {
211
+ if (this.#purgeIds.length === 0) return EMPTY_IDS;
212
+ const ids = this.#purgeIds;
213
+ this.#purgeIds = [];
214
+ return ids;
215
+ }
216
+
217
+ /** All image ids believed to be loaded in the terminal store; clears tracking. */
218
+ takeAllTransmittedIds(): readonly number[] {
219
+ if (this.#transmitted.size === 0) return EMPTY_IDS;
220
+ const ids = [...this.#transmitted];
221
+ this.#transmitted.clear();
222
+ this.#purgeIds = [];
223
+ this.#pendingTransmits = [];
224
+ this.#keyToId.clear();
225
+ this.#idToKey.clear();
226
+ return ids;
227
+ }
228
+
229
+ /** Whether `imageId`'s data still needs to be transmitted to the terminal. */
230
+ shouldTransmit(imageId: number): boolean {
231
+ return !this.#transmitted.has(imageId);
232
+ }
233
+
234
+ /**
235
+ * Queue a one-time transmit for `imageId`. No-op if already transmitted, so a
236
+ * repeated call (e.g. a width-change re-render) never re-sends the data.
237
+ */
238
+ enqueueTransmit(imageId: number, sequence: string): void {
239
+ if (this.#transmitted.has(imageId)) return;
240
+ this.#transmitted.add(imageId);
241
+ this.#pendingTransmits.push(sequence);
242
+ }
243
+
244
+ /** Whether a frame has image data queued but not yet written to the terminal. */
245
+ hasPendingTransmits(): boolean {
246
+ return this.#pendingTransmits.length > 0;
247
+ }
248
+
249
+ /**
250
+ * True when the budget has nothing in flight: no live images observed on
251
+ * the last pass, no queued transmits, no pending purges, and no stricter
252
+ * threshold left to apply. A component-scoped frame may skip the observe
253
+ * pass only then — a partial tree walk would under-count display order.
254
+ */
255
+ get quiescent(): boolean {
256
+ return (
257
+ this.#lastTotal === 0 &&
258
+ this.#pendingTransmits.length === 0 &&
259
+ this.#purgeIds.length === 0 &&
260
+ this.#planned === this.#onTerminal
261
+ );
262
+ }
263
+
264
+ /** Transmit sequences to write before this frame's placements; clears the queue. */
265
+ takeTransmits(): readonly string[] {
266
+ if (this.#pendingTransmits.length === 0) return EMPTY_TRANSMITS;
267
+ const sequences = this.#pendingTransmits;
268
+ this.#pendingTransmits = [];
269
+ return sequences;
270
+ }
271
+
272
+ /**
273
+ * Drop transmit tracking so every still-live image re-enqueues its data
274
+ * (`a=t`) on the next render. Recovers when the terminal dropped the original
275
+ * transmit — e.g. Ghostty discarding graphics sent during its post-startup
276
+ * window — where a placement-only replay can never bind a Unicode placeholder.
277
+ * Pair with a component invalidate + forced repaint so the data and placement
278
+ * re-emit together; keeps no base64 in budget state (the transmit-once design).
279
+ */
280
+ forgetTransmitted(): void {
281
+ if (this.#transmitted.size === 0 && this.#pendingTransmits.length === 0) return;
282
+ this.#transmitted.clear();
283
+ this.#pendingTransmits = [];
284
+ }
285
+
286
+ #forgetKeyForId(id: number): void {
287
+ const key = this.#idToKey.get(id);
288
+ if (key === undefined) return;
289
+ this.#idToKey.delete(id);
290
+ if (this.#keyToId.get(key) === id) this.#keyToId.delete(key);
291
+ }
292
+
293
+ #reconcile(total: number): void {
294
+ const desired = this.#cap > 0 ? Math.max(0, total - this.#cap) : 0;
295
+ if (desired === this.#planned) {
296
+ // Budget relaxed without a stricter frame (cap raised or images
297
+ // removed): surviving graphics are untouched and re-exposed rows
298
+ // repaint normally, so just track the looser threshold.
299
+ if (this.#planned < this.#onTerminal) this.#onTerminal = this.#planned;
300
+ return;
301
+ }
302
+ this.#planned = desired;
303
+ // More images must be demoted than the terminal shows: schedule the purge +
304
+ // full-redraw frame. Fewer: no ghosts to clear, so just catch the tracking
305
+ // up — a normal repaint re-exposes the un-demoted images. Either way a
306
+ // render is needed to apply the new threshold.
307
+ if (desired <= this.#onTerminal) this.#onTerminal = desired;
308
+ this.#requestRender();
309
+ }
310
+ }
311
+
312
+ function normalizeCap(cap: number): number {
313
+ if (!Number.isFinite(cap)) return 0;
314
+ return Math.max(0, Math.trunc(cap));
315
+ }
316
+
317
+ export class Image implements Component {
318
+ #base64Data: string;
319
+ #mimeType: string;
320
+ #dimensions: ImageDimensions;
321
+ #theme: ImageTheme;
322
+ #options: ImageOptions;
323
+ #budget?: ImageBudget;
324
+ #imageId?: number;
325
+
326
+ #cachedLines?: string[];
327
+ #cachedWidth?: number;
328
+ #cachedSuppressed = false;
329
+ #cachedImageProtocol: typeof TERMINAL.imageProtocol = null;
330
+ #cachedCellWidthPx = 0;
331
+ #cachedCellHeightPx = 0;
332
+ #cachedKittyUnicodePlaceholders = false;
333
+ // Tallest graphic placement this image has rendered. The text fallback
334
+ // pads itself to this height so a budget demotion never shrinks the block
335
+ // (its rows may already be committed to native scrollback).
336
+ #renderedGraphicRows = 0;
337
+
338
+ constructor(
339
+ base64Data: string,
340
+ mimeType: string,
341
+ theme: ImageTheme,
342
+ options: ImageOptions = {},
343
+ dimensions?: ImageDimensions,
344
+ ) {
345
+ this.#base64Data = base64Data;
346
+ this.#mimeType = mimeType;
347
+ this.#theme = theme;
348
+ this.#options = options;
349
+ this.#dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 };
350
+ this.#budget = options.budget;
351
+ this.#imageId = options.budget ? options.budget.acquireId(options.imageKey) : undefined;
352
+ }
353
+
354
+ invalidate(): void {
355
+ this.#cachedLines = undefined;
356
+ this.#cachedWidth = undefined;
357
+ }
358
+
359
+ render(width: number): readonly string[] {
360
+ const imageProtocol = TERMINAL.imageProtocol;
361
+ const hasProtocol = imageProtocol != null;
362
+ const cellDimensions = getCellDimensions();
363
+ const kittyUnicodePlaceholders = getKittyGraphics().unicodePlaceholders;
364
+ // observe() must run on every pass — even a cache hit — so the image keeps
365
+ // its display-order slot in the budget. Only graphics-capable frames count
366
+ // toward (and are demoted by) the budget; without a protocol every image is
367
+ // already text.
368
+ const suppressed = hasProtocol && this.#budget !== undefined ? this.#budget.observe(this.#imageId ?? 0) : false;
369
+
370
+ if (
371
+ this.#cachedLines &&
372
+ this.#cachedWidth === width &&
373
+ this.#cachedSuppressed === suppressed &&
374
+ this.#cachedImageProtocol === imageProtocol &&
375
+ this.#cachedCellWidthPx === cellDimensions.widthPx &&
376
+ this.#cachedCellHeightPx === cellDimensions.heightPx &&
377
+ this.#cachedKittyUnicodePlaceholders === kittyUnicodePlaceholders
378
+ ) {
379
+ return this.#cachedLines;
380
+ }
381
+
382
+ const cap = this.#options.maxWidthCells;
383
+ const maxWidth = cap != null && cap > 0 ? Math.min(width - 2, cap) : width - 2;
384
+
385
+ let lines: string[];
386
+
387
+ if (hasProtocol && !suppressed) {
388
+ // Transmit the data once (keyed by id); thereafter renderImage returns
389
+ // just the placement, so repaints never re-send the base64.
390
+ const needsTransmit = this.#imageId != null && (this.#budget?.shouldTransmit(this.#imageId) ?? false);
391
+ const result = renderImage(this.#base64Data, this.#dimensions, {
392
+ maxWidthCells: maxWidth,
393
+ maxHeightCells: this.#options.maxHeightCells,
394
+ imageId: this.#imageId,
395
+ includeTransmit: needsTransmit,
396
+ });
397
+
398
+ if (result?.transmit && this.#imageId != null && this.#budget !== undefined) {
399
+ this.#budget.enqueueTransmit(this.#imageId, result.transmit);
400
+ }
401
+
402
+ if (result?.lines) {
403
+ // Unicode placeholders: the image is already a block of real text-cell
404
+ // lines (line 0 carries the virtual-placement APC). No cursor moves.
405
+ lines = result.lines;
406
+ } else if (result) {
407
+ // Direct placement: return `rows` lines so TUI accounts for image
408
+ // height. First (rows-1) lines are empty (TUI clears them); the last
409
+ // saves the final-row cursor, moves up to the image origin, emits the
410
+ // image sequence, then restores the final-row cursor. Save/restore is
411
+ // required because CUU clamps at the viewport top when leading rows are
412
+ // clipped away.
413
+ lines = [];
414
+ for (let i = 0; i < result.rows - 1; i++) {
415
+ lines.push(RESERVED_IMAGE_ROW);
416
+ }
417
+ const cursorRows = result.rows - 1;
418
+ const moveUp = cursorRows > 0 ? `\x1b[${cursorRows}A` : "";
419
+ const placement = moveUp + (result.sequence ?? "");
420
+ lines.push(cursorRows > 0 ? SAVE_CURSOR + placement + RESTORE_CURSOR : placement);
421
+ } else {
422
+ lines = this.#fallbackLines();
423
+ }
424
+ this.#renderedGraphicRows = Math.max(this.#renderedGraphicRows, lines.length);
425
+ } else {
426
+ lines = this.#fallbackLines();
427
+ }
428
+
429
+ this.#cachedLines = lines;
430
+ this.#cachedWidth = width;
431
+ this.#cachedSuppressed = suppressed;
432
+ this.#cachedImageProtocol = imageProtocol;
433
+ this.#cachedCellWidthPx = cellDimensions.widthPx;
434
+ this.#cachedCellHeightPx = cellDimensions.heightPx;
435
+ this.#cachedKittyUnicodePlaceholders = kittyUnicodePlaceholders;
436
+
437
+ return lines;
438
+ }
439
+
440
+ /**
441
+ * Text fallback, height-preserving once a graphic has rendered: a demoted
442
+ * image must keep occupying the rows its placement used, because those
443
+ * rows may already be committed to native scrollback — shrinking the block
444
+ * would shift everything below it and force the renderer's commit-resync
445
+ * (stale band + recommit). Reserved rows stay non-plain so blank-edge
446
+ * trimming cannot collapse the block either.
447
+ */
448
+ #fallbackLines(): string[] {
449
+ const fallback = this.#theme.fallbackColor(
450
+ imageFallback(this.#mimeType, this.#dimensions, this.#options.filename),
451
+ );
452
+ if (this.#renderedGraphicRows <= 1) return [fallback];
453
+ const lines: string[] = [];
454
+ for (let i = 0; i < this.#renderedGraphicRows - 1; i++) {
455
+ lines.push(RESERVED_IMAGE_ROW);
456
+ }
457
+ lines.push(fallback);
458
+ return lines;
459
+ }
460
+ }