@gajae-code/tui 0.18.8 → 0.18.10

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;
@@ -1138,6 +1152,7 @@ export class TUI extends Container {
1138
1152
  #fullRedrawCount = 0;
1139
1153
  #stopped = false;
1140
1154
  #terminalUnavailable = false;
1155
+ #restartNeedsClear = false; // Set when stop() resets viewport state
1141
1156
  #bottomPinnedComponent: Component | null = null;
1142
1157
  #pendingTerminalCleanup: Array<{ payload: string; onDelivered?: () => void }> = [];
1143
1158
  #mouseSelectionStart: MouseSelectionPoint | null = null;
@@ -1151,6 +1166,14 @@ export class TUI extends Container {
1151
1166
  #mouseGestureDragged = false;
1152
1167
  #mousePressPoint: MouseSelectionPoint | null = null;
1153
1168
  #mousePressAt = 0;
1169
+ /** Last screen cell of a held drag; scrolling re-resolves it against the new viewport. */
1170
+ #mouseDragPointer: { x: number; y: number } | null = null;
1171
+ /** Edge scrolling waits until the drag leaves the press row, so a small drag on an edge row stays put. */
1172
+ #mousePressRow = 0;
1173
+ #mouseDragLeftPressRow = false;
1174
+ #mouseAutoScrollTimer?: NodeJS.Timeout;
1175
+ /** Held only while this TUI runs with mouse copying, so other render() consumers stay stock. */
1176
+ #releaseCopyAnnotations: (() => void) | undefined;
1154
1177
  #lastClickPoint: MouseSelectionPoint | null = null;
1155
1178
  #lastClickAt = 0;
1156
1179
  #clickCount = 0;
@@ -1159,6 +1182,7 @@ export class TUI extends Container {
1159
1182
  #manualOutputNotice = false;
1160
1183
  #manualTranscriptLineCount = 0;
1161
1184
  #manualSuffixLineCount = 0;
1185
+ #manualFrontierSpacerLineCount = 0;
1162
1186
  #committedTranscriptRows: Array<number | null> = [];
1163
1187
  #paintedManualOutputNotice = false;
1164
1188
  #rasterGeneration = 0;
@@ -1330,6 +1354,9 @@ export class TUI extends Container {
1330
1354
  override dispose(): void {
1331
1355
  if (this.#preparationDisposed) return;
1332
1356
  this.#preparationDisposed = true;
1357
+ this.#clearMouseSelection();
1358
+ this.#releaseCopyAnnotations?.();
1359
+ this.#releaseCopyAnnotations = undefined;
1333
1360
  this.#renderRequested = false;
1334
1361
  this.#renderRequestedGeneration = 0;
1335
1362
  this.#inputRenderPending = false;
@@ -1406,6 +1433,11 @@ export class TUI extends Container {
1406
1433
  this.#clearOnShrink = enabled;
1407
1434
  }
1408
1435
 
1436
+ /** Overlays rely on padded rows, so contraction replays only without them. */
1437
+ #clearOnShrinkReplays(): boolean {
1438
+ return this.#clearOnShrink && this.overlayStack.length === 0;
1439
+ }
1440
+
1409
1441
  setFocus(component: Component | null): void {
1410
1442
  // Clear focused flag on old component
1411
1443
  if (isFocusable(this.#focusedComponent)) {
@@ -1546,6 +1578,11 @@ export class TUI extends Container {
1546
1578
  getViewportAnchorComponent(): Component | null {
1547
1579
  return this.#viewportAnchorComponent;
1548
1580
  }
1581
+ #captureManualResumeViewportTop(): void {
1582
+ if (this.#latestRenderedFrontierSpacerLineCount === 0) return;
1583
+ const committedFrontier = this.#scrollbackResumeViewportTop ?? this.#nativeScrollbackViewportTop;
1584
+ this.#manualResumeViewportTop = committedFrontier > 0 ? committedFrontier : undefined;
1585
+ }
1549
1586
 
1550
1587
  /** Clear manual viewport ownership and durable history before replacing the transcript identity. */
1551
1588
  resetViewportAnchorIntent(): void {
@@ -1557,6 +1594,7 @@ export class TUI extends Container {
1557
1594
  this.#scrollbackResumeViewportTop = undefined;
1558
1595
  this.#nativeScrollbackViewportTop = 0;
1559
1596
  this.#nativeScrollbackAdmissionPending = false;
1597
+ this.#manualResumeViewportTop = undefined;
1560
1598
  this.#transcriptIdentityResetPending = true;
1561
1599
  this.#manualOutputNotice = false;
1562
1600
  this.#paintedManualOutputNotice = false;
@@ -1579,6 +1617,8 @@ export class TUI extends Container {
1579
1617
 
1580
1618
  /** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
1581
1619
  prepareViewportAnchorForTranscriptRebuild(): void {
1620
+ this.#transcriptRebuildPending = true;
1621
+ this.#manualResumeViewportTop = undefined;
1582
1622
  if (this.#manualViewportAnchor !== null) this.#reconcileMissingViewportAnchor = true;
1583
1623
  }
1584
1624
 
@@ -1633,6 +1673,7 @@ export class TUI extends Container {
1633
1673
  this.#suspendRasterLeasesForManualViewport(() => this.revealViewportAnchor(id, alignment))
1634
1674
  )
1635
1675
  return true;
1676
+ if (this.#manualViewportTop === undefined) this.#captureManualResumeViewportTop();
1636
1677
  this.#manualViewportAnchor = {
1637
1678
  id: selected.id,
1638
1679
  graphemeIndex:
@@ -1684,6 +1725,7 @@ export class TUI extends Container {
1684
1725
  const previousManualViewportAnchor = this.#manualViewportAnchor;
1685
1726
  const previousManualViewportFallbackAnchors = this.#manualViewportFallbackAnchors;
1686
1727
  const previousReconcileMissingViewportAnchor = this.#reconcileMissingViewportAnchor;
1728
+ const previousManualResumeViewportTop = this.#manualResumeViewportTop;
1687
1729
 
1688
1730
  const direction: -1 | 1 = delta < 0 ? -1 : 1;
1689
1731
  const pin = options?.pin ?? "stable";
@@ -1778,6 +1820,7 @@ export class TUI extends Container {
1778
1820
  this.#manualViewportFallbackAnchors = fallbacks;
1779
1821
  }
1780
1822
  }
1823
+ if (previousManualViewportTop === undefined) this.#captureManualResumeViewportTop();
1781
1824
  this.#manualViewportTop = targetViewportTop;
1782
1825
  let contentPainted = false;
1783
1826
  const painted = this.#repaintViewportFromLines(
@@ -1792,13 +1835,15 @@ export class TUI extends Container {
1792
1835
  contentPainted = true;
1793
1836
  this.#manualTranscriptLineCount = this.#latestRenderedTranscriptLineCount;
1794
1837
  this.#manualSuffixLineCount = this.#latestRenderedSuffixLineCount;
1838
+ this.#manualFrontierSpacerLineCount = this.#latestRenderedFrontierSpacerLineCount;
1795
1839
  },
1796
1840
  false,
1797
1841
  this.#kittyPlacementSpans,
1798
1842
  this.#kittyPlacementSpansForLines(this.#previousLines, this.#latestRenderedPlacementOwners),
1799
1843
  {
1800
- transcriptLineCount: this.#latestRenderedTranscriptLineCount,
1801
- suffixLineCount: this.#latestRenderedSuffixLineCount,
1844
+ transcriptLineCount: this.#manualTranscriptLineCount,
1845
+ suffixLineCount: this.#manualSuffixLineCount,
1846
+ frontierSpacerLineCount: this.#manualFrontierSpacerLineCount,
1802
1847
  },
1803
1848
  true,
1804
1849
  );
@@ -1807,6 +1852,7 @@ export class TUI extends Container {
1807
1852
  this.#manualViewportAnchor = previousManualViewportAnchor;
1808
1853
  this.#manualViewportFallbackAnchors = previousManualViewportFallbackAnchors;
1809
1854
  this.#reconcileMissingViewportAnchor = previousReconcileMissingViewportAnchor;
1855
+ this.#manualResumeViewportTop = previousManualResumeViewportTop;
1810
1856
  }
1811
1857
  return painted;
1812
1858
  }
@@ -1822,14 +1868,19 @@ export class TUI extends Container {
1822
1868
  if (this.#manualViewportTop === undefined) return false;
1823
1869
  const height = this.terminal.rows;
1824
1870
  const width = this.terminal.columns;
1871
+ // Manual rendering can drop transient padding from the latest frame; restore the
1872
+ // frontier captured when manual history began before rebuilding the live viewport.
1873
+ const committedFrontier = this.#manualResumeViewportTop;
1825
1874
  const paddedLiveLines = this.#padBeforeBottomPinnedComponent(
1826
1875
  this.#latestRenderedLines,
1827
- height,
1876
+ committedFrontier === undefined ? height : Math.max(height, committedFrontier + height),
1828
1877
  this.#latestRenderedSuffixLineCount,
1829
1878
  );
1830
1879
  const liveLines = paddedLiveLines.lines;
1831
1880
  const liveTranscriptLineCount = this.#latestRenderedTranscriptLineCount;
1832
- const liveSuffixLineCount = this.#latestRenderedSuffixLineCount + paddedLiveLines.insertedBlankRows;
1881
+ const liveSuffixLineCount = this.#latestRenderedSuffixLineCount;
1882
+ const liveFrontierSpacerLineCount =
1883
+ this.#latestRenderedFrontierSpacerLineCount + paddedLiveLines.insertedBlankRows;
1833
1884
  const liveKittyPlacementSpans = this.#kittyPlacementSpansForLines(liveLines, this.#latestRenderedPlacementOwners);
1834
1885
  let liveCursorPosition = this.#lastCursorPosition;
1835
1886
  if (liveCursorPosition !== null && liveCursorPosition.row >= paddedLiveLines.insertionRow) {
@@ -1855,11 +1906,14 @@ export class TUI extends Container {
1855
1906
  this.#manualOutputNotice = false;
1856
1907
  this.#committedTranscriptRows = [];
1857
1908
  this.#paintedManualOutputNotice = false;
1909
+ this.#manualResumeViewportTop = undefined;
1858
1910
  this.#lastCursorPosition = liveCursorPosition;
1859
1911
  this.#previousLines = liveLines;
1860
1912
  this.#manualTranscriptLineCount = liveTranscriptLineCount;
1861
1913
  this.#manualSuffixLineCount = liveSuffixLineCount;
1914
+ this.#manualFrontierSpacerLineCount = liveFrontierSpacerLineCount;
1862
1915
  this.#latestRenderedLines = liveLines.slice();
1916
+ this.#latestRenderedFrontierSpacerLineCount = liveFrontierSpacerLineCount;
1863
1917
  if (this.#scrollbackResumeViewportTop === undefined) {
1864
1918
  this.#nativeScrollbackViewportTop = liveViewportTop;
1865
1919
  }
@@ -1876,7 +1930,11 @@ export class TUI extends Container {
1876
1930
  true,
1877
1931
  this.#kittyPlacementSpans,
1878
1932
  liveKittyPlacementSpans,
1879
- { transcriptLineCount: liveTranscriptLineCount, suffixLineCount: liveSuffixLineCount },
1933
+ {
1934
+ transcriptLineCount: liveTranscriptLineCount,
1935
+ suffixLineCount: liveSuffixLineCount,
1936
+ frontierSpacerLineCount: liveFrontierSpacerLineCount,
1937
+ },
1880
1938
  );
1881
1939
  }
1882
1940
 
@@ -2100,9 +2158,7 @@ export class TUI extends Container {
2100
2158
  if (!shouldWrite) return { queueId: id, operation: op.type, status: "stale-token" };
2101
2159
  }
2102
2160
  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
- );
2161
+ const prefixWritten = this.#guardTerminalOperation(() => this.#emit(new TextDecoder().decode(op.prefix)));
2106
2162
  if (!prefixWritten) return failed();
2107
2163
  const abortBarrier = () => {
2108
2164
  // Abort/cursor-restoration bytes are terminal writes: never emit
@@ -2112,7 +2168,7 @@ export class TUI extends Container {
2112
2168
  const abortSuffix = op.abortSuffix === undefined ? "" : new TextDecoder().decode(op.abortSuffix);
2113
2169
  const cursorVisibility = op.restoreCursorVisibility ? this.#cursorVisibilitySequence() : "";
2114
2170
  if (abortSuffix || cursorVisibility)
2115
- this.#guardTerminalOperation(() => this.terminal.write(abortSuffix + cursorVisibility));
2171
+ this.#guardTerminalOperation(() => this.#emit(abortSuffix + cursorVisibility));
2116
2172
  };
2117
2173
  const flushed = await (this.terminal as Terminal & { flush?: () => Promise<boolean> }).flush?.();
2118
2174
  // Async boundary: the terminal may have stopped while we awaited.
@@ -2166,7 +2222,7 @@ export class TUI extends Container {
2166
2222
  const dependent = op.type === "generic-render" || op.type === "generic-full-redraw";
2167
2223
  const ok = dependent
2168
2224
  ? this.#writeProtectedRenderIngress(finalBytes)
2169
- : this.#guardTerminalOperation(() => this.terminal.write(finalBytes));
2225
+ : this.#guardTerminalOperation(() => this.#emit(finalBytes));
2170
2226
  if (!ok && dependent) {
2171
2227
  const rect = (op as { rect: CellRect }).rect;
2172
2228
  const blockedBy = [...this.#rasterCleanup.entries()]
@@ -2191,7 +2247,7 @@ export class TUI extends Container {
2191
2247
  lease.revoked = true;
2192
2248
  this.#rasterLeases.delete(request.token.ownerId);
2193
2249
  const erase = this.#cursorGuardedRasterSequence(new TextDecoder().decode(lease.erase));
2194
- const ok = this.#guardTerminalOperation(() => this.terminal.write(erase));
2250
+ const ok = this.#guardTerminalOperation(() => this.#emit(erase));
2195
2251
  if (!ok)
2196
2252
  this.#rasterCleanup.set(request.token.ownerId, {
2197
2253
  token: lease.token,
@@ -2357,7 +2413,7 @@ export class TUI extends Container {
2357
2413
  )
2358
2414
  )
2359
2415
  return false;
2360
- return this.#guardTerminalOperation(() => this.terminal.write(buffer));
2416
+ return this.#guardTerminalOperation(() => this.#emit(buffer));
2361
2417
  }
2362
2418
  #writeProtectedRenderIngress(buffer: string): boolean {
2363
2419
  const affected = [...this.#rasterLeases.values()];
@@ -2371,7 +2427,7 @@ export class TUI extends Container {
2371
2427
  ...affected.map(lease => new TextDecoder().decode(lease.erase)),
2372
2428
  ].join(""),
2373
2429
  );
2374
- const ok = this.#guardTerminalOperation(() => this.terminal.write(cleanup + buffer));
2430
+ const ok = this.#guardTerminalOperation(() => this.#emit(cleanup + buffer));
2375
2431
  if (!ok) {
2376
2432
  this.#terminalUnavailable = true;
2377
2433
  this.#previousLines = [];
@@ -2454,6 +2510,9 @@ export class TUI extends Container {
2454
2510
  this.#markTerminalUnavailable();
2455
2511
  throw error;
2456
2512
  }
2513
+ // Taken only once the terminal started, so a failed start never leaves annotations on.
2514
+ if (this.options.enableMouse === true && this.options.copySelection && !this.#releaseCopyAnnotations)
2515
+ this.#releaseCopyAnnotations = retainCopyAnnotations();
2457
2516
  if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
2458
2517
  void this.notifyTerminalLifecycle({
2459
2518
  kind: "availability-restored",
@@ -2591,8 +2650,13 @@ export class TUI extends Container {
2591
2650
  this.#clearSixelProbeState();
2592
2651
  }
2593
2652
 
2653
+ /** The only terminal write sink: copy APCs are in-process metadata and never reach the terminal. */
2654
+ #emit(data: string): void {
2655
+ this.terminal.write(stripCopyApcs(data));
2656
+ }
2657
+
2594
2658
  #writeTerminal(data: string, deferRenderFailure = false): boolean {
2595
- return this.#guardTerminalOperation(() => this.terminal.write(data), !deferRenderFailure);
2659
+ return this.#guardTerminalOperation(() => this.#emit(data), !deferRenderFailure);
2596
2660
  }
2597
2661
 
2598
2662
  #frameSynchronizedOutput(payload: string): string {
@@ -2631,7 +2695,7 @@ export class TUI extends Container {
2631
2695
  return false;
2632
2696
  }
2633
2697
  try {
2634
- this.terminal.write(data);
2698
+ this.#emit(data);
2635
2699
  } catch {
2636
2700
  this.#markTerminalUnavailable();
2637
2701
  return false;
@@ -2812,6 +2876,15 @@ export class TUI extends Container {
2812
2876
  }
2813
2877
 
2814
2878
  stop(): void {
2879
+ this.#stop(false);
2880
+ }
2881
+
2882
+ /** Temporarily releases terminal modes for foreground handoff and preserves viewport state for restart. */
2883
+ suspend(): void {
2884
+ this.#stop(true);
2885
+ }
2886
+
2887
+ #stop(temporary: boolean): void {
2815
2888
  this.#invalidatePreparations();
2816
2889
  // Invalidate every raster-queue body captured under the running epoch
2817
2890
  // before any teardown: nothing queued before stop may write after
@@ -2823,6 +2896,8 @@ export class TUI extends Container {
2823
2896
  this.#clearSixelProbeState();
2824
2897
  this.#clearMouseSelection();
2825
2898
  this.#resetClickCount();
2899
+ this.#releaseCopyAnnotations?.();
2900
+ this.#releaseCopyAnnotations = undefined;
2826
2901
  this.#stopped = true;
2827
2902
  this.#settleRenderCommitWaiters(false);
2828
2903
  if (this.#renderTimer) {
@@ -2837,17 +2912,26 @@ export class TUI extends Container {
2837
2912
  // An armed TIMER dies with the session, but a repair already deferred while
2838
2913
  // the user was reading scrollback must survive a temporary stop/start
2839
2914
  // (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.
2915
+ // restart, so followLiveViewport() still needs the pending repair and saved
2916
+ // frontier because teardown drops its transient spacers. Without manual
2917
+ // ownership the flags are moot — start() issues a forced full render.
2842
2918
  if (this.#manualViewportTop === undefined) {
2843
2919
  this.#widthSettleRepairPending = false;
2844
2920
  this.#tabWidthRepairPending = false;
2921
+ this.#manualResumeViewportTop = undefined;
2845
2922
  }
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
2923
  const displayedFrameLines = this.#previousLines.length || this.#latestRenderedLines.length;
2850
- if (displayedFrameLines > 0) {
2924
+ if (displayedFrameLines > 0 && this.#manualViewportTop !== undefined) {
2925
+ // The manual viewport's logical row is not a physical screen coordinate.
2926
+ // Insert a blank bottom handoff row and discard the pinned row rather than
2927
+ // scrolling committed history into native scrollback. start() redraws the
2928
+ // pinned suffix on resume.
2929
+ const bottomRow = Math.max(1, this.terminal.rows);
2930
+ this.#writeTerminal(`\x1b[${bottomRow};1H\x1b[1L`);
2931
+ this.#hardwareCursorRow = bottomRow - 1;
2932
+ } else if (displayedFrameLines > 0) {
2933
+ // The latest logical frame can differ from the last painted frame when a
2934
+ // semantic viewport retains its previous image.
2851
2935
  const targetRow = displayedFrameLines; // Line after the last content
2852
2936
  const lineDiff = targetRow - this.#hardwareCursorRow;
2853
2937
  if (lineDiff > 0) {
@@ -2856,6 +2940,7 @@ export class TUI extends Container {
2856
2940
  this.#writeTerminal(`\x1b[${-lineDiff}A`);
2857
2941
  }
2858
2942
  this.#writeTerminal("\r\n");
2943
+ this.#hardwareCursorRow = targetRow + 1;
2859
2944
  }
2860
2945
 
2861
2946
  if (this.#useImeBlockCursor) {
@@ -2871,7 +2956,9 @@ export class TUI extends Container {
2871
2956
  // non-manual restart keeps only the durable baseline until its first render:
2872
2957
  // that render can admit a raw-prefix-proven append without replaying history.
2873
2958
  this.#restartViewportRepaintPending =
2874
- this.#manualViewportTop === undefined && (this.#previousLines.length > 0 || this.#maxLinesRendered > 0);
2959
+ temporary &&
2960
+ this.#manualViewportTop === undefined &&
2961
+ (this.#previousLines.length > 0 || this.#maxLinesRendered > 0);
2875
2962
  if (this.#restartViewportRepaintPending) {
2876
2963
  this.#restartDurableLineCount = this.#durableLineCount;
2877
2964
  this.#restartDurableRenderedLines = this.#durableRenderedLines.slice();
@@ -2900,6 +2987,17 @@ export class TUI extends Container {
2900
2987
  this.#lineEmitWidthCache.clear();
2901
2988
  this.#previousWidth = 0;
2902
2989
  this.#previousHeight = 0;
2990
+ this.#cursorRow = 0;
2991
+ this.#hardwareCursorRow = 0;
2992
+ this.#viewportTopRow = 0;
2993
+ this.#maxLinesRendered = 0;
2994
+ // Mark that the next initial render needs to clear the screen after terminal reset
2995
+ this.#restartNeedsClear = true;
2996
+ this.#forcedRenderPreviousWidth = 0;
2997
+ this.#forcedRenderPreviousHeight = 0;
2998
+ this.#transcriptRebuildPending = false;
2999
+ this.#latestRenderedFrontierSpacerLineCount = 0;
3000
+ this.#manualFrontierSpacerLineCount = 0;
2903
3001
  this.#resizeRenderQueued = false;
2904
3002
  this.#resizeRenderMutationQueued = false;
2905
3003
  this.#renderMutationQueued = false;
@@ -3206,8 +3304,14 @@ export class TUI extends Container {
3206
3304
  if (force) {
3207
3305
  // A forced full redraw supersedes any queued input-priority render.
3208
3306
  this.#inputRenderPending = false;
3209
- if (!widthSettleRequest) this.#forcedRenderQueued = true;
3210
3307
  if (!widthSettleRequest) {
3308
+ if (!this.#forcedRenderQueued) {
3309
+ this.#forcedRenderPreviousWidth =
3310
+ this.#previousWidth < 0 ? this.#forcedRenderPreviousWidth : this.#previousWidth;
3311
+ this.#forcedRenderPreviousHeight =
3312
+ this.#previousHeight < 0 ? this.#forcedRenderPreviousHeight : this.#previousHeight;
3313
+ }
3314
+ this.#forcedRenderQueued = true;
3211
3315
  this.#previousWidth = -1; // -1 triggers widthChanged
3212
3316
  this.#previousHeight = -1; // -1 triggers heightChanged
3213
3317
  }
@@ -3311,15 +3415,29 @@ export class TUI extends Container {
3311
3415
  data = current;
3312
3416
  }
3313
3417
 
3314
- const mouse = parseSgrMouseEvent(data);
3418
+ let mouse = parseSgrMouseEvent(data);
3315
3419
  if (mouse) {
3420
+ // A held selection may leave the window; clamp it to the nearest cell so
3421
+ // the drag keeps extending instead of being discarded.
3422
+ if (this.#mouseButtonDown && (mouse.kind === "drag" || mouse.kind === "release" || mouse.kind === "wheel")) {
3423
+ mouse = {
3424
+ ...mouse,
3425
+ x: Math.min(mouse.x, this.terminal.columns),
3426
+ y: Math.min(mouse.y, this.terminal.rows),
3427
+ };
3428
+ }
3316
3429
  // Coordinates outside the current terminal cannot name a visible cell.
3317
3430
  if (mouse.x > this.terminal.columns || mouse.y > this.terminal.rows) {
3318
3431
  this.#clearMouseSelection();
3319
3432
  this.#resetClickCount();
3320
3433
  return;
3321
3434
  }
3322
- if (mouse.kind === "wheel") {
3435
+ if (mouse.kind === "wheel" && this.#mouseButtonDown && this.#mouseSelectionAnchor !== null) {
3436
+ // Wheel during a held drag scrolls under the pointer and keeps extending.
3437
+ this.scrollViewportBy(mouse.direction! * DEFAULT_WHEEL_LINES, { pin: "stable" });
3438
+ this.#extendMouseSelectionAt(mouse);
3439
+ this.#updateMouseAutoScroll();
3440
+ } else if (mouse.kind === "wheel") {
3323
3441
  this.#clearMouseSelection();
3324
3442
  this.#resetClickCount();
3325
3443
  this.scrollViewportBy(mouse.direction! * DEFAULT_WHEEL_LINES, { pin: "stable" });
@@ -3428,6 +3546,100 @@ export class TUI extends Container {
3428
3546
  : { line, column: mouse.x - 1 };
3429
3547
  }
3430
3548
 
3549
+ /** Screen rows (zero-based) that currently show transcript lines. */
3550
+ #transcriptScreenRows(): { first: number; last: number } | null {
3551
+ this.#ensureLiveSelectionRows();
3552
+ let first = -1;
3553
+ let last = -1;
3554
+ for (let row = 0; row < this.#committedTranscriptRows.length; row++) {
3555
+ const line = this.#committedTranscriptRows[row];
3556
+ if (line === null || line === undefined || line < 0 || line >= this.#manualTranscriptLineCount) continue;
3557
+ if (first < 0) first = row;
3558
+ last = row;
3559
+ }
3560
+ return first < 0 ? null : { first, last };
3561
+ }
3562
+
3563
+ /**
3564
+ * A held drag over editor/status chrome or past the transcript still names a
3565
+ * transcript cell: the nearest visible row, at the pointer column, extended to
3566
+ * the row edge so a downward drag reaches the end of the last line.
3567
+ */
3568
+ #mouseDragPoint(pointer: { x: number; y: number }): MouseSelectionPoint | null {
3569
+ const rows = this.#transcriptScreenRows();
3570
+ const direct = this.#mouseSelectionPoint({ kind: "drag", x: pointer.x, y: pointer.y });
3571
+ if (direct !== null || rows === null) return direct;
3572
+ const above = pointer.y - 1 < rows.first;
3573
+ const line = this.#committedTranscriptRows[above ? rows.first : rows.last]!;
3574
+ return { line, column: above ? 0 : Math.max(0, this.terminal.columns - 1) };
3575
+ }
3576
+
3577
+ /** Following live clears the screen-row map until the next paint; rebuild it from the live viewport. */
3578
+ #ensureLiveSelectionRows(): void {
3579
+ if (this.#manualViewportTop !== undefined || this.#committedTranscriptRows.length > 0) return;
3580
+ this.#refreshPaintedLiveViewportObservation(this.terminal.rows);
3581
+ }
3582
+
3583
+ #extendMouseSelectionAt(pointer: { x: number; y: number }): void {
3584
+ const anchor = this.#mouseSelectionAnchor;
3585
+ if (this.#mouseSelectionStart === null || anchor === null) return;
3586
+ this.#mouseDragPointer = { x: pointer.x, y: pointer.y };
3587
+ this.#mouseGestureDragged = true;
3588
+ if (pointer.y !== this.#mousePressRow) this.#mouseDragLeftPressRow = true;
3589
+ const point = this.#mouseDragPoint(pointer);
3590
+ if (point === null) return;
3591
+ const forward = point.line > anchor.line || (point.line === anchor.line && point.column >= anchor.column);
3592
+ // The anchor span stays whole while the far end grows, so dragging after a
3593
+ // double click extends word by word instead of collapsing back to one cell.
3594
+ this.#mouseSelectionStart = this.#snapSelectionPoint(anchor, forward ? "start" : "end");
3595
+ this.#mouseSelectionEnd = this.#snapSelectionPoint(point, forward ? "end" : "start");
3596
+ this.#mouseSelectionActive = true;
3597
+ }
3598
+
3599
+ /** Edge direction for a held drag: above the first or at/below the last transcript row. */
3600
+ #mouseAutoScrollDirection(pointer: { x: number; y: number }): -1 | 0 | 1 {
3601
+ const rows = this.#transcriptScreenRows();
3602
+ if (rows === null || !this.#mouseDragLeftPressRow) return 0;
3603
+ const row = pointer.y - 1;
3604
+ if (row <= rows.first) return -1;
3605
+ if (row >= rows.last || row >= this.terminal.rows - 1) return 1;
3606
+ return 0;
3607
+ }
3608
+
3609
+ #updateMouseAutoScroll(): void {
3610
+ const pointer = this.#mouseDragPointer;
3611
+ const direction = pointer && this.#mouseButtonDown ? this.#mouseAutoScrollDirection(pointer) : 0;
3612
+ if (direction === 0) {
3613
+ this.#stopMouseAutoScroll();
3614
+ return;
3615
+ }
3616
+ if (this.#mouseAutoScrollTimer !== undefined) return;
3617
+ this.#mouseAutoScrollTimer = setInterval(() => {
3618
+ const held = this.#mouseDragPointer;
3619
+ const step = held && this.#mouseButtonDown ? this.#mouseAutoScrollDirection(held) : 0;
3620
+ const before = this.#transcriptScreenRows();
3621
+ const firstLine = before === null ? null : this.#committedTranscriptRows[before.first];
3622
+ if (held === null || step === 0 || !this.scrollViewportBy(step, { pin: "stable" })) {
3623
+ this.#stopMouseAutoScroll();
3624
+ return;
3625
+ }
3626
+ // A boundary (live bottom, history top) reports success without moving.
3627
+ const after = this.#transcriptScreenRows();
3628
+ if (after === null || this.#committedTranscriptRows[after.first] === firstLine) {
3629
+ this.#stopMouseAutoScroll();
3630
+ return;
3631
+ }
3632
+ this.#extendMouseSelectionAt(held);
3633
+ this.requestRender(false, "mouse");
3634
+ }, MOUSE_AUTO_SCROLL_INTERVAL_MS);
3635
+ }
3636
+
3637
+ #stopMouseAutoScroll(): void {
3638
+ if (this.#mouseAutoScrollTimer === undefined) return;
3639
+ clearInterval(this.#mouseAutoScrollTimer);
3640
+ this.#mouseAutoScrollTimer = undefined;
3641
+ }
3642
+
3431
3643
  /**
3432
3644
  * Resolve the selection granularity for this press. SGR mouse reports carry no
3433
3645
  * click counter, so repeats are inferred from the previous press: same cell and
@@ -3523,6 +3735,8 @@ export class TUI extends Container {
3523
3735
  const mode = this.#nextClickMode(point, pressAt);
3524
3736
  this.#mouseButtonDown = true;
3525
3737
  this.#mouseGestureDragged = false;
3738
+ this.#mousePressRow = mouse.y;
3739
+ this.#mouseDragLeftPressRow = false;
3526
3740
  this.#mousePressPoint = point;
3527
3741
  this.#mousePressAt = pressAt;
3528
3742
  this.#mouseSelectionMode = mode;
@@ -3539,17 +3753,8 @@ export class TUI extends Container {
3539
3753
  this.#resetClickCount();
3540
3754
  return;
3541
3755
  }
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;
3756
+ this.#extendMouseSelectionAt(mouse);
3757
+ this.#updateMouseAutoScroll();
3553
3758
  }
3554
3759
 
3555
3760
  #finishMouseSelection(mouse: MouseEvent): void {
@@ -3558,7 +3763,10 @@ export class TUI extends Container {
3558
3763
  return;
3559
3764
  }
3560
3765
  this.#mouseButtonDown = false;
3561
- const point = this.#mouseSelectionPoint(mouse);
3766
+ this.#stopMouseAutoScroll();
3767
+ this.#mouseDragPointer = null;
3768
+ const dragged = this.#mouseGestureDragged;
3769
+ const point = dragged ? this.#mouseDragPoint(mouse) : this.#mouseSelectionPoint(mouse);
3562
3770
  const pressPoint = this.#mousePressPoint;
3563
3771
  const pressAt = this.#mousePressAt;
3564
3772
  const completedClick =
@@ -3587,7 +3795,9 @@ export class TUI extends Container {
3587
3795
  this.#clearMouseSelection();
3588
3796
  return;
3589
3797
  }
3590
- const text = this.#extractMouseSelection();
3798
+ // Source-aware rows return original Markdown, which may carry ESC/C0 bytes from
3799
+ // model or tool text; sanitize at the clipboard boundary (\t and \n survive).
3800
+ const text = stripTerminalControls(this.#extractMouseSelection());
3591
3801
  if (!text) {
3592
3802
  this.#clearMouseSelection();
3593
3803
  return;
@@ -3601,6 +3811,8 @@ export class TUI extends Container {
3601
3811
  }
3602
3812
 
3603
3813
  #clearMouseSelection(): void {
3814
+ this.#stopMouseAutoScroll();
3815
+ this.#mouseDragPointer = null;
3604
3816
  this.#mouseSelectionStart = null;
3605
3817
  this.#mouseSelectionEnd = null;
3606
3818
  this.#mouseSelectionAnchor = null;
@@ -3638,23 +3850,182 @@ export class TUI extends Container {
3638
3850
  #extractMouseSelection(): string {
3639
3851
  const selection = this.#orderedMouseSelection();
3640
3852
  if (selection === null) return "";
3641
- const selected: string[] = [];
3853
+ type SelectedRow = {
3854
+ line: number;
3855
+ fragment: string;
3856
+ sourceId?: string;
3857
+ annotation?: CopyRowAnnotation;
3858
+ covered?: boolean;
3859
+ atContentStart?: boolean;
3860
+ /** Selected text per table cell; empty where the selection misses the cell. */
3861
+ cells?: string[];
3862
+ /** Folded into the row above as the soft-wrapped tail of its cells. */
3863
+ merged?: boolean;
3864
+ };
3865
+ const selected: SelectedRow[] = [];
3642
3866
  const selectionLines = this.#selectionSourceLines();
3643
3867
  for (let lineIndex = selection.start.line; lineIndex <= selection.end.line; lineIndex++) {
3644
3868
  const line = selectionLines[lineIndex];
3645
3869
  if (line === undefined || TERMINAL.isImageLine(line)) {
3646
- selected.push("");
3870
+ selected.push({ line: lineIndex, fragment: "" });
3647
3871
  continue;
3648
3872
  }
3649
- const plain = stripTerminalControls(line);
3873
+ // Read renderer metadata before stripping APC/ANSI controls. The metadata is
3874
+ // attached to this physical row, so identical text in another component can
3875
+ // never be mistaken for this selection.
3876
+ const copy = extractCopyRowAnnotation(line);
3877
+ const rendered = copy?.line ?? line;
3878
+ const plain = stripTerminalControls(rendered);
3650
3879
  const columns = this.#mouseSelectionColumns(lineIndex, plain);
3651
3880
  if (columns === null || columns.end <= columns.start) {
3652
- selected.push("");
3881
+ selected.push({ line: lineIndex, fragment: "" });
3653
3882
  continue;
3654
3883
  }
3655
- selected.push(sliceByColumn(plain, columns.start, columns.end - columns.start, false));
3884
+ const candidate = copy?.annotation;
3885
+ const annotation =
3886
+ candidate && copy.originColumn + candidate.contentEnd <= visibleWidth(plain) ? candidate : undefined;
3887
+ const localColumns = copy
3888
+ ? { start: columns.start - copy.originColumn, end: columns.end - copy.originColumn }
3889
+ : columns;
3890
+ const presentationStart = annotation ? annotation.contentStart + annotation.prefixColumns : localColumns.start;
3891
+ const start = annotation ? Math.max(localColumns.start, presentationStart) : localColumns.start;
3892
+ const end = annotation ? Math.min(localColumns.end, annotation.contentEnd) : localColumns.end;
3893
+ let fragment = end > start ? sliceByColumn(plain, (copy?.originColumn ?? 0) + start, end - start, false) : "";
3894
+ // Cell padding is presentation; Markdown cell text never keeps outer spaces.
3895
+ const cells = annotation?.ranges?.map(([rangeStart, rangeEnd]) => {
3896
+ const overlapStart = Math.max(start, rangeStart);
3897
+ const overlapEnd = Math.min(end, rangeEnd);
3898
+ return overlapEnd > overlapStart
3899
+ ? sliceByColumn(plain, (copy?.originColumn ?? 0) + overlapStart, overlapEnd - overlapStart, false).trim()
3900
+ : "";
3901
+ });
3902
+ if (cells) fragment = cells.filter(cell => cell.length > 0).join("\t");
3903
+ if (annotation?.fence) fragment = "";
3904
+ const covered = annotation
3905
+ ? localColumns.start <= annotation.contentStart && localColumns.end >= annotation.contentEnd
3906
+ : false;
3907
+ // A fully covered rendered code row, nested or not, restores its row-local
3908
+ // original slice (not the whole logical line), which preserves an original tab.
3909
+ if (covered && annotation?.originalCode) fragment = annotation.source;
3910
+ selected.push({
3911
+ line: lineIndex,
3912
+ fragment,
3913
+ sourceId: copy?.sourceId,
3914
+ annotation,
3915
+ covered,
3916
+ atContentStart: annotation ? localColumns.start <= presentationStart : true,
3917
+ ...(cells ? { cells } : {}),
3918
+ });
3656
3919
  }
3657
- return selected.join("\n");
3920
+ // Nothing but decoration (quote margins, table borders, fences) was selected.
3921
+ if (selected.every(row => row.fragment.length === 0)) return "";
3922
+
3923
+ const groups = new Map<string, { rows: SelectedRow[]; complete: boolean }>();
3924
+ for (const row of selected) {
3925
+ if (!row.annotation || !row.sourceId) continue;
3926
+ const key = `${row.sourceId}:${row.annotation.token}`;
3927
+ const group = groups.get(key) ?? { rows: [], complete: false };
3928
+ group.rows.push(row);
3929
+ groups.set(key, group);
3930
+ }
3931
+ // A token is complete when every selected row is covered, the selection holds
3932
+ // every rendered row of the token in order (so a preview that clipped the token's
3933
+ // head or tail cannot restore hidden rows), the last of them carries the
3934
+ // whole-token source, and the rows just outside the group belong to something else.
3935
+ const rowKey = (lineIndex: number): string | undefined => {
3936
+ const line = selectionLines[lineIndex];
3937
+ if (line === undefined || TERMINAL.isImageLine(line)) return undefined;
3938
+ const copy = extractCopyRowAnnotation(line);
3939
+ return copy ? `${copy.sourceId}:${copy.annotation.token}` : undefined;
3940
+ };
3941
+ for (const [key, group] of groups) {
3942
+ const first = group.rows[0]!;
3943
+ const last = group.rows[group.rows.length - 1]!;
3944
+ group.complete =
3945
+ group.rows.every(row => row.covered) &&
3946
+ group.rows.every((row, index) => row.annotation?.tokenRow === index) &&
3947
+ last.annotation?.tokenRows === group.rows.length &&
3948
+ last.annotation.tokenSource !== undefined &&
3949
+ last.line - first.line + 1 === group.rows.length &&
3950
+ rowKey(first.line - 1) !== key &&
3951
+ rowKey(last.line + 1) !== key;
3952
+ }
3953
+
3954
+ const allSemanticRowsAreComplete = selected.every(row => {
3955
+ if (!row.annotation || !row.sourceId) return row.fragment.length === 0;
3956
+ return groups.get(`${row.sourceId}:${row.annotation.token}`)?.complete === true;
3957
+ });
3958
+ if (allSemanticRowsAreComplete) {
3959
+ const completed = [...groups.values()];
3960
+ const codeOnly = completed.length === 1 && completed[0]?.rows[0]?.annotation?.kind === "code";
3961
+ if (codeOnly) {
3962
+ const rows = completed[0]!.rows;
3963
+ const raw = rows[rows.length - 1]?.annotation?.tokenSource ?? "";
3964
+ // Token raw keeps the block's trailing newline; drop it so the closing fence is the last line.
3965
+ const lines = raw.replace(/\n+$/u, "").split("\n");
3966
+ if (/^\s*(```|~~~)/u.test(lines[0] ?? "")) {
3967
+ lines.shift();
3968
+ if (/^\s*(```|~~~)/u.test(lines[lines.length - 1] ?? "")) lines.pop();
3969
+ }
3970
+ return lines.join("\n");
3971
+ }
3972
+ let result = "";
3973
+ for (const row of selected) {
3974
+ // Each complete token contributes once, from its final row.
3975
+ if (row.annotation?.tokenSource !== undefined) result += row.annotation.tokenSource;
3976
+ }
3977
+ return result;
3978
+ }
3979
+ // A table row that soft-wraps its cells is one source row: fold each wrapped
3980
+ // row's cells into the row it continues, rejoined with the removed source gap.
3981
+ let head: SelectedRow | undefined;
3982
+ let headEnd = -1;
3983
+ for (const row of selected) {
3984
+ const gaps = row.annotation?.cellGaps;
3985
+ const headCells = head?.cells;
3986
+ if (
3987
+ head &&
3988
+ headCells &&
3989
+ row.cells &&
3990
+ gaps &&
3991
+ head.sourceId === row.sourceId &&
3992
+ head.annotation?.token === row.annotation?.token &&
3993
+ headEnd + 1 === row.line
3994
+ ) {
3995
+ for (const [cell, text] of row.cells.entries()) {
3996
+ if (!text) continue;
3997
+ headCells[cell] = headCells[cell] ? `${headCells[cell]}${gaps[cell] ?? ""}${text}` : text;
3998
+ }
3999
+ head.fragment = headCells.filter(cell => cell.length > 0).join("\t");
4000
+ row.merged = true;
4001
+ headEnd = row.line;
4002
+ continue;
4003
+ }
4004
+ head = row.cells ? row : undefined;
4005
+ headEnd = row.line;
4006
+ }
4007
+ let result = "";
4008
+ let previous: SelectedRow | undefined;
4009
+ for (const row of selected) {
4010
+ const annotation = row.annotation;
4011
+ if (row.merged || annotation?.fence || annotation?.ranges?.length === 0) continue;
4012
+ if (previous) {
4013
+ if (
4014
+ annotation?.continuation &&
4015
+ previous.annotation?.token === annotation.token &&
4016
+ previous.sourceId === row.sourceId
4017
+ ) {
4018
+ result += annotation.joinGap;
4019
+ } else {
4020
+ result += "\n";
4021
+ }
4022
+ }
4023
+ if (annotation?.quoteDepth && !annotation.continuation && row.atContentStart)
4024
+ result += "> ".repeat(annotation.quoteDepth);
4025
+ result += row.fragment;
4026
+ previous = row;
4027
+ }
4028
+ return result;
3658
4029
  }
3659
4030
 
3660
4031
  #applyMouseSelection(lines: string[]): string[] {
@@ -3665,17 +4036,28 @@ export class TUI extends Container {
3665
4036
  for (let lineIndex = selection.start.line; lineIndex <= selection.end.line; lineIndex++) {
3666
4037
  const line = highlighted[lineIndex];
3667
4038
  if (line === undefined || TERMINAL.isImageLine(line)) continue;
3668
- const plain = stripTerminalControls(line);
4039
+ // Detach copy APCs verbatim (no payload decoding on this per-frame path) and
4040
+ // put them back around the painted row so a later copy still reads them.
4041
+ const {
4042
+ line: rendered,
4043
+ origin: originMarker,
4044
+ originColumn,
4045
+ rows: annotationSuffix,
4046
+ } = splitCopyAnnotations(line);
4047
+ const plain = stripTerminalControls(rendered);
3669
4048
  const width = visibleWidth(plain);
3670
4049
  const columns = this.#mouseSelectionColumns(lineIndex, plain);
3671
4050
  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(
4051
+ const before = columns.start > 0 ? sliceByColumn(rendered, 0, columns.start, false) : "";
4052
+ const selected = sliceByColumn(rendered, columns.start, columns.end - columns.start, false).replace(
3674
4053
  /\x1b\[[0-9;]*m/gu,
3675
4054
  control => `${control}\x1b[7m`,
3676
4055
  );
3677
- const after = sliceByColumn(line, columns.end, Math.max(0, width - columns.end), false);
3678
- highlighted[lineIndex] = `${before}\x1b[7m${selected}\x1b[27m${after}`;
4056
+ const after = columns.end < width ? sliceByColumn(rendered, columns.end, width - columns.end, false) : "";
4057
+ const painted = `${before}\x1b[7m${selected}\x1b[27m${after}`;
4058
+ const head = originColumn > 0 ? sliceByColumn(painted, 0, originColumn, false) : "";
4059
+ const tail = originColumn < width ? sliceByColumn(painted, originColumn, width - originColumn, false) : "";
4060
+ highlighted[lineIndex] = `${head}${originMarker}${tail}${annotationSuffix}`;
3679
4061
  }
3680
4062
  return highlighted;
3681
4063
  }
@@ -4334,14 +4716,14 @@ export class TUI extends Container {
4334
4716
 
4335
4717
  #padBeforeBottomPinnedComponent(
4336
4718
  lines: string[],
4337
- height: number,
4719
+ minimumLineCount: number,
4338
4720
  pinnedLineCount: number,
4339
4721
  ): { lines: string[]; insertionRow: number; insertedBlankRows: number } {
4340
- if (pinnedLineCount <= 0 || lines.length >= height) {
4722
+ if (pinnedLineCount <= 0 || lines.length >= minimumLineCount) {
4341
4723
  return { lines, insertionRow: lines.length, insertedBlankRows: 0 };
4342
4724
  }
4343
4725
 
4344
- const insertedBlankRows = height - lines.length;
4726
+ const insertedBlankRows = minimumLineCount - lines.length;
4345
4727
  const insertionRow = Math.max(0, lines.length - pinnedLineCount);
4346
4728
  const padded = [...lines];
4347
4729
  padded.splice(insertionRow, 0, ...Array.from({ length: insertedBlankRows }, () => ""));
@@ -4412,12 +4794,16 @@ export class TUI extends Container {
4412
4794
  paintLive = false,
4413
4795
  placementsToClear: KittyPlacementSpan[] = this.#kittyPlacementSpans,
4414
4796
  placementsToPaint: KittyPlacementSpan[] = placementsToClear,
4415
- geometry?: { transcriptLineCount: number; suffixLineCount: number },
4797
+ geometry?: { transcriptLineCount: number; suffixLineCount: number; frontierSpacerLineCount?: number },
4416
4798
  avoidScrollback = true,
4417
4799
  ): boolean {
4418
4800
  const paintManual = this.#manualViewportTop !== undefined && !paintLive;
4419
4801
  const transcriptLineCount = geometry?.transcriptLineCount ?? this.#manualTranscriptLineCount;
4420
4802
  const suffixLineCount = geometry?.suffixLineCount ?? this.#manualSuffixLineCount;
4803
+ const frontierSpacerLineCount =
4804
+ geometry?.frontierSpacerLineCount ??
4805
+ (paintManual ? this.#manualFrontierSpacerLineCount : this.#latestRenderedFrontierSpacerLineCount);
4806
+ const suffixStart = transcriptLineCount + frontierSpacerLineCount;
4421
4807
  if (height <= 0 || width <= 0) return false;
4422
4808
  const maxViewportTop = Math.max(
4423
4809
  0,
@@ -4442,7 +4828,7 @@ export class TUI extends Container {
4442
4828
  const emittedRegions: KittyPlacementRegion[] = paintManual
4443
4829
  ? [
4444
4830
  { top: nextViewportTop, bottom: nextViewportTop + transcriptCapacity },
4445
- { top: transcriptLineCount, bottom: transcriptLineCount + suffixLineCount },
4831
+ { top: suffixStart, bottom: suffixStart + suffixLineCount },
4446
4832
  ]
4447
4833
  : [{ top: nextViewportTop, bottom: nextViewportTop + height }];
4448
4834
  const lineForScreenRow = (screenRow: number): string => {
@@ -4451,7 +4837,7 @@ export class TUI extends Container {
4451
4837
  return paintManual && screenRow === transcriptCapacity && noticeRows > 0
4452
4838
  ? "New output — type to follow"
4453
4839
  : paintManual && suffixRow >= 0
4454
- ? (lines[transcriptLineCount + suffixRow] ?? "")
4840
+ ? (lines[suffixStart + suffixRow] ?? "")
4455
4841
  : paintManual && lineIndex >= transcriptLineCount
4456
4842
  ? ""
4457
4843
  : (lines[lineIndex] ?? "");
@@ -4542,6 +4928,9 @@ export class TUI extends Container {
4542
4928
  }
4543
4929
  #recordPaintedViewportObservation(viewportTop: number, height: number, paintManual: boolean): void {
4544
4930
  const transcriptCapacity = this.#manualTranscriptCapacity(height);
4931
+ const transcriptLineCount = this.#manualTranscriptLineCount;
4932
+ const suffixStart = transcriptLineCount + this.#manualFrontierSpacerLineCount;
4933
+ const noticeRows = this.#manualOutputNotice && height > this.#manualSuffixLineCount ? 1 : 0;
4545
4934
  const anchorFrame = this.#viewportAnchorFrame;
4546
4935
  const semanticAnchor =
4547
4936
  anchorFrame === null
@@ -4556,9 +4945,10 @@ export class TUI extends Container {
4556
4945
  const cursor = this.#lastCursorPosition;
4557
4946
  let cursorRow: number | null = null;
4558
4947
  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);
4948
+ if (paintManual && cursor.row >= suffixStart) {
4949
+ cursorRow = transcriptCapacity + noticeRows + (cursor.row - suffixStart);
4950
+ } else if (paintManual && cursor.row >= transcriptLineCount) {
4951
+ cursorRow = null;
4562
4952
  } else if (paintManual) {
4563
4953
  cursorRow = this.#committedTranscriptRows.indexOf(cursor.row);
4564
4954
  } else {
@@ -4695,6 +5085,10 @@ export class TUI extends Container {
4695
5085
  const widthSettleRenderQueued = this.#widthSettleRenderQueued;
4696
5086
  const tabWidthRepairPending = this.#tabWidthRepairPending;
4697
5087
  const forcedRenderQueued = this.#forcedRenderQueued;
5088
+ const frontierPreviousWidth = this.#previousWidth < 0 ? this.#forcedRenderPreviousWidth : this.#previousWidth;
5089
+ const frontierPreviousHeight = this.#previousHeight < 0 ? this.#forcedRenderPreviousHeight : this.#previousHeight;
5090
+ const transcriptRebuildPending = this.#transcriptRebuildPending;
5091
+ this.#transcriptRebuildPending = false;
4698
5092
  this.#resizeRenderQueued = false;
4699
5093
  this.#resizeRenderMutationQueued = false;
4700
5094
  this.#renderMutationQueued = false;
@@ -4730,6 +5124,8 @@ export class TUI extends Container {
4730
5124
  this.#manualViewportTop === undefined &&
4731
5125
  this.#latestRaw.length > 0 &&
4732
5126
  this.#latestRenderedLines.length === this.#latestRaw.length &&
5127
+ // A cached spacer count is not valid after natural rows replace the transient padding.
5128
+ this.#latestRenderedFrontierSpacerLineCount === 0 &&
4733
5129
  this.#previousLines === this.#latestRenderedLines;
4734
5130
  let previousKittyPlacementSpans = this.#kittyPlacementSpans;
4735
5131
  const placementOwners = new Map<string, KittyPlacementOwner>();
@@ -4843,6 +5239,7 @@ export class TUI extends Container {
4843
5239
  let diffStart = 0;
4844
5240
  let usedWindowNormalize = false;
4845
5241
  let stitched = false;
5242
+ let frontierSpacerLineCount = 0;
4846
5243
  if (reusedAnchor !== null && layoutPrefixEligible && !layoutPrefixBlocked) {
4847
5244
  const reused = this.#reuseCachedLayoutPrefix(
4848
5245
  width,
@@ -4859,6 +5256,7 @@ export class TUI extends Container {
4859
5256
  cursorPos = reused.cursorPos;
4860
5257
  diffStart = reused.diffStart;
4861
5258
  usedWindowNormalize = true;
5259
+ frontierSpacerLineCount = this.#latestRenderedFrontierSpacerLineCount;
4862
5260
  }
4863
5261
  }
4864
5262
  if (!stitched) {
@@ -4880,11 +5278,24 @@ export class TUI extends Container {
4880
5278
  }
4881
5279
  newLines = this.#constrainPinnedSuffix(renderedLines, height, renderedChildren);
4882
5280
  if (hasStickySuffix && height > 0 && this.#manualViewportTop === undefined) {
4883
- newLines = this.#padBeforeBottomPinnedComponent(
5281
+ // Rebuilds start at their new transcript frontier. Forced redraws use the
5282
+ // captured rendered dimensions rather than their -1 invalidation sentinels.
5283
+ // clearOnShrink replays the whole frame on contraction instead; spacer rows
5284
+ // would otherwise stay on screen as a blank gap above the pinned component.
5285
+ const preserveLiveFrontier =
5286
+ !this.#clearOnShrinkReplays() &&
5287
+ !transcriptIdentityReplaced &&
5288
+ !transcriptRebuildPending &&
5289
+ frontierPreviousWidth === width &&
5290
+ (frontierPreviousHeight === height || this.#viewportRepaintHost());
5291
+ const minimumLineCount = preserveLiveFrontier ? prevViewportTop + height : height;
5292
+ const padded = this.#padBeforeBottomPinnedComponent(
4884
5293
  newLines,
4885
- height,
5294
+ minimumLineCount,
4886
5295
  newLines.length - sourceTranscriptLineCount,
4887
- ).lines;
5296
+ );
5297
+ newLines = padded.lines;
5298
+ frontierSpacerLineCount = padded.insertedBlankRows;
4888
5299
  }
4889
5300
  // Composite overlays into the rendered lines (before differential compare)
4890
5301
  if (this.overlayStack.length > 0) {
@@ -4941,7 +5352,15 @@ export class TUI extends Container {
4941
5352
  }
4942
5353
  }
4943
5354
  const nextTranscriptLineCount = sourceTranscriptLineCount;
4944
- const nextSuffixLineCount = hasStickySuffix ? Math.max(0, newLines.length - nextTranscriptLineCount) : 0;
5355
+ // Live-only frontier rows must not consume the manual viewport's pinned-suffix capacity.
5356
+ const nextSuffixLineCount = hasStickySuffix
5357
+ ? Math.max(0, newLines.length - nextTranscriptLineCount - frontierSpacerLineCount)
5358
+ : 0;
5359
+ const nextFrameGeometry = {
5360
+ transcriptLineCount: nextTranscriptLineCount,
5361
+ suffixLineCount: nextSuffixLineCount,
5362
+ frontierSpacerLineCount,
5363
+ };
4945
5364
  this.#lastCursorPosition = cursorPos;
4946
5365
  const total = rawLines.length;
4947
5366
  if (renderMetrics.enabled) {
@@ -4958,6 +5377,7 @@ export class TUI extends Container {
4958
5377
  this.#latestRenderedLines = newLines;
4959
5378
  this.#latestRenderedTranscriptLineCount = nextTranscriptLineCount;
4960
5379
  this.#latestRenderedSuffixLineCount = nextSuffixLineCount;
5380
+ this.#latestRenderedFrontierSpacerLineCount = frontierSpacerLineCount;
4961
5381
  this.#latestRenderedPlacementOwners = placementOwners;
4962
5382
  const naturalViewportTop = Math.max(0, newLines.length - height);
4963
5383
  const priorLogicalLineCount = Math.max(this.#previousLines.length, this.#maxLinesRendered);
@@ -5018,11 +5438,12 @@ export class TUI extends Container {
5018
5438
  this.#previousHeight = height;
5019
5439
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5020
5440
  this.#manualSuffixLineCount = nextSuffixLineCount;
5441
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5021
5442
  },
5022
5443
  false,
5023
5444
  previousKittyPlacementSpans,
5024
5445
  nextKittyPlacementSpans,
5025
- { transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
5446
+ nextFrameGeometry,
5026
5447
  true,
5027
5448
  );
5028
5449
  if (contentPainted) {
@@ -5074,6 +5495,9 @@ export class TUI extends Container {
5074
5495
  newLines.length === this.#previousLines.length &&
5075
5496
  newLines.every((line, index) => line === this.#previousLines[index])
5076
5497
  ) {
5498
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
5499
+ this.#manualSuffixLineCount = nextSuffixLineCount;
5500
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5077
5501
  return;
5078
5502
  }
5079
5503
  this.#manualViewportTop = nextViewportTop;
@@ -5095,11 +5519,12 @@ export class TUI extends Container {
5095
5519
  this.#paintedManualOutputNotice = this.#manualOutputNotice;
5096
5520
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5097
5521
  this.#manualSuffixLineCount = nextSuffixLineCount;
5522
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5098
5523
  },
5099
5524
  false,
5100
5525
  previousKittyPlacementSpans,
5101
5526
  nextKittyPlacementSpans,
5102
- { transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
5527
+ nextFrameGeometry,
5103
5528
  );
5104
5529
  if (!contentPainted) restoreManualIntent();
5105
5530
  return;
@@ -5171,6 +5596,7 @@ export class TUI extends Container {
5171
5596
  );
5172
5597
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5173
5598
  this.#manualSuffixLineCount = nextSuffixLineCount;
5599
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5174
5600
  this.#refreshPaintedLiveViewportObservation(height);
5175
5601
  this.#durableLineCount = newLines.length;
5176
5602
  this.#durableRenderedLines = newLines.slice();
@@ -5209,6 +5635,7 @@ export class TUI extends Container {
5209
5635
  this.#previousHeight = height;
5210
5636
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5211
5637
  this.#manualSuffixLineCount = nextSuffixLineCount;
5638
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5212
5639
  this.#refreshPaintedLiveViewportObservation(height);
5213
5640
  this.#latestRenderedLines = newLines.slice();
5214
5641
  if (this.#virtualViewport) this.#latestRaw = rawLines.slice();
@@ -5216,7 +5643,7 @@ export class TUI extends Container {
5216
5643
  false,
5217
5644
  previousKittyPlacementSpans,
5218
5645
  nextKittyPlacementSpans,
5219
- { transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
5646
+ nextFrameGeometry,
5220
5647
  true,
5221
5648
  );
5222
5649
  };
@@ -5376,9 +5803,12 @@ export class TUI extends Container {
5376
5803
  return;
5377
5804
  }
5378
5805
  // First render - just output everything without clearing (assumes clean screen)
5806
+ // unless we're restarting after a stop() which leaves stale content
5379
5807
  if (initialRender) {
5380
5808
  logRedraw("first render");
5381
- fullRender(false, "first render");
5809
+ const shouldClear = this.#restartNeedsClear;
5810
+ this.#restartNeedsClear = false;
5811
+ fullRender(shouldClear, "first render");
5382
5812
  return;
5383
5813
  }
5384
5814
 
@@ -5436,13 +5866,11 @@ export class TUI extends Container {
5436
5866
  // Content shrunk below the previous render and no overlays - re-render to clear empty rows
5437
5867
  // (overlays need the padding, so only do this when no overlays are active)
5438
5868
  // Configurable via setClearOnShrink() or GJC_CLEAR_ON_SHRINK=0 env var
5439
- if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
5869
+ // Erase native scrollback too: scrolled-off rows cannot be pulled back into the
5870
+ // viewport, and repainting them on screen would duplicate them in history.
5871
+ if (this.#clearOnShrinkReplays() && newLines.length < this.#previousLines.length) {
5440
5872
  logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
5441
- if (useViewportRepaintPath) {
5442
- viewportRepaint(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
5443
- } else {
5444
- fullRender(true, "clearOnShrink");
5445
- }
5873
+ fullRender(true, "clearOnShrink", true);
5446
5874
  return;
5447
5875
  }
5448
5876
 
@@ -5638,6 +6066,7 @@ export class TUI extends Container {
5638
6066
  );
5639
6067
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5640
6068
  this.#manualSuffixLineCount = nextSuffixLineCount;
6069
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5641
6070
  this.#refreshPaintedLiveViewportObservation(height);
5642
6071
  })
5643
6072
  )
@@ -5655,6 +6084,7 @@ export class TUI extends Container {
5655
6084
  this.#viewportTopRow = Math.max(0, newLines.length - height);
5656
6085
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5657
6086
  this.#manualSuffixLineCount = nextSuffixLineCount;
6087
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5658
6088
  this.#refreshPaintedLiveViewportObservation(height);
5659
6089
  return;
5660
6090
  }
@@ -5958,6 +6388,7 @@ export class TUI extends Container {
5958
6388
  );
5959
6389
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
5960
6390
  this.#manualSuffixLineCount = nextSuffixLineCount;
6391
+ this.#manualFrontierSpacerLineCount = frontierSpacerLineCount;
5961
6392
  this.#refreshPaintedLiveViewportObservation(height);
5962
6393
  },
5963
6394
  preserveRasterLeases,