@prometheus-ai/tui 0.5.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 (65) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +704 -0
  3. package/dist/types/autocomplete.d.ts +76 -0
  4. package/dist/types/bracketed-paste.d.ts +26 -0
  5. package/dist/types/components/box.d.ts +17 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +105 -0
  8. package/dist/types/components/image.d.ts +84 -0
  9. package/dist/types/components/input.d.ts +18 -0
  10. package/dist/types/components/loader.d.ts +13 -0
  11. package/dist/types/components/markdown.d.ts +61 -0
  12. package/dist/types/components/scroll-view.d.ts +40 -0
  13. package/dist/types/components/select-list.d.ts +48 -0
  14. package/dist/types/components/settings-list.d.ts +41 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +56 -0
  17. package/dist/types/components/text.d.ts +13 -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/editor-component.d.ts +36 -0
  21. package/dist/types/fuzzy.d.ts +15 -0
  22. package/dist/types/index.d.ts +28 -0
  23. package/dist/types/keybindings.d.ts +189 -0
  24. package/dist/types/keys.d.ts +208 -0
  25. package/dist/types/kill-ring.d.ts +27 -0
  26. package/dist/types/kitty-graphics.d.ts +94 -0
  27. package/dist/types/stdin-buffer.d.ts +43 -0
  28. package/dist/types/symbols.d.ts +25 -0
  29. package/dist/types/terminal-capabilities.d.ts +196 -0
  30. package/dist/types/terminal.d.ts +103 -0
  31. package/dist/types/ttyid.d.ts +9 -0
  32. package/dist/types/tui.d.ts +275 -0
  33. package/dist/types/utils.d.ts +89 -0
  34. package/package.json +73 -0
  35. package/src/autocomplete.ts +871 -0
  36. package/src/bracketed-paste.ts +47 -0
  37. package/src/components/box.ts +156 -0
  38. package/src/components/cancellable-loader.ts +40 -0
  39. package/src/components/editor.ts +2695 -0
  40. package/src/components/image.ts +318 -0
  41. package/src/components/input.ts +459 -0
  42. package/src/components/loader.ts +86 -0
  43. package/src/components/markdown.ts +1189 -0
  44. package/src/components/scroll-view.ts +166 -0
  45. package/src/components/select-list.ts +331 -0
  46. package/src/components/settings-list.ts +212 -0
  47. package/src/components/spacer.ts +28 -0
  48. package/src/components/tab-bar.ts +175 -0
  49. package/src/components/text.ts +110 -0
  50. package/src/components/truncated-text.ts +61 -0
  51. package/src/deccara.ts +314 -0
  52. package/src/editor-component.ts +71 -0
  53. package/src/fuzzy.ts +143 -0
  54. package/src/index.ts +44 -0
  55. package/src/keybindings.ts +279 -0
  56. package/src/keys.ts +537 -0
  57. package/src/kill-ring.ts +46 -0
  58. package/src/kitty-graphics.ts +270 -0
  59. package/src/stdin-buffer.ts +423 -0
  60. package/src/symbols.ts +26 -0
  61. package/src/terminal-capabilities.ts +1009 -0
  62. package/src/terminal.ts +1114 -0
  63. package/src/ttyid.ts +70 -0
  64. package/src/tui.ts +2988 -0
  65. package/src/utils.ts +452 -0
