623 lines
24 KiB
TypeScript
623 lines
24 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { getKittyGraphics } from "../kitty-graphics";
|
|
import {
|
|
getCellDimensions,
|
|
getImageDimensions,
|
|
type ImageDimensions,
|
|
ImageProtocol,
|
|
imageFallback,
|
|
renderImage,
|
|
TERMINAL,
|
|
} from "../terminal-capabilities";
|
|
import type { Component } from "../tui";
|
|
import { visibleWidth } from "../utils";
|
|
|
|
export interface ImageTheme {
|
|
fallbackColor: (str: string) => string;
|
|
}
|
|
|
|
export interface ImageOptions {
|
|
maxWidthCells?: number;
|
|
maxHeightCells?: number;
|
|
filename?: string;
|
|
/** Shared budget that caps how many inline images render as live graphics. */
|
|
budget?: ImageBudget;
|
|
/**
|
|
* Stable identity for the underlying image (e.g. `toolCallId:index`). Lets the
|
|
* budget hand back the same graphics id across component re-creations so a
|
|
* repaint replaces the placement instead of stacking a duplicate.
|
|
*/
|
|
imageKey?: string;
|
|
}
|
|
|
|
const EMPTY_IDS: readonly number[] = [];
|
|
const EMPTY_TRANSMITS: readonly string[] = [];
|
|
const SAVE_CURSOR = "\x1b7";
|
|
const RESTORE_CURSOR = "\x1b8";
|
|
const ERASE_LINE = "\x1b[2K";
|
|
// Internal line markers consumed by TUI before terminal output. A per-process
|
|
// capability prevents marker-shaped tool/user text from entering this raw
|
|
// control-sequence path. Direct Kitty placements must render on their first
|
|
// logical row so WezTerm can carry the full placement into scrollback;
|
|
// continuation rows then advance without EL, which would detach the image.
|
|
const DIRECT_KITTY_ROW_CAPABILITY = randomBytes(16).toString("hex");
|
|
const DIRECT_KITTY_PLACEMENT_PREFIX = `\x1b]pi:img:${DIRECT_KITTY_ROW_CAPABILITY}:p:`;
|
|
const DIRECT_KITTY_PLACEMENT_SUFFIX = `\x1b]pi:img:${DIRECT_KITTY_ROW_CAPABILITY}:e\x07`;
|
|
const DIRECT_KITTY_PLACEMENT_ANCHOR = `\x1b]pi:img:${DIRECT_KITTY_ROW_CAPABILITY}:a\x07`;
|
|
const DIRECT_KITTY_CONTINUATION_PREFIX = `\x1b]pi:img:${DIRECT_KITTY_ROW_CAPABILITY}:c:`;
|
|
// Direct placements for non-Kitty protocols still reserve height with leading
|
|
// zero-width rows. Keep them non-plain so transcript blank-edge trimming does
|
|
// not collapse image-only blocks.
|
|
const RESERVED_IMAGE_ROW = "\x1b[0m";
|
|
|
|
interface SizedDirectKittyMarker {
|
|
markerStart: number;
|
|
payloadStart: number;
|
|
columns: number;
|
|
rows: number;
|
|
}
|
|
|
|
interface DirectKittyPlacementFrame extends SizedDirectKittyMarker {
|
|
placementStart: number;
|
|
placementEnd: number;
|
|
markerEnd: number;
|
|
}
|
|
|
|
function findSizedDirectKittyMarker(line: string, prefix: string): SizedDirectKittyMarker | null {
|
|
const markerStart = line.indexOf(prefix);
|
|
if (markerStart < 0) return null;
|
|
const sizeStart = markerStart + prefix.length;
|
|
const payloadStart = line.indexOf("\x07", sizeStart);
|
|
if (payloadStart < 0) return null;
|
|
const match = line.slice(sizeStart, payloadStart).match(/^(\d+)x(\d+)$/);
|
|
if (match === null) return null;
|
|
const columns = Number(match[1]);
|
|
const rows = Number(match[2]);
|
|
if (!Number.isSafeInteger(columns) || columns <= 0 || !Number.isSafeInteger(rows) || rows <= 0) return null;
|
|
return { markerStart, payloadStart: payloadStart + 1, columns, rows };
|
|
}
|
|
|
|
function findDirectKittyPlacementFrame(line: string): DirectKittyPlacementFrame | null {
|
|
const marker = findSizedDirectKittyMarker(line, DIRECT_KITTY_PLACEMENT_PREFIX);
|
|
if (marker === null) return null;
|
|
const placementStart = line.indexOf(DIRECT_KITTY_PLACEMENT_ANCHOR, marker.payloadStart);
|
|
if (placementStart < 0) return null;
|
|
const placementEnd = placementStart + DIRECT_KITTY_PLACEMENT_ANCHOR.length;
|
|
const markerEnd = line.indexOf(DIRECT_KITTY_PLACEMENT_SUFFIX, placementEnd);
|
|
if (markerEnd < 0) return null;
|
|
return { ...marker, placementStart, placementEnd, markerEnd };
|
|
}
|
|
|
|
/** Whether this row contains a capability-framed direct Kitty placement. */
|
|
export function isDirectKittyPlacement(line: string): boolean {
|
|
return findDirectKittyPlacementFrame(line) !== null;
|
|
}
|
|
/** Expected logical row count for a capability-framed direct Kitty placement. */
|
|
export function getDirectKittyPlacementRows(line: string): number | null {
|
|
return findDirectKittyPlacementFrame(line)?.rows ?? null;
|
|
}
|
|
|
|
/** Visible cell width of a marked image row, including any surrounding wrapper. */
|
|
export function getDirectKittyRowWidth(line: string): number | null {
|
|
const placement = findDirectKittyPlacementFrame(line);
|
|
if (placement !== null) {
|
|
return (
|
|
visibleWidth(line.slice(0, placement.markerStart)) +
|
|
placement.columns +
|
|
visibleWidth(line.slice(placement.markerEnd + DIRECT_KITTY_PLACEMENT_SUFFIX.length))
|
|
);
|
|
}
|
|
const continuation = findSizedDirectKittyMarker(line, DIRECT_KITTY_CONTINUATION_PREFIX);
|
|
if (continuation === null) return null;
|
|
return (
|
|
visibleWidth(line.slice(0, continuation.markerStart)) +
|
|
continuation.columns +
|
|
visibleWidth(line.slice(continuation.payloadStart))
|
|
);
|
|
}
|
|
|
|
/** Remove a framed direct-Kitty placement marker while preserving wrappers. */
|
|
export function unwrapDirectKittyPlacement(line: string): string | null {
|
|
const frame = findDirectKittyPlacementFrame(line);
|
|
if (frame === null) return null;
|
|
const prefix = line.slice(0, frame.markerStart);
|
|
const sequence = line.slice(frame.placementEnd, frame.markerEnd);
|
|
const implicitPosition =
|
|
visibleWidth(prefix) > 0 && !/^\x1b\[\d+G/.test(sequence) ? `\x1b[${visibleWidth(prefix) + 1}G` : "";
|
|
const suffix = line.slice(frame.markerEnd + DIRECT_KITTY_PLACEMENT_SUFFIX.length);
|
|
const suffixAdvance = visibleWidth(suffix) > 0 ? `\x1b[${frame.columns}C` : "";
|
|
// Clear under the default background before emitting wrapper SGR/padding;
|
|
// BCE terminals otherwise extend a narrow Box background across full rows.
|
|
return (
|
|
RESERVED_IMAGE_ROW +
|
|
line.slice(frame.payloadStart, frame.placementStart) +
|
|
prefix +
|
|
implicitPosition +
|
|
sequence +
|
|
suffixAdvance +
|
|
suffix
|
|
);
|
|
}
|
|
|
|
/** Position a marked placement without hiding its internal dispatch prefix. */
|
|
export function positionDirectKittyPlacement(line: string, columns: number): string | null {
|
|
const frame = findDirectKittyPlacementFrame(line);
|
|
if (frame === null) return null;
|
|
const offset = Number.isFinite(columns) ? Math.max(0, Math.trunc(columns)) : 0;
|
|
if (offset === 0) return line;
|
|
const wrapperPrefix = line.slice(0, frame.markerStart);
|
|
const wrapperSuffix = line.slice(frame.markerEnd + DIRECT_KITTY_PLACEMENT_SUFFIX.length);
|
|
const wrapperPosition = wrapperPrefix.length > 0 || wrapperSuffix.length > 0 ? `\x1b[${offset + 1}G` : "";
|
|
const placementColumn = offset + visibleWidth(wrapperPrefix) + 1;
|
|
return `${wrapperPosition}${line.slice(0, frame.placementEnd)}\x1b[${placementColumn}G${line.slice(frame.placementEnd)}`;
|
|
}
|
|
|
|
/** Whether this logical row must advance without erasing its Kitty image cells. */
|
|
export function isDirectKittyContinuation(line: string): boolean {
|
|
return findSizedDirectKittyMarker(line, DIRECT_KITTY_CONTINUATION_PREFIX) !== null;
|
|
}
|
|
|
|
/** Remove a continuation marker while retaining wrapper padding and borders. */
|
|
export function unwrapDirectKittyContinuation(line: string): string | null {
|
|
const frame = findSizedDirectKittyMarker(line, DIRECT_KITTY_CONTINUATION_PREFIX);
|
|
if (frame === null) return null;
|
|
const prefix = line.slice(0, frame.markerStart);
|
|
const suffix = line.slice(frame.payloadStart);
|
|
const suffixAdvance = visibleWidth(suffix) > 0 ? `\x1b[${frame.columns}C` : "";
|
|
return prefix + suffixAdvance + suffix;
|
|
}
|
|
|
|
/** Position a wrapped continuation row within an overlay. */
|
|
export function positionDirectKittyContinuation(line: string, columns: number): string | null {
|
|
const frame = findSizedDirectKittyMarker(line, DIRECT_KITTY_CONTINUATION_PREFIX);
|
|
if (frame === null) return null;
|
|
const offset = Number.isFinite(columns) ? Math.max(0, Math.trunc(columns)) : 0;
|
|
const hasWrapper = frame.markerStart > 0 || frame.payloadStart < line.length;
|
|
return offset > 0 && hasWrapper ? `\x1b[${offset + 1}G${line}` : line;
|
|
}
|
|
|
|
function reserveDirectKittyRows(rows: number): string {
|
|
let sequence = ERASE_LINE;
|
|
for (let row = 1; row < rows; row++) {
|
|
sequence += `\r\n${ERASE_LINE}`;
|
|
}
|
|
return `${sequence}\x1b[${rows - 1}A`;
|
|
}
|
|
|
|
/** Default count of inline images kept as live graphics before older ones fall back to text. */
|
|
export const DEFAULT_MAX_INLINE_IMAGES = 8;
|
|
|
|
let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
|
|
function nextImageIdSeed(): number {
|
|
nextImageBudgetSeed = (nextImageBudgetSeed + 0x10000) & 0xffffff;
|
|
return nextImageBudgetSeed || 1;
|
|
}
|
|
/**
|
|
* Bounds how many inline images render as live terminal graphics at once.
|
|
*
|
|
* Terminal graphics protocols — Kitty especially — keep every transmitted image
|
|
* in a per-terminal store and re-draw placements as content scrolls; text-clear
|
|
* escapes (`CSI 2 J` / `CSI 3 J`) do not remove them. Unbounded, a session that
|
|
* shows many images piles up placements plus store memory and leaves ghosts in
|
|
* scrollback.
|
|
*
|
|
* The budget keeps the most recent `cap` images live and demotes older ones to
|
|
* their text fallback. Demotion needs a full redraw (so off-screen rows are
|
|
* rewritten) plus an explicit graphics purge of the demoted ids — {@link Image}
|
|
* reports display order via {@link observe}, and the TUI drives the purge +
|
|
* redraw on the frame after a new image pushes the count past the cap.
|
|
*
|
|
* `cap <= 0` disables budgeting: every image stays a live graphic.
|
|
*/
|
|
export class ImageBudget {
|
|
#cap: number;
|
|
#requestRender: () => void;
|
|
#nextId = nextImageIdSeed();
|
|
#keyToId = new Map<string, number>();
|
|
#idToKey = new Map<number, string>();
|
|
/** Display-order image ids observed during the in-flight pass. */
|
|
#passIds: number[] = [];
|
|
/**
|
|
* Suppress threshold reflected in the frame currently on the terminal: images
|
|
* at display indices `[0, #onTerminal)` are shown as text there.
|
|
*/
|
|
#onTerminal = 0;
|
|
/** Suppress threshold the current/next render should apply. */
|
|
#planned = 0;
|
|
/**
|
|
* True while the in-flight pass applies a stricter threshold than the terminal
|
|
* shows — the demotion frame that must purge graphics and fully repaint.
|
|
*/
|
|
#applyingReset = false;
|
|
#lastTotal = 0;
|
|
#purgeIds: number[] = [];
|
|
/** Image ids whose data is believed to be loaded in the terminal's store. */
|
|
#transmitted = new Set<number>();
|
|
/** Transmit sequences (full base64) to write once, before this frame's placements. */
|
|
#pendingTransmits: string[] = [];
|
|
// True while the in-flight pass is a partial/throwaway pass (the
|
|
// non-multiplexer resize viewport fast path) that walks only the visible
|
|
// tail, bottom-up. Such a pass cannot derive display order from observe()
|
|
// call order, so its suppression decisions replay the committed split below.
|
|
#stablePass = false;
|
|
// Image ids shown as text in the frame currently on the terminal: the
|
|
// display-order prefix [0, #onTerminal) of the last full pass, snapshotted by
|
|
// id so a partial pass reproduces the on-screen live/text split without a
|
|
// full, correctly-ordered walk.
|
|
#suppressedIds = new Set<number>();
|
|
|
|
constructor(cap: number = DEFAULT_MAX_INLINE_IMAGES, requestRender: () => void = () => {}) {
|
|
this.#cap = normalizeCap(cap);
|
|
this.#requestRender = requestRender;
|
|
}
|
|
|
|
get cap(): number {
|
|
return this.#cap;
|
|
}
|
|
|
|
get enabled(): boolean {
|
|
return this.#cap > 0;
|
|
}
|
|
|
|
setRequestRender(requestRender: () => void): void {
|
|
this.#requestRender = requestRender;
|
|
}
|
|
|
|
setCap(cap: number): void {
|
|
const next = normalizeCap(cap);
|
|
if (next === this.#cap) return;
|
|
this.#cap = next;
|
|
this.#reconcile(this.#lastTotal);
|
|
}
|
|
|
|
/**
|
|
* Stable graphics id for a logical image. A non-empty `key` maps to the same
|
|
* id across re-creations (so repaints replace the placement); a missing key
|
|
* gets a fresh id every call.
|
|
*/
|
|
acquireId(key?: string): number {
|
|
if (key) {
|
|
const existing = this.#keyToId.get(key);
|
|
if (existing !== undefined) return existing;
|
|
const id = this.#nextId;
|
|
this.#nextId = (this.#nextId + 1) & 0xffffff || 1;
|
|
this.#keyToId.set(key, id);
|
|
this.#idToKey.set(id, key);
|
|
return id;
|
|
}
|
|
const id = this.#nextId;
|
|
this.#nextId = (this.#nextId + 1) & 0xffffff || 1;
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Begin a render pass. Called by the renderer before composing the frame.
|
|
* Pass `stable: true` for a partial/throwaway pass that does not walk the
|
|
* whole tree in display order (the resize viewport fast path): {@link observe}
|
|
* then replays the last committed per-id decision instead of one derived from
|
|
* call order, and the pass must NOT be closed with {@link endPass}.
|
|
*/
|
|
beginPass(stable = false): void {
|
|
this.#passIds.length = 0;
|
|
this.#stablePass = stable;
|
|
this.#applyingReset = !stable && this.#cap > 0 && this.#planned > this.#onTerminal;
|
|
}
|
|
|
|
/**
|
|
* Record an image in display order and report whether it must render its text
|
|
* fallback this frame. Called by every {@link Image} during render — including
|
|
* on a cache hit, so the image keeps its display-order slot.
|
|
*
|
|
* During a `stable` pass ({@link beginPass}) the call order and visible subset
|
|
* are not authoritative, so the decision is the committed on-terminal split
|
|
* (`#suppressedIds`) keyed by id — order- and partiality-independent.
|
|
*/
|
|
observe(imageId: number): boolean {
|
|
if (this.#stablePass) {
|
|
const suppressed = this.#cap > 0 && this.#suppressedIds.has(imageId);
|
|
if (suppressed) this.#forgetKeyForId(imageId);
|
|
return suppressed;
|
|
}
|
|
const index = this.#passIds.length;
|
|
this.#passIds.push(imageId);
|
|
const suppressed = this.#cap > 0 && index < this.#planned;
|
|
if (suppressed) this.#forgetKeyForId(imageId);
|
|
return suppressed;
|
|
}
|
|
|
|
/**
|
|
* End a render pass. Returns true when this frame must purge graphics and
|
|
* fully repaint to apply a stricter budget; read the ids via
|
|
* {@link takePurgeIds}.
|
|
*/
|
|
endPass(): boolean {
|
|
const total = this.#passIds.length;
|
|
this.#lastTotal = total;
|
|
let reset = false;
|
|
if (this.#applyingReset) {
|
|
for (let i = this.#onTerminal; i < this.#planned && i < total; i++) {
|
|
const id = this.#passIds[i];
|
|
this.#purgeIds.push(id);
|
|
// d=I frees the data too, so the image must re-transmit if it returns.
|
|
this.#transmitted.delete(id);
|
|
this.#forgetKeyForId(id);
|
|
}
|
|
this.#onTerminal = this.#planned;
|
|
this.#applyingReset = false;
|
|
reset = true;
|
|
}
|
|
this.#reconcile(total);
|
|
// Snapshot the committed display-order suppression by id: the prefix
|
|
// [0, #onTerminal) is what the terminal currently shows as text. Partial
|
|
// passes replay this per id (see #stablePass) instead of re-deriving it
|
|
// from a reversed, tail-only walk.
|
|
this.#suppressedIds = new Set(this.#passIds.slice(0, this.#onTerminal));
|
|
return reset;
|
|
}
|
|
|
|
/** Image ids to delete from the terminal this frame; clears the pending set. */
|
|
takePurgeIds(): readonly number[] {
|
|
if (this.#purgeIds.length === 0) return EMPTY_IDS;
|
|
const ids = this.#purgeIds;
|
|
this.#purgeIds = [];
|
|
return ids;
|
|
}
|
|
|
|
/** All image ids believed to be loaded in the terminal store; clears tracking. */
|
|
takeAllTransmittedIds(): readonly number[] {
|
|
if (this.#transmitted.size === 0) return EMPTY_IDS;
|
|
const ids = [...this.#transmitted];
|
|
this.#transmitted.clear();
|
|
this.#purgeIds = [];
|
|
this.#pendingTransmits = [];
|
|
this.#keyToId.clear();
|
|
this.#idToKey.clear();
|
|
return ids;
|
|
}
|
|
|
|
/** Whether `imageId`'s data still needs to be transmitted to the terminal. */
|
|
shouldTransmit(imageId: number): boolean {
|
|
return !this.#transmitted.has(imageId);
|
|
}
|
|
|
|
/**
|
|
* Queue a one-time transmit for `imageId`. No-op if already transmitted, so a
|
|
* repeated call (e.g. a width-change re-render) never re-sends the data.
|
|
*/
|
|
enqueueTransmit(imageId: number, sequence: string): void {
|
|
if (this.#transmitted.has(imageId)) return;
|
|
this.#transmitted.add(imageId);
|
|
this.#pendingTransmits.push(sequence);
|
|
}
|
|
|
|
/** Whether a frame has image data queued but not yet written to the terminal. */
|
|
hasPendingTransmits(): boolean {
|
|
return this.#pendingTransmits.length > 0;
|
|
}
|
|
|
|
/**
|
|
* True when the budget has nothing in flight: no live images observed on
|
|
* the last pass, no queued transmits, no pending purges, and no stricter
|
|
* threshold left to apply. A component-scoped frame may skip the observe
|
|
* pass only then — a partial tree walk would under-count display order.
|
|
*/
|
|
get quiescent(): boolean {
|
|
return (
|
|
this.#lastTotal === 0 &&
|
|
this.#pendingTransmits.length === 0 &&
|
|
this.#purgeIds.length === 0 &&
|
|
this.#planned === this.#onTerminal
|
|
);
|
|
}
|
|
|
|
/** Transmit sequences to write before this frame's placements; clears the queue. */
|
|
takeTransmits(): readonly string[] {
|
|
if (this.#pendingTransmits.length === 0) return EMPTY_TRANSMITS;
|
|
const sequences = this.#pendingTransmits;
|
|
this.#pendingTransmits = [];
|
|
return sequences;
|
|
}
|
|
|
|
/**
|
|
* Drop transmit tracking so every still-live image re-enqueues its data
|
|
* (`a=t`) on the next render. Recovers when the terminal dropped the original
|
|
* transmit — e.g. Ghostty discarding graphics sent during its post-startup
|
|
* window — where a placement-only replay can never bind a Unicode placeholder.
|
|
* Pair with a component invalidate + forced repaint so the data and placement
|
|
* re-emit together; keeps no base64 in budget state (the transmit-once design).
|
|
*/
|
|
forgetTransmitted(): void {
|
|
if (this.#transmitted.size === 0 && this.#pendingTransmits.length === 0) return;
|
|
this.#transmitted.clear();
|
|
this.#pendingTransmits = [];
|
|
}
|
|
|
|
#forgetKeyForId(id: number): void {
|
|
const key = this.#idToKey.get(id);
|
|
if (key === undefined) return;
|
|
this.#idToKey.delete(id);
|
|
if (this.#keyToId.get(key) === id) this.#keyToId.delete(key);
|
|
}
|
|
|
|
#reconcile(total: number): void {
|
|
const desired = this.#cap > 0 ? Math.max(0, total - this.#cap) : 0;
|
|
if (desired === this.#planned) {
|
|
// Budget relaxed without a stricter frame (cap raised or images
|
|
// removed): surviving graphics are untouched and re-exposed rows
|
|
// repaint normally, so just track the looser threshold.
|
|
if (this.#planned < this.#onTerminal) this.#onTerminal = this.#planned;
|
|
return;
|
|
}
|
|
this.#planned = desired;
|
|
// More images must be demoted than the terminal shows: schedule the purge +
|
|
// full-redraw frame. Fewer: no ghosts to clear, so just catch the tracking
|
|
// up — a normal repaint re-exposes the un-demoted images. Either way a
|
|
// render is needed to apply the new threshold.
|
|
if (desired <= this.#onTerminal) this.#onTerminal = desired;
|
|
this.#requestRender();
|
|
}
|
|
}
|
|
|
|
function normalizeCap(cap: number): number {
|
|
if (!Number.isFinite(cap)) return 0;
|
|
return Math.max(0, Math.trunc(cap));
|
|
}
|
|
|
|
export class Image implements Component {
|
|
#base64Data: string;
|
|
#mimeType: string;
|
|
#dimensions: ImageDimensions;
|
|
#theme: ImageTheme;
|
|
#options: ImageOptions;
|
|
#budget?: ImageBudget;
|
|
#imageId?: number;
|
|
|
|
#cachedLines?: string[];
|
|
#cachedWidth?: number;
|
|
#cachedSuppressed = false;
|
|
#cachedImageProtocol: typeof TERMINAL.imageProtocol = null;
|
|
#cachedCellWidthPx = 0;
|
|
#cachedCellHeightPx = 0;
|
|
#cachedKittyUnicodePlaceholders = false;
|
|
// Tallest graphic placement this image has rendered. The text fallback
|
|
// pads itself to this height so a budget demotion never shrinks the block
|
|
// (its rows may already be committed to native scrollback).
|
|
#renderedGraphicRows = 0;
|
|
|
|
constructor(
|
|
base64Data: string,
|
|
mimeType: string,
|
|
theme: ImageTheme,
|
|
options: ImageOptions = {},
|
|
dimensions?: ImageDimensions,
|
|
) {
|
|
this.#base64Data = base64Data;
|
|
this.#mimeType = mimeType;
|
|
this.#theme = theme;
|
|
this.#options = options;
|
|
this.#dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 };
|
|
this.#budget = options.budget;
|
|
this.#imageId = options.budget ? options.budget.acquireId(options.imageKey) : undefined;
|
|
}
|
|
|
|
invalidate(): void {
|
|
this.#cachedLines = undefined;
|
|
this.#cachedWidth = undefined;
|
|
}
|
|
|
|
render(width: number): readonly string[] {
|
|
const imageProtocol = TERMINAL.imageProtocol;
|
|
const hasProtocol = imageProtocol != null;
|
|
const cellDimensions = getCellDimensions();
|
|
const kittyUnicodePlaceholders = getKittyGraphics().unicodePlaceholders;
|
|
// observe() must run on every pass — even a cache hit — so the image keeps
|
|
// its display-order slot in the budget. Only graphics-capable frames count
|
|
// toward (and are demoted by) the budget; without a protocol every image is
|
|
// already text.
|
|
const suppressed = hasProtocol && this.#budget !== undefined ? this.#budget.observe(this.#imageId ?? 0) : false;
|
|
|
|
if (
|
|
this.#cachedLines &&
|
|
this.#cachedWidth === width &&
|
|
this.#cachedSuppressed === suppressed &&
|
|
this.#cachedImageProtocol === imageProtocol &&
|
|
this.#cachedCellWidthPx === cellDimensions.widthPx &&
|
|
this.#cachedCellHeightPx === cellDimensions.heightPx &&
|
|
this.#cachedKittyUnicodePlaceholders === kittyUnicodePlaceholders
|
|
) {
|
|
return this.#cachedLines;
|
|
}
|
|
|
|
const cap = this.#options.maxWidthCells;
|
|
const maxWidth = cap != null && cap > 0 ? Math.min(width - 2, cap) : width - 2;
|
|
|
|
let lines: string[];
|
|
|
|
if (hasProtocol && !suppressed) {
|
|
// Transmit the data once (keyed by id); thereafter renderImage returns
|
|
// just the placement, so repaints never re-send the base64.
|
|
const needsTransmit = this.#imageId != null && (this.#budget?.shouldTransmit(this.#imageId) ?? false);
|
|
const result = renderImage(this.#base64Data, this.#dimensions, {
|
|
maxWidthCells: maxWidth,
|
|
maxHeightCells: this.#options.maxHeightCells,
|
|
imageId: this.#imageId,
|
|
includeTransmit: needsTransmit,
|
|
});
|
|
|
|
if (result?.transmit && this.#imageId != null && this.#budget !== undefined) {
|
|
this.#budget.enqueueTransmit(this.#imageId, result.transmit);
|
|
}
|
|
|
|
if (result?.lines) {
|
|
// Unicode placeholders: the image is already a block of real text-cell
|
|
// lines (line 0 carries the virtual-placement APC). No cursor moves.
|
|
lines = result.lines;
|
|
} else if (result && imageProtocol === ImageProtocol.Kitty && result.rows > 1) {
|
|
// Place first, then advance across protected continuation rows. A
|
|
// last-row placement is clipped when its logical origin has already
|
|
// scrolled above WezTerm's viewport; repainting continuation rows
|
|
// with EL also detaches the image from those cells. Reserve and
|
|
// clear the whole block before moving back to its first row, so C=1
|
|
// always has enough physical rows even when the block starts at the
|
|
// viewport bottom.
|
|
lines = [
|
|
`${DIRECT_KITTY_PLACEMENT_PREFIX}${result.columns}x${result.rows}\x07` +
|
|
reserveDirectKittyRows(result.rows) +
|
|
DIRECT_KITTY_PLACEMENT_ANCHOR +
|
|
(result.sequence ?? "") +
|
|
DIRECT_KITTY_PLACEMENT_SUFFIX,
|
|
];
|
|
for (let i = 1; i < result.rows; i++) {
|
|
lines.push(`${DIRECT_KITTY_CONTINUATION_PREFIX}${result.columns}x${result.rows}\x07`);
|
|
}
|
|
} else if (result) {
|
|
// Other direct protocols retain the final-row cursor anchor.
|
|
lines = [];
|
|
for (let i = 0; i < result.rows - 1; i++) {
|
|
lines.push(RESERVED_IMAGE_ROW);
|
|
}
|
|
const cursorRows = result.rows - 1;
|
|
const moveUp = cursorRows > 0 ? `\x1b[${cursorRows}A` : "";
|
|
const placement = moveUp + (result.sequence ?? "");
|
|
lines.push(cursorRows > 0 ? SAVE_CURSOR + placement + RESTORE_CURSOR : placement);
|
|
} else {
|
|
lines = this.#fallbackLines();
|
|
}
|
|
this.#renderedGraphicRows = Math.max(this.#renderedGraphicRows, lines.length);
|
|
} else {
|
|
lines = this.#fallbackLines();
|
|
}
|
|
|
|
this.#cachedLines = lines;
|
|
this.#cachedWidth = width;
|
|
this.#cachedSuppressed = suppressed;
|
|
this.#cachedImageProtocol = imageProtocol;
|
|
this.#cachedCellWidthPx = cellDimensions.widthPx;
|
|
this.#cachedCellHeightPx = cellDimensions.heightPx;
|
|
this.#cachedKittyUnicodePlaceholders = kittyUnicodePlaceholders;
|
|
|
|
return lines;
|
|
}
|
|
|
|
/**
|
|
* Text fallback, height-preserving once a graphic has rendered: a demoted
|
|
* image must keep occupying the rows its placement used, because those
|
|
* rows may already be committed to native scrollback — shrinking the block
|
|
* would shift everything below it and force the renderer's commit-resync
|
|
* (stale band + recommit). Reserved rows stay non-plain so blank-edge
|
|
* trimming cannot collapse the block either.
|
|
*/
|
|
#fallbackLines(): string[] {
|
|
const fallback = this.#theme.fallbackColor(
|
|
imageFallback(this.#mimeType, this.#dimensions, this.#options.filename),
|
|
);
|
|
if (this.#renderedGraphicRows <= 1) return [fallback];
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < this.#renderedGraphicRows - 1; i++) {
|
|
lines.push(RESERVED_IMAGE_ROW);
|
|
}
|
|
lines.push(fallback);
|
|
return lines;
|
|
}
|
|
}
|