@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/pi-tui",
4
- "version": "20.15.3",
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.15.3",
41
- "@f5-sales-demo/pi-utils": "20.15.3",
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": {
@@ -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 paragraphText = this.#renderInlineTokens(token.tokens || [], styleContext);
330
- lines.push(paragraphText);
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.