@f5-sales-demo/pi-tui 20.15.3 → 20.16.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/package.json +3 -3
- package/src/components/image.ts +2 -0
- package/src/components/markdown.ts +108 -3
- package/src/index.ts +2 -0
- package/src/media-playback.ts +100 -0
- package/src/terminal-capabilities.ts +17 -0
- package/src/tui.ts +45 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/pi-tui",
|
|
4
|
-
"version": "20.
|
|
4
|
+
"version": "20.16.0",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@f5-sales-demo/pi-natives": "20.
|
|
41
|
-
"@f5-sales-demo/pi-utils": "20.
|
|
40
|
+
"@f5-sales-demo/pi-natives": "20.16.0",
|
|
41
|
+
"@f5-sales-demo/pi-utils": "20.16.0",
|
|
42
42
|
"marked": "^18.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
package/src/components/image.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface ImageOptions {
|
|
|
15
15
|
maxWidthCells?: number;
|
|
16
16
|
maxHeightCells?: number;
|
|
17
17
|
filename?: string;
|
|
18
|
+
imageId?: number;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export class Image implements Component {
|
|
@@ -60,6 +61,7 @@ export class Image implements Component {
|
|
|
60
61
|
const result = renderImage(this.#base64Data, this.#dimensions, {
|
|
61
62
|
maxWidthCells: maxWidth,
|
|
62
63
|
maxHeightCells: this.#options.maxHeightCells,
|
|
64
|
+
imageId: this.#options.imageId,
|
|
63
65
|
});
|
|
64
66
|
|
|
65
67
|
if (result) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { marked, type Token, type Tokens } from "marked";
|
|
2
2
|
import type { SymbolTheme } from "../symbols";
|
|
3
|
-
import { TERMINAL } from "../terminal-capabilities";
|
|
3
|
+
import { getImageDimensions, imageFallback, renderImage, stableKittyImageId, TERMINAL } from "../terminal-capabilities";
|
|
4
4
|
import type { Component } from "../tui";
|
|
5
5
|
import {
|
|
6
6
|
applyBackgroundToLine,
|
|
@@ -16,6 +16,21 @@ import {
|
|
|
16
16
|
* Default text styling for markdown content.
|
|
17
17
|
* Applied to all text unless overridden by markdown formatting.
|
|
18
18
|
*/
|
|
19
|
+
export interface ResolvedMarkdownMedia {
|
|
20
|
+
id: string;
|
|
21
|
+
data: string;
|
|
22
|
+
mimeType: string;
|
|
23
|
+
filename?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type MarkdownMediaResolver = (request: { source: string; alt: string }) => Promise<ResolvedMarkdownMedia>;
|
|
27
|
+
|
|
28
|
+
export interface MarkdownMediaOptions {
|
|
29
|
+
resolve: MarkdownMediaResolver;
|
|
30
|
+
onInvalidate?: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Default text styling for markdown content. */
|
|
19
34
|
export interface DefaultTextStyle {
|
|
20
35
|
/** Foreground color function */
|
|
21
36
|
color?: (text: string) => string;
|
|
@@ -74,6 +89,16 @@ type ListToken = Token & { items: Array<{ tokens?: Token[] }>; ordered: boolean;
|
|
|
74
89
|
type TableCellToken = { tokens?: Token[] };
|
|
75
90
|
type TableToken = Token & { header: TableCellToken[]; rows: TableCellToken[][]; raw?: string };
|
|
76
91
|
|
|
92
|
+
function isImageToken(token: Token): token is Tokens.Image {
|
|
93
|
+
return (
|
|
94
|
+
token.type === "image" &&
|
|
95
|
+
"href" in token &&
|
|
96
|
+
typeof token.href === "string" &&
|
|
97
|
+
"text" in token &&
|
|
98
|
+
typeof token.text === "string"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
function formatHyperlink(text: string, target: string): string {
|
|
78
103
|
if (!TERMINAL.hyperlinks || !target) {
|
|
79
104
|
return text;
|
|
@@ -96,6 +121,11 @@ export class Markdown implements Component {
|
|
|
96
121
|
#defaultStylePrefix?: string;
|
|
97
122
|
/** Number of spaces used to indent code block content. */
|
|
98
123
|
#codeBlockIndent: number;
|
|
124
|
+
#mediaOptions?: MarkdownMediaOptions;
|
|
125
|
+
#mediaCache = new Map<
|
|
126
|
+
string,
|
|
127
|
+
{ status: "pending" } | { status: "loaded"; media: ResolvedMarkdownMedia } | { status: "error"; message: string }
|
|
128
|
+
>();
|
|
99
129
|
|
|
100
130
|
// Cache for rendered output
|
|
101
131
|
#cachedText?: string;
|
|
@@ -109,6 +139,7 @@ export class Markdown implements Component {
|
|
|
109
139
|
theme: MarkdownTheme,
|
|
110
140
|
defaultTextStyle?: DefaultTextStyle,
|
|
111
141
|
codeBlockIndent: number = 2,
|
|
142
|
+
mediaOptions?: MarkdownMediaOptions,
|
|
112
143
|
) {
|
|
113
144
|
this.#text = text;
|
|
114
145
|
this.#paddingX = paddingX;
|
|
@@ -116,6 +147,7 @@ export class Markdown implements Component {
|
|
|
116
147
|
this.#theme = theme;
|
|
117
148
|
this.#defaultTextStyle = defaultTextStyle;
|
|
118
149
|
this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
|
|
150
|
+
this.#mediaOptions = mediaOptions;
|
|
119
151
|
}
|
|
120
152
|
|
|
121
153
|
setText(text: string): void {
|
|
@@ -326,8 +358,12 @@ export class Markdown implements Component {
|
|
|
326
358
|
}
|
|
327
359
|
|
|
328
360
|
case "paragraph": {
|
|
329
|
-
const
|
|
330
|
-
|
|
361
|
+
const paragraphTokens = token.tokens || [];
|
|
362
|
+
if (this.#mediaOptions && paragraphTokens.some(child => child.type === "image")) {
|
|
363
|
+
lines.push(...this.#renderParagraphWithMedia(paragraphTokens, width, styleContext));
|
|
364
|
+
} else {
|
|
365
|
+
lines.push(this.#renderInlineTokens(paragraphTokens, styleContext));
|
|
366
|
+
}
|
|
331
367
|
// Don't add spacing if next token is space or list
|
|
332
368
|
if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
|
|
333
369
|
lines.push("");
|
|
@@ -476,6 +512,75 @@ export class Markdown implements Component {
|
|
|
476
512
|
return lines;
|
|
477
513
|
}
|
|
478
514
|
|
|
515
|
+
#renderParagraphWithMedia(tokens: Token[], width: number, styleContext?: InlineStyleContext): string[] {
|
|
516
|
+
const lines: string[] = [];
|
|
517
|
+
let inline: Token[] = [];
|
|
518
|
+
const flushInline = (): void => {
|
|
519
|
+
if (inline.length === 0) return;
|
|
520
|
+
const rendered = this.#renderInlineTokens(inline, styleContext);
|
|
521
|
+
lines.push(...rendered.split("\n"));
|
|
522
|
+
inline = [];
|
|
523
|
+
};
|
|
524
|
+
for (const token of tokens) {
|
|
525
|
+
if (!isImageToken(token)) {
|
|
526
|
+
inline.push(token);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
flushInline();
|
|
530
|
+
lines.push(...this.#renderMarkdownMedia(token, width));
|
|
531
|
+
}
|
|
532
|
+
flushInline();
|
|
533
|
+
return lines;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
#renderMarkdownMedia(token: Tokens.Image, width: number): string[] {
|
|
537
|
+
const source = token.href;
|
|
538
|
+
let state = this.#mediaCache.get(source);
|
|
539
|
+
if (!state) {
|
|
540
|
+
state = { status: "pending" };
|
|
541
|
+
this.#mediaCache.set(source, state);
|
|
542
|
+
void this.#mediaOptions
|
|
543
|
+
?.resolve({ source, alt: token.text })
|
|
544
|
+
.then(media => {
|
|
545
|
+
this.#mediaCache.set(source, { status: "loaded", media });
|
|
546
|
+
})
|
|
547
|
+
.catch(error => {
|
|
548
|
+
this.#mediaCache.set(source, {
|
|
549
|
+
status: "error",
|
|
550
|
+
message: error instanceof Error ? error.message : String(error),
|
|
551
|
+
});
|
|
552
|
+
})
|
|
553
|
+
.finally(() => {
|
|
554
|
+
this.invalidate();
|
|
555
|
+
this.#mediaOptions?.onInvalidate?.();
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
if (state.status === "pending") {
|
|
559
|
+
return [this.#theme.link(`[Loading media: ${token.text || source}]`)];
|
|
560
|
+
}
|
|
561
|
+
if (state.status === "error") {
|
|
562
|
+
return [this.#theme.linkUrl(`[Media unavailable: ${state.message}]`)];
|
|
563
|
+
}
|
|
564
|
+
const dimensions = getImageDimensions(state.media.data, state.media.mimeType) ?? undefined;
|
|
565
|
+
if (dimensions && TERMINAL.imageProtocol) {
|
|
566
|
+
const rendered = renderImage(state.media.data, dimensions, {
|
|
567
|
+
maxWidthCells: Math.max(1, width),
|
|
568
|
+
imageId: stableKittyImageId(state.media.id),
|
|
569
|
+
});
|
|
570
|
+
if (rendered) {
|
|
571
|
+
const result = Array.from({ length: Math.max(0, rendered.rows - 1) }, () => "");
|
|
572
|
+
const moveUp = rendered.rows > 1 ? `\x1b[${rendered.rows - 1}A` : "";
|
|
573
|
+
result.push(moveUp + rendered.sequence);
|
|
574
|
+
return result;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return [
|
|
578
|
+
this.#theme.linkUrl(
|
|
579
|
+
imageFallback(state.media.mimeType, dimensions, state.media.filename ?? token.text ?? undefined),
|
|
580
|
+
),
|
|
581
|
+
];
|
|
582
|
+
}
|
|
583
|
+
|
|
479
584
|
#renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string {
|
|
480
585
|
let result = "";
|
|
481
586
|
const resolvedStyleContext = styleContext ?? this.#getDefaultInlineStyleContext();
|
package/src/index.ts
CHANGED
|
@@ -49,6 +49,8 @@ export {
|
|
|
49
49
|
export * from "./keybindings";
|
|
50
50
|
// Kitty keyboard protocol helpers
|
|
51
51
|
export * from "./keys";
|
|
52
|
+
// Media playback state machine
|
|
53
|
+
export * from "./media-playback";
|
|
52
54
|
// Mermaid diagram support
|
|
53
55
|
// Input buffering for batch splitting
|
|
54
56
|
export * from "./stdin-buffer";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export type MediaPlaybackState = "stopped" | "playing" | "paused";
|
|
2
|
+
|
|
3
|
+
export interface MediaPlaybackOptions {
|
|
4
|
+
autoplay: boolean;
|
|
5
|
+
loop: boolean;
|
|
6
|
+
fpsCap: number;
|
|
7
|
+
reducedMotion: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class MediaPlaybackScheduler {
|
|
11
|
+
readonly #durations: number[];
|
|
12
|
+
readonly #options: MediaPlaybackOptions;
|
|
13
|
+
#state: MediaPlaybackState = "stopped";
|
|
14
|
+
#frameIndex = 0;
|
|
15
|
+
#lastTick = 0;
|
|
16
|
+
#visible = false;
|
|
17
|
+
#autoplayConsumed = false;
|
|
18
|
+
#resumeWhenVisible = false;
|
|
19
|
+
|
|
20
|
+
constructor(frameDurationsMs: number[], options: MediaPlaybackOptions) {
|
|
21
|
+
if (frameDurationsMs.length === 0) throw new Error("media playback requires at least one frame");
|
|
22
|
+
if (!Number.isFinite(options.fpsCap) || options.fpsCap < 1) throw new Error("fpsCap must be positive");
|
|
23
|
+
const minimumDuration = 1000 / Math.min(60, options.fpsCap);
|
|
24
|
+
this.#durations = frameDurationsMs.map(duration => Math.max(minimumDuration, duration));
|
|
25
|
+
this.#options = options;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get state(): MediaPlaybackState {
|
|
29
|
+
return this.#state;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get frameIndex(): number {
|
|
33
|
+
return this.#frameIndex;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setVisible(visible: boolean, now: number): void {
|
|
37
|
+
if (visible === this.#visible) return;
|
|
38
|
+
this.#visible = visible;
|
|
39
|
+
if (!visible && this.#state === "playing") {
|
|
40
|
+
this.#resumeWhenVisible = true;
|
|
41
|
+
this.pause();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!visible) return;
|
|
45
|
+
if (this.#resumeWhenVisible) {
|
|
46
|
+
this.#resumeWhenVisible = false;
|
|
47
|
+
this.play(now);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (this.#options.autoplay && !this.#options.reducedMotion && !this.#autoplayConsumed) {
|
|
51
|
+
this.#autoplayConsumed = true;
|
|
52
|
+
this.play(now);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
play(now: number): void {
|
|
57
|
+
if (this.#frameIndex >= this.#durations.length) this.#frameIndex = 0;
|
|
58
|
+
this.#lastTick = now;
|
|
59
|
+
this.#state = "playing";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
pause(): void {
|
|
63
|
+
if (this.#state === "playing") this.#state = "paused";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
stop(): void {
|
|
67
|
+
this.#state = "stopped";
|
|
68
|
+
this.#frameIndex = 0;
|
|
69
|
+
this.#resumeWhenVisible = false;
|
|
70
|
+
this.#autoplayConsumed = true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
tick(now: number): number {
|
|
74
|
+
if (this.#state !== "playing") return this.#frameIndex;
|
|
75
|
+
let elapsed = Math.max(0, now - this.#lastTick);
|
|
76
|
+
while (elapsed >= (this.#durations[this.#frameIndex] ?? Number.POSITIVE_INFINITY)) {
|
|
77
|
+
const duration = this.#durations[this.#frameIndex] ?? Number.POSITIVE_INFINITY;
|
|
78
|
+
elapsed -= duration;
|
|
79
|
+
if (this.#frameIndex + 1 < this.#durations.length) {
|
|
80
|
+
this.#frameIndex++;
|
|
81
|
+
this.#lastTick = now - elapsed;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (this.#options.loop) {
|
|
85
|
+
this.#frameIndex = 0;
|
|
86
|
+
this.#lastTick = now - elapsed;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
this.#state = "stopped";
|
|
90
|
+
this.#frameIndex = this.#durations.length - 1;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
return this.#frameIndex;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
dispose(): void {
|
|
97
|
+
this.#state = "stopped";
|
|
98
|
+
this.#resumeWhenVisible = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -218,6 +218,7 @@ export interface ImageRenderOptions {
|
|
|
218
218
|
maxWidthCells?: number;
|
|
219
219
|
maxHeightCells?: number;
|
|
220
220
|
preserveAspectRatio?: boolean;
|
|
221
|
+
imageId?: number;
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
// Default cell dimensions - updated by TUI when terminal responds to query
|
|
@@ -231,6 +232,21 @@ export function setCellDimensions(dims: CellDimensions): void {
|
|
|
231
232
|
cellDimensions = dims;
|
|
232
233
|
}
|
|
233
234
|
|
|
235
|
+
/** Derive a deterministic Kitty image/placement id from a durable media id. */
|
|
236
|
+
export function stableKittyImageId(mediaId: string): number {
|
|
237
|
+
let hash = 0x811c9dc5;
|
|
238
|
+
for (let index = 0; index < mediaId.length; index++) {
|
|
239
|
+
hash ^= mediaId.charCodeAt(index);
|
|
240
|
+
hash = Math.imul(hash, 0x01000193);
|
|
241
|
+
}
|
|
242
|
+
return ((hash >>> 0) % 2_147_483_646) + 1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Delete a Kitty image and all placements that use its stable id. */
|
|
246
|
+
export function deleteKittyImage(imageId: number): string {
|
|
247
|
+
return `\x1b_Ga=d,d=I,i=${imageId},q=2;\x1b\\`;
|
|
248
|
+
}
|
|
249
|
+
|
|
234
250
|
export function encodeKitty(
|
|
235
251
|
base64Data: string,
|
|
236
252
|
options: {
|
|
@@ -497,6 +513,7 @@ export function renderImage(
|
|
|
497
513
|
const sequence = encodeKitty(base64Data, {
|
|
498
514
|
columns: fit.columns,
|
|
499
515
|
rows: fit.rows,
|
|
516
|
+
imageId: options.imageId,
|
|
500
517
|
});
|
|
501
518
|
return { sequence, rows: fit.rows };
|
|
502
519
|
}
|
package/src/tui.ts
CHANGED
|
@@ -41,6 +41,9 @@ export interface Component {
|
|
|
41
41
|
* Called when theme changes or when component needs to re-render from scratch.
|
|
42
42
|
*/
|
|
43
43
|
invalidate(): void;
|
|
44
|
+
|
|
45
|
+
/** Release timers, subprocesses, and terminal placements when removed from a container. */
|
|
46
|
+
unmount?(): void;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/**
|
|
@@ -183,10 +186,12 @@ export class Container implements Component {
|
|
|
183
186
|
const index = this.children.indexOf(component);
|
|
184
187
|
if (index !== -1) {
|
|
185
188
|
this.children.splice(index, 1);
|
|
189
|
+
component.unmount?.();
|
|
186
190
|
}
|
|
187
191
|
}
|
|
188
192
|
|
|
189
193
|
clear(): void {
|
|
194
|
+
for (const child of this.children) child.unmount?.();
|
|
190
195
|
this.children = [];
|
|
191
196
|
}
|
|
192
197
|
|
|
@@ -239,6 +244,8 @@ export class TUI extends Container {
|
|
|
239
244
|
#maxLinesRendered = 0; // High-water line count used for clear-on-shrink policy
|
|
240
245
|
#fullRedrawCount = 0;
|
|
241
246
|
#stopped = false;
|
|
247
|
+
#viewportObservers = new Map<number, { callback: (visible: boolean) => void; visible: boolean }>();
|
|
248
|
+
#nextViewportObserverId = 1;
|
|
242
249
|
|
|
243
250
|
// Overlay stack for modal components rendered on top of base content
|
|
244
251
|
overlayStack: {
|
|
@@ -260,6 +267,43 @@ export class TUI extends Container {
|
|
|
260
267
|
return this.#fullRedrawCount;
|
|
261
268
|
}
|
|
262
269
|
|
|
270
|
+
registerViewportObserver(callback: (visible: boolean) => void): { marker: string; dispose: () => void } {
|
|
271
|
+
const id = this.#nextViewportObserverId++;
|
|
272
|
+
this.#viewportObservers.set(id, { callback, visible: false });
|
|
273
|
+
return {
|
|
274
|
+
marker: `\x1b_pi:v=${id}\x07`,
|
|
275
|
+
dispose: () => {
|
|
276
|
+
const observer = this.#viewportObservers.get(id);
|
|
277
|
+
if (observer?.visible) observer.callback(false);
|
|
278
|
+
this.#viewportObservers.delete(id);
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#updateViewportObservers(lines: string[], height: number): void {
|
|
284
|
+
if (this.#viewportObservers.size === 0) return;
|
|
285
|
+
const visibleIds = new Set<number>();
|
|
286
|
+
const viewportTop = Math.max(0, lines.length - height);
|
|
287
|
+
const marker = /\x1b_pi:v=(\d+)\x07/gu;
|
|
288
|
+
for (let index = 0; index < lines.length; index++) {
|
|
289
|
+
lines[index] = lines[index].replace(marker, (_sequence, rawId: string) => {
|
|
290
|
+
const id = Number(rawId);
|
|
291
|
+
if (index >= viewportTop && this.#viewportObservers.has(id)) visibleIds.add(id);
|
|
292
|
+
return "";
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
for (const [id, observer] of this.#viewportObservers) {
|
|
296
|
+
const visible = visibleIds.has(id);
|
|
297
|
+
if (visible === observer.visible) continue;
|
|
298
|
+
observer.visible = visible;
|
|
299
|
+
try {
|
|
300
|
+
observer.callback(visible);
|
|
301
|
+
} catch {
|
|
302
|
+
// Viewport observers must not break terminal rendering.
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
263
307
|
getShowHardwareCursor(): boolean {
|
|
264
308
|
return this.#showHardwareCursor;
|
|
265
309
|
}
|
|
@@ -1022,6 +1066,7 @@ export class TUI extends Container {
|
|
|
1022
1066
|
|
|
1023
1067
|
// Render all components to get new lines
|
|
1024
1068
|
let newLines = this.render(width);
|
|
1069
|
+
this.#updateViewportObservers(newLines, height);
|
|
1025
1070
|
|
|
1026
1071
|
// Clamp any oversized lines before dirty-checking so the truncated form
|
|
1027
1072
|
// matches what we store in #previousLines, preventing perpetual repaints.
|