@gajae-code/tui 0.18.8 → 0.18.9

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
@@ -21,12 +21,17 @@ import {
21
21
  TERMINAL,
22
22
  } from "./terminal-capabilities";
23
23
  import {
24
+ type CopyRowAnnotation,
24
25
  Ellipsis,
26
+ extractCopyRowAnnotation,
25
27
  extractSegments,
26
28
  isPrintableAscii,
27
29
  normalizeTerminalOutput,
30
+ retainCopyAnnotations,
28
31
  sliceByColumn,
29
32
  sliceWithWidth,
33
+ splitCopyAnnotations,
34
+ stripCopyApcs,
30
35
  truncateLinesToWidth,
31
36
  truncateToWidth,
32
37
  visibleWidth,
@@ -105,6 +110,8 @@ const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
105
110
  const MOUSE_SELECTION_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
106
111
  /** Discrete mouse-wheel notch size in terminal rows (xterm/less-style). */
107
112
  export const DEFAULT_WHEEL_LINES = 3;
113
+ /** Row step cadence while a held drag rests on the transcript edge. */
114
+ const MOUSE_AUTO_SCROLL_INTERVAL_MS = 50;
108
115
  /**
109
116
  * Repeat clicks on the same cell within this window escalate char -> word -> line
110
117
  * selection. SGR mouse reports carry no click counter, so the escalation is timed
@@ -1002,6 +1009,8 @@ export class TUI extends Container {
1002
1009
  #latestRenderedLines: string[] = [];
1003
1010
  #latestRenderedTranscriptLineCount = 0;
1004
1011
  #latestRenderedSuffixLineCount = 0;
1012
+ // Transient live rows between transcript content and the pinned suffix.
1013
+ #latestRenderedFrontierSpacerLineCount = 0;
1005
1014
  #latestRenderedPlacementOwners = new Map<string, KittyPlacementOwner>();
1006
1015
  #kittyPlacementSpans: KittyPlacementSpan[] = [];
1007
1016
  #latestRaw: string[] = [];
@@ -1019,6 +1028,7 @@ export class TUI extends Container {
1019
1028
  #restartDurableRawLines: string[] = [];
1020
1029
  #restartDurableWidth = 0;
1021
1030
  #transcriptIdentityReplaced = false;
1031
+ #transcriptRebuildPending = false;
1022
1032
  #lineNormalizationCache = new Map<string, LineNormalizationCacheEntry>();
1023
1033
  #lineEmitWidthCache = new Map<string, number>();
1024
1034
  #lineTruncationCache = new Map<string, string>();
@@ -1078,6 +1088,9 @@ export class TUI extends Container {
1078
1088
  #forcedRenderQueued = false;
1079
1089
  #restartViewportRepaintPending = false;
1080
1090
  #lastObservedWidth = 0;
1091
+ // Preserve the last committed grid before requestRender(true) invalidates diff dimensions.
1092
+ #forcedRenderPreviousWidth = 0;
1093
+ #forcedRenderPreviousHeight = 0;
1081
1094
  // Trailing debounce for the settled width repair. Instance-local: taken from
1082
1095
  // options.widthSettleMs when provided (deterministic harnesses pass 0 to
1083
1096
  // disable), otherwise from GJC_TUI_WIDTH_SETTLE_MS / PI_TUI_WIDTH_SETTLE_MS,
@@ -1106,6 +1119,7 @@ export class TUI extends Container {
1106
1119
  #nativeScrollbackAdmissionPending = false;
1107
1120
  #transcriptIdentityResetPending = false;
1108
1121
  #manualViewportTop: number | undefined;
1122
+ #manualResumeViewportTop: number | undefined; // Preserved through temporary stop/start while manual history is active.
1109
1123
  #viewportAnchorComponent: Component | null = null;
1110
1124
  #viewportAnchorFrame: ViewportAnchorFrame | null = null;
1111
1125
  #manualViewportAnchor: ManualViewportAnchor | null = null;
@@ -1151,6 +1165,14 @@ export class TUI extends Container {
1151
1165
  #mouseGestureDragged = false;
1152
1166
  #mousePressPoint: MouseSelectionPoint | null = null;
1153
1167
  #mousePressAt = 0;
1168
+ /** Last screen cell of a held drag; scrolling re-resolves it against the new viewport. */
1169
+ #mouseDragPointer: { x: number; y: number } | null = null;
1170
+ /** Edge scrolling waits until the drag leaves the press row, so a small drag on an edge row stays put. */
1171
+ #mousePressRow = 0;
1172
+ #mouseDragLeftPressRow = false;
1173
+ #mouseAutoScrollTimer?: NodeJS.Timeout;
1174
+ /** Held only while this TUI runs with mouse copying, so other render() consumers stay stock. */
1175
+ #releaseCopyAnnotations: (() => void) | undefined;
1154
1176
  #lastClickPoint: MouseSelectionPoint | null = null;
1155
1177
  #lastClickAt = 0;
1156
1178
  #clickCount = 0;
@@ -1159,6 +1181,7 @@ export class TUI extends Container {
1159
1181
  #manualOutputNotice = false;
1160
1182
  #manualTranscriptLineCount = 0;
1161
1183
  #manualSuffixLineCount = 0;
1184
+ #manualFrontierSpacerLineCount = 0;
1162
1185
  #committedTranscriptRows: Array<number | null> = [];
1163
1186
  #paintedManualOutputNotice = false;
1164
1187
  #rasterGeneration = 0;
@@ -1330,6 +1353,9 @@ export class TUI extends Container {
1330
1353
  override dispose(): void {
1331
1354
  if (this.#preparationDisposed) return;
1332
1355
  this.#preparationDisposed = true;
1356
+ this.#clearMouseSelection();
1357
+ this.#releaseCopyAnnotations?.();
1358
+ this.#releaseCopyAnnotations = undefined;
1333
1359
  this.#renderRequested = false;
1334
1360
  this.#renderRequestedGeneration = 0;
1335
1361
  this.#inputRenderPending = false;
@@ -1546,6 +1572,11 @@ export class TUI extends Container {
1546
1572
  getViewportAnchorComponent(): Component | null {
1547
1573
  return this.#viewportAnchorComponent;
1548
1574
  }
1575
+ #captureManualResumeViewportTop(): void {
1576
+ if (this.#latestRenderedFrontierSpacerLineCount === 0) return;
1577
+ const committedFrontier = this.#scrollbackResumeViewportTop ?? this.#nativeScrollbackViewportTop;
1578
+ this.#manualResumeViewportTop = committedFrontier > 0 ? committedFrontier : undefined;
1579
+ }
1549
1580
 
1550
1581
  /** Clear manual viewport ownership and durable history before replacing the transcript identity. */
1551
1582
  resetViewportAnchorIntent(): void {
@@ -1557,6 +1588,7 @@ export class TUI extends Container {
1557
1588
  this.#scrollbackResumeViewportTop = undefined;
1558
1589
  this.#nativeScrollbackViewportTop = 0;
1559
1590
  this.#nativeScrollbackAdmissionPending = false;
1591
+ this.#manualResumeViewportTop = undefined;
1560
1592
  this.#transcriptIdentityResetPending = true;
1561
1593
  this.#manualOutputNotice = false;
1562
1594
  this.#paintedManualOutputNotice = false;
@@ -1579,6 +1611,8 @@ export class TUI extends Container {
1579
1611
 
1580
1612
  /** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
1581
1613
  prepareViewportAnchorForTranscriptRebuild(): void {
1614
+ this.#transcriptRebuildPending = true;
1615
+ this.#manualResumeViewportTop = undefined;
1582
1616
  if (this.#manualViewportAnchor !== null) this.#reconcileMissingViewportAnchor = true;
1583
1617
  }
1584
1618
 
@@ -1633,6 +1667,7 @@ export class TUI extends Container {
1633
1667
  this.#suspendRasterLeasesForManualViewport(() => this.revealViewportAnchor(id, alignment))
1634
1668
  )
1635
1669
  return true;
1670
+ if (this.#manualViewportTop === undefined) this.#captureManualResumeViewportTop();
1636
1671
  this.#manualViewportAnchor = {
1637
1672
  id: selected.id,
1638
1673
  graphemeIndex:
@@ -1684,6 +1719,7 @@ export class TUI extends Container {
1684
1719
  const previousManualViewportAnchor = this.#manualViewportAnchor;
1685
1720
  const previousManualViewportFallbackAnchors = this.#manualViewportFallbackAnchors;
1686
1721
  const previousReconcileMissingViewportAnchor = this.#reconcileMissingViewportAnchor;
1722
+ const previousManualResumeViewportTop = this.#manualResumeViewportTop;
1687
1723
 
1688
1724
  const direction: -1 | 1 = delta < 0 ? -1 : 1;
1689
1725
  const pin = options?.pin ?? "stable";
@@ -1778,6 +1814,7 @@ export class TUI extends Container {
1778
1814
  this.#manualViewportFallbackAnchors = fallbacks;
1779
1815
  }
1780
1816
  }
1817
+ if (previousManualViewportTop === undefined) this.#captureManualResumeViewportTop();
1781
1818
  this.#manualViewportTop = targetViewportTop;
1782
1819
  let contentPainted = false;
1783
1820
  const painted = this.#repaintViewportFromLines(
@@ -1792,13 +1829,15 @@ export class TUI extends Container {
1792
1829
  contentPainted = true;
1793
1830
  this.#manualTranscriptLineCount = this.#latestRenderedTranscriptLineCount;
1794
1831
  this.#manualSuffixLineCount = this.#latestRenderedSuffixLineCount;
1832
+ this.#manualFrontierSpacerLineCount = this.#latestRenderedFrontierSpacerLineCount;
1795
1833
  },
1796
1834
  false,
1797
1835
  this.#kittyPlacementSpans,
1798
1836
  this.#kittyPlacementSpansForLines(this.#previousLines, this.#latestRenderedPlacementOwners),
1799
1837
  {
1800
- transcriptLineCount: this.#latestRenderedTranscriptLineCount,
1801
- suffixLineCount: this.#latestRenderedSuffixLineCount,
1838
+ transcriptLineCount: this.#manualTranscriptLineCount,
1839
+ suffixLineCount: this.#manualSuffixLineCount,
1840
+ frontierSpacerLineCount: this.#manualFrontierSpacerLineCount,
1802
1841
  },
1803
1842
  true,
1804
1843
  );
@@ -1807,6 +1846,7 @@ export class TUI extends Container {
1807
1846
  this.#manualViewportAnchor = previousManualViewportAnchor;
1808
1847
  this.#manualViewportFallbackAnchors = previousManualViewportFallbackAnchors;
1809
1848
  this.#reconcileMissingViewportAnchor = previousReconcileMissingViewportAnchor;
1849
+ this.#manualResumeViewportTop = previousManualResumeViewportTop;
1810
1850
  }
1811
1851
  return painted;
1812
1852
  }
@@ -1822,14 +1862,19 @@ export class TUI extends Container {
1822
1862
  if (this.#manualViewportTop === undefined) return false;
1823
1863
  const height = this.terminal.rows;
1824
1864
  const width = this.terminal.columns;
1865
+ // Manual rendering can drop transient padding from the latest frame; restore the
1866
+ // frontier captured when manual history began before rebuilding the live viewport.
1867
+ const committedFrontier = this.#manualResumeViewportTop;
1825
1868
  const paddedLiveLines = this.#padBeforeBottomPinnedComponent(
1826
1869
  this.#latestRenderedLines,
1827
- height,
1870
+ committedFrontier === undefined ? height : Math.max(height, committedFrontier + height),
1828
1871
  this.#latestRenderedSuffixLineCount,
1829
1872
  );
1830
1873
  const liveLines = paddedLiveLines.lines;
1831
1874
  const liveTranscriptLineCount = this.#latestRenderedTranscriptLineCount;
1832
- const liveSuffixLineCount = this.#latestRenderedSuffixLineCount + paddedLiveLines.insertedBlankRows;
1875
+ const liveSuffixLineCount = this.#latestRenderedSuffixLineCount;
1876
+ const liveFrontierSpacerLineCount =
1877
+ this.#latestRenderedFrontierSpacerLineCount + paddedLiveLines.insertedBlankRows;
1833
1878
  const liveKittyPlacementSpans = this.#kittyPlacementSpansForLines(liveLines, this.#latestRenderedPlacementOwners);
1834
1879
  let liveCursorPosition = this.#lastCursorPosition;
1835
1880
  if (liveCursorPosition !== null && liveCursorPosition.row >= paddedLiveLines.insertionRow) {
@@ -1855,11 +1900,14 @@ export class TUI extends Container {
1855
1900
  this.#manualOutputNotice = false;
1856
1901
  this.#committedTranscriptRows = [];
1857
1902
  this.#paintedManualOutputNotice = false;
1903
+ this.#manualResumeViewportTop = undefined;
1858
1904
  this.#lastCursorPosition = liveCursorPosition;
1859
1905
  this.#previousLines = liveLines;
1860
1906
  this.#manualTranscriptLineCount = liveTranscriptLineCount;
1861
1907
  this.#manualSuffixLineCount = liveSuffixLineCount;
1908
+ this.#manualFrontierSpacerLineCount = liveFrontierSpacerLineCount;
1862
1909
  this.#latestRenderedLines = liveLines.slice();
1910
+ this.#latestRenderedFrontierSpacerLineCount = liveFrontierSpacerLineCount;
1863
1911
  if (this.#scrollbackResumeViewportTop === undefined) {
1864
1912
  this.#nativeScrollbackViewportTop = liveViewportTop;
1865
1913
  }
@@ -1876,7 +1924,11 @@ export class TUI extends Container {
1876
1924
  true,
1877
1925
  this.#kittyPlacementSpans,
1878
1926
  liveKittyPlacementSpans,
1879
- { transcriptLineCount: liveTranscriptLineCount, suffixLineCount: liveSuffixLineCount },
1927
+ {
1928
+ transcriptLineCount: liveTranscriptLineCount,
1929
+ suffixLineCount: liveSuffixLineCount,
1930
+ frontierSpacerLineCount: liveFrontierSpacerLineCount,
1931
+ },
1880
1932
  );
1881
1933
  }
1882
1934
 
@@ -2100,9 +2152,7 @@ export class TUI extends Container {
2100
2152
  if (!shouldWrite) return { queueId: id, operation: op.type, status: "stale-token" };
2101
2153
  }
2102
2154
  if (op.type === "raster-multipart-batch" && op.prefix !== undefined && op.afterPrefix !== undefined) {
2103
- const prefixWritten = this.#guardTerminalOperation(() =>
2104
- this.terminal.write(new TextDecoder().decode(op.prefix)),
2105
- );
2155
+ const prefixWritten = this.#guardTerminalOperation(() => this.#emit(new TextDecoder().decode(op.prefix)));
2106
2156
  if (!prefixWritten) return failed();
2107
2157
  const abortBarrier = () => {
2108
2158
  // Abort/cursor-restoration bytes are terminal writes: never emit
@@ -2112,7 +2162,7 @@ export class TUI extends Container {
2112
2162
  const abortSuffix = op.abortSuffix === undefined ? "" : new TextDecoder().decode(op.abortSuffix);
2113
2163
  const cursorVisibility = op.restoreCursorVisibility ? this.#cursorVisibilitySequence() : "";
2114
2164
  if (abortSuffix || cursorVisibility)
2115
- this.#guardTerminalOperation(() => this.terminal.write(abortSuffix + cursorVisibility));
2165
+ this.#guardTerminalOperation(() => this.#emit(abortSuffix + cursorVisibility));
2116
2166
  };
2117
2167
  const flushed = await (this.terminal as Terminal & { flush?: () => Promise<boolean> }).flush?.();
2118
2168
  // Async boundary: the terminal may have stopped while we awaited.
@@ -2166,7 +2216,7 @@ export class TUI extends Container {
2166
2216
  const dependent = op.type === "generic-render" || op.type === "generic-full-redraw";
2167
2217
  const ok = dependent
2168
2218
  ? this.#writeProtectedRenderIngress(finalBytes)
2169
- : this.#guardTerminalOperation(() => this.terminal.write(finalBytes));
2219
+ : this.#guardTerminalOperation(() => this.#emit(finalBytes));
2170
2220
  if (!ok && dependent) {
2171
2221
  const rect = (op as { rect: CellRect }).rect;
2172
2222
  const blockedBy = [...this.#rasterCleanup.entries()]
@@ -2191,7 +2241,7 @@ export class TUI extends Container {
2191
2241
  lease.revoked = true;
2192
2242
  this.#rasterLeases.delete(request.token.ownerId);
2193
2243
  const erase = this.#cursorGuardedRasterSequence(new TextDecoder().decode(lease.erase));
2194
- const ok = this.#guardTerminalOperation(() => this.terminal.write(erase));
2244
+ const ok = this.#guardTerminalOperation(() => this.#emit(erase));
2195
2245
  if (!ok)
2196
2246
  this.#rasterCleanup.set(request.token.ownerId, {
2197
2247
  token: lease.token,
@@ -2357,7 +2407,7 @@ export class TUI extends Container {
2357
2407
  )
2358
2408
  )
2359
2409
  return false;
2360
- return this.#guardTerminalOperation(() => this.terminal.write(buffer));
2410
+ return this.#guardTerminalOperation(() => this.#emit(buffer));
2361
2411
  }
2362
2412
  #writeProtectedRenderIngress(buffer: string): boolean {
2363
2413
  const affected = [...this.#rasterLeases.values()];
@@ -2371,7 +2421,7 @@ export class TUI extends Container {
2371
2421
  ...affected.map(lease => new TextDecoder().decode(lease.erase)),
2372
2422
  ].join(""),
2373
2423
  );
2374
- const ok = this.#guardTerminalOperation(() => this.terminal.write(cleanup + buffer));
2424
+ const ok = this.#guardTerminalOperation(() => this.#emit(cleanup + buffer));
2375
2425
  if (!ok) {
2376
2426
  this.#terminalUnavailable = true;
2377
2427
  this.#previousLines = [];
@@ -2454,6 +2504,9 @@ export class TUI extends Container {
2454
2504
  this.#markTerminalUnavailable();
2455
2505
  throw error;
2456
2506
  }
2507
+ // Taken only once the terminal started, so a failed start never leaves annotations on.
2508
+ if (this.options.enableMouse === true && this.options.copySelection && !this.#releaseCopyAnnotations)
2509
+ this.#releaseCopyAnnotations = retainCopyAnnotations();
2457
2510
  if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
2458
2511
  void this.notifyTerminalLifecycle({
2459
2512
  kind: "availability-restored",
@@ -2591,8 +2644,13 @@ export class TUI extends Container {
2591
2644
  this.#clearSixelProbeState();
2592
2645
  }
2593
2646
 
2647
+ /** The only terminal write sink: copy APCs are in-process metadata and never reach the terminal. */
2648
+ #emit(data: string): void {
2649
+ this.terminal.write(stripCopyApcs(data));
2650
+ }
2651
+
2594
2652
  #writeTerminal(data: string, deferRenderFailure = false): boolean {
2595
- return this.#guardTerminalOperation(() => this.terminal.write(data), !deferRenderFailure);
2653
+ return this.#guardTerminalOperation(() => this.#emit(data), !deferRenderFailure);
2596
2654
  }
2597
2655
 
2598
2656
  #frameSynchronizedOutput(payload: string): string {
@@ -2631,7 +2689,7 @@ export class TUI extends Container {
2631
2689
  return false;
2632
2690
  }
2633
2691
  try {
2634
- this.terminal.write(data);
2692
+ this.#emit(data);
2635
2693
  } catch {
2636
2694
  this.#markTerminalUnavailable();
2637
2695
  return false;
@@ -2812,6 +2870,15 @@ export class TUI extends Container {
2812
2870
  }
2813
2871
 
2814
2872
  stop(): void {
2873
+ this.#stop(false);
2874
+ }
2875
+
2876
+ /** Temporarily releases terminal modes for foreground handoff and preserves viewport state for restart. */
2877
+ suspend(): void {
2878
+ this.#stop(true);
2879
+ }
2880
+
2881
+ #stop(temporary: boolean): void {
2815
2882
  this.#invalidatePreparations();
2816
2883
  // Invalidate every raster-queue body captured under the running epoch
2817
2884
  // before any teardown: nothing queued before stop may write after
@@ -2823,6 +2890,8 @@ export class TUI extends Container {
2823
2890
  this.#clearSixelProbeState();
2824
2891
  this.#clearMouseSelection();
2825
2892
  this.#resetClickCount();
2893
+ this.#releaseCopyAnnotations?.();
2894
+ this.#releaseCopyAnnotations = undefined;
2826
2895
  this.#stopped = true;
2827
2896
  this.#settleRenderCommitWaiters(false);
2828
2897
  if (this.#renderTimer) {
@@ -2837,17 +2906,26 @@ export class TUI extends Container {
2837
2906
  // An armed TIMER dies with the session, but a repair already deferred while
2838
2907
  // the user was reading scrollback must survive a temporary stop/start
2839
2908
  // (Ctrl-Z resume, external editor): manual viewport ownership survives
2840
- // restart, so followLiveViewport() still needs the pending repair. Without
2841
- // manual ownership the flags are moot — start() issues a forced full render.
2909
+ // restart, so followLiveViewport() still needs the pending repair and saved
2910
+ // frontier because teardown drops its transient spacers. Without manual
2911
+ // ownership the flags are moot — start() issues a forced full render.
2842
2912
  if (this.#manualViewportTop === undefined) {
2843
2913
  this.#widthSettleRepairPending = false;
2844
2914
  this.#tabWidthRepairPending = false;
2915
+ this.#manualResumeViewportTop = undefined;
2845
2916
  }
2846
- // Move the cursor after the frame actually displayed to prevent
2847
- // overwriting/artifacts on exit. The latest logical frame can differ while
2848
- // a semantic viewport retains the previously painted frame.
2849
2917
  const displayedFrameLines = this.#previousLines.length || this.#latestRenderedLines.length;
2850
- if (displayedFrameLines > 0) {
2918
+ if (displayedFrameLines > 0 && this.#manualViewportTop !== undefined) {
2919
+ // The manual viewport's logical row is not a physical screen coordinate.
2920
+ // Insert a blank bottom handoff row and discard the pinned row rather than
2921
+ // scrolling committed history into native scrollback. start() redraws the
2922
+ // pinned suffix on resume.
2923
+ const bottomRow = Math.max(1, this.terminal.rows);
2924
+ this.#writeTerminal(`\x1b[${bottomRow};1H\x1b[1L`);
2925
+ this.#hardwareCursorRow = bottomRow - 1;
2926
+ } else if (displayedFrameLines > 0) {
2927
+ // The latest logical frame can differ from the last painted frame when a
2928
+ // semantic viewport retains its previous image.
2851
2929
  const targetRow = displayedFrameLines; // Line after the last content
2852
2930
  const lineDiff = targetRow - this.#hardwareCursorRow;
2853
2931
  if (lineDiff > 0) {
@@ -2856,6 +2934,7 @@ export class TUI extends Container {
2856
2934
  this.#writeTerminal(`\x1b[${-lineDiff}A`);
2857
2935
  }
2858
2936
  this.#writeTerminal("\r\n");
2937
+ this.#hardwareCursorRow = targetRow + 1;
2859
2938
  }
2860
2939
 
2861
2940
  if (this.#useImeBlockCursor) {
@@ -2871,7 +2950,9 @@ export class TUI extends Container {
2871
2950
  // non-manual restart keeps only the durable baseline until its first render:
2872
2951
  // that render can admit a raw-prefix-proven append without replaying history.
2873
2952
  this.#restartViewportRepaintPending =
2874
- this.#manualViewportTop === undefined && (this.#previousLines.length > 0 || this.#maxLinesRendered > 0);
2953
+ temporary &&
2954
+ this.#manualViewportTop === undefined &&
2955
+ (this.#previousLines.length > 0 || this.#maxLinesRendered > 0);
2875
2956
  if (this.#restartViewportRepaintPending) {
2876
2957
  this.#restartDurableLineCount = this.#durableLineCount;
2877
2958
  this.#restartDurableRenderedLines = this.#durableRenderedLines.slice();
@@ -2900,6 +2981,11 @@ export class TUI extends Container {
2900
2981
  this.#lineEmitWidthCache.clear();
2901
2982
  this.#previousWidth = 0;
2902
2983
  this.#previousHeight = 0;
2984
+ this.#forcedRenderPreviousWidth = 0;
2985
+ this.#forcedRenderPreviousHeight = 0;
2986
+ this.#transcriptRebuildPending = false;
2987
+ this.#latestRenderedFrontierSpacerLineCount = 0;
2988
+ this.#manualFrontierSpacerLineCount = 0;
2903
2989
  this.#resizeRenderQueued = false;
2904
2990
  this.#resizeRenderMutationQueued = false;
2905
2991
  this.#renderMutationQueued = false;
@@ -3206,8 +3292,14 @@ export class TUI extends Container {
3206
3292
  if (force) {
3207
3293
  // A forced full redraw supersedes any queued input-priority render.
3208
3294
  this.#inputRenderPending = false;
3209
- if (!widthSettleRequest) this.#forcedRenderQueued = true;
3210
3295
  if (!widthSettleRequest) {
3296
+ if (!this.#forcedRenderQueued) {
3297
+ this.#forcedRenderPreviousWidth =
3298
+ this.#previousWidth < 0 ? this.#forcedRenderPreviousWidth : this.#previousWidth;
3299
+ this.#forcedRenderPreviousHeight =
3300
+ this.#previousHeight < 0 ? this.#forcedRenderPreviousHeight : this.#previousHeight;
3301
+ }
3302
+ this.#forcedRenderQueued = true;
3211
3303
  this.#previousWidth = -1; // -1 triggers widthChanged
3212
3304
  this.#previousHeight = -1; // -1 triggers heightChanged
3213
3305
  }
@@ -3311,15 +3403,29 @@ export class TUI extends Container {
3311
3403
  data = current;
3312
3404
  }
3313
3405
 
3314
- const mouse = parseSgrMouseEvent(data);
3406
+ let mouse = parseSgrMouseEvent(data);
3315
3407
  if (mouse) {
3408
+ // A held selection may leave the window; clamp it to the nearest cell so
3409
+ // the drag keeps extending instead of being discarded.
3410
+ if (this.#mouseButtonDown && (mouse.kind === "drag" || mouse.kind === "release" || mouse.kind === "wheel")) {
3411
+ mouse = {
3412
+ ...mouse,
3413
+ x: Math.min(mouse.x, this.terminal.columns),
3414
+ y: Math.min(mouse.y, this.terminal.rows),
3415
+ };
3416
+ }
3316
3417
  // Coordinates outside the current terminal cannot name a visible cell.
3317
3418
  if (mouse.x > this.terminal.columns || mouse.y > this.terminal.rows) {
3318
3419
  this.#clearMouseSelection();
3319
3420
  this.#resetClickCount();
3320
3421
  return;
3321
3422
  }
3322
- if (mouse.kind === "wheel") {
3423
+ if (mouse.kind === "wheel" && this.#mouseButtonDown && this.#mouseSelectionAnchor !== null) {
3424
+ // Wheel during a held drag scrolls under the pointer and keeps extending.
3425
+ this.scrollViewportBy(mouse.direction! * DEFAULT_WHEEL_LINES, { pin: "stable" });
3426
+ this.#extendMouseSelectionAt(mouse);
3427
+ this.#updateMouseAutoScroll();
3428
+ } else if (mouse.kind === "wheel") {
3323
3429
  this.#clearMouseSelection();
3324
3430
  this.#resetClickCount();
3325
3431
  this.scrollViewportBy(mouse.direction! * DEFAULT_WHEEL_LINES, { pin: "stable" });
@@ -3428,6 +3534,100 @@ export class TUI extends Container {
3428
3534
  : { line, column: mouse.x - 1 };
3429
3535
  }
3430
3536
 
3537
+ /** Screen rows (zero-based) that currently show transcript lines. */
3538
+ #transcriptScreenRows(): { first: number; last: number } | null {
3539
+ this.#ensureLiveSelectionRows();
3540
+ let first = -1;
3541
+ let last = -1;
3542
+ for (let row = 0; row < this.#committedTranscriptRows.length; row++) {
3543
+ const line = this.#committedTranscriptRows[row];
3544
+ if (line === null || line === undefined || line < 0 || line >= this.#manualTranscriptLineCount) continue;
3545
+ if (first < 0) first = row;
3546
+ last = row;
3547
+ }
3548
+ return first < 0 ? null : { first, last };
3549
+ }
3550
+
3551
+ /**
3552
+ * A held drag over editor/status chrome or past the transcript still names a
3553
+ * transcript cell: the nearest visible row, at the pointer column, extended to
3554
+ * the row edge so a downward drag reaches the end of the last line.
3555
+ */
3556
+ #mouseDragPoint(pointer: { x: number; y: number }): MouseSelectionPoint | null {
3557
+ const rows = this.#transcriptScreenRows();
3558
+ const direct = this.#mouseSelectionPoint({ kind: "drag", x: pointer.x, y: pointer.y });
3559
+ if (direct !== null || rows === null) return direct;
3560
+ const above = pointer.y - 1 < rows.first;
3561
+ const line = this.#committedTranscriptRows[above ? rows.first : rows.last]!;
3562
+ return { line, column: above ? 0 : Math.max(0, this.terminal.columns - 1) };
3563
+ }
3564
+
3565
+ /** Following live clears the screen-row map until the next paint; rebuild it from the live viewport. */
3566
+ #ensureLiveSelectionRows(): void {
3567
+ if (this.#manualViewportTop !== undefined || this.#committedTranscriptRows.length > 0) return;
3568
+ this.#refreshPaintedLiveViewportObservation(this.terminal.rows);
3569
+ }
3570
+
3571
+ #extendMouseSelectionAt(pointer: { x: number; y: number }): void {
3572
+ const anchor = this.#mouseSelectionAnchor;
3573
+ if (this.#mouseSelectionStart === null || anchor === null) return;
3574
+ this.#mouseDragPointer = { x: pointer.x, y: pointer.y };
3575
+ this.#mouseGestureDragged = true;
3576
+ if (pointer.y !== this.#mousePressRow) this.#mouseDragLeftPressRow = true;
3577
+ const point = this.#mouseDragPoint(pointer);
3578
+ if (point === null) return;
3579
+ const forward = point.line > anchor.line || (point.line === anchor.line && point.column >= anchor.column);
3580
+ // The anchor span stays whole while the far end grows, so dragging after a
3581
+ // double click extends word by word instead of collapsing back to one cell.
3582
+ this.#mouseSelectionStart = this.#snapSelectionPoint(anchor, forward ? "start" : "end");
3583
+ this.#mouseSelectionEnd = this.#snapSelectionPoint(point, forward ? "end" : "start");
3584
+ this.#mouseSelectionActive = true;
3585
+ }
3586
+
3587
+ /** Edge direction for a held drag: above the first or at/below the last transcript row. */
3588
+ #mouseAutoScrollDirection(pointer: { x: number; y: number }): -1 | 0 | 1 {
3589
+ const rows = this.#transcriptScreenRows();
3590
+ if (rows === null || !this.#mouseDragLeftPressRow) return 0;
3591
+ const row = pointer.y - 1;
3592
+ if (row <= rows.first) return -1;
3593
+ if (row >= rows.last || row >= this.terminal.rows - 1) return 1;
3594
+ return 0;
3595
+ }
3596
+
3597
+ #updateMouseAutoScroll(): void {
3598
+ const pointer = this.#mouseDragPointer;
3599
+ const direction = pointer && this.#mouseButtonDown ? this.#mouseAutoScrollDirection(pointer) : 0;
3600
+ if (direction === 0) {
3601
+ this.#stopMouseAutoScroll();
3602
+ return;
3603
+ }
3604
+ if (this.#mouseAutoScrollTimer !== undefined) return;
3605
+ this.#mouseAutoScrollTimer = setInterval(() => {
3606
+ const held = this.#mouseDragPointer;
3607
+ const step = held && this.#mouseButtonDown ? this.#mouseAutoScrollDirection(held) : 0;
3608
+ const before = this.#transcriptScreenRows();
3609
+ const firstLine = before === null ? null : this.#committedTranscriptRows[before.first];
3610
+ if (held === null || step === 0 || !this.scrollViewportBy(step, { pin: "stable" })) {
3611
+ this.#stopMouseAutoScroll();
3612
+ return;
3613
+ }
3614
+ // A boundary (live bottom, history top) reports success without moving.
3615
+ const after = this.#transcriptScreenRows();
3616
+ if (after === null || this.#committedTranscriptRows[after.first] === firstLine) {
3617
+ this.#stopMouseAutoScroll();
3618
+ return;
3619
+ }
3620
+ this.#extendMouseSelectionAt(held);
3621
+ this.requestRender(false, "mouse");
3622
+ }, MOUSE_AUTO_SCROLL_INTERVAL_MS);
3623
+ }
3624
+
3625
+ #stopMouseAutoScroll(): void {
3626
+ if (this.#mouseAutoScrollTimer === undefined) return;
3627
+ clearInterval(this.#mouseAutoScrollTimer);
3628
+ this.#mouseAutoScrollTimer = undefined;
3629
+ }
3630
+
3431
3631
  /**
3432
3632
  * Resolve the selection granularity for this press. SGR mouse reports carry no
3433
3633
  * click counter, so repeats are inferred from the previous press: same cell and
@@ -3523,6 +3723,8 @@ export class TUI extends Container {
3523
3723
  const mode = this.#nextClickMode(point, pressAt);
3524
3724
  this.#mouseButtonDown = true;
3525
3725
  this.#mouseGestureDragged = false;
3726
+ this.#mousePressRow = mouse.y;
3727
+ this.#mouseDragLeftPressRow = false;
3526
3728
  this.#mousePressPoint = point;
3527
3729
  this.#mousePressAt = pressAt;
3528
3730
  this.#mouseSelectionMode = mode;
@@ -3539,17 +3741,8 @@ export class TUI extends Container {
3539
3741
  this.#resetClickCount();
3540
3742
  return;
3541
3743
  }
3542
- const anchor = this.#mouseSelectionAnchor;
3543
- if (this.#mouseSelectionStart === null || anchor === null) return;
3544
- const point = this.#mouseSelectionPoint(mouse);
3545
- this.#mouseGestureDragged = true;
3546
- if (point === null) return;
3547
- const forward = point.line > anchor.line || (point.line === anchor.line && point.column >= anchor.column);
3548
- // The anchor span stays whole while the far end grows, so dragging after a
3549
- // double click extends word by word instead of collapsing back to one cell.
3550
- this.#mouseSelectionStart = this.#snapSelectionPoint(anchor, forward ? "start" : "end");
3551
- this.#mouseSelectionEnd = this.#snapSelectionPoint(point, forward ? "end" : "start");
3552
- this.#mouseSelectionActive = true;
3744
+ this.#extendMouseSelectionAt(mouse);
3745
+ this.#updateMouseAutoScroll();
3553
3746
  }
3554
3747
 
3555
3748
  #finishMouseSelection(mouse: MouseEvent): void {
@@ -3558,7 +3751,10 @@ export class TUI extends Container {
3558
3751
  return;
3559
3752
  }
3560
3753
  this.#mouseButtonDown = false;
3561
- const point = this.#mouseSelectionPoint(mouse);
3754
+ this.#stopMouseAutoScroll();
3755
+ this.#mouseDragPointer = null;
3756
+ const dragged = this.#mouseGestureDragged;
3757
+ const point = dragged ? this.#mouseDragPoint(mouse) : this.#mouseSelectionPoint(mouse);
3562
3758
  const pressPoint = this.#mousePressPoint;
3563
3759
  const pressAt = this.#mousePressAt;
3564
3760
  const completedClick =
@@ -3587,7 +3783,9 @@ export class TUI extends Container {
3587
3783
  this.#clearMouseSelection();
3588
3784
  return;
3589
3785
  }
3590
- const text = this.#extractMouseSelection();
3786
+ // Source-aware rows return original Markdown, which may carry ESC/C0 bytes from
3787
+ // model or tool text; sanitize at the clipboard boundary (\t and \n survive).
3788
+ const text = stripTerminalControls(this.#extractMouseSelection());
3591
3789
  if (!text) {
3592
3790
  this.#clearMouseSelection();
3593
3791
  return;
@@ -3601,6 +3799,8 @@ export class TUI extends Container {
3601
3799
  }
3602
3800
 
3603
3801
  #clearMouseSelection(): void {
3802
+ this.#stopMouseAutoScroll();
3803
+ this.#mouseDragPointer = null;
3604
3804
  this.#mouseSelectionStart = null;
3605
3805
  this.#mouseSelectionEnd = null;
3606
3806
  this.#mouseSelectionAnchor = null;
@@ -3638,23 +3838,182 @@ export class TUI extends Container {
3638
3838
  #extractMouseSelection(): string {
3639
3839
  const selection = this.#orderedMouseSelection();
3640
3840
  if (selection === null) return "";
3641
- const selected: string[] = [];
3841
+ type SelectedRow = {
3842
+ line: number;
3843
+ fragment: string;
3844
+ sourceId?: string;
3845
+ annotation?: CopyRowAnnotation;
3846
+ covered?: boolean;
3847
+ atContentStart?: boolean;
3848
+ /** Selected text per table cell; empty where the selection misses the cell. */
3849
+ cells?: string[];
3850
+ /** Folded into the row above as the soft-wrapped tail of its cells. */
3851
+ merged?: boolean;
3852
+ };
3853
+ const selected: SelectedRow[] = [];
3642
3854
  const selectionLines = this.#selectionSourceLines();
3643
3855
  for (let lineIndex = selection.start.line; lineIndex <= selection.end.line; lineIndex++) {
3644
3856
  const line = selectionLines[lineIndex];
3645
3857
  if (line === undefined || TERMINAL.isImageLine(line)) {
3646
- selected.push("");
3858
+ selected.push({ line: lineIndex, fragment: "" });
3647
3859
  continue;
3648
3860
  }
3649
- const plain = stripTerminalControls(line);
3861
+ // Read renderer metadata before stripping APC/ANSI controls. The metadata is
3862
+ // attached to this physical row, so identical text in another component can
3863
+ // never be mistaken for this selection.
3864
+ const copy = extractCopyRowAnnotation(line);
3865
+ const rendered = copy?.line ?? line;
3866
+ const plain = stripTerminalControls(rendered);
3650
3867
  const columns = this.#mouseSelectionColumns(lineIndex, plain);
3651
3868
  if (columns === null || columns.end <= columns.start) {
3652
- selected.push("");
3869
+ selected.push({ line: lineIndex, fragment: "" });
3653
3870
  continue;
3654
3871
  }
3655
- selected.push(sliceByColumn(plain, columns.start, columns.end - columns.start, false));
3872
+ const candidate = copy?.annotation;
3873
+ const annotation =
3874
+ candidate && copy.originColumn + candidate.contentEnd <= visibleWidth(plain) ? candidate : undefined;
3875
+ const localColumns = copy
3876
+ ? { start: columns.start - copy.originColumn, end: columns.end - copy.originColumn }
3877
+ : columns;
3878
+ const presentationStart = annotation ? annotation.contentStart + annotation.prefixColumns : localColumns.start;
3879
+ const start = annotation ? Math.max(localColumns.start, presentationStart) : localColumns.start;
3880
+ const end = annotation ? Math.min(localColumns.end, annotation.contentEnd) : localColumns.end;
3881
+ let fragment = end > start ? sliceByColumn(plain, (copy?.originColumn ?? 0) + start, end - start, false) : "";
3882
+ // Cell padding is presentation; Markdown cell text never keeps outer spaces.
3883
+ const cells = annotation?.ranges?.map(([rangeStart, rangeEnd]) => {
3884
+ const overlapStart = Math.max(start, rangeStart);
3885
+ const overlapEnd = Math.min(end, rangeEnd);
3886
+ return overlapEnd > overlapStart
3887
+ ? sliceByColumn(plain, (copy?.originColumn ?? 0) + overlapStart, overlapEnd - overlapStart, false).trim()
3888
+ : "";
3889
+ });
3890
+ if (cells) fragment = cells.filter(cell => cell.length > 0).join("\t");
3891
+ if (annotation?.fence) fragment = "";
3892
+ const covered = annotation
3893
+ ? localColumns.start <= annotation.contentStart && localColumns.end >= annotation.contentEnd
3894
+ : false;
3895
+ // A fully covered rendered code row, nested or not, restores its row-local
3896
+ // original slice (not the whole logical line), which preserves an original tab.
3897
+ if (covered && annotation?.originalCode) fragment = annotation.source;
3898
+ selected.push({
3899
+ line: lineIndex,
3900
+ fragment,
3901
+ sourceId: copy?.sourceId,
3902
+ annotation,
3903
+ covered,
3904
+ atContentStart: annotation ? localColumns.start <= presentationStart : true,
3905
+ ...(cells ? { cells } : {}),
3906
+ });
3656
3907
  }
3657
- return selected.join("\n");
3908
+ // Nothing but decoration (quote margins, table borders, fences) was selected.
3909
+ if (selected.every(row => row.fragment.length === 0)) return "";
3910
+
3911
+ const groups = new Map<string, { rows: SelectedRow[]; complete: boolean }>();
3912
+ for (const row of selected) {
3913
+ if (!row.annotation || !row.sourceId) continue;
3914
+ const key = `${row.sourceId}:${row.annotation.token}`;
3915
+ const group = groups.get(key) ?? { rows: [], complete: false };
3916
+ group.rows.push(row);
3917
+ groups.set(key, group);
3918
+ }
3919
+ // A token is complete when every selected row is covered, the selection holds
3920
+ // every rendered row of the token in order (so a preview that clipped the token's
3921
+ // head or tail cannot restore hidden rows), the last of them carries the
3922
+ // whole-token source, and the rows just outside the group belong to something else.
3923
+ const rowKey = (lineIndex: number): string | undefined => {
3924
+ const line = selectionLines[lineIndex];
3925
+ if (line === undefined || TERMINAL.isImageLine(line)) return undefined;
3926
+ const copy = extractCopyRowAnnotation(line);
3927
+ return copy ? `${copy.sourceId}:${copy.annotation.token}` : undefined;
3928
+ };
3929
+ for (const [key, group] of groups) {
3930
+ const first = group.rows[0]!;
3931
+ const last = group.rows[group.rows.length - 1]!;
3932
+ group.complete =
3933
+ group.rows.every(row => row.covered) &&
3934
+ group.rows.every((row, index) => row.annotation?.tokenRow === index) &&
3935
+ last.annotation?.tokenRows === group.rows.length &&
3936
+ last.annotation.tokenSource !== undefined &&
3937
+ last.line - first.line + 1 === group.rows.length &&
3938
+ rowKey(first.line - 1) !== key &&
3939
+ rowKey(last.line + 1) !== key;
3940
+ }
3941
+
3942
+ const allSemanticRowsAreComplete = selected.every(row => {
3943
+ if (!row.annotation || !row.sourceId) return row.fragment.length === 0;
3944
+ return groups.get(`${row.sourceId}:${row.annotation.token}`)?.complete === true;
3945
+ });
3946
+ if (allSemanticRowsAreComplete) {
3947
+ const completed = [...groups.values()];
3948
+ const codeOnly = completed.length === 1 && completed[0]?.rows[0]?.annotation?.kind === "code";
3949
+ if (codeOnly) {
3950
+ const rows = completed[0]!.rows;
3951
+ const raw = rows[rows.length - 1]?.annotation?.tokenSource ?? "";
3952
+ // Token raw keeps the block's trailing newline; drop it so the closing fence is the last line.
3953
+ const lines = raw.replace(/\n+$/u, "").split("\n");
3954
+ if (/^\s*(```|~~~)/u.test(lines[0] ?? "")) {
3955
+ lines.shift();
3956
+ if (/^\s*(```|~~~)/u.test(lines[lines.length - 1] ?? "")) lines.pop();
3957
+ }
3958
+ return lines.join("\n");
3959
+ }
3960
+ let result = "";
3961
+ for (const row of selected) {
3962
+ // Each complete token contributes once, from its final row.
3963
+ if (row.annotation?.tokenSource !== undefined) result += row.annotation.tokenSource;
3964
+ }
3965
+ return result;
3966
+ }
3967
+ // A table row that soft-wraps its cells is one source row: fold each wrapped
3968
+ // row's cells into the row it continues, rejoined with the removed source gap.
3969
+ let head: SelectedRow | undefined;
3970
+ let headEnd = -1;
3971
+ for (const row of selected) {
3972
+ const gaps = row.annotation?.cellGaps;
3973
+ const headCells = head?.cells;
3974
+ if (
3975
+ head &&
3976
+ headCells &&
3977
+ row.cells &&
3978
+ gaps &&
3979
+ head.sourceId === row.sourceId &&
3980
+ head.annotation?.token === row.annotation?.token &&
3981
+ headEnd + 1 === row.line
3982
+ ) {
3983
+ for (const [cell, text] of row.cells.entries()) {
3984
+ if (!text) continue;
3985
+ headCells[cell] = headCells[cell] ? `${headCells[cell]}${gaps[cell] ?? ""}${text}` : text;
3986
+ }
3987
+ head.fragment = headCells.filter(cell => cell.length > 0).join("\t");
3988
+ row.merged = true;
3989
+ headEnd = row.line;
3990
+ continue;
3991
+ }
3992
+ head = row.cells ? row : undefined;
3993
+ headEnd = row.line;
3994
+ }
3995
+ let result = "";
3996
+ let previous: SelectedRow | undefined;
3997
+ for (const row of selected) {
3998
+ const annotation = row.annotation;
3999
+ if (row.merged || annotation?.fence || annotation?.ranges?.length === 0) continue;
4000
+ if (previous) {
4001
+ if (
4002
+ annotation?.continuation &&
4003
+ previous.annotation?.token === annotation.token &&
4004
+ previous.sourceId === row.sourceId
4005
+ ) {
4006
+ result += annotation.joinGap;
4007
+ } else {
4008
+ result += "\n";
4009
+ }
4010
+ }
4011
+ if (annotation?.quoteDepth && !annotation.continuation && row.atContentStart)
4012
+ result += "> ".repeat(annotation.quoteDepth);
4013
+ result += row.fragment;
4014
+ previous = row;
4015
+ }
4016
+ return result;
3658
4017
  }
3659
4018
 
3660
4019
  #applyMouseSelection(lines: string[]): string[] {
@@ -3665,17 +4024,28 @@ export class TUI extends Container {
3665
4024
  for (let lineIndex = selection.start.line; lineIndex <= selection.end.line; lineIndex++) {
3666
4025
  const line = highlighted[lineIndex];
3667
4026
  if (line === undefined || TERMINAL.isImageLine(line)) continue;
3668
- const plain = stripTerminalControls(line);
4027
+ // Detach copy APCs verbatim (no payload decoding on this per-frame path) and
4028
+ // put them back around the painted row so a later copy still reads them.
4029
+ const {
4030
+ line: rendered,
4031
+ origin: originMarker,
4032
+ originColumn,
4033
+ rows: annotationSuffix,
4034
+ } = splitCopyAnnotations(line);
4035
+ const plain = stripTerminalControls(rendered);
3669
4036
  const width = visibleWidth(plain);
3670
4037
  const columns = this.#mouseSelectionColumns(lineIndex, plain);
3671
4038
  if (columns === null || columns.end <= columns.start) continue;
3672
- const before = sliceByColumn(line, 0, columns.start, false);
3673
- const selected = sliceByColumn(line, columns.start, columns.end - columns.start, false).replace(
4039
+ const before = columns.start > 0 ? sliceByColumn(rendered, 0, columns.start, false) : "";
4040
+ const selected = sliceByColumn(rendered, columns.start, columns.end - columns.start, false).replace(
3674
4041
  /\x1b\[[0-9;]*m/gu,
3675
4042
  control => `${control}\x1b[7m`,
3676
4043
  );
3677
- const after = sliceByColumn(line, columns.end, Math.max(0, width - columns.end), false);
3678
- highlighted[lineIndex] = `${before}\x1b[7m${selected}\x1b[27m${after}`;
4044
+ const after = columns.end < width ? sliceByColumn(rendered, columns.end, width - columns.end, false) : "";
4045
+ const painted = `${before}\x1b[7m${selected}\x1b[27m${after}`;
4046
+ const head = originColumn > 0 ? sliceByColumn(painted, 0, originColumn, false) : "";
4047
+ const tail = originColumn < width ? sliceByColumn(painted, originColumn, width - originColumn, false) : "";
4048
+ highlighted[lineIndex] = `${head}${originMarker}${tail}${annotationSuffix}`;
3679
4049
  }
3680
4050
  return highlighted;
3681
4051
  }
@@ -4334,14 +4704,14 @@ export class TUI extends Container {
4334
4704
 
4335
4705
  #padBeforeBottomPinnedComponent(
4336
4706
  lines: string[],
4337
- height: number,
4707
+ minimumLineCount: number,
4338
4708
  pinnedLineCount: number,
4339
4709
  ): { lines: string[]; insertionRow: number; insertedBlankRows: number } {
4340
- if (pinnedLineCount <= 0 || lines.length >= height) {
4710
+ if (pinnedLineCount <= 0 || lines.length >= minimumLineCount) {
4341
4711
  return { lines, insertionRow: lines.length, insertedBlankRows: 0 };
4342
4712
  }
4343
4713
 
4344
- const insertedBlankRows = height - lines.length;
4714
+ const insertedBlankRows = minimumLineCount - lines.length;
4345
4715
  const insertionRow = Math.max(0, lines.length - pinnedLineCount);
4346
4716
  const padded = [...lines];
4347
4717
  padded.splice(insertionRow, 0, ...Array.from({ length: insertedBlankRows }, () => ""));
@@ -4412,12 +4782,16 @@ export class TUI extends Container {
4412
4782
  paintLive = false,
4413
4783
  placementsToClear: KittyPlacementSpan[] = this.#kittyPlacementSpans,
4414
4784
  placementsToPaint: KittyPlacementSpan[] = placementsToClear,
4415
- geometry?: { transcriptLineCount: number; suffixLineCount: number },
4785
+ geometry?: { transcriptLineCount: number; suffixLineCount: number; frontierSpacerLineCount?: number },
4416
4786
  avoidScrollback = true,
4417
4787
  ): boolean {
4418
4788
  const paintManual = this.#manualViewportTop !== undefined && !paintLive;
4419
4789
  const transcriptLineCount = geometry?.transcriptLineCount ?? this.#manualTranscriptLineCount;
4420
4790
  const suffixLineCount = geometry?.suffixLineCount ?? this.#manualSuffixLineCount;
4791
+ const frontierSpacerLineCount =
4792
+ geometry?.frontierSpacerLineCount ??
4793
+ (paintManual ? this.#manualFrontierSpacerLineCount : this.#latestRenderedFrontierSpacerLineCount);
4794
+ const suffixStart = transcriptLineCount + frontierSpacerLineCount;
4421
4795
  if (height <= 0 || width <= 0) return false;
4422
4796
  const maxViewportTop = Math.max(
4423
4797
  0,
@@ -4442,7 +4816,7 @@ export class TUI extends Container {
4442
4816
  const emittedRegions: KittyPlacementRegion[] = paintManual
4443
4817
  ? [
4444
4818
  { top: nextViewportTop, bottom: nextViewportTop + transcriptCapacity },
4445
- { top: transcriptLineCount, bottom: transcriptLineCount + suffixLineCount },
4819
+ { top: suffixStart, bottom: suffixStart + suffixLineCount },
4446
4820
  ]
4447
4821
  : [{ top: nextViewportTop, bottom: nextViewportTop + height }];
4448
4822
  const lineForScreenRow = (screenRow: number): string => {
@@ -4451,7 +4825,7 @@ export class TUI extends Container {
4451
4825
  return paintManual && screenRow === transcriptCapacity && noticeRows > 0
4452
4826
  ? "New output — type to follow"
4453
4827
  : paintManual && suffixRow >= 0
4454
- ? (lines[transcriptLineCount + suffixRow] ?? "")
4828
+ ? (lines[suffixStart + suffixRow] ?? "")
4455
4829
  : paintManual && lineIndex >= transcriptLineCount
4456
4830
  ? ""
4457
4831
  : (lines[lineIndex] ?? "");
@@ -4542,6 +4916,9 @@ export class TUI extends Container {
4542
4916
  }
4543
4917
  #recordPaintedViewportObservation(viewportTop: number, height: number, paintManual: boolean): void {
4544
4918
  const transcriptCapacity = this.#manualTranscriptCapacity(height);
4919
+ const transcriptLineCount = this.#manualTranscriptLineCount;
4920
+ const suffixStart = transcriptLineCount + this.#manualFrontierSpacerLineCount;
4921
+ const noticeRows = this.#manualOutputNotice && height > this.#manualSuffixLineCount ? 1 : 0;
4545
4922
  const anchorFrame = this.#viewportAnchorFrame;
4546
4923
  const semanticAnchor =
4547
4924
  anchorFrame === null
@@ -4556,9 +4933,10 @@ export class TUI extends Container {
4556
4933
  const cursor = this.#lastCursorPosition;
4557
4934
  let cursorRow: number | null = null;
4558
4935
  if (cursor !== null) {
4559
- if (paintManual && cursor.row >= this.#manualTranscriptLineCount) {
4560
- const noticeRows = this.#manualOutputNotice && height > this.#manualSuffixLineCount ? 1 : 0;
4561
- cursorRow = transcriptCapacity + noticeRows + (cursor.row - this.#manualTranscriptLineCount);
4936
+ if (paintManual && cursor.row >= suffixStart) {
4937
+ cursorRow = transcriptCapacity + noticeRows + (cursor.row - suffixStart);
4938
+ } else if (paintManual && cursor.row >= transcriptLineCount) {
4939
+ cursorRow = null;
4562
4940
  } else if (paintManual) {
4563
4941
  cursorRow = this.#committedTranscriptRows.indexOf(cursor.row);
4564
4942
  } else {
@@ -4695,6 +5073,10 @@ export class TUI extends Container {
4695
5073
  const widthSettleRenderQueued = this.#widthSettleRenderQueued;
4696
5074
  const tabWidthRepairPending = this.#tabWidthRepairPending;
4697
5075
  const forcedRenderQueued = this.#forcedRenderQueued;
5076
+ const frontierPreviousWidth = this.#previousWidth < 0 ? this.#forcedRenderPreviousWidth : this.#previousWidth;
5077
+ const frontierPreviousHeight = this.#previousHeight < 0 ? this.#forcedRenderPreviousHeight : this.#previousHeight;
5078
+ const transcriptRebuildPending = this.#transcriptRebuildPending;
5079
+ this.#transcriptRebuildPending = false;
4698
5080
  this.#resizeRenderQueued = false;
4699
5081
  this.#resizeRenderMutationQueued = false;
4700
5082
  this.#renderMutationQueued = false;
@@ -4730,6 +5112,8 @@ export class TUI extends Container {
4730
5112
  this.#manualViewportTop === undefined &&
4731
5113
  this.#latestRaw.length > 0 &&
4732
5114
  this.#latestRenderedLines.length === this.#latestRaw.length &&
5115
+ // A cached spacer count is not valid after natural rows replace the transient padding.
5116
+ this.#latestRenderedFrontierSpacerLineCount === 0 &&
4733
5117
  this.#previousLines === this.#latestRenderedLines;
4734
5118
  let previousKittyPlacementSpans = this.#kittyPlacementSpans;
4735
5119
  const placementOwners = new Map<string, KittyPlacementOwner>();
@@ -4843,6 +5227,7 @@ export class TUI extends Container {
4843
5227
  let diffStart = 0;
4844
5228
  let usedWindowNormalize = false;
4845
5229
  let stitched = false;
5230
+ let frontierSpacerLineCount = 0;
4846
5231
  if (reusedAnchor !== null && layoutPrefixEligible && !layoutPrefixBlocked) {
4847
5232
  const reused = this.#reuseCachedLayoutPrefix(
4848
5233
  width,
@@ -4859,6 +5244,7 @@ export class TUI extends Container {
4859
5244
  cursorPos = reused.cursorPos;
4860
5245
  diffStart = reused.diffStart;
4861
5246
  usedWindowNormalize = true;
5247
+ frontierSpacerLineCount = this.#latestRenderedFrontierSpacerLineCount;
4862
5248
  }
4863
5249
  }
4864
5250
  if (!stitched) {
@@ -4880,11 +5266,21 @@ export class TUI extends Container {
4880
5266
  }
4881
5267
  newLines = this.#constrainPinnedSuffix(renderedLines, height, renderedChildren);
4882
5268
  if (hasStickySuffix && height > 0 && this.#manualViewportTop === undefined) {
4883
- newLines = this.#padBeforeBottomPinnedComponent(
5269
+ // Rebuilds start at their new transcript frontier. Forced redraws use the
5270
+ // captured rendered dimensions rather than their -1 invalidation sentinels.
5271
+ const preserveLiveFrontier =
5272
+ !transcriptIdentityReplaced &&
5273
+ !transcriptRebuildPending &&
5274
+ frontierPreviousWidth === width &&
5275
+ (frontierPreviousHeight === height || this.#viewportRepaintHost());
5276
+ const minimumLineCount = preserveLiveFrontier ? prevViewportTop + height : height;
5277
+ const padded = this.#padBeforeBottomPinnedComponent(
4884
5278
  newLines,
4885
- height,
5279
+ minimumLineCount,
4886
5280
  newLines.length - sourceTranscriptLineCount,
4887
- ).lines;
5281
+ );
5282
+ newLines = padded.lines;
5283
+ frontierSpacerLineCount = padded.insertedBlankRows;
4888
5284
  }
4889
5285
  // Composite overlays into the rendered lines (before differential compare)
4890
5286
  if (this.overlayStack.length > 0) {
@@ -4941,7 +5337,15 @@ export class TUI extends Container {
4941
5337
  }
4942
5338
  }
4943
5339
  const nextTranscriptLineCount = sourceTranscriptLineCount;
4944
- const nextSuffixLineCount = hasStickySuffix ? Math.max(0, newLines.length - nextTranscriptLineCount) : 0;
5340
+ // Live-only frontier rows must not consume the manual viewport's pinned-suffix capacity.
5341
+ const nextSuffixLineCount = hasStickySuffix
5342
+ ? Math.max(0, newLines.length - nextTranscriptLineCount - frontierSpacerLineCount)
5343
+ : 0;
5344
+ const nextFrameGeometry = {
5345
+ transcriptLineCount: nextTranscriptLineCount,
5346
+ suffixLineCount: nextSuffixLineCount,
5347
+ frontierSpacerLineCount,
5348
+ };
4945
5349
  this.#lastCursorPosition = cursorPos;
4946
5350
  const total = rawLines.length;
4947
5351
  if (renderMetrics.enabled) {
@@ -4958,6 +5362,7 @@ export class TUI extends Container {
4958
5362
  this.#latestRenderedLines = newLines;
4959
5363
  this.#latestRenderedTranscriptLineCount = nextTranscriptLineCount;
4960
5364
  this.#latestRenderedSuffixLineCount = nextSuffixLineCount;
5365
+ this.#latestRenderedFrontierSpacerLineCount = frontierSpacerLineCount;
4961
5366
  this.#latestRenderedPlacementOwners = placementOwners;
4962
5367
  const naturalViewportTop = Math.max(0, newLines.length - height);
4963
5368
  const priorLogicalLineCount = Math.max(this.#previousLines.length, this.#maxLinesRendered);
@@ -5018,11 +5423,12 @@ export class TUI extends Container {
5018
5423
  this.#previousHeight = height;
5019
5424
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5020
5425
  this.#manualSuffixLineCount = nextSuffixLineCount;
5426
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5021
5427
  },
5022
5428
  false,
5023
5429
  previousKittyPlacementSpans,
5024
5430
  nextKittyPlacementSpans,
5025
- { transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
5431
+ nextFrameGeometry,
5026
5432
  true,
5027
5433
  );
5028
5434
  if (contentPainted) {
@@ -5074,6 +5480,9 @@ export class TUI extends Container {
5074
5480
  newLines.length === this.#previousLines.length &&
5075
5481
  newLines.every((line, index) => line === this.#previousLines[index])
5076
5482
  ) {
5483
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
5484
+ this.#manualSuffixLineCount = nextSuffixLineCount;
5485
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5077
5486
  return;
5078
5487
  }
5079
5488
  this.#manualViewportTop = nextViewportTop;
@@ -5095,11 +5504,12 @@ export class TUI extends Container {
5095
5504
  this.#paintedManualOutputNotice = this.#manualOutputNotice;
5096
5505
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5097
5506
  this.#manualSuffixLineCount = nextSuffixLineCount;
5507
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5098
5508
  },
5099
5509
  false,
5100
5510
  previousKittyPlacementSpans,
5101
5511
  nextKittyPlacementSpans,
5102
- { transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
5512
+ nextFrameGeometry,
5103
5513
  );
5104
5514
  if (!contentPainted) restoreManualIntent();
5105
5515
  return;
@@ -5171,6 +5581,7 @@ export class TUI extends Container {
5171
5581
  );
5172
5582
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5173
5583
  this.#manualSuffixLineCount = nextSuffixLineCount;
5584
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5174
5585
  this.#refreshPaintedLiveViewportObservation(height);
5175
5586
  this.#durableLineCount = newLines.length;
5176
5587
  this.#durableRenderedLines = newLines.slice();
@@ -5209,6 +5620,7 @@ export class TUI extends Container {
5209
5620
  this.#previousHeight = height;
5210
5621
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5211
5622
  this.#manualSuffixLineCount = nextSuffixLineCount;
5623
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5212
5624
  this.#refreshPaintedLiveViewportObservation(height);
5213
5625
  this.#latestRenderedLines = newLines.slice();
5214
5626
  if (this.#virtualViewport) this.#latestRaw = rawLines.slice();
@@ -5216,7 +5628,7 @@ export class TUI extends Container {
5216
5628
  false,
5217
5629
  previousKittyPlacementSpans,
5218
5630
  nextKittyPlacementSpans,
5219
- { transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
5631
+ nextFrameGeometry,
5220
5632
  true,
5221
5633
  );
5222
5634
  };
@@ -5638,6 +6050,7 @@ export class TUI extends Container {
5638
6050
  );
5639
6051
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5640
6052
  this.#manualSuffixLineCount = nextSuffixLineCount;
6053
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5641
6054
  this.#refreshPaintedLiveViewportObservation(height);
5642
6055
  })
5643
6056
  )
@@ -5655,6 +6068,7 @@ export class TUI extends Container {
5655
6068
  this.#viewportTopRow = Math.max(0, newLines.length - height);
5656
6069
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5657
6070
  this.#manualSuffixLineCount = nextSuffixLineCount;
6071
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5658
6072
  this.#refreshPaintedLiveViewportObservation(height);
5659
6073
  return;
5660
6074
  }
@@ -5958,6 +6372,7 @@ export class TUI extends Container {
5958
6372
  );
5959
6373
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5960
6374
  this.#manualSuffixLineCount = nextSuffixLineCount;
6375
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5961
6376
  this.#refreshPaintedLiveViewportObservation(height);
5962
6377
  },
5963
6378
  preserveRasterLeases,