@@ -0,0 +1,318 @@
1
+ import {
2
+ getImageDimensions,
3
+ type ImageDimensions,
4
+ imageFallback,
5
+ renderImage,
6
+ TERMINAL,
7
+ } from "../terminal-capabilities";
8
+ import type { Component } from "../tui";
9
+
10
+ export interface ImageTheme {
11
+ fallbackColor: (str: string) => string;
12
+ }
13
+
14
+ export interface ImageOptions {
15
+ maxWidthCells?: number;
16
+ maxHeightCells?: number;
17
+ filename?: string;
18
+ /** Shared budget that caps how many inline images render as live graphics. */
19
+ budget?: ImageBudget;
20
+ /**
21
+ * Stable identity for the underlying image (e.g. `toolCallId:index`). Lets the
22
+ * budget hand back the same graphics id across component re-creations so a
23
+ * repaint replaces the placement instead of stacking a duplicate.
24
+ */
25
+ imageKey?: string;
26
+ }
27
+
28
+ const EMPTY_IDS: readonly number[] = [];
29
+ const EMPTY_TRANSMITS: readonly string[] = [];
30
+
31
+ /** Default count of inline images kept as live graphics before older ones fall back to text. */
32
+ export const DEFAULT_MAX_INLINE_IMAGES = 8;
33
+
34
+ /**
35
+ * Bounds how many inline images render as live terminal graphics at once.
36
+ *
37
+ * Terminal graphics protocols — Kitty especially — keep every transmitted image
38
+ * in a per-terminal store and re-draw placements as content scrolls; text-clear
39
+ * escapes (`CSI 2 J` / `CSI 3 J`) do not remove them. Unbounded, a session that
40
+ * shows many images piles up placements plus store memory and leaves ghosts in
41
+ * scrollback.
42
+ *
43
+ * The budget keeps the most recent `cap` images live and demotes older ones to
44
+ * their text fallback. Demotion needs a full redraw (so off-screen rows are
45
+ * rewritten) plus an explicit graphics purge of the demoted ids — {@link Image}
46
+ * reports display order via {@link observe}, and the TUI drives the purge +
47
+ * redraw on the frame after a new image pushes the count past the cap.
48
+ *
49
+ * `cap <= 0` disables budgeting: every image stays a live graphic.
50
+ */
51
+ export class ImageBudget {
52
+ #cap: number;
53
+ #requestRender: () => void;
54
+ #nextId = 1;
55
+ #keyToId = new Map<string, number>();
56
+ /** Display-order image ids observed during the in-flight pass. */
57
+ #passIds: number[] = [];
58
+ /**
59
+ * Suppress threshold reflected in the frame currently on the terminal: images
60
+ * at display indices `[0, #onTerminal)` are shown as text there.
61
+ */
62
+ #onTerminal = 0;
63
+ /** Suppress threshold the current/next render should apply. */
64
+ #planned = 0;
65
+ /**
66
+ * True while the in-flight pass applies a stricter threshold than the terminal
67
+ * shows — the demotion frame that must purge graphics and fully repaint.
68
+ */
69
+ #applyingReset = false;
70
+ #lastTotal = 0;
71
+ #purgeIds: number[] = [];
72
+ /** Image ids whose data is believed to be loaded in the terminal's store. */
73
+ #transmitted = new Set<number>();
74
+ /** Transmit sequences (full base64) to write once, before this frame's placements. */
75
+ #pendingTransmits: string[] = [];
76
+
77
+ constructor(cap: number = DEFAULT_MAX_INLINE_IMAGES, requestRender: () => void = () => {}) {
78
+ this.#cap = normalizeCap(cap);
79
+ this.#requestRender = requestRender;
80
+ }
81
+
82
+ get cap(): number {
83
+ return this.#cap;
84
+ }
85
+
86
+ get enabled(): boolean {
87
+ return this.#cap > 0;
88
+ }
89
+
90
+ setRequestRender(requestRender: () => void): void {
91
+ this.#requestRender = requestRender;
92
+ }
93
+
94
+ setCap(cap: number): void {
95
+ const next = normalizeCap(cap);
96
+ if (next === this.#cap) return;
97
+ this.#cap = next;
98
+ this.#reconcile(this.#lastTotal);
99
+ }
100
+
101
+ /**
102
+ * Stable graphics id for a logical image. A non-empty `key` maps to the same
103
+ * id across re-creations (so repaints replace the placement); a missing key
104
+ * gets a fresh id every call.
105
+ */
106
+ acquireId(key?: string): number {
107
+ if (key) {
108
+ const existing = this.#keyToId.get(key);
109
+ if (existing !== undefined) return existing;
110
+ const id = this.#nextId++;
111
+ this.#keyToId.set(key, id);
112
+ return id;
113
+ }
114
+ return this.#nextId++;
115
+ }
116
+
117
+ /** Begin a render pass. Called by the renderer before composing the frame. */
118
+ beginPass(): void {
119
+ this.#passIds.length = 0;
120
+ this.#applyingReset = this.#cap > 0 && this.#planned > this.#onTerminal;
121
+ }
122
+
123
+ /**
124
+ * Record an image in display order and report whether it must render its text
125
+ * fallback this frame. Called by every {@link Image} during render — including
126
+ * on a cache hit, so the image keeps its display-order slot.
127
+ */
128
+ observe(imageId: number): boolean {
129
+ const index = this.#passIds.length;
130
+ this.#passIds.push(imageId);
131
+ return this.#cap > 0 && index < this.#planned;
132
+ }
133
+
134
+ /**
135
+ * End a render pass. Returns true when this frame must purge graphics and
136
+ * fully repaint to apply a stricter budget; read the ids via
137
+ * {@link takePurgeIds}.
138
+ */
139
+ endPass(): boolean {
140
+ const total = this.#passIds.length;
141
+ this.#lastTotal = total;
142
+ let reset = false;
143
+ if (this.#applyingReset) {
144
+ for (let i = this.#onTerminal; i < this.#planned && i < total; i++) {
145
+ const id = this.#passIds[i];
146
+ this.#purgeIds.push(id);
147
+ // d=I frees the data too, so the image must re-transmit if it returns.
148
+ this.#transmitted.delete(id);
149
+ }
150
+ this.#onTerminal = this.#planned;
151
+ this.#applyingReset = false;
152
+ reset = true;
153
+ }
154
+ this.#reconcile(total);
155
+ return reset;
156
+ }
157
+
158
+ /** Image ids to delete from the terminal this frame; clears the pending set. */
159
+ takePurgeIds(): readonly number[] {
160
+ if (this.#purgeIds.length === 0) return EMPTY_IDS;
161
+ const ids = this.#purgeIds;
162
+ this.#purgeIds = [];
163
+ return ids;
164
+ }
165
+
166
+ /** All image ids believed to be loaded in the terminal store; clears tracking. */
167
+ takeAllTransmittedIds(): readonly number[] {
168
+ if (this.#transmitted.size === 0) return EMPTY_IDS;
169
+ const ids = [...this.#transmitted];
170
+ this.#transmitted.clear();
171
+ this.#purgeIds = [];
172
+ this.#pendingTransmits = [];
173
+ return ids;
174
+ }
175
+
176
+ /** Whether `imageId`'s data still needs to be transmitted to the terminal. */
177
+ shouldTransmit(imageId: number): boolean {
178
+ return !this.#transmitted.has(imageId);
179
+ }
180
+
181
+ /**
182
+ * Queue a one-time transmit for `imageId`. No-op if already transmitted, so a
183
+ * repeated call (e.g. a width-change re-render) never re-sends the data.
184
+ */
185
+ enqueueTransmit(imageId: number, sequence: string): void {
186
+ if (this.#transmitted.has(imageId)) return;
187
+ this.#transmitted.add(imageId);
188
+ this.#pendingTransmits.push(sequence);
189
+ }
190
+
191
+ /** Transmit sequences to write before this frame's placements; clears the queue. */
192
+ takeTransmits(): readonly string[] {
193
+ if (this.#pendingTransmits.length === 0) return EMPTY_TRANSMITS;
194
+ const sequences = this.#pendingTransmits;
195
+ this.#pendingTransmits = [];
196
+ return sequences;
197
+ }
198
+
199
+ #reconcile(total: number): void {
200
+ const desired = this.#cap > 0 ? Math.max(0, total - this.#cap) : 0;
201
+ if (desired === this.#planned) {
202
+ // Budget relaxed without a stricter frame (cap raised or images
203
+ // removed): surviving graphics are untouched and re-exposed rows
204
+ // repaint normally, so just track the looser threshold.
205
+ if (this.#planned < this.#onTerminal) this.#onTerminal = this.#planned;
206
+ return;
207
+ }
208
+ this.#planned = desired;
209
+ // More images must be demoted than the terminal shows: schedule the purge +
210
+ // full-redraw frame. Fewer: no ghosts to clear, so just catch the tracking
211
+ // up — a normal repaint re-exposes the un-demoted images. Either way a
212
+ // render is needed to apply the new threshold.
213
+ if (desired <= this.#onTerminal) this.#onTerminal = desired;
214
+ this.#requestRender();
215
+ }
216
+ }
217
+
218
+ function normalizeCap(cap: number): number {
219
+ if (!Number.isFinite(cap)) return 0;
220
+ return Math.max(0, Math.trunc(cap));
221
+ }
222
+
223
+ export class Image implements Component {
224
+ #base64Data: string;
225
+ #mimeType: string;
226
+ #dimensions: ImageDimensions;
227
+ #theme: ImageTheme;
228
+ #options: ImageOptions;
229
+ #budget?: ImageBudget;
230
+ #imageId?: number;
231
+
232
+ #cachedLines?: string[];
233
+ #cachedWidth?: number;
234
+ #cachedSuppressed = false;
235
+
236
+ constructor(
237
+ base64Data: string,
238
+ mimeType: string,
239
+ theme: ImageTheme,
240
+ options: ImageOptions = {},
241
+ dimensions?: ImageDimensions,
242
+ ) {
243
+ this.#base64Data = base64Data;
244
+ this.#mimeType = mimeType;
245
+ this.#theme = theme;
246
+ this.#options = options;
247
+ this.#dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 };
248
+ this.#budget = options.budget;
249
+ this.#imageId = options.budget ? options.budget.acquireId(options.imageKey) : undefined;
250
+ }
251
+
252
+ invalidate(): void {
253
+ this.#cachedLines = undefined;
254
+ this.#cachedWidth = undefined;
255
+ }
256
+
257
+ render(width: number): string[] {
258
+ const hasProtocol = TERMINAL.imageProtocol != null;
259
+ // observe() must run on every pass — even a cache hit — so the image keeps
260
+ // its display-order slot in the budget. Only graphics-capable frames count
261
+ // toward (and are demoted by) the budget; without a protocol every image is
262
+ // already text.
263
+ const suppressed = hasProtocol && this.#budget !== undefined ? this.#budget.observe(this.#imageId ?? 0) : false;
264
+
265
+ if (this.#cachedLines && this.#cachedWidth === width && this.#cachedSuppressed === suppressed) {
266
+ return this.#cachedLines;
267
+ }
268
+
269
+ const cap = this.#options.maxWidthCells;
270
+ const maxWidth = cap != null && cap > 0 ? Math.min(width - 2, cap) : width - 2;
271
+
272
+ let lines: string[];
273
+
274
+ if (hasProtocol && !suppressed) {
275
+ // Transmit the data once (keyed by id); thereafter renderImage returns
276
+ // just the placement, so repaints never re-send the base64.
277
+ const needsTransmit = this.#imageId != null && (this.#budget?.shouldTransmit(this.#imageId) ?? false);
278
+ const result = renderImage(this.#base64Data, this.#dimensions, {
279
+ maxWidthCells: maxWidth,
280
+ maxHeightCells: this.#options.maxHeightCells,
281
+ imageId: this.#imageId,
282
+ includeTransmit: needsTransmit,
283
+ });
284
+
285
+ if (result?.transmit && this.#imageId != null && this.#budget !== undefined) {
286
+ this.#budget.enqueueTransmit(this.#imageId, result.transmit);
287
+ }
288
+
289
+ if (result?.lines) {
290
+ // Unicode placeholders: the image is already a block of real text-cell
291
+ // lines (line 0 carries the virtual-placement APC). No cursor moves.
292
+ lines = result.lines;
293
+ } else if (result) {
294
+ // Direct placement: return `rows` lines so TUI accounts for image
295
+ // height. First (rows-1) lines are empty (TUI clears them); the last
296
+ // moves the cursor back up, then emits the image sequence.
297
+ lines = [];
298
+ for (let i = 0; i < result.rows - 1; i++) {
299
+ lines.push("");
300
+ }
301
+ const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
302
+ lines.push(moveUp + (result.sequence ?? ""));
303
+ } else {
304
+ lines = [
305
+ this.#theme.fallbackColor(imageFallback(this.#mimeType, this.#dimensions, this.#options.filename)),
306
+ ];
307
+ }
308
+ } else {
309
+ lines = [this.#theme.fallbackColor(imageFallback(this.#mimeType, this.#dimensions, this.#options.filename))];
310
+ }
311
+
312
+ this.#cachedLines = lines;
313
+ this.#cachedWidth = width;
314
+ this.#cachedSuppressed = suppressed;
315
+
316
+ return lines;
317
+ }
318
+ }