@oh-my-pi/pi-tui 16.0.3 → 16.0.5
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 +16 -0
- package/dist/types/components/box.d.ts +1 -0
- package/dist/types/components/image.d.ts +9 -0
- package/dist/types/components/markdown.d.ts +1 -0
- package/dist/types/components/text.d.ts +1 -0
- package/dist/types/terminal-capabilities.d.ts +6 -4
- package/dist/types/tui.d.ts +12 -0
- package/dist/types/utils.d.ts +3 -0
- package/package.json +3 -3
- package/src/components/box.ts +15 -3
- package/src/components/image.ts +58 -8
- package/src/components/markdown.ts +14 -4
- package/src/components/text.ts +13 -5
- package/src/terminal-capabilities.ts +8 -6
- package/src/tui.ts +165 -10
- package/src/utils.ts +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.0.5] - 2026-06-17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added tight layout support (`setTuiTight`/`getPaddingX`) to dynamically remove 1-character horizontal padding from Text, Markdown, Box, and TruncatedText components.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Coalesced byte-adjacent SGR sequences in emitted lines into a single `CSI … m`. The component tree styles each span as `<set>text<reset>`, so adjacent spans emit runs of back-to-back SGR sequences (e.g. a `CSI 39 m` fg-reset immediately followed by the next span's `CSI 38;2;r;g;b m`); merging the run is behavior-preserving because SGR parameters apply left-to-right regardless of framing. On a real transcript this drops ~30-40% of all SGR sequences, cutting the per-frame byte volume and SGR-dispatch count a slow terminal engine (e.g. xterm.js/WebGL under a large viewport) must process. Each emitted sequence is capped at 16 parameter tokens so a long adjacent run is split across several valid CSIs instead of overflowing a terminal's parameter buffer (xterm.js caps at 32 and silently truncates, corrupting colors). A run is never extended past a parameter list that ends in an incomplete semicolon-form extended color (`38/48/58;2` missing a channel or `;5` missing the index), so a following code can't be absorbed as the missing component. Disable with `PI_NO_SGR_COALESCE=1`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed image cache invalidation when terminal image protocol, Kitty placeholder mode, or cell dimensions change, preventing stale rendered output
|
|
18
|
+
- Fixed direct inline-image placements leaving the cursor inside the reserved image block, which let following chat rows overwrite the middle of rendered screenshots ([#2863](https://github.com/can1357/oh-my-pi/issues/2863)).
|
|
19
|
+
- Fixed inline-image replay after startup or resume fallback paints by invalidating cached image rows when the terminal image protocol, Kitty placeholder mode, or cell dimensions change.
|
|
20
|
+
|
|
5
21
|
## [16.0.3] - 2026-06-16
|
|
6
22
|
|
|
7
23
|
### Added
|
|
@@ -5,6 +5,7 @@ import type { Component } from "../tui";
|
|
|
5
5
|
export declare class Box implements Component {
|
|
6
6
|
#private;
|
|
7
7
|
children: Component[];
|
|
8
|
+
setIgnoreTight(ignore: boolean): this;
|
|
8
9
|
constructor(paddingX?: number, paddingY?: number, bgFn?: (text: string) => string);
|
|
9
10
|
addChild(component: Component): void;
|
|
10
11
|
removeChild(component: Component): void;
|
|
@@ -94,6 +94,15 @@ export declare class ImageBudget {
|
|
|
94
94
|
get quiescent(): boolean;
|
|
95
95
|
/** Transmit sequences to write before this frame's placements; clears the queue. */
|
|
96
96
|
takeTransmits(): readonly string[];
|
|
97
|
+
/**
|
|
98
|
+
* Drop transmit tracking so every still-live image re-enqueues its data
|
|
99
|
+
* (`a=t`) on the next render. Recovers when the terminal dropped the original
|
|
100
|
+
* transmit — e.g. Ghostty discarding graphics sent during its post-startup
|
|
101
|
+
* window — where a placement-only replay can never bind a Unicode placeholder.
|
|
102
|
+
* Pair with a component invalidate + forced repaint so the data and placement
|
|
103
|
+
* re-emit together; keeps no base64 in budget state (the transmit-once design).
|
|
104
|
+
*/
|
|
105
|
+
forgetTransmitted(): void;
|
|
97
106
|
}
|
|
98
107
|
export declare class Image implements Component {
|
|
99
108
|
#private;
|
|
@@ -49,6 +49,7 @@ export interface MarkdownTheme {
|
|
|
49
49
|
}
|
|
50
50
|
export declare class Markdown implements Component {
|
|
51
51
|
#private;
|
|
52
|
+
setIgnoreTight(ignore: boolean): this;
|
|
52
53
|
constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
|
|
53
54
|
setText(text: string): void;
|
|
54
55
|
invalidate(): void;
|
|
@@ -4,6 +4,7 @@ import type { Component } from "../tui";
|
|
|
4
4
|
*/
|
|
5
5
|
export declare class Text implements Component {
|
|
6
6
|
#private;
|
|
7
|
+
setIgnoreTight(ignore: boolean): this;
|
|
7
8
|
constructor(text?: string, paddingX?: number, paddingY?: number, customBgFn?: (text: string) => string);
|
|
8
9
|
getText(): string;
|
|
9
10
|
setText(text: string): boolean;
|
|
@@ -191,10 +191,12 @@ export declare function encodeKitty(base64Data: string, options?: {
|
|
|
191
191
|
*/
|
|
192
192
|
export declare function encodeKittyTransmit(base64Data: string, imageId: number): string;
|
|
193
193
|
/**
|
|
194
|
-
* Display a previously transmitted image (`a=p`) at the cursor.
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
194
|
+
* Display a previously transmitted image (`a=p`) at the cursor. `C=1` keeps
|
|
195
|
+
* the terminal cursor anchored at the placement origin so the renderer's
|
|
196
|
+
* explicit cursor movement remains the only row accounting. Carrying a stable
|
|
197
|
+
* `placementId` (`p=`) means re-emitting the sequence on a repaint *replaces*
|
|
198
|
+
* the existing placement (moving/resizing it without flicker) rather than
|
|
199
|
+
* stacking a duplicate.
|
|
198
200
|
*/
|
|
199
201
|
export declare function encodeKittyPlacement(options: {
|
|
200
202
|
imageId: number;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -58,6 +58,10 @@ export interface Component {
|
|
|
58
58
|
* Called when theme changes or when component needs to re-render from scratch.
|
|
59
59
|
*/
|
|
60
60
|
invalidate?(): void;
|
|
61
|
+
/**
|
|
62
|
+
* Optional hook to set whether this component ignores tight layout mode.
|
|
63
|
+
*/
|
|
64
|
+
setIgnoreTight?(ignore: boolean): any;
|
|
61
65
|
/**
|
|
62
66
|
* Optional teardown. Called when the component is permanently removed from
|
|
63
67
|
* the live tree (e.g. a transcript reset). Release timers, intervals, and
|
|
@@ -265,6 +269,7 @@ export interface OverlayHandle {
|
|
|
265
269
|
export declare class Container implements Component {
|
|
266
270
|
#private;
|
|
267
271
|
children: Component[];
|
|
272
|
+
setIgnoreTight(ignore: boolean): this;
|
|
268
273
|
addChild(component: Component): void;
|
|
269
274
|
removeChild(component: Component): void;
|
|
270
275
|
clear(): void;
|
|
@@ -277,6 +282,13 @@ export declare class Container implements Component {
|
|
|
277
282
|
dispose(): void;
|
|
278
283
|
render(width: number): readonly string[];
|
|
279
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Merge runs of byte-adjacent SGR sequences (`CSI [0-9;:]* m`) into one. Only
|
|
287
|
+
* CSI-SGR sequences are touched; text, cursor moves, OSC, hyperlinks and image
|
|
288
|
+
* payloads pass through verbatim. Returns the original reference when nothing
|
|
289
|
+
* merges, so SGR-light lines incur only a single `indexOf` scan.
|
|
290
|
+
*/
|
|
291
|
+
export declare function coalesceAdjacentSgr(line: string): string;
|
|
280
292
|
/**
|
|
281
293
|
* Decide whether `frame` still aligns with the committed prefix, and where to
|
|
282
294
|
* re-anchor the commit index when it does not. Returns the resync row index,
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -90,3 +90,6 @@ export declare function applyBackgroundToLine(line: string, width: number, bgFn:
|
|
|
90
90
|
* @param strict - If true, exclude wide chars at boundary that would extend past the range
|
|
91
91
|
*/
|
|
92
92
|
export declare function sliceByColumn(line: string, startCol: number, length: number, strict?: boolean): string;
|
|
93
|
+
export declare function setTuiTight(tight: boolean): void;
|
|
94
|
+
export declare function isTuiTight(): boolean;
|
|
95
|
+
export declare function getPaddingX(basePadding: number): number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "16.0.
|
|
4
|
+
"version": "16.0.5",
|
|
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": "16.0.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.0.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.0.5",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.0.5",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.5"
|
|
44
44
|
},
|
package/src/components/box.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Component } from "../tui";
|
|
2
|
-
import { applyBackgroundToLine, padding, visibleWidth } from "../utils";
|
|
2
|
+
import { applyBackgroundToLine, getPaddingX, padding, visibleWidth } from "../utils";
|
|
3
3
|
|
|
4
4
|
type Cache = {
|
|
5
5
|
width: number;
|
|
@@ -17,6 +17,14 @@ export class Box implements Component {
|
|
|
17
17
|
#paddingY: number;
|
|
18
18
|
#bgFn?: (text: string) => string;
|
|
19
19
|
|
|
20
|
+
#ignoreTight = false;
|
|
21
|
+
|
|
22
|
+
setIgnoreTight(ignore: boolean): this {
|
|
23
|
+
this.#ignoreTight = ignore;
|
|
24
|
+
this.#invalidateCache();
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
// Cache for rendered output
|
|
21
29
|
#cached?: Cache;
|
|
22
30
|
|
|
@@ -28,6 +36,9 @@ export class Box implements Component {
|
|
|
28
36
|
|
|
29
37
|
addChild(component: Component): void {
|
|
30
38
|
this.children.push(component);
|
|
39
|
+
if (this.#ignoreTight) {
|
|
40
|
+
component.setIgnoreTight?.(true);
|
|
41
|
+
}
|
|
31
42
|
this.#invalidateCache();
|
|
32
43
|
}
|
|
33
44
|
|
|
@@ -75,7 +86,8 @@ export class Box implements Component {
|
|
|
75
86
|
render(width: number): readonly string[] {
|
|
76
87
|
const children = this.children;
|
|
77
88
|
const count = children.length;
|
|
78
|
-
const
|
|
89
|
+
const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
|
|
90
|
+
const contentWidth = Math.max(1, width - paddingX * 2);
|
|
79
91
|
// bgFn output can change without the function reference changing (theme
|
|
80
92
|
// mutation); sample it so a silent palette swap still misses the cache.
|
|
81
93
|
const bgSample = this.#bgFn ? this.#bgFn("test") : undefined;
|
|
@@ -102,7 +114,7 @@ export class Box implements Component {
|
|
|
102
114
|
|
|
103
115
|
const result: string[] = [];
|
|
104
116
|
if (contentRows > 0) {
|
|
105
|
-
const leftPad = padding(
|
|
117
|
+
const leftPad = padding(paddingX);
|
|
106
118
|
// Top padding
|
|
107
119
|
for (let i = 0; i < this.#paddingY; i++) {
|
|
108
120
|
result.push(this.#applyBg("", width));
|
package/src/components/image.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { getKittyGraphics } from "../kitty-graphics";
|
|
1
2
|
import {
|
|
3
|
+
getCellDimensions,
|
|
2
4
|
getImageDimensions,
|
|
3
5
|
type ImageDimensions,
|
|
4
6
|
imageFallback,
|
|
@@ -27,6 +29,8 @@ export interface ImageOptions {
|
|
|
27
29
|
|
|
28
30
|
const EMPTY_IDS: readonly number[] = [];
|
|
29
31
|
const EMPTY_TRANSMITS: readonly string[] = [];
|
|
32
|
+
const SAVE_CURSOR = "\x1b7";
|
|
33
|
+
const RESTORE_CURSOR = "\x1b8";
|
|
30
34
|
// Direct placements reserve height with leading zero-width rows. Keep them
|
|
31
35
|
// non-plain so transcript blank-edge trimming does not collapse image-only blocks.
|
|
32
36
|
const RESERVED_IMAGE_ROW = "\x1b[0m";
|
|
@@ -34,6 +38,11 @@ const RESERVED_IMAGE_ROW = "\x1b[0m";
|
|
|
34
38
|
/** Default count of inline images kept as live graphics before older ones fall back to text. */
|
|
35
39
|
export const DEFAULT_MAX_INLINE_IMAGES = 8;
|
|
36
40
|
|
|
41
|
+
let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
|
|
42
|
+
function nextImageIdSeed(): number {
|
|
43
|
+
nextImageBudgetSeed = (nextImageBudgetSeed + 0x10000) & 0xffffff;
|
|
44
|
+
return nextImageBudgetSeed || 1;
|
|
45
|
+
}
|
|
37
46
|
/**
|
|
38
47
|
* Bounds how many inline images render as live terminal graphics at once.
|
|
39
48
|
*
|
|
@@ -54,7 +63,7 @@ export const DEFAULT_MAX_INLINE_IMAGES = 8;
|
|
|
54
63
|
export class ImageBudget {
|
|
55
64
|
#cap: number;
|
|
56
65
|
#requestRender: () => void;
|
|
57
|
-
#nextId =
|
|
66
|
+
#nextId = nextImageIdSeed();
|
|
58
67
|
#keyToId = new Map<string, number>();
|
|
59
68
|
/** Display-order image ids observed during the in-flight pass. */
|
|
60
69
|
#passIds: number[] = [];
|
|
@@ -120,11 +129,14 @@ export class ImageBudget {
|
|
|
120
129
|
if (key) {
|
|
121
130
|
const existing = this.#keyToId.get(key);
|
|
122
131
|
if (existing !== undefined) return existing;
|
|
123
|
-
const id = this.#nextId
|
|
132
|
+
const id = this.#nextId;
|
|
133
|
+
this.#nextId = (this.#nextId + 1) & 0xffffff || 1;
|
|
124
134
|
this.#keyToId.set(key, id);
|
|
125
135
|
return id;
|
|
126
136
|
}
|
|
127
|
-
|
|
137
|
+
const id = this.#nextId;
|
|
138
|
+
this.#nextId = (this.#nextId + 1) & 0xffffff || 1;
|
|
139
|
+
return id;
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
/**
|
|
@@ -248,6 +260,20 @@ export class ImageBudget {
|
|
|
248
260
|
return sequences;
|
|
249
261
|
}
|
|
250
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Drop transmit tracking so every still-live image re-enqueues its data
|
|
265
|
+
* (`a=t`) on the next render. Recovers when the terminal dropped the original
|
|
266
|
+
* transmit — e.g. Ghostty discarding graphics sent during its post-startup
|
|
267
|
+
* window — where a placement-only replay can never bind a Unicode placeholder.
|
|
268
|
+
* Pair with a component invalidate + forced repaint so the data and placement
|
|
269
|
+
* re-emit together; keeps no base64 in budget state (the transmit-once design).
|
|
270
|
+
*/
|
|
271
|
+
forgetTransmitted(): void {
|
|
272
|
+
if (this.#transmitted.size === 0 && this.#pendingTransmits.length === 0) return;
|
|
273
|
+
this.#transmitted.clear();
|
|
274
|
+
this.#pendingTransmits = [];
|
|
275
|
+
}
|
|
276
|
+
|
|
251
277
|
#reconcile(total: number): void {
|
|
252
278
|
const desired = this.#cap > 0 ? Math.max(0, total - this.#cap) : 0;
|
|
253
279
|
if (desired === this.#planned) {
|
|
@@ -284,6 +310,10 @@ export class Image implements Component {
|
|
|
284
310
|
#cachedLines?: string[];
|
|
285
311
|
#cachedWidth?: number;
|
|
286
312
|
#cachedSuppressed = false;
|
|
313
|
+
#cachedImageProtocol: typeof TERMINAL.imageProtocol = null;
|
|
314
|
+
#cachedCellWidthPx = 0;
|
|
315
|
+
#cachedCellHeightPx = 0;
|
|
316
|
+
#cachedKittyUnicodePlaceholders = false;
|
|
287
317
|
// Tallest graphic placement this image has rendered. The text fallback
|
|
288
318
|
// pads itself to this height so a budget demotion never shrinks the block
|
|
289
319
|
// (its rows may already be committed to native scrollback).
|
|
@@ -311,14 +341,25 @@ export class Image implements Component {
|
|
|
311
341
|
}
|
|
312
342
|
|
|
313
343
|
render(width: number): readonly string[] {
|
|
314
|
-
const
|
|
344
|
+
const imageProtocol = TERMINAL.imageProtocol;
|
|
345
|
+
const hasProtocol = imageProtocol != null;
|
|
346
|
+
const cellDimensions = getCellDimensions();
|
|
347
|
+
const kittyUnicodePlaceholders = getKittyGraphics().unicodePlaceholders;
|
|
315
348
|
// observe() must run on every pass — even a cache hit — so the image keeps
|
|
316
349
|
// its display-order slot in the budget. Only graphics-capable frames count
|
|
317
350
|
// toward (and are demoted by) the budget; without a protocol every image is
|
|
318
351
|
// already text.
|
|
319
352
|
const suppressed = hasProtocol && this.#budget !== undefined ? this.#budget.observe(this.#imageId ?? 0) : false;
|
|
320
353
|
|
|
321
|
-
if (
|
|
354
|
+
if (
|
|
355
|
+
this.#cachedLines &&
|
|
356
|
+
this.#cachedWidth === width &&
|
|
357
|
+
this.#cachedSuppressed === suppressed &&
|
|
358
|
+
this.#cachedImageProtocol === imageProtocol &&
|
|
359
|
+
this.#cachedCellWidthPx === cellDimensions.widthPx &&
|
|
360
|
+
this.#cachedCellHeightPx === cellDimensions.heightPx &&
|
|
361
|
+
this.#cachedKittyUnicodePlaceholders === kittyUnicodePlaceholders
|
|
362
|
+
) {
|
|
322
363
|
return this.#cachedLines;
|
|
323
364
|
}
|
|
324
365
|
|
|
@@ -349,13 +390,18 @@ export class Image implements Component {
|
|
|
349
390
|
} else if (result) {
|
|
350
391
|
// Direct placement: return `rows` lines so TUI accounts for image
|
|
351
392
|
// height. First (rows-1) lines are empty (TUI clears them); the last
|
|
352
|
-
//
|
|
393
|
+
// saves the final-row cursor, moves up to the image origin, emits the
|
|
394
|
+
// image sequence, then restores the final-row cursor. Save/restore is
|
|
395
|
+
// required because CUU clamps at the viewport top when leading rows are
|
|
396
|
+
// clipped away.
|
|
353
397
|
lines = [];
|
|
354
398
|
for (let i = 0; i < result.rows - 1; i++) {
|
|
355
399
|
lines.push(RESERVED_IMAGE_ROW);
|
|
356
400
|
}
|
|
357
|
-
const
|
|
358
|
-
|
|
401
|
+
const cursorRows = result.rows - 1;
|
|
402
|
+
const moveUp = cursorRows > 0 ? `\x1b[${cursorRows}A` : "";
|
|
403
|
+
const placement = moveUp + (result.sequence ?? "");
|
|
404
|
+
lines.push(cursorRows > 0 ? SAVE_CURSOR + placement + RESTORE_CURSOR : placement);
|
|
359
405
|
} else {
|
|
360
406
|
lines = this.#fallbackLines();
|
|
361
407
|
}
|
|
@@ -367,6 +413,10 @@ export class Image implements Component {
|
|
|
367
413
|
this.#cachedLines = lines;
|
|
368
414
|
this.#cachedWidth = width;
|
|
369
415
|
this.#cachedSuppressed = suppressed;
|
|
416
|
+
this.#cachedImageProtocol = imageProtocol;
|
|
417
|
+
this.#cachedCellWidthPx = cellDimensions.widthPx;
|
|
418
|
+
this.#cachedCellHeightPx = cellDimensions.heightPx;
|
|
419
|
+
this.#cachedKittyUnicodePlaceholders = kittyUnicodePlaceholders;
|
|
370
420
|
|
|
371
421
|
return lines;
|
|
372
422
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
applyBackgroundToLine,
|
|
10
10
|
Ellipsis,
|
|
11
11
|
encodeTextSized,
|
|
12
|
+
getPaddingX,
|
|
12
13
|
getSegmenter,
|
|
13
14
|
padding,
|
|
14
15
|
replaceTabs,
|
|
@@ -475,6 +476,14 @@ export class Markdown implements Component {
|
|
|
475
476
|
#streamPrefixText?: string;
|
|
476
477
|
#streamPrefixTokens?: Token[];
|
|
477
478
|
|
|
479
|
+
#ignoreTight = false;
|
|
480
|
+
|
|
481
|
+
setIgnoreTight(ignore: boolean): this {
|
|
482
|
+
this.#ignoreTight = ignore;
|
|
483
|
+
this.invalidate();
|
|
484
|
+
return this;
|
|
485
|
+
}
|
|
486
|
+
|
|
478
487
|
constructor(
|
|
479
488
|
text: string,
|
|
480
489
|
paddingX: number,
|
|
@@ -599,7 +608,8 @@ export class Markdown implements Component {
|
|
|
599
608
|
}
|
|
600
609
|
|
|
601
610
|
// Calculate available width for content (subtract horizontal padding)
|
|
602
|
-
const
|
|
611
|
+
const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
|
|
612
|
+
const contentWidth = Math.max(1, width - paddingX * 2);
|
|
603
613
|
|
|
604
614
|
// Don't render anything if there's no actual text
|
|
605
615
|
if (!this.#text || this.#text.trim() === "") {
|
|
@@ -628,7 +638,7 @@ export class Markdown implements Component {
|
|
|
628
638
|
if (!this.transientRenderCache) {
|
|
629
639
|
const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
|
|
630
640
|
const headingProbe = this.#theme.heading("");
|
|
631
|
-
cacheKey = `${normalizedText}\x00${width}\x00${
|
|
641
|
+
cacheKey = `${normalizedText}\x00${width}\x00${paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${TERMINAL.textSizing ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
|
|
632
642
|
const cached = renderCache.get(cacheKey);
|
|
633
643
|
if (cached !== undefined) {
|
|
634
644
|
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
|
|
@@ -665,8 +675,8 @@ export class Markdown implements Component {
|
|
|
665
675
|
}
|
|
666
676
|
|
|
667
677
|
// Add margins and background to each wrapped line
|
|
668
|
-
const leftMargin = padding(
|
|
669
|
-
const rightMargin = padding(
|
|
678
|
+
const leftMargin = padding(paddingX);
|
|
679
|
+
const rightMargin = padding(paddingX);
|
|
670
680
|
const bgFn = this.#defaultTextStyle?.bgColor;
|
|
671
681
|
const contentLines: string[] = [];
|
|
672
682
|
|
package/src/components/text.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Component } from "../tui";
|
|
2
|
-
import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
2
|
+
import { applyBackgroundToLine, getPaddingX, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Text component - displays multi-line text with word wrapping
|
|
@@ -10,6 +10,14 @@ export class Text implements Component {
|
|
|
10
10
|
#paddingY: number; // Top/bottom padding
|
|
11
11
|
#customBgFn?: (text: string) => string;
|
|
12
12
|
|
|
13
|
+
#ignoreTight = false;
|
|
14
|
+
|
|
15
|
+
setIgnoreTight(ignore: boolean): this {
|
|
16
|
+
this.#ignoreTight = ignore;
|
|
17
|
+
this.invalidate();
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
// Cache for rendered output
|
|
14
22
|
#cachedText?: string;
|
|
15
23
|
#cachedWidth?: number;
|
|
@@ -69,14 +77,14 @@ export class Text implements Component {
|
|
|
69
77
|
const normalizedText = replaceTabs(this.#text);
|
|
70
78
|
|
|
71
79
|
// Calculate content width (subtract left/right margins)
|
|
72
|
-
const
|
|
73
|
-
|
|
80
|
+
const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
|
|
81
|
+
const contentWidth = Math.max(1, width - paddingX * 2);
|
|
74
82
|
// Wrap text (this preserves ANSI codes but does NOT pad)
|
|
75
83
|
const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
|
|
76
84
|
|
|
77
85
|
// Add margins and background to each line
|
|
78
|
-
const leftMargin = padding(
|
|
79
|
-
const rightMargin = padding(
|
|
86
|
+
const leftMargin = padding(paddingX);
|
|
87
|
+
const rightMargin = padding(paddingX);
|
|
80
88
|
const contentLines: string[] = [];
|
|
81
89
|
|
|
82
90
|
for (const line of wrappedLines) {
|
|
@@ -544,7 +544,7 @@ export function encodeKitty(
|
|
|
544
544
|
imageId?: number;
|
|
545
545
|
} = {},
|
|
546
546
|
): string {
|
|
547
|
-
const params: string[] = ["a=T", "f=100", "q=2"];
|
|
547
|
+
const params: string[] = ["a=T", "f=100", "q=2", "C=1"];
|
|
548
548
|
if (options.columns) params.push(`c=${options.columns}`);
|
|
549
549
|
if (options.rows) params.push(`r=${options.rows}`);
|
|
550
550
|
if (options.imageId) params.push(`i=${options.imageId}`);
|
|
@@ -563,10 +563,12 @@ export function encodeKittyTransmit(base64Data: string, imageId: number): string
|
|
|
563
563
|
}
|
|
564
564
|
|
|
565
565
|
/**
|
|
566
|
-
* Display a previously transmitted image (`a=p`) at the cursor.
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
566
|
+
* Display a previously transmitted image (`a=p`) at the cursor. `C=1` keeps
|
|
567
|
+
* the terminal cursor anchored at the placement origin so the renderer's
|
|
568
|
+
* explicit cursor movement remains the only row accounting. Carrying a stable
|
|
569
|
+
* `placementId` (`p=`) means re-emitting the sequence on a repaint *replaces*
|
|
570
|
+
* the existing placement (moving/resizing it without flicker) rather than
|
|
571
|
+
* stacking a duplicate.
|
|
570
572
|
*/
|
|
571
573
|
export function encodeKittyPlacement(options: {
|
|
572
574
|
imageId: number;
|
|
@@ -574,7 +576,7 @@ export function encodeKittyPlacement(options: {
|
|
|
574
576
|
columns?: number;
|
|
575
577
|
rows?: number;
|
|
576
578
|
}): string {
|
|
577
|
-
const params: string[] = ["a=p", "q=2", `i=${options.imageId}`];
|
|
579
|
+
const params: string[] = ["a=p", "q=2", "C=1", `i=${options.imageId}`];
|
|
578
580
|
if (options.placementId) params.push(`p=${options.placementId}`);
|
|
579
581
|
if (options.columns) params.push(`c=${options.columns}`);
|
|
580
582
|
if (options.rows) params.push(`r=${options.rows}`);
|
package/src/tui.ts
CHANGED
|
@@ -161,6 +161,10 @@ export interface Component {
|
|
|
161
161
|
* Called when theme changes or when component needs to re-render from scratch.
|
|
162
162
|
*/
|
|
163
163
|
invalidate?(): void;
|
|
164
|
+
/**
|
|
165
|
+
* Optional hook to set whether this component ignores tight layout mode.
|
|
166
|
+
*/
|
|
167
|
+
setIgnoreTight?(ignore: boolean): any;
|
|
164
168
|
|
|
165
169
|
/**
|
|
166
170
|
* Optional teardown. Called when the component is permanently removed from
|
|
@@ -505,8 +509,22 @@ export class Container implements Component {
|
|
|
505
509
|
#memoChildLines: (readonly string[])[] = [];
|
|
506
510
|
#memoWidth = -1;
|
|
507
511
|
|
|
512
|
+
#ignoreTight = false;
|
|
513
|
+
|
|
514
|
+
setIgnoreTight(ignore: boolean): this {
|
|
515
|
+
this.#ignoreTight = ignore;
|
|
516
|
+
for (const child of this.children) {
|
|
517
|
+
child.setIgnoreTight?.(ignore);
|
|
518
|
+
}
|
|
519
|
+
this.invalidate();
|
|
520
|
+
return this;
|
|
521
|
+
}
|
|
522
|
+
|
|
508
523
|
addChild(component: Component): void {
|
|
509
524
|
this.children.push(component);
|
|
525
|
+
if (this.#ignoreTight) {
|
|
526
|
+
component.setIgnoreTight?.(true);
|
|
527
|
+
}
|
|
510
528
|
this.#memoLines = undefined;
|
|
511
529
|
}
|
|
512
530
|
|
|
@@ -640,6 +658,139 @@ interface PreparedLine {
|
|
|
640
658
|
|
|
641
659
|
const SGR_SEQUENCE = /\x1b\[[0-9;:]*m/g;
|
|
642
660
|
|
|
661
|
+
// SGR coalescing. The renderer's component tree emits a styled span as
|
|
662
|
+
// `<set-color>text<reset>`, so adjacent spans produce runs of byte-adjacent
|
|
663
|
+
// SGR sequences (e.g. a `CSI 39 m` fg-reset immediately followed by the next
|
|
664
|
+
// span's `CSI 38;2;r;g;b m`). Two byte-adjacent SGR sequences are semantically
|
|
665
|
+
// identical to one SGR carrying both parameter lists (SGR params apply
|
|
666
|
+
// left-to-right), so merging the run into a single `CSI … m` is
|
|
667
|
+
// behavior-preserving: it drops the redundant `ESC[`/`m` framing and lets the
|
|
668
|
+
// terminal dispatch one SGR instead of several. On a real transcript ~40% of
|
|
669
|
+
// all SGR sequences are collapsible this way, which meaningfully cuts the
|
|
670
|
+
// per-frame byte volume and SGR-dispatch count a slow (xterm.js/WebGL) terminal
|
|
671
|
+
// must process. On by default; `PI_NO_SGR_COALESCE=1` disables it.
|
|
672
|
+
const SGR_COALESCE_ENABLED = !$flag("PI_NO_SGR_COALESCE");
|
|
673
|
+
const CC_ESC = 0x1b;
|
|
674
|
+
const CC_BRACKET = 0x5b; // [
|
|
675
|
+
const CC_M = 0x6d; // m
|
|
676
|
+
const CC_SEMI = 0x3b; // ;
|
|
677
|
+
const CC_COLON = 0x3a; // :
|
|
678
|
+
// Max parameter tokens per emitted merged SGR. Kept well under xterm.js's
|
|
679
|
+
// 32-param cap (and the tighter limits of some real terminals) so a long
|
|
680
|
+
// adjacent run is split into several valid CSIs instead of overflowing one.
|
|
681
|
+
const MERGE_TOKEN_CAP = 16;
|
|
682
|
+
|
|
683
|
+
function isSgrParamByte(c: number): boolean {
|
|
684
|
+
return (c >= 0x30 && c <= 0x39) || c === CC_SEMI || c === CC_COLON;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// True when a parameter list ends mid extended-color spec in the ambiguous
|
|
688
|
+
// semicolon form: `38/48/58;2` with fewer than three channel values, or
|
|
689
|
+
// `38/48/58;5` with no palette index. Concatenating another list after such a
|
|
690
|
+
// run would let the next code be absorbed as the missing channel/index (e.g.
|
|
691
|
+
// `38;2;255;0` + `31` → `38;2;255;0;31`, where `31` becomes blue instead of a
|
|
692
|
+
// standalone fg-red), changing the rendered color. The self-delimiting colon
|
|
693
|
+
// form (`38:2::r:g:b`) is unambiguous — its tokens never equal a bare `38`, so
|
|
694
|
+
// the scan treats it as a complete unit and merging stays safe.
|
|
695
|
+
function endsWithIncompleteExtendedColor(params: string): boolean {
|
|
696
|
+
const t = params.split(";");
|
|
697
|
+
let i = 0;
|
|
698
|
+
while (i < t.length) {
|
|
699
|
+
const tok = t[i];
|
|
700
|
+
if (tok === "38" || tok === "48" || tok === "58") {
|
|
701
|
+
const mode = t[i + 1];
|
|
702
|
+
if (mode === undefined) return true; // introducer with no mode
|
|
703
|
+
if (mode === "2") {
|
|
704
|
+
if (i + 4 >= t.length) return true; // missing r/g/b
|
|
705
|
+
i += 5;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (mode === "5") {
|
|
709
|
+
if (i + 2 >= t.length) return true; // missing index
|
|
710
|
+
i += 3;
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
i += 1;
|
|
715
|
+
}
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Merge runs of byte-adjacent SGR sequences (`CSI [0-9;:]* m`) into one. Only
|
|
721
|
+
* CSI-SGR sequences are touched; text, cursor moves, OSC, hyperlinks and image
|
|
722
|
+
* payloads pass through verbatim. Returns the original reference when nothing
|
|
723
|
+
* merges, so SGR-light lines incur only a single `indexOf` scan.
|
|
724
|
+
*/
|
|
725
|
+
export function coalesceAdjacentSgr(line: string): string {
|
|
726
|
+
if (!SGR_COALESCE_ENABLED || line.indexOf("\x1b[") === -1) return line;
|
|
727
|
+
const n = line.length;
|
|
728
|
+
let out = "";
|
|
729
|
+
let copiedUpto = 0;
|
|
730
|
+
let i = 0;
|
|
731
|
+
while (i < n) {
|
|
732
|
+
if (line.charCodeAt(i) !== CC_ESC || line.charCodeAt(i + 1) !== CC_BRACKET) {
|
|
733
|
+
i++;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
// Scan a candidate SGR sequence: ESC [ <params> m.
|
|
737
|
+
let j = i + 2;
|
|
738
|
+
while (j < n && isSgrParamByte(line.charCodeAt(j))) j++;
|
|
739
|
+
if (j >= n || line.charCodeAt(j) !== CC_M) {
|
|
740
|
+
// Not an SGR (e.g. cursor move); leave it in the pending region.
|
|
741
|
+
i = j;
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
// Collect the run of adjacent SGR sequences starting here.
|
|
745
|
+
const params: string[] = [line.slice(i + 2, j)];
|
|
746
|
+
let k = j + 1;
|
|
747
|
+
while (k < n && line.charCodeAt(k) === CC_ESC && line.charCodeAt(k + 1) === CC_BRACKET) {
|
|
748
|
+
let p = k + 2;
|
|
749
|
+
while (p < n && isSgrParamByte(line.charCodeAt(p))) p++;
|
|
750
|
+
if (p >= n || line.charCodeAt(p) !== CC_M) break;
|
|
751
|
+
params.push(line.slice(k + 2, p));
|
|
752
|
+
k = p + 1;
|
|
753
|
+
}
|
|
754
|
+
if (params.length > 1) {
|
|
755
|
+
out += line.slice(copiedUpto, i);
|
|
756
|
+
// Emit the merged run, but flush the current group before appending a
|
|
757
|
+
// list when (a) the previous list ended mid extended-color, so the
|
|
758
|
+
// next code cannot be absorbed as its missing channel/index, or (b)
|
|
759
|
+
// the token count would exceed MERGE_TOKEN_CAP. SGR params apply
|
|
760
|
+
// left-to-right regardless of how they are grouped across adjacent
|
|
761
|
+
// CSIs, so a capped/guarded split stays behavior-preserving — while a
|
|
762
|
+
// single unbounded merge would overflow a terminal's CSI parameter
|
|
763
|
+
// buffer (xterm.js caps at 32 and silently truncates the rest,
|
|
764
|
+
// corrupting colors). Empty params (`CSI m`) mean a full reset;
|
|
765
|
+
// normalize to `0` so the merged list stays unambiguous.
|
|
766
|
+
let group = "";
|
|
767
|
+
let groupTokens = 0;
|
|
768
|
+
let groupOpenSafe = true;
|
|
769
|
+
for (let q = 0; q < params.length; q++) {
|
|
770
|
+
const norm = params[q]!.length === 0 ? "0" : params[q]!;
|
|
771
|
+
let tk = 1;
|
|
772
|
+
for (let z = 0; z < norm.length; z++) {
|
|
773
|
+
const cc = norm.charCodeAt(z);
|
|
774
|
+
if (cc === CC_SEMI || cc === CC_COLON) tk++;
|
|
775
|
+
}
|
|
776
|
+
if (groupTokens > 0 && (!groupOpenSafe || groupTokens + tk > MERGE_TOKEN_CAP)) {
|
|
777
|
+
out += `\x1b[${group}m`;
|
|
778
|
+
group = "";
|
|
779
|
+
groupTokens = 0;
|
|
780
|
+
}
|
|
781
|
+
group += group.length === 0 ? norm : `;${norm}`;
|
|
782
|
+
groupTokens += tk;
|
|
783
|
+
groupOpenSafe = !endsWithIncompleteExtendedColor(norm);
|
|
784
|
+
}
|
|
785
|
+
if (group.length > 0) out += `\x1b[${group}m`;
|
|
786
|
+
copiedUpto = k;
|
|
787
|
+
}
|
|
788
|
+
i = k;
|
|
789
|
+
}
|
|
790
|
+
if (copiedUpto === 0) return line;
|
|
791
|
+
return out + line.slice(copiedUpto);
|
|
792
|
+
}
|
|
793
|
+
|
|
643
794
|
/** Compare two rows ignoring SGR styling (theme restyles keep alignment). */
|
|
644
795
|
function rowsEquivalent(a: string, b: string): boolean {
|
|
645
796
|
if (a === b) return true;
|
|
@@ -1218,6 +1369,7 @@ export class TUI extends Container {
|
|
|
1218
1369
|
* Returns a handle to control the overlay's visibility.
|
|
1219
1370
|
*/
|
|
1220
1371
|
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
|
|
1372
|
+
component.setIgnoreTight?.(true);
|
|
1221
1373
|
const entry = { component, options, preFocus: this.#focusedComponent, hidden: false };
|
|
1222
1374
|
this.overlayStack.push(entry);
|
|
1223
1375
|
// Only focus if overlay is actually visible
|
|
@@ -2249,7 +2401,8 @@ export class TUI extends Container {
|
|
|
2249
2401
|
|
|
2250
2402
|
#terminalLine(line: string): string {
|
|
2251
2403
|
if (TERMINAL.isImageLine(line)) return line;
|
|
2252
|
-
|
|
2404
|
+
const coalesced = coalesceAdjacentSgr(line);
|
|
2405
|
+
return coalesced + (line.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
2253
2406
|
}
|
|
2254
2407
|
|
|
2255
2408
|
/**
|
|
@@ -2521,14 +2674,11 @@ export class TUI extends Container {
|
|
|
2521
2674
|
this.#logRedraw(intent, frameLength, height);
|
|
2522
2675
|
|
|
2523
2676
|
// Load newly-displayed image data once, before this frame's placements
|
|
2524
|
-
//
|
|
2525
|
-
//
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
for (const seq of imageTransmits) transmitBuffer += seq;
|
|
2530
|
-
this.terminal.write(transmitBuffer);
|
|
2531
|
-
}
|
|
2677
|
+
// reference it. For full paints, the emitter may need to place the
|
|
2678
|
+
// transmit after a destructive clear (ED2/ED3) but before row replay, so
|
|
2679
|
+
// build the buffer here and let the emitter decide where it lands.
|
|
2680
|
+
let imageTransmitBuffer = "";
|
|
2681
|
+
for (const seq of this.#imageBudget.takeTransmits()) imageTransmitBuffer += seq;
|
|
2532
2682
|
// Purge graphics for images the budget demoted to text. Kitty keeps
|
|
2533
2683
|
// images in a store that text clears don't touch; demoted rows still
|
|
2534
2684
|
// visible re-render as text and the window diff repaints them.
|
|
@@ -2543,7 +2693,7 @@ export class TUI extends Container {
|
|
|
2543
2693
|
|
|
2544
2694
|
// 6. Emit.
|
|
2545
2695
|
if (intent.kind === "fullPaint") {
|
|
2546
|
-
this.#emitFullPaint(frame, window, width, height, cursorPos, purgeSequence, {
|
|
2696
|
+
this.#emitFullPaint(frame, window, width, height, cursorPos, purgeSequence, imageTransmitBuffer, {
|
|
2547
2697
|
clearScrollback: intent.clearScrollback,
|
|
2548
2698
|
chunkTo,
|
|
2549
2699
|
windowTop,
|
|
@@ -2555,6 +2705,9 @@ export class TUI extends Container {
|
|
|
2555
2705
|
if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
|
|
2556
2706
|
return;
|
|
2557
2707
|
}
|
|
2708
|
+
if (imageTransmitBuffer.length > 0) {
|
|
2709
|
+
this.terminal.write(imageTransmitBuffer);
|
|
2710
|
+
}
|
|
2558
2711
|
this.#emitUpdate(frame, window, width, height, cursorPos, purgeSequence, {
|
|
2559
2712
|
chunkTo,
|
|
2560
2713
|
windowTop,
|
|
@@ -2909,6 +3062,7 @@ export class TUI extends Container {
|
|
|
2909
3062
|
height: number,
|
|
2910
3063
|
cursorPos: { row: number; col: number } | null,
|
|
2911
3064
|
purgeSequence: string,
|
|
3065
|
+
imageTransmitBuffer: string,
|
|
2912
3066
|
options: { clearScrollback: boolean; chunkTo: number; windowTop: number },
|
|
2913
3067
|
): void {
|
|
2914
3068
|
this.#fullRedrawCount += 1;
|
|
@@ -2925,6 +3079,7 @@ export class TUI extends Container {
|
|
|
2925
3079
|
if (TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
|
|
2926
3080
|
buffer += "\x1b[2J\x1b[H";
|
|
2927
3081
|
}
|
|
3082
|
+
if (imageTransmitBuffer.length > 0) buffer += imageTransmitBuffer;
|
|
2928
3083
|
// DECCARA fills optimize only the rows that stay visible; history-bound
|
|
2929
3084
|
// rows are written as full styled strings (their background must
|
|
2930
3085
|
// survive in scrollback, which DECCARA cannot reach).
|
package/src/utils.ts
CHANGED
|
@@ -482,3 +482,17 @@ export function applyBackgroundToLine(line: string, width: number, bgFn: (text:
|
|
|
482
482
|
export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string {
|
|
483
483
|
return sliceWithWidth(line, startCol, length, strict).text;
|
|
484
484
|
}
|
|
485
|
+
|
|
486
|
+
let globalTight = false;
|
|
487
|
+
|
|
488
|
+
export function setTuiTight(tight: boolean): void {
|
|
489
|
+
globalTight = tight;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function isTuiTight(): boolean {
|
|
493
|
+
return globalTight;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function getPaddingX(basePadding: number): number {
|
|
497
|
+
return globalTight ? Math.max(0, basePadding - 1) : basePadding;
|
|
498
|
+
}
|