@oh-my-pi/pi-tui 17.2.12 → 17.2.14
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 +6 -0
- package/dist/types/components/image.d.ts +47 -0
- package/dist/types/terminal-capabilities.d.ts +39 -0
- package/package.json +3 -3
- package/src/components/image.ts +159 -3
- package/src/terminal-capabilities.ts +84 -0
- package/src/tui.ts +97 -16
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.2.13] - 2026-08-11
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed inline images rendering permanently cropped on Kitty direct-placement terminals (WezTerm, Warp) when an image block straddled the viewport top during streaming: placements are now clipped to the visible slice at write time, and a placement id whose cells reached native scrollback is never re-used ([#8070](https://github.com/can1357/oh-my-pi/pull/8070) by [@voonfoo](https://github.com/voonfoo))
|
|
10
|
+
|
|
5
11
|
## [17.2.12] - 2026-08-08
|
|
6
12
|
|
|
7
13
|
### Fixed
|
|
@@ -78,6 +78,53 @@ export declare class ImageBudget {
|
|
|
78
78
|
takeAllTransmittedIds(): readonly number[];
|
|
79
79
|
/** Whether `imageId`'s data still needs to be transmitted to the terminal. */
|
|
80
80
|
shouldTransmit(imageId: number): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Record a direct-placement image's source pixel geometry so the renderer
|
|
83
|
+
* can clip its placement to the visible slice at write time; cleared when
|
|
84
|
+
* the image is purged from the terminal store.
|
|
85
|
+
*/
|
|
86
|
+
registerPlacementGeometry(imageId: number, widthPx: number, heightPx: number): void;
|
|
87
|
+
/**
|
|
88
|
+
* Record this frame's native-scrollback commit target (the frame-row count
|
|
89
|
+
* that is committed once the frame's writes land). Called once per rendered
|
|
90
|
+
* frame — including frames that emit no placements — so an epoch whose rows
|
|
91
|
+
* commit while its line is never rewritten is still flagged before the next
|
|
92
|
+
* re-emission.
|
|
93
|
+
*/
|
|
94
|
+
observeCommitWatermark(committedTo: number): void;
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the placement id and geometry for a direct-placement emit whose
|
|
97
|
+
* topmost attached cell sits at `attachTopFrameRow` — the first frame row
|
|
98
|
+
* the placement covers, i.e. the block's first *visible* row, not its
|
|
99
|
+
* origin (-1 when the writer has no frame-space position: alt-screen,
|
|
100
|
+
* resize, ConPTY-truncated replays). `committedTo` is this frame's commit
|
|
101
|
+
* target in the same frame-row space (-1 when unknown).
|
|
102
|
+
*
|
|
103
|
+
* Invariant: a placement id may be re-used (Kitty replace strips that id's
|
|
104
|
+
* cells everywhere, scrollback included) only while none of the cells it
|
|
105
|
+
* attached have entered native scrollback. The epoch — the `p=` id —
|
|
106
|
+
* advances exactly when the archived flag says otherwise; rewrites with no
|
|
107
|
+
* commit progression keep replacing the same id in place.
|
|
108
|
+
*/
|
|
109
|
+
resolvePlacementEmit(imageId: number, attachTopFrameRow: number, committedTo: number): {
|
|
110
|
+
placementId: number;
|
|
111
|
+
widthPx: number;
|
|
112
|
+
heightPx: number;
|
|
113
|
+
} | null;
|
|
114
|
+
/**
|
|
115
|
+
* Restart every placement epoch after a destructive history clear (`CSI 3 J`
|
|
116
|
+
* full paint). The clear destroys all placement cells — scrollback rows are
|
|
117
|
+
* gone and the replay rewrites the viewport — so no archive remains to
|
|
118
|
+
* protect. Reverting to epoch 1 lets the replay's placements replace the
|
|
119
|
+
* terminal's stale registry entries; the returned list names every image
|
|
120
|
+
* and the highest epoch it reached so the caller can delete all of its
|
|
121
|
+
* registry entries explicitly (`d=i` keeps the transmitted data) — an image
|
|
122
|
+
* absent from the replay never re-places, so even its epoch-1 entry must go.
|
|
123
|
+
*/
|
|
124
|
+
resetPlacementEpochs(): ReadonlyArray<{
|
|
125
|
+
imageId: number;
|
|
126
|
+
lastEpoch: number;
|
|
127
|
+
}>;
|
|
81
128
|
/**
|
|
82
129
|
* Queue a one-time transmit for `imageId`. No-op if already transmitted, so a
|
|
83
130
|
* repeated call (e.g. a width-change re-render) never re-sends the data.
|
|
@@ -235,6 +235,38 @@ export declare function encodeKittyPlacement(options: {
|
|
|
235
235
|
columns?: number;
|
|
236
236
|
rows?: number;
|
|
237
237
|
}): string;
|
|
238
|
+
export interface ParsedKittyPlacementLine {
|
|
239
|
+
imageId: number;
|
|
240
|
+
placementId: number | undefined;
|
|
241
|
+
columns: number;
|
|
242
|
+
rows: number;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Parse a frame line that consists solely of a Kitty direct placement (the
|
|
246
|
+
* last line of an {@link Image} block). Returns null for anything else —
|
|
247
|
+
* placeholder grids, tmux-wrapped placements, sixel/iTerm2 payloads — so
|
|
248
|
+
* callers fall back to writing the line verbatim.
|
|
249
|
+
*/
|
|
250
|
+
export declare function parseKittyDirectPlacementLine(line: string): ParsedKittyPlacementLine | null;
|
|
251
|
+
/**
|
|
252
|
+
* Rebuild an {@link Image} direct-placement line for the viewport row it is
|
|
253
|
+
* written at. The component-rendered line encodes `CUU(rows-1)`, which clamps
|
|
254
|
+
* at the viewport top once the block's leading rows have scrolled out — the
|
|
255
|
+
* placement then re-anchors the full image shifted down over foreign rows.
|
|
256
|
+
* Anchor at the block's first *visible* row instead, clipping the source
|
|
257
|
+
* rectangle (`y=`/`h=`, image pixels) to the visible bottom slice.
|
|
258
|
+
*/
|
|
259
|
+
export declare function encodeKittyPlacementLine(options: {
|
|
260
|
+
imageId: number;
|
|
261
|
+
placementId: number;
|
|
262
|
+
columns: number;
|
|
263
|
+
/** Total cell rows of the image block. */
|
|
264
|
+
rows: number;
|
|
265
|
+
/** Viewport row the block's last line is being written at. */
|
|
266
|
+
screenRow: number;
|
|
267
|
+
/** Source image height in pixels, for the clipped source rectangle. */
|
|
268
|
+
imageHeightPx: number;
|
|
269
|
+
}): string;
|
|
238
270
|
/**
|
|
239
271
|
* Kitty graphics delete command for a single image id. Uses `d=I` (capital)
|
|
240
272
|
* which removes the image and every one of its placements — on screen *and* in
|
|
@@ -243,6 +275,13 @@ export declare function encodeKittyPlacement(options: {
|
|
|
243
275
|
* this is the only way to actually purge a placed image.
|
|
244
276
|
*/
|
|
245
277
|
export declare function encodeKittyDeleteImage(imageId: number): string;
|
|
278
|
+
/**
|
|
279
|
+
* Delete a single placement of an image (`d=i`, lowercase): removes its cells
|
|
280
|
+
* and registry entry but keeps the transmitted data, so a later `a=p` under a
|
|
281
|
+
* fresh placement id needs no retransmit. Used to clear stale placement-epoch
|
|
282
|
+
* entries after a destructive history clear.
|
|
283
|
+
*/
|
|
284
|
+
export declare function encodeKittyDeletePlacement(imageId: number, placementId: number): string;
|
|
246
285
|
export declare function encodeITerm2(base64Data: string, options?: {
|
|
247
286
|
width?: number | string;
|
|
248
287
|
height?: number | string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "17.2.
|
|
4
|
+
"version": "17.2.14",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "17.2.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.2.
|
|
40
|
+
"@oh-my-pi/pi-natives": "17.2.14",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.2.14"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"ghostty-web": "^0.4.0"
|
package/src/components/image.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface ImageOptions {
|
|
|
29
29
|
|
|
30
30
|
const EMPTY_IDS: readonly number[] = [];
|
|
31
31
|
const EMPTY_TRANSMITS: readonly string[] = [];
|
|
32
|
+
const EMPTY_STALE_EPOCHS: ReadonlyArray<{ imageId: number; lastEpoch: number }> = [];
|
|
32
33
|
const SAVE_CURSOR = "\x1b7";
|
|
33
34
|
const RESTORE_CURSOR = "\x1b8";
|
|
34
35
|
// Direct placements reserve height with leading zero-width rows. Keep them
|
|
@@ -38,6 +39,24 @@ const RESERVED_IMAGE_ROW = "\x1b[0m";
|
|
|
38
39
|
/** Default count of inline images kept as live graphics before older ones fall back to text. */
|
|
39
40
|
export const DEFAULT_MAX_INLINE_IMAGES = 8;
|
|
40
41
|
|
|
42
|
+
/** Per-image direct-placement emit state tracked by {@link ImageBudget}. */
|
|
43
|
+
interface PlacementEmitState {
|
|
44
|
+
widthPx: number;
|
|
45
|
+
heightPx: number;
|
|
46
|
+
/** Current placement-id (`p=`) generation. */
|
|
47
|
+
epoch: number;
|
|
48
|
+
/** First frame row the current epoch's last emit attached cells to. */
|
|
49
|
+
lastAttachTopFrameRow: number | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Whether any cell attached by the current epoch's last emit has entered
|
|
52
|
+
* native scrollback. Set by {@link ImageBudget.observeCommitWatermark}
|
|
53
|
+
* comparing each frame's raw commit target against the attach top —
|
|
54
|
+
* era-local comparisons, so a divergence recommit that rewinds and
|
|
55
|
+
* re-advances the ledger is detected the moment it re-crosses the attach
|
|
56
|
+
* top, and a stale pre-rewind peak can never re-trigger.
|
|
57
|
+
*/
|
|
58
|
+
cellsArchived: boolean;
|
|
59
|
+
}
|
|
41
60
|
let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
|
|
42
61
|
function nextImageIdSeed(): number {
|
|
43
62
|
nextImageBudgetSeed = (nextImageBudgetSeed + 0x10000) & 0xffffff;
|
|
@@ -96,6 +115,22 @@ export class ImageBudget {
|
|
|
96
115
|
// id so a partial pass reproduces the on-screen live/text split without a
|
|
97
116
|
// full, correctly-ordered walk.
|
|
98
117
|
#suppressedIds = new Set<number>();
|
|
118
|
+
/**
|
|
119
|
+
* Per-image direct-placement emit state: source pixel geometry for the
|
|
120
|
+
* renderer's clipped source rectangle, plus the placement-id epoch (see
|
|
121
|
+
* {@link resolvePlacementEmit}). Entries deliberately live as long as the
|
|
122
|
+
* terminal's own placement registry for the image — they are the ledger the
|
|
123
|
+
* destructive-clear sweep uses to delete every registry entry an image ever
|
|
124
|
+
* placed — and die with it on demotion purge (`d=I`) or full cleanup.
|
|
125
|
+
*/
|
|
126
|
+
#placementState = new Map<number, PlacementEmitState>();
|
|
127
|
+
/**
|
|
128
|
+
* States with an un-archived live attach top — the only ones a frame's
|
|
129
|
+
* commit watermark can affect. {@link observeCommitWatermark} runs every
|
|
130
|
+
* rendered frame, so it scans this set (bounded by concurrently live
|
|
131
|
+
* placements) instead of every image ever registered.
|
|
132
|
+
*/
|
|
133
|
+
#watchedPlacements = new Set<PlacementEmitState>();
|
|
99
134
|
|
|
100
135
|
constructor(cap: number = DEFAULT_MAX_INLINE_IMAGES, requestRender: () => void = () => {}) {
|
|
101
136
|
this.#cap = normalizeCap(cap);
|
|
@@ -191,6 +226,7 @@ export class ImageBudget {
|
|
|
191
226
|
this.#purgeIds.push(id);
|
|
192
227
|
// d=I frees the data too, so the image must re-transmit if it returns.
|
|
193
228
|
this.#transmitted.delete(id);
|
|
229
|
+
this.#deletePlacementState(id);
|
|
194
230
|
this.#forgetKeyForId(id);
|
|
195
231
|
}
|
|
196
232
|
this.#onTerminal = this.#planned;
|
|
@@ -223,6 +259,8 @@ export class ImageBudget {
|
|
|
223
259
|
this.#pendingTransmits = [];
|
|
224
260
|
this.#keyToId.clear();
|
|
225
261
|
this.#idToKey.clear();
|
|
262
|
+
this.#placementState.clear();
|
|
263
|
+
this.#watchedPlacements.clear();
|
|
226
264
|
return ids;
|
|
227
265
|
}
|
|
228
266
|
|
|
@@ -231,6 +269,116 @@ export class ImageBudget {
|
|
|
231
269
|
return !this.#transmitted.has(imageId);
|
|
232
270
|
}
|
|
233
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Record a direct-placement image's source pixel geometry so the renderer
|
|
274
|
+
* can clip its placement to the visible slice at write time; cleared when
|
|
275
|
+
* the image is purged from the terminal store.
|
|
276
|
+
*/
|
|
277
|
+
registerPlacementGeometry(imageId: number, widthPx: number, heightPx: number): void {
|
|
278
|
+
const state = this.#placementState.get(imageId);
|
|
279
|
+
if (state) {
|
|
280
|
+
state.widthPx = widthPx;
|
|
281
|
+
state.heightPx = heightPx;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
this.#placementState.set(imageId, {
|
|
285
|
+
widthPx,
|
|
286
|
+
heightPx,
|
|
287
|
+
epoch: 1,
|
|
288
|
+
lastAttachTopFrameRow: undefined,
|
|
289
|
+
cellsArchived: false,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Record this frame's native-scrollback commit target (the frame-row count
|
|
295
|
+
* that is committed once the frame's writes land). Called once per rendered
|
|
296
|
+
* frame — including frames that emit no placements — so an epoch whose rows
|
|
297
|
+
* commit while its line is never rewritten is still flagged before the next
|
|
298
|
+
* re-emission.
|
|
299
|
+
*/
|
|
300
|
+
observeCommitWatermark(committedTo: number): void {
|
|
301
|
+
if (committedTo < 0 || this.#watchedPlacements.size === 0) return;
|
|
302
|
+
for (const state of this.#watchedPlacements) {
|
|
303
|
+
if (state.lastAttachTopFrameRow !== undefined && committedTo > state.lastAttachTopFrameRow) {
|
|
304
|
+
// Latched: the flag only clears when the next emit consumes it,
|
|
305
|
+
// so the state needs no further per-frame scans until then.
|
|
306
|
+
state.cellsArchived = true;
|
|
307
|
+
this.#watchedPlacements.delete(state);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Resolve the placement id and geometry for a direct-placement emit whose
|
|
314
|
+
* topmost attached cell sits at `attachTopFrameRow` — the first frame row
|
|
315
|
+
* the placement covers, i.e. the block's first *visible* row, not its
|
|
316
|
+
* origin (-1 when the writer has no frame-space position: alt-screen,
|
|
317
|
+
* resize, ConPTY-truncated replays). `committedTo` is this frame's commit
|
|
318
|
+
* target in the same frame-row space (-1 when unknown).
|
|
319
|
+
*
|
|
320
|
+
* Invariant: a placement id may be re-used (Kitty replace strips that id's
|
|
321
|
+
* cells everywhere, scrollback included) only while none of the cells it
|
|
322
|
+
* attached have entered native scrollback. The epoch — the `p=` id —
|
|
323
|
+
* advances exactly when the archived flag says otherwise; rewrites with no
|
|
324
|
+
* commit progression keep replacing the same id in place.
|
|
325
|
+
*/
|
|
326
|
+
resolvePlacementEmit(
|
|
327
|
+
imageId: number,
|
|
328
|
+
attachTopFrameRow: number,
|
|
329
|
+
committedTo: number,
|
|
330
|
+
): { placementId: number; widthPx: number; heightPx: number } | null {
|
|
331
|
+
const state = this.#placementState.get(imageId);
|
|
332
|
+
if (!state) return null;
|
|
333
|
+
// Frames that commit as they write (seam/full-paint chunk passes) pass
|
|
334
|
+
// their own commit target; fold it in before deciding, so a commit that
|
|
335
|
+
// lands in the same frame as the re-emission still advances the epoch.
|
|
336
|
+
if (committedTo >= 0 && state.lastAttachTopFrameRow !== undefined && committedTo > state.lastAttachTopFrameRow) {
|
|
337
|
+
state.cellsArchived = true;
|
|
338
|
+
this.#watchedPlacements.delete(state);
|
|
339
|
+
}
|
|
340
|
+
if (state.cellsArchived) {
|
|
341
|
+
state.epoch += 1;
|
|
342
|
+
state.cellsArchived = false;
|
|
343
|
+
state.lastAttachTopFrameRow = undefined;
|
|
344
|
+
}
|
|
345
|
+
if (attachTopFrameRow >= 0) {
|
|
346
|
+
state.lastAttachTopFrameRow = attachTopFrameRow;
|
|
347
|
+
this.#watchedPlacements.add(state);
|
|
348
|
+
}
|
|
349
|
+
return { placementId: state.epoch, widthPx: state.widthPx, heightPx: state.heightPx };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Restart every placement epoch after a destructive history clear (`CSI 3 J`
|
|
354
|
+
* full paint). The clear destroys all placement cells — scrollback rows are
|
|
355
|
+
* gone and the replay rewrites the viewport — so no archive remains to
|
|
356
|
+
* protect. Reverting to epoch 1 lets the replay's placements replace the
|
|
357
|
+
* terminal's stale registry entries; the returned list names every image
|
|
358
|
+
* and the highest epoch it reached so the caller can delete all of its
|
|
359
|
+
* registry entries explicitly (`d=i` keeps the transmitted data) — an image
|
|
360
|
+
* absent from the replay never re-places, so even its epoch-1 entry must go.
|
|
361
|
+
*/
|
|
362
|
+
resetPlacementEpochs(): ReadonlyArray<{ imageId: number; lastEpoch: number }> {
|
|
363
|
+
let stale: Array<{ imageId: number; lastEpoch: number }> | undefined;
|
|
364
|
+
for (const [imageId, state] of this.#placementState) {
|
|
365
|
+
stale ??= [];
|
|
366
|
+
stale.push({ imageId, lastEpoch: state.epoch });
|
|
367
|
+
state.epoch = 1;
|
|
368
|
+
state.lastAttachTopFrameRow = undefined;
|
|
369
|
+
state.cellsArchived = false;
|
|
370
|
+
}
|
|
371
|
+
this.#watchedPlacements.clear();
|
|
372
|
+
return stale ?? EMPTY_STALE_EPOCHS;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#deletePlacementState(imageId: number): void {
|
|
376
|
+
const state = this.#placementState.get(imageId);
|
|
377
|
+
if (!state) return;
|
|
378
|
+
this.#watchedPlacements.delete(state);
|
|
379
|
+
this.#placementState.delete(imageId);
|
|
380
|
+
}
|
|
381
|
+
|
|
234
382
|
/**
|
|
235
383
|
* Queue a one-time transmit for `imageId`. No-op if already transmitted, so a
|
|
236
384
|
* repeated call (e.g. a width-change re-render) never re-sends the data.
|
|
@@ -407,9 +555,17 @@ export class Image implements Component {
|
|
|
407
555
|
// Direct placement: return `rows` lines so TUI accounts for image
|
|
408
556
|
// height. First (rows-1) lines are empty (TUI clears them); the last
|
|
409
557
|
// saves the final-row cursor, moves up to the image origin, emits the
|
|
410
|
-
// image sequence, then restores the final-row cursor.
|
|
411
|
-
//
|
|
412
|
-
//
|
|
558
|
+
// image sequence, then restores the final-row cursor. When the block
|
|
559
|
+
// straddles the viewport top, the renderer rewrites this line to the
|
|
560
|
+
// visible slice (encodeKittyPlacementLine) from the geometry
|
|
561
|
+
// registered below.
|
|
562
|
+
if (this.#imageId != null && this.#budget !== undefined) {
|
|
563
|
+
this.#budget.registerPlacementGeometry(
|
|
564
|
+
this.#imageId,
|
|
565
|
+
this.#dimensions.widthPx,
|
|
566
|
+
this.#dimensions.heightPx,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
413
569
|
lines = [];
|
|
414
570
|
for (let i = 0; i < result.rows - 1; i++) {
|
|
415
571
|
lines.push(RESERVED_IMAGE_ROW);
|
|
@@ -731,6 +731,80 @@ export function encodeKittyPlacement(options: {
|
|
|
731
731
|
return wrapTmuxPassthroughIfNeeded(`\x1b_G${params.join(",")}\x1b\\`);
|
|
732
732
|
}
|
|
733
733
|
|
|
734
|
+
/**
|
|
735
|
+
* Exact shape of the direct-placement line {@link Image} emits as its block's
|
|
736
|
+
* last row: optional `ESC 7` + `CUU(rows-1)` prefix, the {@link encodeKittyPlacement}
|
|
737
|
+
* APC, optional `ESC 8` suffix. tmux-passthrough-wrapped lines deliberately do
|
|
738
|
+
* not match (passthrough placements stay untouched).
|
|
739
|
+
*/
|
|
740
|
+
const KITTY_DIRECT_PLACEMENT_LINE =
|
|
741
|
+
/^(?:\x1b7(?:\x1b\[(\d+)A)?)?\x1b_Ga=p,q=2,C=1,i=(\d+)(?:,p=(\d+))?(?:,c=(\d+))?(?:,r=(\d+))?\x1b\\(?:\x1b8)?$/;
|
|
742
|
+
|
|
743
|
+
export interface ParsedKittyPlacementLine {
|
|
744
|
+
imageId: number;
|
|
745
|
+
placementId: number | undefined;
|
|
746
|
+
columns: number;
|
|
747
|
+
rows: number;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Parse a frame line that consists solely of a Kitty direct placement (the
|
|
752
|
+
* last line of an {@link Image} block). Returns null for anything else —
|
|
753
|
+
* placeholder grids, tmux-wrapped placements, sixel/iTerm2 payloads — so
|
|
754
|
+
* callers fall back to writing the line verbatim.
|
|
755
|
+
*/
|
|
756
|
+
export function parseKittyDirectPlacementLine(line: string): ParsedKittyPlacementLine | null {
|
|
757
|
+
const m = KITTY_DIRECT_PLACEMENT_LINE.exec(line);
|
|
758
|
+
if (!m) return null;
|
|
759
|
+
const columns = m[4] !== undefined ? Number(m[4]) : 0;
|
|
760
|
+
const rows = m[5] !== undefined ? Number(m[5]) : 0;
|
|
761
|
+
if (columns <= 0 || rows <= 0) return null;
|
|
762
|
+
return {
|
|
763
|
+
imageId: Number(m[2]),
|
|
764
|
+
placementId: m[3] !== undefined ? Number(m[3]) : undefined,
|
|
765
|
+
columns,
|
|
766
|
+
rows,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Rebuild an {@link Image} direct-placement line for the viewport row it is
|
|
772
|
+
* written at. The component-rendered line encodes `CUU(rows-1)`, which clamps
|
|
773
|
+
* at the viewport top once the block's leading rows have scrolled out — the
|
|
774
|
+
* placement then re-anchors the full image shifted down over foreign rows.
|
|
775
|
+
* Anchor at the block's first *visible* row instead, clipping the source
|
|
776
|
+
* rectangle (`y=`/`h=`, image pixels) to the visible bottom slice.
|
|
777
|
+
*/
|
|
778
|
+
export function encodeKittyPlacementLine(options: {
|
|
779
|
+
imageId: number;
|
|
780
|
+
placementId: number;
|
|
781
|
+
columns: number;
|
|
782
|
+
/** Total cell rows of the image block. */
|
|
783
|
+
rows: number;
|
|
784
|
+
/** Viewport row the block's last line is being written at. */
|
|
785
|
+
screenRow: number;
|
|
786
|
+
/** Source image height in pixels, for the clipped source rectangle. */
|
|
787
|
+
imageHeightPx: number;
|
|
788
|
+
}): string {
|
|
789
|
+
// Without a source pixel height the slice cannot be expressed — emit the
|
|
790
|
+
// component's own full form (status quo) rather than squashing the whole
|
|
791
|
+
// image into the reduced row count.
|
|
792
|
+
const clippable = options.imageHeightPx > 0;
|
|
793
|
+
const hiddenRows = clippable ? Math.max(0, options.rows - 1 - options.screenRow) : 0;
|
|
794
|
+
const visibleRows = options.rows - hiddenRows;
|
|
795
|
+
const params: string[] = ["a=p", "q=2", "C=1", `i=${options.imageId}`, `p=${options.placementId}`];
|
|
796
|
+
params.push(`c=${options.columns}`, `r=${visibleRows}`);
|
|
797
|
+
if (hiddenRows > 0) {
|
|
798
|
+
const srcY = Math.floor((options.imageHeightPx * hiddenRows) / options.rows);
|
|
799
|
+
params.push(`y=${srcY}`, `h=${Math.max(1, options.imageHeightPx - srcY)}`);
|
|
800
|
+
}
|
|
801
|
+
// No tmux passthrough: inside tmux the component's own line arrives
|
|
802
|
+
// wrapped, never parses, and never reaches this rewrite.
|
|
803
|
+
const apc = `\x1b_G${params.join(",")}\x1b\\`;
|
|
804
|
+
const cuu = visibleRows - 1;
|
|
805
|
+
return cuu > 0 ? `\x1b7\x1b[${cuu}A${apc}\x1b8` : apc;
|
|
806
|
+
}
|
|
807
|
+
|
|
734
808
|
/**
|
|
735
809
|
* Kitty graphics delete command for a single image id. Uses `d=I` (capital)
|
|
736
810
|
* which removes the image and every one of its placements — on screen *and* in
|
|
@@ -742,6 +816,16 @@ export function encodeKittyDeleteImage(imageId: number): string {
|
|
|
742
816
|
return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`);
|
|
743
817
|
}
|
|
744
818
|
|
|
819
|
+
/**
|
|
820
|
+
* Delete a single placement of an image (`d=i`, lowercase): removes its cells
|
|
821
|
+
* and registry entry but keeps the transmitted data, so a later `a=p` under a
|
|
822
|
+
* fresh placement id needs no retransmit. Used to clear stale placement-epoch
|
|
823
|
+
* entries after a destructive history clear.
|
|
824
|
+
*/
|
|
825
|
+
export function encodeKittyDeletePlacement(imageId: number, placementId: number): string {
|
|
826
|
+
return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=i,i=${imageId},p=${placementId},q=2\x1b\\`);
|
|
827
|
+
}
|
|
828
|
+
|
|
745
829
|
export function encodeITerm2(
|
|
746
830
|
base64Data: string,
|
|
747
831
|
options: {
|
package/src/tui.ts
CHANGED
|
@@ -25,8 +25,11 @@ import { LoopWatchdog } from "./loop-watchdog";
|
|
|
25
25
|
import { isConPTYHosted, setAltScreenActive, type Terminal } from "./terminal";
|
|
26
26
|
import {
|
|
27
27
|
encodeKittyDeleteImage,
|
|
28
|
+
encodeKittyDeletePlacement,
|
|
29
|
+
encodeKittyPlacementLine,
|
|
28
30
|
ImageProtocol,
|
|
29
31
|
isInsideTerminalMultiplexer,
|
|
32
|
+
parseKittyDirectPlacementLine,
|
|
30
33
|
setCellDimensions,
|
|
31
34
|
setTerminalImageProtocol,
|
|
32
35
|
shouldEnableSynchronizedOutputByDefault,
|
|
@@ -2133,7 +2136,13 @@ export class TUI extends Container {
|
|
|
2133
2136
|
buffer += "\r";
|
|
2134
2137
|
for (let i = firstChanged; i <= lastChanged; i++) {
|
|
2135
2138
|
if (i > firstChanged) buffer += "\r\n";
|
|
2136
|
-
buffer += this.#lineRewriteSequence(
|
|
2139
|
+
buffer += this.#lineRewriteSequence(
|
|
2140
|
+
this.#preparedFrame[segment.start + i] ?? "",
|
|
2141
|
+
width,
|
|
2142
|
+
screenStart + i,
|
|
2143
|
+
segment.start + i,
|
|
2144
|
+
this.#committedRows,
|
|
2145
|
+
);
|
|
2137
2146
|
}
|
|
2138
2147
|
const cursorControl = this.#cursorControlSequence(
|
|
2139
2148
|
cursorPos,
|
|
@@ -2796,8 +2805,39 @@ export class TUI extends Container {
|
|
|
2796
2805
|
};
|
|
2797
2806
|
}
|
|
2798
2807
|
|
|
2799
|
-
|
|
2800
|
-
|
|
2808
|
+
/**
|
|
2809
|
+
* Rewrite a Kitty direct-placement line for the viewport row it is written
|
|
2810
|
+
* at, clipping to the visible slice (see {@link encodeKittyPlacementLine})
|
|
2811
|
+
* under the placement id resolved by the budget's epoch tracking (see
|
|
2812
|
+
* {@link ImageBudget.resolvePlacementEmit}). `screenRow` -1 (write position
|
|
2813
|
+
* unknown) and non-placement image lines (placeholder grids, sixel, iTerm2,
|
|
2814
|
+
* tmux-wrapped) pass through verbatim.
|
|
2815
|
+
*/
|
|
2816
|
+
#imageLineSequence(line: string, screenRow: number, frameRow: number, committedTo: number): string {
|
|
2817
|
+
if (screenRow < 0) return line;
|
|
2818
|
+
const parsed = parseKittyDirectPlacementLine(line);
|
|
2819
|
+
if (!parsed) return line;
|
|
2820
|
+
// The emitted placement attaches from the block's first *visible* row
|
|
2821
|
+
// (the clip drops the rows above the viewport), so epoch tracking keys
|
|
2822
|
+
// on that row — not the block origin, which may be long committed.
|
|
2823
|
+
const placement = this.#imageBudget.resolvePlacementEmit(
|
|
2824
|
+
parsed.imageId,
|
|
2825
|
+
frameRow >= 0 ? frameRow - Math.min(parsed.rows - 1, screenRow) : -1,
|
|
2826
|
+
committedTo,
|
|
2827
|
+
);
|
|
2828
|
+
if (!placement) return line;
|
|
2829
|
+
return encodeKittyPlacementLine({
|
|
2830
|
+
imageId: parsed.imageId,
|
|
2831
|
+
placementId: placement.placementId,
|
|
2832
|
+
columns: parsed.columns,
|
|
2833
|
+
rows: parsed.rows,
|
|
2834
|
+
screenRow,
|
|
2835
|
+
imageHeightPx: placement.heightPx,
|
|
2836
|
+
});
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
#terminalLine(line: string, screenRow = -1, frameRow = -1, committedTo = -1): string {
|
|
2840
|
+
if (TERMINAL.isImageLine(line)) return this.#imageLineSequence(line, screenRow, frameRow, committedTo);
|
|
2801
2841
|
const coalesced = coalesceAdjacentSgr(line);
|
|
2802
2842
|
return coalesced + (line.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
2803
2843
|
}
|
|
@@ -3208,6 +3248,12 @@ export class TUI extends Container {
|
|
|
3208
3248
|
} else {
|
|
3209
3249
|
this.#imageBudget.takePurgeIds();
|
|
3210
3250
|
}
|
|
3251
|
+
// Feed this frame's commit target to the placement-epoch tracker before
|
|
3252
|
+
// any placement resolves against it — an epoch whose rows commit during
|
|
3253
|
+
// frames that never rewrite its line must still advance on the next
|
|
3254
|
+
// re-emission, and the raw per-frame value keeps the check correct
|
|
3255
|
+
// across committed-ledger rewinds.
|
|
3256
|
+
this.#imageBudget.observeCommitWatermark(chunkTo);
|
|
3211
3257
|
|
|
3212
3258
|
// 6. Emit.
|
|
3213
3259
|
if (intent.kind === "fullPaint") {
|
|
@@ -3506,8 +3552,10 @@ export class TUI extends Container {
|
|
|
3506
3552
|
return col;
|
|
3507
3553
|
}
|
|
3508
3554
|
|
|
3509
|
-
#lineRewriteSequence(line: string, width: number): string {
|
|
3510
|
-
if (TERMINAL.isImageLine(line))
|
|
3555
|
+
#lineRewriteSequence(line: string, width: number, screenRow = -1, frameRow = -1, committedTo = -1): string {
|
|
3556
|
+
if (TERMINAL.isImageLine(line)) {
|
|
3557
|
+
return ERASE_LINE + this.#imageLineSequence(line, screenRow, frameRow, committedTo);
|
|
3558
|
+
}
|
|
3511
3559
|
const terminalLine = this.#terminalLine(line);
|
|
3512
3560
|
const asciiWidth = this.#ansiAsciiLineWidth(line, width);
|
|
3513
3561
|
if (asciiWidth !== undefined) {
|
|
@@ -3674,7 +3722,17 @@ export class TUI extends Container {
|
|
|
3674
3722
|
// Clear native history without blanking the live viewport first. The
|
|
3675
3723
|
// replay below rewrites every visible row from home, including blanks,
|
|
3676
3724
|
// so terminals without DEC 2026 never expose an ED2-cleared frame.
|
|
3725
|
+
// The clear also destroys every placement cell, so placement epochs
|
|
3726
|
+
// restart and every registry entry each image ever placed is deleted
|
|
3727
|
+
// explicitly (`d=i` keeps the transmitted data, so the replay needs
|
|
3728
|
+
// no retransmit). Deleting epoch 1 too matters for images absent from
|
|
3729
|
+
// the replay — nothing would ever replace their stale entry.
|
|
3677
3730
|
buffer += "\x1b[H\x1b[3J";
|
|
3731
|
+
for (const { imageId, lastEpoch } of this.#imageBudget.resetPlacementEpochs()) {
|
|
3732
|
+
for (let placementId = 1; placementId <= lastEpoch; placementId++) {
|
|
3733
|
+
buffer += encodeKittyDeletePlacement(imageId, placementId);
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3678
3736
|
} else {
|
|
3679
3737
|
// Best-effort: push the pre-paint screen into scrollback on
|
|
3680
3738
|
// terminals that implement kitty's ED 22
|
|
@@ -3711,20 +3769,31 @@ export class TUI extends Container {
|
|
|
3711
3769
|
// each row must self-clear stale cells left by the previous viewport.
|
|
3712
3770
|
for (let i = 0; i < chunkTo; i++) {
|
|
3713
3771
|
if (i > 0) buffer += "\r\n";
|
|
3772
|
+
const writeRow = Math.min(i, height - 1);
|
|
3714
3773
|
buffer += options.clearScrollback
|
|
3715
|
-
? this.#lineRewriteSequence(frame[i] ?? "", width)
|
|
3716
|
-
: this.#terminalLine(frame[i] ?? "");
|
|
3774
|
+
? this.#lineRewriteSequence(frame[i] ?? "", width, writeRow, i, chunkTo)
|
|
3775
|
+
: this.#terminalLine(frame[i] ?? "", writeRow, i, chunkTo);
|
|
3717
3776
|
}
|
|
3718
3777
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
3719
3778
|
if (chunkTo + screenRow > 0) buffer += "\r\n";
|
|
3720
3779
|
const line = visibleTexts ? (visibleTexts[screenRow] ?? "") : (window[screenRow] ?? "");
|
|
3721
|
-
|
|
3780
|
+
const writeRow = Math.min(chunkTo + screenRow, height - 1);
|
|
3781
|
+
const frameRow = windowTop + screenRow;
|
|
3782
|
+
buffer += options.clearScrollback
|
|
3783
|
+
? this.#lineRewriteSequence(line, width, writeRow, frameRow, chunkTo)
|
|
3784
|
+
: this.#terminalLine(line, writeRow, frameRow, chunkTo);
|
|
3722
3785
|
}
|
|
3723
3786
|
} else {
|
|
3787
|
+
// ConPTY-truncated replay: leading rows were dropped, so frame-space
|
|
3788
|
+
// positions are unknown — placements still clip to the write row but
|
|
3789
|
+
// skip epoch bookkeeping.
|
|
3724
3790
|
for (let i = 0; i < paintLines.length; i++) {
|
|
3725
3791
|
if (i > 0) buffer += "\r\n";
|
|
3726
3792
|
const line = visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? "");
|
|
3727
|
-
|
|
3793
|
+
const writeRow = Math.min(i, height - 1);
|
|
3794
|
+
buffer += options.clearScrollback
|
|
3795
|
+
? this.#lineRewriteSequence(line, width, writeRow, -1, chunkTo)
|
|
3796
|
+
: this.#terminalLine(line, writeRow, -1, chunkTo);
|
|
3728
3797
|
}
|
|
3729
3798
|
}
|
|
3730
3799
|
buffer += fillSequence;
|
|
@@ -3922,7 +3991,7 @@ export class TUI extends Container {
|
|
|
3922
3991
|
let buffer = `${this.#paintBeginSequence + altEnter}\x1b[H`;
|
|
3923
3992
|
for (let r = 0; r < height; r++) {
|
|
3924
3993
|
if (r > 0) buffer += "\r\n";
|
|
3925
|
-
buffer += this.#lineRewriteSequence(window[r] ?? "", width);
|
|
3994
|
+
buffer += this.#lineRewriteSequence(window[r] ?? "", width, r, -1, this.#committedRows);
|
|
3926
3995
|
}
|
|
3927
3996
|
// Park the hardware cursor at the real content bottom, not the padded
|
|
3928
3997
|
// viewport bottom: a later height shrink would otherwise scroll the live
|
|
@@ -3988,7 +4057,7 @@ export class TUI extends Container {
|
|
|
3988
4057
|
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
|
3989
4058
|
for (let r = 0; r < height; r++) {
|
|
3990
4059
|
if (r > 0) buffer += "\r\n";
|
|
3991
|
-
buffer += this.#lineRewriteSequence(fitted[r], width);
|
|
4060
|
+
buffer += this.#lineRewriteSequence(fitted[r], width, r, -1, -1);
|
|
3992
4061
|
}
|
|
3993
4062
|
buffer += this.#paintEndSequence;
|
|
3994
4063
|
this.terminal.write(buffer);
|
|
@@ -4067,7 +4136,7 @@ export class TUI extends Container {
|
|
|
4067
4136
|
const moveToBottom = height - 1 - currentScreenRow;
|
|
4068
4137
|
if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
|
|
4069
4138
|
for (let r = height - scroll; r < height; r++) {
|
|
4070
|
-
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width)}`;
|
|
4139
|
+
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width, height - 1, windowTop + r, chunkTo)}`;
|
|
4071
4140
|
}
|
|
4072
4141
|
// Rewrite any remaining changed rows after the shift.
|
|
4073
4142
|
let firstChanged = -1;
|
|
@@ -4084,7 +4153,7 @@ export class TUI extends Container {
|
|
|
4084
4153
|
buffer += "\r";
|
|
4085
4154
|
for (let r = firstChanged; r <= lastChanged; r++) {
|
|
4086
4155
|
if (r > firstChanged) buffer += "\r\n";
|
|
4087
|
-
buffer += this.#lineRewriteSequence(window[r] ?? "", width);
|
|
4156
|
+
buffer += this.#lineRewriteSequence(window[r] ?? "", width, r, windowTop + r, chunkTo);
|
|
4088
4157
|
}
|
|
4089
4158
|
cursorFromRow = windowTop + lastChanged;
|
|
4090
4159
|
}
|
|
@@ -4153,7 +4222,13 @@ export class TUI extends Container {
|
|
|
4153
4222
|
}
|
|
4154
4223
|
for (let r = firstChanged; r <= lastChanged; r++) {
|
|
4155
4224
|
if (r > firstChanged) buffer += "\r\n";
|
|
4156
|
-
buffer += this.#lineRewriteSequence(
|
|
4225
|
+
buffer += this.#lineRewriteSequence(
|
|
4226
|
+
fillTexts ? fillTexts[r - firstChanged] : (window[r] ?? ""),
|
|
4227
|
+
width,
|
|
4228
|
+
r,
|
|
4229
|
+
windowTop + r,
|
|
4230
|
+
this.#committedRows,
|
|
4231
|
+
);
|
|
4157
4232
|
}
|
|
4158
4233
|
buffer += fillSequence;
|
|
4159
4234
|
// Never park below real content (a height shrink would scroll live
|
|
@@ -4184,12 +4259,18 @@ export class TUI extends Container {
|
|
|
4184
4259
|
let wroteLine = false;
|
|
4185
4260
|
for (let i = chunkFrom; i < chunkTo; i++) {
|
|
4186
4261
|
if (wroteLine) buffer += "\r\n";
|
|
4187
|
-
buffer += this.#lineRewriteSequence(frame[i] ?? "", width);
|
|
4262
|
+
buffer += this.#lineRewriteSequence(frame[i] ?? "", width, Math.min(i - chunkFrom, height - 1), i, chunkTo);
|
|
4188
4263
|
wroteLine = true;
|
|
4189
4264
|
}
|
|
4190
4265
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
4191
4266
|
if (wroteLine) buffer += "\r\n";
|
|
4192
|
-
buffer += this.#lineRewriteSequence(
|
|
4267
|
+
buffer += this.#lineRewriteSequence(
|
|
4268
|
+
window[screenRow] ?? "",
|
|
4269
|
+
width,
|
|
4270
|
+
Math.min(chunkTo - chunkFrom + screenRow, height - 1),
|
|
4271
|
+
windowTop + screenRow,
|
|
4272
|
+
chunkTo,
|
|
4273
|
+
);
|
|
4193
4274
|
wroteLine = true;
|
|
4194
4275
|
}
|
|
4195
4276
|
const parkUp = height - 1 - (contentBottomRow - windowTop);
|