@gajae-code/tui 0.12.0 → 0.12.1

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/src/tui.ts CHANGED
@@ -10,9 +10,12 @@ import { isKeyRelease } from "./keys";
10
10
  import { renderMetrics } from "./metrics";
11
11
  import type { Terminal } from "./terminal";
12
12
  import {
13
+ encodeKittyPlacementDelete,
14
+ extractKittyPlacementReferences,
13
15
  ImageProtocol,
14
16
  isImageProtocolForced,
15
17
  isUnderTerminalMultiplexer,
18
+ type KittyPlacementReference,
16
19
  setCellDimensions,
17
20
  setTerminalImageProtocol,
18
21
  TERMINAL,
@@ -42,6 +45,9 @@ const MOUSE_SELECTION_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "
42
45
  /** Discrete mouse-wheel notch size in terminal rows (xterm/less-style). */
43
46
  export const DEFAULT_WHEEL_LINES = 3;
44
47
 
48
+ /** DA1 (`CSI ? … c`) and XTSMGRAPHICS (`CSI ? … S`) replies to the sixel probe. */
49
+ const DEVICE_REPORT_PATTERN = /^\x1b\[\?[\d;]*[cS]$/u;
50
+
45
51
  function stripTerminalControls(text: string): string {
46
52
  return Bun.stripANSI(text)
47
53
  .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/gu, "")
@@ -635,6 +641,23 @@ type RenderCommitWaiter = {
635
641
  timer: NodeJS.Timeout;
636
642
  };
637
643
 
644
+ type KittyPlacementOwner = "transcript" | "suffix" | "overlay";
645
+
646
+ type KittyPlacementSpan = KittyPlacementReference & {
647
+ row: number;
648
+ owner: KittyPlacementOwner;
649
+ };
650
+
651
+ type KittyPlacementRegion = {
652
+ top: number;
653
+ bottom: number;
654
+ };
655
+
656
+ type KittyPlacementDeletePlan = {
657
+ deletedKeys: Set<string>;
658
+ output: string;
659
+ };
660
+
638
661
  /**
639
662
  * TUI - Main class for managing terminal UI with differential rendering
640
663
  */
@@ -642,6 +665,9 @@ export class TUI extends Container {
642
665
  terminal: Terminal;
643
666
  #previousLines: string[] = [];
644
667
  #latestRenderedLines: string[] = [];
668
+ #latestRenderedTranscriptLineCount = 0;
669
+ #latestRenderedSuffixLineCount = 0;
670
+ #latestRenderedPlacementOwners = new Map<string, KittyPlacementOwner>();
645
671
  /**
646
672
  * Raw (pre-normalization) lines from the previous frame, kept only when the
647
673
  * virtual-viewport flag is on. Used to detect whether the off-screen prefix is
@@ -649,6 +675,7 @@ export class TUI extends Container {
649
675
  * return stable string instances) so its normalized form can be reused (bounded normalize).
650
676
  */
651
677
  #previousRaw: string[] = [];
678
+ #kittyPlacementSpans: KittyPlacementSpan[] = [];
652
679
  #lineNormalizationCache = new Map<string, LineNormalizationCacheEntry>();
653
680
  #lineEmitWidthCache = new Map<string, number>();
654
681
  #lineTruncationCache = new Map<string, string>();
@@ -1026,6 +1053,10 @@ export class TUI extends Container {
1026
1053
  if (!Number.isFinite(deltaRows)) return false;
1027
1054
  const delta = Math.trunc(deltaRows);
1028
1055
  if (delta === 0) return false;
1056
+ const previousManualViewportTop = this.#manualViewportTop;
1057
+ const previousManualViewportAnchor = this.#manualViewportAnchor;
1058
+ const previousManualViewportFallbackAnchors = this.#manualViewportFallbackAnchors;
1059
+ const previousReconcileMissingViewportAnchor = this.#reconcileMissingViewportAnchor;
1029
1060
 
1030
1061
  const direction: -1 | 1 = delta < 0 ? -1 : 1;
1031
1062
  const pin = options?.pin ?? "stable";
@@ -1116,7 +1147,8 @@ export class TUI extends Container {
1116
1147
  }
1117
1148
  }
1118
1149
  this.#manualViewportTop = targetViewportTop;
1119
- return this.#repaintViewportFromLines(
1150
+ let contentPainted = false;
1151
+ const painted = this.#repaintViewportFromLines(
1120
1152
  this.#previousLines,
1121
1153
  width,
1122
1154
  height,
@@ -1124,7 +1156,26 @@ export class TUI extends Container {
1124
1156
  null,
1125
1157
  "manual viewport scroll",
1126
1158
  this.#manualViewportAnchor !== null,
1159
+ () => {
1160
+ contentPainted = true;
1161
+ this.#manualTranscriptLineCount = this.#latestRenderedTranscriptLineCount;
1162
+ this.#manualSuffixLineCount = this.#latestRenderedSuffixLineCount;
1163
+ },
1164
+ false,
1165
+ this.#kittyPlacementSpans,
1166
+ this.#kittyPlacementSpansForLines(this.#previousLines, this.#latestRenderedPlacementOwners),
1167
+ {
1168
+ transcriptLineCount: this.#latestRenderedTranscriptLineCount,
1169
+ suffixLineCount: this.#latestRenderedSuffixLineCount,
1170
+ },
1127
1171
  );
1172
+ if (!contentPainted) {
1173
+ this.#manualViewportTop = previousManualViewportTop;
1174
+ this.#manualViewportAnchor = previousManualViewportAnchor;
1175
+ this.#manualViewportFallbackAnchors = previousManualViewportFallbackAnchors;
1176
+ this.#reconcileMissingViewportAnchor = previousReconcileMissingViewportAnchor;
1177
+ }
1178
+ return painted;
1128
1179
  }
1129
1180
 
1130
1181
  scrollViewportPages(direction: -1 | 1): boolean {
@@ -1141,9 +1192,12 @@ export class TUI extends Container {
1141
1192
  const paddedLiveLines = this.#padBeforeBottomPinnedComponent(
1142
1193
  this.#latestRenderedLines,
1143
1194
  height,
1144
- this.#manualSuffixLineCount,
1195
+ this.#latestRenderedSuffixLineCount,
1145
1196
  );
1146
1197
  const liveLines = paddedLiveLines.lines;
1198
+ const liveTranscriptLineCount = this.#latestRenderedTranscriptLineCount;
1199
+ const liveSuffixLineCount = this.#latestRenderedSuffixLineCount + paddedLiveLines.insertedBlankRows;
1200
+ const liveKittyPlacementSpans = this.#kittyPlacementSpansForLines(liveLines, this.#latestRenderedPlacementOwners);
1147
1201
  let liveCursorPosition = this.#lastCursorPosition;
1148
1202
  if (liveCursorPosition !== null && liveCursorPosition.row >= paddedLiveLines.insertionRow) {
1149
1203
  liveCursorPosition = {
@@ -1170,6 +1224,8 @@ export class TUI extends Container {
1170
1224
  this.#paintedManualOutputNotice = false;
1171
1225
  this.#lastCursorPosition = liveCursorPosition;
1172
1226
  this.#previousLines = liveLines;
1227
+ this.#manualTranscriptLineCount = liveTranscriptLineCount;
1228
+ this.#manualSuffixLineCount = liveSuffixLineCount;
1173
1229
  if (this.#scrollbackResumeViewportTop === undefined) {
1174
1230
  this.#nativeScrollbackViewportTop = liveViewportTop;
1175
1231
  }
@@ -1183,6 +1239,9 @@ export class TUI extends Container {
1183
1239
  }
1184
1240
  },
1185
1241
  true,
1242
+ this.#kittyPlacementSpans,
1243
+ liveKittyPlacementSpans,
1244
+ { transcriptLineCount: liveTranscriptLineCount, suffixLineCount: liveSuffixLineCount },
1186
1245
  );
1187
1246
  }
1188
1247
 
@@ -1561,6 +1620,8 @@ export class TUI extends Container {
1561
1620
 
1562
1621
  stop(): void {
1563
1622
  this.flushTerminalCleanup();
1623
+ const placementCleanup = this.#kittyPlacementDeletePlan(this.#kittyPlacementSpans, [], [], true).output;
1624
+ if (placementCleanup.length > 0 && this.#writeTerminal(placementCleanup)) this.#kittyPlacementSpans = [];
1564
1625
  this.#clearSixelProbeState();
1565
1626
  this.#stopped = true;
1566
1627
  this.#settleRenderCommitWaiters(false);
@@ -1612,6 +1673,7 @@ export class TUI extends Container {
1612
1673
  this.#previousLines = [];
1613
1674
  this.#latestRenderedLines = [];
1614
1675
  this.#previousRaw = [];
1676
+ this.#kittyPlacementSpans = [];
1615
1677
  this.#lineNormalizationCache.clear();
1616
1678
  this.#lineTruncationCache.clear();
1617
1679
  this.#lineEmitWidthCache.clear();
@@ -1715,8 +1777,6 @@ export class TUI extends Container {
1715
1777
  }
1716
1778
  if (renderMetrics.enabled) renderMetrics.recordRequest(source);
1717
1779
  if (force) {
1718
- const preserveViewportCursor =
1719
- useViewportRepaintPath(this.terminal) || shouldPreserveScrollbackOnFullClear(this.terminal);
1720
1780
  // A forced full redraw supersedes any queued input-priority render.
1721
1781
  this.#inputRenderPending = false;
1722
1782
  this.#previousLines = [];
@@ -1729,12 +1789,6 @@ export class TUI extends Container {
1729
1789
  this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
1730
1790
  this.#lineNormalizationCacheLimit = 0;
1731
1791
  this.#lineTruncationCacheLimit = 0;
1732
- if (!preserveViewportCursor) {
1733
- this.#cursorRow = 0;
1734
- this.#hardwareCursorRow = 0;
1735
- this.#viewportTopRow = 0;
1736
- this.#maxLinesRendered = 0;
1737
- }
1738
1792
  if (this.#renderTimer) {
1739
1793
  clearTimeout(this.#renderTimer);
1740
1794
  this.#renderTimer = undefined;
@@ -1898,6 +1952,15 @@ export class TUI extends Container {
1898
1952
  return;
1899
1953
  }
1900
1954
 
1955
+ // DA1 and XTSMGRAPHICS replies belong to the sixel probe, whose listener runs
1956
+ // above. Reaching this point means the probe already finished, timed out, or
1957
+ // was cleared by a stop()/start() cycle while the terminal still owed the
1958
+ // reply. These are terminal-to-host reports, never user input, so drop them
1959
+ // instead of typing them into the focused component.
1960
+ if (DEVICE_REPORT_PATTERN.test(data)) {
1961
+ return;
1962
+ }
1963
+
1901
1964
  // Global debug key handler (registry: tui.global.debug, default Shift+Ctrl+D)
1902
1965
  if (getKeybindings().matches(data, "tui.global.debug") && this.onDebug) {
1903
1966
  this.onDebug();
@@ -2217,7 +2280,12 @@ export class TUI extends Container {
2217
2280
  }
2218
2281
 
2219
2282
  /** Composite all overlays into content lines (in stack order, later = on top). */
2220
- #compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
2283
+ #compositeOverlays(
2284
+ lines: string[],
2285
+ termWidth: number,
2286
+ termHeight: number,
2287
+ placementOwners?: Map<string, KittyPlacementOwner>,
2288
+ ): string[] {
2221
2289
  if (this.overlayStack.length === 0) return lines;
2222
2290
  const result = [...lines];
2223
2291
  for (const entry of this.overlayStack) entry.mouseBounds = undefined;
@@ -2278,6 +2346,11 @@ export class TUI extends Container {
2278
2346
  // (components should already respect width, but this ensures it)
2279
2347
  const truncatedOverlayLine =
2280
2348
  visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
2349
+ if (placementOwners !== undefined) {
2350
+ for (const placement of extractKittyPlacementReferences(truncatedOverlayLine)) {
2351
+ placementOwners.set(this.#kittyPlacementKey(placement), "overlay");
2352
+ }
2353
+ }
2281
2354
  result[idx] = this.#compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
2282
2355
  modifiedLines.add(idx);
2283
2356
  }
@@ -2495,6 +2568,92 @@ export class TUI extends Container {
2495
2568
  return lines;
2496
2569
  }
2497
2570
 
2571
+ #kittyPlacementKey(reference: KittyPlacementReference): string {
2572
+ return `${reference.imageId}:${reference.placementId}`;
2573
+ }
2574
+
2575
+ #kittyPlacementSpansForLines(
2576
+ lines: string[],
2577
+ owners: ReadonlyMap<string, KittyPlacementOwner>,
2578
+ ): KittyPlacementSpan[] {
2579
+ const placements: KittyPlacementSpan[] = [];
2580
+ for (let row = 0; row < lines.length; row++) {
2581
+ for (const placement of extractKittyPlacementReferences(lines[row])) {
2582
+ placements.push({
2583
+ ...placement,
2584
+ row,
2585
+ owner: owners.get(this.#kittyPlacementKey(placement)) ?? "transcript",
2586
+ });
2587
+ }
2588
+ }
2589
+ return placements;
2590
+ }
2591
+
2592
+ #kittyPlacementIntersectsRegion(placement: KittyPlacementSpan, region: KittyPlacementRegion): boolean {
2593
+ return placement.row < region.bottom && placement.row + placement.rows > region.top;
2594
+ }
2595
+
2596
+ #kittyPlacementDeletePlan(
2597
+ previous: KittyPlacementSpan[],
2598
+ next: KittyPlacementSpan[],
2599
+ overwrittenRegions: KittyPlacementRegion[],
2600
+ deleteAll = false,
2601
+ overwrittenOwners: KittyPlacementOwner[] = [],
2602
+ ): KittyPlacementDeletePlan {
2603
+ const deletedKeys = new Set<string>();
2604
+ if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return { deletedKeys, output: "" };
2605
+ const nextByKey = new Map(next.map(placement => [this.#kittyPlacementKey(placement), placement]));
2606
+ let output = "";
2607
+ for (const placement of previous) {
2608
+ const key = this.#kittyPlacementKey(placement);
2609
+ if (deletedKeys.has(key)) continue;
2610
+ const candidate = nextByKey.get(key);
2611
+ const changed =
2612
+ candidate === undefined || candidate.row !== placement.row || candidate.rows !== placement.rows;
2613
+ const overwritten =
2614
+ deleteAll ||
2615
+ overwrittenOwners.includes(placement.owner) ||
2616
+ overwrittenRegions.some(region => this.#kittyPlacementIntersectsRegion(placement, region));
2617
+ if (!changed && !overwritten) continue;
2618
+ deletedKeys.add(key);
2619
+ output += encodeKittyPlacementDelete(placement);
2620
+ }
2621
+ return { deletedKeys, output };
2622
+ }
2623
+
2624
+ #kittyCommittedPlacementsAfterPaint(
2625
+ previous: KittyPlacementSpan[],
2626
+ next: KittyPlacementSpan[],
2627
+ deletePlan: KittyPlacementDeletePlan,
2628
+ emittedRegions: KittyPlacementRegion[],
2629
+ ): KittyPlacementSpan[] {
2630
+ const committed = new Map<string, KittyPlacementSpan>();
2631
+ for (const placement of previous) {
2632
+ const key = this.#kittyPlacementKey(placement);
2633
+ if (!deletePlan.deletedKeys.has(key)) committed.set(key, placement);
2634
+ }
2635
+ for (const placement of next) {
2636
+ if (!emittedRegions.some(region => placement.row >= region.top && placement.row < region.bottom)) continue;
2637
+ committed.set(this.#kittyPlacementKey(placement), placement);
2638
+ }
2639
+ return [...committed.values()];
2640
+ }
2641
+
2642
+ #kittyViewportTopIncludingPlacementAnchors(viewportTop: number, placements: KittyPlacementSpan[]): number {
2643
+ let resolvedTop = viewportTop;
2644
+ let changed: boolean;
2645
+ do {
2646
+ const priorTop = resolvedTop;
2647
+ for (const placement of placements) {
2648
+ if (placement.row < resolvedTop && placement.row + placement.rows > resolvedTop) {
2649
+ resolvedTop = placement.row;
2650
+ }
2651
+ }
2652
+ changed = resolvedTop !== priorTop;
2653
+ } while (changed);
2654
+ return resolvedTop;
2655
+ }
2656
+
2498
2657
  #pinnedChildLines(component: Component, renderedChildren: Map<Component, string[]>): string[] {
2499
2658
  const lines = renderedChildren.get(component);
2500
2659
  if (lines === undefined) throw new Error("Missing rendered direct child for pinned suffix");
@@ -2594,9 +2753,9 @@ export class TUI extends Container {
2594
2753
  padded.splice(insertionRow, 0, ...Array.from({ length: insertedBlankRows }, () => ""));
2595
2754
  return { lines: padded, insertionRow, insertedBlankRows };
2596
2755
  }
2597
- #manualTranscriptCapacity(height: number): number {
2598
- const noticeRows = this.#manualOutputNotice && height > this.#manualSuffixLineCount ? 1 : 0;
2599
- return Math.max(0, height - this.#manualSuffixLineCount - noticeRows);
2756
+ #manualTranscriptCapacity(height: number, suffixLineCount = this.#manualSuffixLineCount): number {
2757
+ const noticeRows = this.#manualOutputNotice && height > suffixLineCount ? 1 : 0;
2758
+ return Math.max(0, height - suffixLineCount - noticeRows);
2600
2759
  }
2601
2760
  #resolveManualAnchor(frame: ViewportAnchorFrame): number | null {
2602
2761
  const anchor = this.#manualViewportAnchor;
@@ -2657,8 +2816,13 @@ export class TUI extends Container {
2657
2816
  allowPastLiveBottom = false,
2658
2817
  onPainted?: () => void,
2659
2818
  paintLive = false,
2819
+ placementsToClear: KittyPlacementSpan[] = this.#kittyPlacementSpans,
2820
+ placementsToPaint: KittyPlacementSpan[] = placementsToClear,
2821
+ geometry?: { transcriptLineCount: number; suffixLineCount: number },
2660
2822
  ): boolean {
2661
2823
  const paintManual = this.#manualViewportTop !== undefined && !paintLive;
2824
+ const transcriptLineCount = geometry?.transcriptLineCount ?? this.#manualTranscriptLineCount;
2825
+ const suffixLineCount = geometry?.suffixLineCount ?? this.#manualSuffixLineCount;
2662
2826
  if (height <= 0 || width <= 0) return false;
2663
2827
  const maxViewportTop = Math.max(
2664
2828
  0,
@@ -2666,18 +2830,32 @@ export class TUI extends Container {
2666
2830
  ? lines.length - (allowPastLiveBottom ? 1 : height)
2667
2831
  : allowPastLiveBottom
2668
2832
  ? lines.length - 1
2669
- : this.#manualTranscriptLineCount - this.#manualTranscriptCapacity(height),
2833
+ : transcriptLineCount - this.#manualTranscriptCapacity(height, suffixLineCount),
2670
2834
  );
2671
- const nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
2835
+ let nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
2836
+ if (paintManual)
2837
+ nextViewportTop = this.#kittyViewportTopIncludingPlacementAnchors(nextViewportTop, placementsToPaint);
2672
2838
  const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
2673
- let buffer = "\x1b[?2026h";
2839
+ const transcriptCapacity = paintManual ? this.#manualTranscriptCapacity(height, suffixLineCount) : height;
2840
+ const noticeRows = paintManual && this.#manualOutputNotice && height > suffixLineCount ? 1 : 0;
2841
+ const deletePlan = this.#kittyPlacementDeletePlan(
2842
+ placementsToClear,
2843
+ placementsToPaint,
2844
+ [{ top: this.#viewportTopRow, bottom: this.#viewportTopRow + height }],
2845
+ false,
2846
+ paintManual ? ["suffix", "overlay"] : [],
2847
+ );
2848
+ const emittedRegions: KittyPlacementRegion[] = paintManual
2849
+ ? [
2850
+ { top: nextViewportTop, bottom: nextViewportTop + transcriptCapacity },
2851
+ { top: transcriptLineCount, bottom: transcriptLineCount + suffixLineCount },
2852
+ ]
2853
+ : [{ top: nextViewportTop, bottom: nextViewportTop + height }];
2854
+ let buffer = `\x1b[?2026h${deletePlan.output}`;
2674
2855
  if (currentScreenRow > 0) {
2675
2856
  buffer += `\x1b[${currentScreenRow}A`;
2676
2857
  }
2677
2858
  buffer += "\r";
2678
-
2679
- const transcriptCapacity = paintManual ? this.#manualTranscriptCapacity(height) : height;
2680
- const noticeRows = paintManual && this.#manualOutputNotice && height > this.#manualSuffixLineCount ? 1 : 0;
2681
2859
  const committedTranscriptRows: Array<number | null> = [];
2682
2860
  for (let screenRow = 0; screenRow < height; screenRow++) {
2683
2861
  if (screenRow > 0) buffer += "\r\n";
@@ -2688,12 +2866,12 @@ export class TUI extends Container {
2688
2866
  paintManual && screenRow === transcriptCapacity && noticeRows > 0
2689
2867
  ? "New output — type to follow"
2690
2868
  : paintManual && suffixRow >= 0
2691
- ? (lines[this.#manualTranscriptLineCount + suffixRow] ?? "")
2692
- : paintManual && lineIndex >= this.#manualTranscriptLineCount
2869
+ ? (lines[transcriptLineCount + suffixRow] ?? "")
2870
+ : paintManual && lineIndex >= transcriptLineCount
2693
2871
  ? ""
2694
2872
  : (lines[lineIndex] ?? "");
2695
2873
  committedTranscriptRows.push(
2696
- screenRow < transcriptCapacity && lineIndex < this.#manualTranscriptLineCount ? lineIndex : null,
2874
+ screenRow < transcriptCapacity && lineIndex < transcriptLineCount ? lineIndex : null,
2697
2875
  );
2698
2876
  const isImage = TERMINAL.isImageLine(line);
2699
2877
  if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
@@ -2715,23 +2893,30 @@ export class TUI extends Container {
2715
2893
  }
2716
2894
  buffer += cursorSeq;
2717
2895
  buffer += "\x1b[?2026l";
2718
- if (
2719
- !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length, () => {
2720
- this.#hardwareCursorRow = cursorToRow;
2721
- this.#committedTranscriptRows = committedTranscriptRows;
2722
- this.#cursorRow = Math.max(0, lines.length - 1);
2723
- this.#maxLinesRendered = lines.length;
2724
- this.#viewportTopRow = nextViewportTop;
2725
- onPainted?.();
2726
- })
2727
- )
2728
- return false;
2896
+ let contentWritten = false;
2897
+ const writeSucceeded = this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length, () => {
2898
+ contentWritten = true;
2899
+ this.#hardwareCursorRow = cursorToRow;
2900
+ this.#committedTranscriptRows = committedTranscriptRows;
2901
+ this.#cursorRow = Math.max(0, lines.length - 1);
2902
+ this.#maxLinesRendered = lines.length;
2903
+ this.#viewportTopRow = nextViewportTop;
2904
+ if (paintManual) this.#manualViewportTop = nextViewportTop;
2905
+ this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
2906
+ placementsToClear,
2907
+ placementsToPaint,
2908
+ deletePlan,
2909
+ emittedRegions,
2910
+ );
2911
+ onPainted?.();
2912
+ });
2913
+ if (!contentWritten) return false;
2729
2914
 
2730
2915
  if (this.#debugRedraw) {
2731
2916
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
2732
2917
  this.#appendDebugRedrawLog(msg);
2733
2918
  }
2734
- return true;
2919
+ return writeSucceeded;
2735
2920
  }
2736
2921
 
2737
2922
  #doRender(): void {
@@ -2752,23 +2937,27 @@ export class TUI extends Container {
2752
2937
  const renderedLines: string[] = [];
2753
2938
  const renderedChildren = new Map<Component, string[]>();
2754
2939
  let anchorFrame: ViewportAnchorFrame | null = null;
2940
+ let previousKittyPlacementSpans = this.#kittyPlacementSpans;
2941
+ const placementOwners = new Map<string, KittyPlacementOwner>();
2942
+ const pinnedChildIndex =
2943
+ this.#bottomPinnedComponent === null ? -1 : this.children.indexOf(this.#bottomPinnedComponent);
2944
+ const hasStickySuffix = pinnedChildIndex >= 0;
2755
2945
  const anchorRenderFailureCountBefore = viewportAnchorRenderFailureCount;
2756
- for (const child of this.children) {
2946
+ for (let childIndex = 0; childIndex < this.children.length; childIndex++) {
2947
+ const child = this.children[childIndex];
2757
2948
  const rendered = safeRenderComponentWithViewportAnchors(child, width, "tui-child");
2758
2949
  renderedChildren.set(child, rendered.lines);
2759
2950
  if (child === this.#viewportAnchorComponent && rendered.anchors.some(anchor => anchor !== null)) {
2760
2951
  anchorFrame = { startRow: renderedLines.length, anchors: rendered.anchors };
2761
2952
  }
2762
- for (const line of rendered.lines) renderedLines.push(line);
2763
- }
2764
- let pinnedChildIndex = -1;
2765
- for (let index = 0; index < this.children.length; index++) {
2766
- if (this.children[index] === this.#bottomPinnedComponent) {
2767
- pinnedChildIndex = index;
2768
- break;
2953
+ const owner: KittyPlacementOwner = hasStickySuffix && childIndex >= pinnedChildIndex ? "suffix" : "transcript";
2954
+ for (const line of rendered.lines) {
2955
+ for (const placement of extractKittyPlacementReferences(line)) {
2956
+ placementOwners.set(this.#kittyPlacementKey(placement), owner);
2957
+ }
2958
+ renderedLines.push(line);
2769
2959
  }
2770
2960
  }
2771
- const hasStickySuffix = pinnedChildIndex >= 0;
2772
2961
  const sourceTranscriptLineCount = hasStickySuffix
2773
2962
  ? this.children
2774
2963
  .slice(0, pinnedChildIndex)
@@ -2786,10 +2975,12 @@ export class TUI extends Container {
2786
2975
  newLines.length - sourceTranscriptLineCount,
2787
2976
  ).lines;
2788
2977
  }
2978
+ const nextTranscriptLineCount = sourceTranscriptLineCount;
2979
+ const nextSuffixLineCount = hasStickySuffix ? Math.max(0, newLines.length - nextTranscriptLineCount) : 0;
2789
2980
 
2790
2981
  // Composite overlays into the rendered lines (before differential compare)
2791
2982
  if (this.overlayStack.length > 0) {
2792
- newLines = this.#compositeOverlays(newLines, width, height);
2983
+ newLines = this.#compositeOverlays(newLines, width, height, placementOwners);
2793
2984
  }
2794
2985
 
2795
2986
  // Extract cursor position (marker must be found before diff comparison)
@@ -2857,9 +3048,11 @@ export class TUI extends Container {
2857
3048
  renderMetrics.recordLineCount("measured", total - diffStart);
2858
3049
  if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
2859
3050
  }
3051
+ const nextKittyPlacementSpans = this.#kittyPlacementSpansForLines(newLines, placementOwners);
2860
3052
  this.#latestRenderedLines = newLines;
2861
- this.#manualTranscriptLineCount = sourceTranscriptLineCount;
2862
- this.#manualSuffixLineCount = Math.max(0, newLines.length - sourceTranscriptLineCount);
3053
+ this.#latestRenderedTranscriptLineCount = nextTranscriptLineCount;
3054
+ this.#latestRenderedSuffixLineCount = nextSuffixLineCount;
3055
+ this.#latestRenderedPlacementOwners = placementOwners;
2863
3056
  const naturalViewportTop = Math.max(0, newLines.length - height);
2864
3057
  const priorLogicalLineCount = Math.max(this.#previousLines.length, this.#maxLinesRendered);
2865
3058
  if (this.#transcriptIdentityResetPending) {
@@ -2875,6 +3068,17 @@ export class TUI extends Container {
2875
3068
  }
2876
3069
 
2877
3070
  if (this.#manualViewportTop !== undefined) {
3071
+ const committedManualViewportTop = this.#manualViewportTop;
3072
+ const committedManualViewportAnchor = this.#manualViewportAnchor;
3073
+ const committedManualViewportFallbackAnchors = this.#manualViewportFallbackAnchors;
3074
+ const committedReconcileMissingViewportAnchor = this.#reconcileMissingViewportAnchor;
3075
+ const restoreManualIntent = (): void => {
3076
+ this.#manualViewportTop = committedManualViewportTop;
3077
+ this.#manualViewportAnchor = committedManualViewportAnchor;
3078
+ this.#manualViewportFallbackAnchors = committedManualViewportFallbackAnchors;
3079
+ this.#reconcileMissingViewportAnchor = committedReconcileMissingViewportAnchor;
3080
+ };
3081
+ let contentPainted = false;
2878
3082
  let resolvedAnchorTop = anchorFrame === null ? null : this.#resolveManualAnchor(anchorFrame);
2879
3083
  if (
2880
3084
  this.#manualViewportAnchor !== null &&
@@ -2892,6 +3096,7 @@ export class TUI extends Container {
2892
3096
  if (anchorRenderFailed) {
2893
3097
  // Keep semantic intent armed for recovery, but render the diagnostic frame
2894
3098
  // instead of masking a provider failure behind stale transcript content.
3099
+ contentPainted = false;
2895
3100
  this.#repaintViewportFromLines(
2896
3101
  newLines,
2897
3102
  width,
@@ -2900,16 +3105,27 @@ export class TUI extends Container {
2900
3105
  null,
2901
3106
  "failed semantic viewport render",
2902
3107
  true,
3108
+ () => {
3109
+ contentPainted = true;
3110
+ this.#previousLines = newLines;
3111
+ this.#previousWidth = width;
3112
+ this.#previousHeight = height;
3113
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3114
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3115
+ },
3116
+ false,
3117
+ previousKittyPlacementSpans,
3118
+ nextKittyPlacementSpans,
3119
+ { transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
2903
3120
  );
2904
- this.#previousLines = newLines;
2905
- this.#previousWidth = width;
2906
- this.#previousHeight = height;
3121
+ if (!contentPainted) restoreManualIntent();
2907
3122
  return;
2908
3123
  }
2909
3124
  // A formerly valid semantic target is temporarily absent (provider removal,
2910
3125
  // replacement, eviction, or object deletion). Keep the last resolved frame
2911
3126
  // instead of silently reinterpreting manual intent as a numeric viewport.
2912
3127
  const retainedLines = this.#previousLines.length > 0 ? this.#previousLines : newLines;
3128
+ contentPainted = false;
2913
3129
  this.#repaintViewportFromLines(
2914
3130
  retainedLines,
2915
3131
  width,
@@ -2918,9 +3134,15 @@ export class TUI extends Container {
2918
3134
  null,
2919
3135
  "unresolved semantic viewport render",
2920
3136
  true,
3137
+ () => {
3138
+ contentPainted = true;
3139
+ this.#previousWidth = width;
3140
+ this.#previousHeight = height;
3141
+ },
3142
+ false,
3143
+ previousKittyPlacementSpans,
2921
3144
  );
2922
- this.#previousWidth = width;
2923
- this.#previousHeight = height;
3145
+ if (!contentPainted) restoreManualIntent();
2924
3146
  return;
2925
3147
  }
2926
3148
  const nextViewportTop = resolvedAnchorTop ?? this.#manualViewportTop;
@@ -2936,26 +3158,34 @@ export class TUI extends Container {
2936
3158
  }
2937
3159
  this.#manualViewportTop = nextViewportTop;
2938
3160
  this.#reconcileMissingViewportAnchor = false;
2939
- if (
2940
- this.#repaintViewportFromLines(
2941
- newLines,
2942
- width,
2943
- height,
2944
- nextViewportTop,
2945
- null,
2946
- "manual viewport render",
2947
- this.#manualViewportAnchor !== null,
2948
- )
2949
- ) {
2950
- this.#previousLines = newLines;
2951
- this.#previousWidth = width;
2952
- this.#previousHeight = height;
2953
- this.#paintedManualOutputNotice = this.#manualOutputNotice;
2954
- }
3161
+ contentPainted = false;
3162
+ this.#repaintViewportFromLines(
3163
+ newLines,
3164
+ width,
3165
+ height,
3166
+ nextViewportTop,
3167
+ null,
3168
+ "manual viewport render",
3169
+ this.#manualViewportAnchor !== null,
3170
+ () => {
3171
+ contentPainted = true;
3172
+ this.#previousLines = newLines;
3173
+ this.#previousWidth = width;
3174
+ this.#previousHeight = height;
3175
+ this.#paintedManualOutputNotice = this.#manualOutputNotice;
3176
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3177
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3178
+ },
3179
+ false,
3180
+ previousKittyPlacementSpans,
3181
+ nextKittyPlacementSpans,
3182
+ { transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
3183
+ );
3184
+ if (!contentPainted) restoreManualIntent();
2955
3185
  return;
2956
3186
  }
2957
3187
  // Helper to clear scrollback and viewport and render all new lines
2958
- let viewportRepaint: (reason: string, targetViewportTop?: number) => void;
3188
+ let viewportRepaint: (reason: string, targetViewportTop?: number) => boolean;
2959
3189
  const fullRender = (clear: boolean, reason = "full render", forceScrollbackClear = false): void => {
2960
3190
  if (
2961
3191
  clear &&
@@ -2968,7 +3198,13 @@ export class TUI extends Container {
2968
3198
  }
2969
3199
  this.#fullRedrawCount += 1;
2970
3200
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
2971
- let buffer = "\x1b[?2026h"; // Begin synchronized output
3201
+ const deletePlan = this.#kittyPlacementDeletePlan(
3202
+ previousKittyPlacementSpans,
3203
+ nextKittyPlacementSpans,
3204
+ [],
3205
+ clear,
3206
+ );
3207
+ let buffer = `\x1b[?2026h${deletePlan.output}`; // Begin synchronized output
2972
3208
  // Skip clearing scrollback (3J) in hosts where clear/replay can snap the
2973
3209
  // native viewport away from the live prompt (tmux/screen, Windows ConPTY) —
2974
3210
  // unless the caller explicitly needs history erased (the settled width
@@ -3004,17 +3240,28 @@ export class TUI extends Container {
3004
3240
  this.#previousLines = newLines;
3005
3241
  this.#previousWidth = width;
3006
3242
  this.#previousHeight = height;
3243
+ this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
3244
+ previousKittyPlacementSpans,
3245
+ nextKittyPlacementSpans,
3246
+ deletePlan,
3247
+ [{ top: Number.NEGATIVE_INFINITY, bottom: Number.POSITIVE_INFINITY }],
3248
+ );
3249
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3250
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3007
3251
  })
3008
3252
  )
3009
3253
  return;
3010
3254
  };
3011
3255
 
3012
- viewportRepaint = (reason: string, targetViewportTop = Math.max(0, newLines.length - height)): void => {
3256
+ viewportRepaint = (reason: string, targetViewportTop = Math.max(0, newLines.length - height)): boolean => {
3013
3257
  this.#fullRedrawCount += 1;
3014
3258
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
3015
3259
  const nextViewportTop = targetViewportTop;
3016
3260
  const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
3017
- let buffer = "\x1b[?2026h";
3261
+ const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
3262
+ { top: prevViewportTop, bottom: prevViewportTop + height },
3263
+ ]);
3264
+ let buffer = `\x1b[?2026h${deletePlan.output}`;
3018
3265
  if (currentScreenRow > 0) {
3019
3266
  buffer += `\x1b[${currentScreenRow}A`;
3020
3267
  }
@@ -3045,23 +3292,32 @@ export class TUI extends Container {
3045
3292
  }
3046
3293
  buffer += cursorSeq;
3047
3294
  buffer += "\x1b[?2026l";
3048
- if (
3049
- !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
3050
- this.#hardwareCursorRow = cursorToRow;
3051
- this.#cursorRow = Math.max(0, newLines.length - 1);
3052
- this.#maxLinesRendered = newLines.length;
3053
- this.#viewportTopRow = nextViewportTop;
3054
- this.#previousLines = newLines;
3055
- this.#previousWidth = width;
3056
- this.#previousHeight = height;
3057
- })
3058
- )
3059
- return;
3295
+ let contentWritten = false;
3296
+ this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
3297
+ contentWritten = true;
3298
+ this.#hardwareCursorRow = cursorToRow;
3299
+ this.#cursorRow = Math.max(0, newLines.length - 1);
3300
+ this.#maxLinesRendered = newLines.length;
3301
+ this.#viewportTopRow = nextViewportTop;
3302
+ this.#previousLines = newLines;
3303
+ this.#previousWidth = width;
3304
+ this.#previousHeight = height;
3305
+ this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
3306
+ previousKittyPlacementSpans,
3307
+ nextKittyPlacementSpans,
3308
+ deletePlan,
3309
+ [{ top: nextViewportTop, bottom: nextViewportTop + height }],
3310
+ );
3311
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3312
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3313
+ });
3314
+ if (!contentWritten) return false;
3060
3315
 
3061
3316
  if (this.#debugRedraw) {
3062
3317
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
3063
3318
  this.#appendDebugRedrawLog(msg);
3064
3319
  }
3320
+ return true;
3065
3321
  };
3066
3322
 
3067
3323
  const debugRedraw = this.#debugRedraw;
@@ -3161,6 +3417,22 @@ export class TUI extends Container {
3161
3417
  lastChanged = newLines.length - 1;
3162
3418
  }
3163
3419
  let appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
3420
+ if (firstChanged >= 0) {
3421
+ const changedTop = firstChanged;
3422
+ let expanded: boolean;
3423
+ do {
3424
+ const priorTop = firstChanged;
3425
+ const priorBottom = lastChanged + 1;
3426
+ for (const placement of previousKittyPlacementSpans) {
3427
+ const placementBottom = placement.row + placement.rows;
3428
+ if (placement.row >= priorBottom || placementBottom <= priorTop) continue;
3429
+ firstChanged = Math.min(firstChanged, placement.row);
3430
+ lastChanged = Math.max(lastChanged, placementBottom - 1);
3431
+ }
3432
+ expanded = firstChanged !== priorTop || lastChanged + 1 !== priorBottom;
3433
+ } while (expanded);
3434
+ if (firstChanged !== changedTop) appendStart = false;
3435
+ }
3164
3436
 
3165
3437
  // No changes - but still need to update hardware cursor position if it moved
3166
3438
  if (firstChanged === -1) {
@@ -3174,6 +3446,21 @@ export class TUI extends Container {
3174
3446
  viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
3175
3447
  return;
3176
3448
  }
3449
+ if (
3450
+ appendedLines &&
3451
+ nextLiveViewportTop > prevViewportTop &&
3452
+ previousKittyPlacementSpans.some(placement =>
3453
+ this.#kittyPlacementIntersectsRegion(placement, {
3454
+ top: prevViewportTop,
3455
+ bottom: prevViewportTop + height,
3456
+ }),
3457
+ )
3458
+ ) {
3459
+ viewportRepaint(
3460
+ `content append moved a Kitty placement viewport (${prevViewportTop} -> ${nextLiveViewportTop})`,
3461
+ );
3462
+ return;
3463
+ }
3177
3464
  if (appendedLines && this.#scrollbackResumeViewportTop !== undefined && nextLiveViewportTop > prevViewportTop) {
3178
3465
  const resumeViewportTop = this.#scrollbackResumeViewportTop;
3179
3466
  if (nextLiveViewportTop <= resumeViewportTop) {
@@ -3186,10 +3473,15 @@ export class TUI extends Container {
3186
3473
  const previousLines = this.#previousLines;
3187
3474
  const previousWidth = this.#previousWidth;
3188
3475
  const previousHeight = this.#previousHeight;
3189
- viewportRepaint(
3190
- `staging committed scrollback frontier before resumed admission (${prevViewportTop} -> ${resumeViewportTop} -> ${nextLiveViewportTop})`,
3191
- resumeViewportTop,
3192
- );
3476
+ if (
3477
+ !viewportRepaint(
3478
+ `staging committed scrollback frontier before resumed admission (${prevViewportTop} -> ${resumeViewportTop} -> ${nextLiveViewportTop})`,
3479
+ resumeViewportTop,
3480
+ )
3481
+ ) {
3482
+ return;
3483
+ }
3484
+ previousKittyPlacementSpans = this.#kittyPlacementSpans;
3193
3485
  this.#previousLines = previousLines;
3194
3486
  this.#previousWidth = previousWidth;
3195
3487
  this.#previousHeight = previousHeight;
@@ -3203,7 +3495,10 @@ export class TUI extends Container {
3203
3495
  // All changes are in deleted lines (nothing to render, just clear)
3204
3496
  if (firstChanged >= newLines.length) {
3205
3497
  if (this.#previousLines.length > newLines.length) {
3206
- let buffer = "\x1b[?2026h";
3498
+ const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
3499
+ { top: firstChanged, bottom: lastChanged + 1 },
3500
+ ]);
3501
+ let buffer = `\x1b[?2026h${deletePlan.output}`;
3207
3502
  // Move to end of new content (clamp to 0 for empty content)
3208
3503
  const targetRow = Math.max(0, newLines.length - 1);
3209
3504
  const lineDiff = computeLineDiff(targetRow);
@@ -3245,6 +3540,14 @@ export class TUI extends Container {
3245
3540
  this.#previousHeight = height;
3246
3541
  this.#maxLinesRendered = newLines.length;
3247
3542
  this.#viewportTopRow = Math.max(0, newLines.length - height);
3543
+ this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
3544
+ previousKittyPlacementSpans,
3545
+ nextKittyPlacementSpans,
3546
+ deletePlan,
3547
+ [],
3548
+ );
3549
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3550
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3248
3551
  })
3249
3552
  )
3250
3553
  return;
@@ -3254,6 +3557,8 @@ export class TUI extends Container {
3254
3557
  this.#previousHeight = height;
3255
3558
  this.#maxLinesRendered = newLines.length;
3256
3559
  this.#viewportTopRow = Math.max(0, newLines.length - height);
3560
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3561
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3257
3562
  return;
3258
3563
  }
3259
3564
 
@@ -3280,7 +3585,10 @@ export class TUI extends Container {
3280
3585
 
3281
3586
  // Render from first changed line to end
3282
3587
  // Build buffer with all updates wrapped in synchronized output
3283
- let buffer = "\x1b[?2026h"; // Begin synchronized output
3588
+ const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
3589
+ { top: firstChanged, bottom: lastChanged + 1 },
3590
+ ]);
3591
+ let buffer = `\x1b[?2026h${deletePlan.output}`; // Begin synchronized output
3284
3592
  const prevViewportBottom = prevViewportTop + height - 1;
3285
3593
  const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
3286
3594
  if (moveTargetRow > prevViewportBottom) {
@@ -3408,6 +3716,14 @@ export class TUI extends Container {
3408
3716
  this.#previousLines = newLines;
3409
3717
  this.#previousWidth = width;
3410
3718
  this.#previousHeight = height;
3719
+ this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
3720
+ previousKittyPlacementSpans,
3721
+ nextKittyPlacementSpans,
3722
+ deletePlan,
3723
+ [{ top: firstChanged, bottom: renderEnd + 1 }],
3724
+ );
3725
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3726
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3411
3727
  })
3412
3728
  )
3413
3729
  return;