jeopi-tui 16.2.13

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