@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.
- package/CHANGELOG.md +7 -0
- package/README.md +704 -0
- package/dist/types/autocomplete.d.ts +76 -0
- package/dist/types/bracketed-paste.d.ts +26 -0
- package/dist/types/components/box.d.ts +17 -0
- package/dist/types/components/cancellable-loader.d.ts +21 -0
- package/dist/types/components/editor.d.ts +105 -0
- package/dist/types/components/image.d.ts +84 -0
- package/dist/types/components/input.d.ts +18 -0
- package/dist/types/components/loader.d.ts +13 -0
- package/dist/types/components/markdown.d.ts +61 -0
- package/dist/types/components/scroll-view.d.ts +40 -0
- package/dist/types/components/select-list.d.ts +48 -0
- package/dist/types/components/settings-list.d.ts +41 -0
- package/dist/types/components/spacer.d.ts +11 -0
- package/dist/types/components/tab-bar.d.ts +56 -0
- package/dist/types/components/text.d.ts +13 -0
- package/dist/types/components/truncated-text.d.ts +10 -0
- package/dist/types/deccara.d.ts +49 -0
- package/dist/types/editor-component.d.ts +36 -0
- package/dist/types/fuzzy.d.ts +15 -0
- package/dist/types/index.d.ts +28 -0
- package/dist/types/keybindings.d.ts +189 -0
- package/dist/types/keys.d.ts +208 -0
- package/dist/types/kill-ring.d.ts +27 -0
- package/dist/types/kitty-graphics.d.ts +94 -0
- package/dist/types/stdin-buffer.d.ts +43 -0
- package/dist/types/symbols.d.ts +25 -0
- package/dist/types/terminal-capabilities.d.ts +196 -0
- package/dist/types/terminal.d.ts +103 -0
- package/dist/types/ttyid.d.ts +9 -0
- package/dist/types/tui.d.ts +275 -0
- package/dist/types/utils.d.ts +89 -0
- package/package.json +73 -0
- package/src/autocomplete.ts +871 -0
- package/src/bracketed-paste.ts +47 -0
- package/src/components/box.ts +156 -0
- package/src/components/cancellable-loader.ts +40 -0
- package/src/components/editor.ts +2695 -0
- package/src/components/image.ts +318 -0
- package/src/components/input.ts +459 -0
- package/src/components/loader.ts +86 -0
- package/src/components/markdown.ts +1189 -0
- package/src/components/scroll-view.ts +166 -0
- package/src/components/select-list.ts +331 -0
- package/src/components/settings-list.ts +212 -0
- package/src/components/spacer.ts +28 -0
- package/src/components/tab-bar.ts +175 -0
- package/src/components/text.ts +110 -0
- package/src/components/truncated-text.ts +61 -0
- package/src/deccara.ts +314 -0
- package/src/editor-component.ts +71 -0
- package/src/fuzzy.ts +143 -0
- package/src/index.ts +44 -0
- package/src/keybindings.ts +279 -0
- package/src/keys.ts +537 -0
- package/src/kill-ring.ts +46 -0
- package/src/kitty-graphics.ts +270 -0
- package/src/stdin-buffer.ts +423 -0
- package/src/symbols.ts +26 -0
- package/src/terminal-capabilities.ts +1009 -0
- package/src/terminal.ts +1114 -0
- package/src/ttyid.ts +70 -0
- package/src/tui.ts +2988 -0
- 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
|
+
}
|