@oh-my-pi/pi-tui 16.0.4 → 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 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. Carrying a
195
- * stable `placementId` (`p=`) means re-emitting the sequence on a repaint
196
- * *replaces* the existing placement (moving/resizing it without flicker) rather
197
- * than stacking a duplicate.
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;
@@ -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,
@@ -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",
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.4",
41
- "@oh-my-pi/pi-utils": "16.0.4",
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
  },
@@ -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 contentWidth = Math.max(1, width - this.#paddingX * 2);
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(this.#paddingX);
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));
@@ -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 = 1;
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
- return this.#nextId++;
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 hasProtocol = TERMINAL.imageProtocol != null;
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 (this.#cachedLines && this.#cachedWidth === width && this.#cachedSuppressed === suppressed) {
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
- // moves the cursor back up, then emits the image sequence.
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 moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
358
- lines.push(moveUp + (result.sequence ?? ""));
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 contentWidth = Math.max(1, width - this.#paddingX * 2);
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${this.#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}`;
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(this.#paddingX);
669
- const rightMargin = padding(this.#paddingX);
678
+ const leftMargin = padding(paddingX);
679
+ const rightMargin = padding(paddingX);
670
680
  const bgFn = this.#defaultTextStyle?.bgColor;
671
681
  const contentLines: string[] = [];
672
682
 
@@ -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 contentWidth = Math.max(1, width - this.#paddingX * 2);
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(this.#paddingX);
79
- const rightMargin = padding(this.#paddingX);
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. Carrying a
567
- * stable `placementId` (`p=`) means re-emitting the sequence on a repaint
568
- * *replaces* the existing placement (moving/resizing it without flicker) rather
569
- * than stacking a duplicate.
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
- return line + (line.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
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
- // (and any emitter) reference it. `a=t` produces no display, so writing
2525
- // it ahead of the synchronized paint is artifact-free.
2526
- const imageTransmits = this.#imageBudget.takeTransmits();
2527
- if (imageTransmits.length > 0) {
2528
- let transmitBuffer = "";
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
+ }