@agi-fans/dsh-tui 0.17.0 → 0.19.0

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.
Files changed (100) hide show
  1. package/lib/CHANGELOG.md +67 -1
  2. package/lib/chrome/box.d.ts +2 -0
  3. package/lib/chrome/box.js +5 -1
  4. package/lib/chrome/box.js.map +1 -1
  5. package/lib/chrome/main-screen-renderer.d.ts +61 -11
  6. package/lib/chrome/main-screen-renderer.js +1674 -71
  7. package/lib/chrome/main-screen-renderer.js.map +1 -1
  8. package/lib/chrome/markdown.js +21 -13
  9. package/lib/chrome/markdown.js.map +1 -1
  10. package/lib/chrome/renderer.d.ts +75 -0
  11. package/lib/chrome/renderer.js.map +1 -1
  12. package/lib/chrome/status-config.d.ts +4 -1
  13. package/lib/chrome/status-config.js.map +1 -1
  14. package/lib/chrome/theme.d.ts +18 -0
  15. package/lib/chrome/theme.js +18 -0
  16. package/lib/chrome/theme.js.map +1 -1
  17. package/lib/chrome/tool-args.d.ts +24 -1
  18. package/lib/chrome/tool-args.js +40 -1
  19. package/lib/chrome/tool-args.js.map +1 -1
  20. package/lib/chrome/tool-renderers.d.ts +11 -0
  21. package/lib/chrome/tool-renderers.js +55 -7
  22. package/lib/chrome/tool-renderers.js.map +1 -1
  23. package/lib/chrome/welcome-tips.js +1 -1
  24. package/lib/chrome/welcome-tips.js.map +1 -1
  25. package/lib/commands/auth.js +4 -2
  26. package/lib/commands/auth.js.map +1 -1
  27. package/lib/commands/jobs.d.ts +2 -2
  28. package/lib/commands/jobs.js +2 -2
  29. package/lib/commands/jobs.js.map +1 -1
  30. package/lib/definition.d.ts +62 -7
  31. package/lib/definition.js.map +1 -1
  32. package/lib/index.d.ts +1 -1
  33. package/lib/index.js +1 -1
  34. package/lib/index.js.map +1 -1
  35. package/lib/input/keybindings-config.d.ts +1 -1
  36. package/lib/input/keybindings-config.js +2 -1
  37. package/lib/input/keybindings-config.js.map +1 -1
  38. package/lib/input/keys.d.ts +7 -0
  39. package/lib/input/keys.js +11 -4
  40. package/lib/input/keys.js.map +1 -1
  41. package/lib/runtime/provider-local.d.ts +63 -11
  42. package/lib/runtime/provider-local.js +506 -91
  43. package/lib/runtime/provider-local.js.map +1 -1
  44. package/lib/runtime/session-runtime.d.ts +9 -1
  45. package/lib/runtime/session-runtime.js +2 -2
  46. package/lib/runtime/session-runtime.js.map +1 -1
  47. package/lib/runtime/startup-notices.js +6 -9
  48. package/lib/runtime/startup-notices.js.map +1 -1
  49. package/lib/runtime/tool-presentation.js +44 -31
  50. package/lib/runtime/tool-presentation.js.map +1 -1
  51. package/lib/session/feature-toggles.d.ts +100 -0
  52. package/lib/session/feature-toggles.js +170 -0
  53. package/lib/session/feature-toggles.js.map +1 -0
  54. package/lib/session/fold-policy.d.ts +21 -0
  55. package/lib/session/fold-policy.js +15 -0
  56. package/lib/session/fold-policy.js.map +1 -0
  57. package/lib/session/job-notice.d.ts +6 -2
  58. package/lib/session/job-notice.js +44 -3
  59. package/lib/session/job-notice.js.map +1 -1
  60. package/lib/session/session-configuration.d.ts +0 -3
  61. package/lib/session/session-configuration.js +0 -4
  62. package/lib/session/session-configuration.js.map +1 -1
  63. package/lib/session/session-controller.d.ts +6 -4
  64. package/lib/session/session-controller.js +147 -55
  65. package/lib/session/session-controller.js.map +1 -1
  66. package/lib/session/session-library.d.ts +20 -0
  67. package/lib/session/session-library.js +52 -0
  68. package/lib/session/session-library.js.map +1 -1
  69. package/lib/session/subagent-roster.js +3 -3
  70. package/lib/session/subagent-roster.js.map +1 -1
  71. package/lib/session/subagent-tracker.d.ts +2 -9
  72. package/lib/session/subagent-tracker.js +3 -4
  73. package/lib/session/subagent-tracker.js.map +1 -1
  74. package/lib/session/tui-settings.d.ts +46 -18
  75. package/lib/session/tui-settings.js +59 -39
  76. package/lib/session/tui-settings.js.map +1 -1
  77. package/lib/views/at-complete.d.ts +2 -0
  78. package/lib/views/at-complete.js +2 -2
  79. package/lib/views/at-complete.js.map +1 -1
  80. package/lib/views/event-views.d.ts +26 -2
  81. package/lib/views/event-views.js +162 -48
  82. package/lib/views/event-views.js.map +1 -1
  83. package/lib/views/hotkeys.d.ts +2 -0
  84. package/lib/views/hotkeys.js +7 -3
  85. package/lib/views/hotkeys.js.map +1 -1
  86. package/lib/views/settings-list.d.ts +12 -6
  87. package/lib/views/settings-list.js +41 -18
  88. package/lib/views/settings-list.js.map +1 -1
  89. package/lib/views/transcript-export.js +0 -2
  90. package/lib/views/transcript-export.js.map +1 -1
  91. package/lib/views/transcript-render.d.ts +133 -18
  92. package/lib/views/transcript-render.js +1179 -112
  93. package/lib/views/transcript-render.js.map +1 -1
  94. package/lib/views/transcript-types.d.ts +79 -1
  95. package/lib/views/transcript-types.js +11 -0
  96. package/lib/views/transcript-types.js.map +1 -1
  97. package/lib/views/workspace-changes.d.ts +27 -0
  98. package/lib/views/workspace-changes.js +128 -0
  99. package/lib/views/workspace-changes.js.map +1 -0
  100. package/package.json +40 -38
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * The terminal owns native scrollback. This renderer owns only the current
5
5
  * screen and a logical boundary for rows already frozen above it. Routine
6
- * updates treat native history as append-only. Explicit transcript epochs
7
- * clear it once and replay the replacement frame from a clean origin.
6
+ * updates treat native history as append-only, and so does a transcript epoch:
7
+ * replacing the document replays the new frame into a fresh index space and
8
+ * leaves the earlier output above, where the terminal keeps it.
8
9
  *
9
10
  * Finalized rows cross the boundary by being painted at the top of the screen
10
11
  * immediately before a newline scrolls them into history. Pending rows remain
@@ -14,14 +15,21 @@
14
15
  * again.
15
16
  *
16
17
  * reset() repairs the visible screen without replaying history. A logical
17
- * conversation replacement must call startEpoch() so stale native history is
18
- * cleared before the replacement transcript is replayed.
18
+ * conversation replacement must call startEpoch() so the replacement frame is
19
+ * laid out from a clean index space rather than diffed against rows that
20
+ * described a different document.
19
21
  *
20
- * The renderer never enables mouse tracking (1000/1006) and wraps every paint
21
- * in one DEC 2026 synchronized write.
22
+ * Nothing here erases native scrollback. It holds the reader's shell output
23
+ * from before this process started, and a routine replacement has no business
24
+ * destroying it; the seam between the two documents is content, and the
25
+ * transcript renders it.
26
+ *
27
+ * The terminal provider owns input modes, including mouse tracking; the
28
+ * renderer wraps every paint in one DEC 2026 synchronized write.
22
29
  * @module @agi-fans/dsh-tui
23
30
  */
24
31
  import { sanitizeDisplayLine } from "./renderer.js";
32
+ import { visibleWidth } from "./width.js";
25
33
  const DISABLE_AUTOWRAP = '\x1b[?7l';
26
34
  const ENABLE_AUTOWRAP = '\x1b[?7h';
27
35
  const SYNC_OUTPUT_BEGIN = '\x1b[?2026h';
@@ -30,17 +38,143 @@ const HIDE_CURSOR = '\x1b[?25l';
30
38
  const SHOW_CURSOR = '\x1b[?25h';
31
39
  const ENTER_ALT_SCREEN = '\x1b[?1049h';
32
40
  const EXIT_ALT_SCREEN = '\x1b[?1049l';
33
- const CLEAR_SCROLLBACK = '\x1b[3J';
34
41
  const CLEAR_SCREEN = '\x1b[2J\x1b[H';
35
42
  const CLEAR_LINE = '\x1b[2K';
36
43
  function csi(row, column) {
37
44
  return `\x1b[${Math.max(1, row + 1)};${Math.max(1, column + 1)}H`;
38
45
  }
46
+ /**
47
+ * The committed rows whose text still reads as it did when they were confirmed, as
48
+ * ranges. A row that has since been rewritten carries no credit: the terminal
49
+ * committed *that other* row, and the row now standing there still owes a commit
50
+ * of its own.
51
+ */
52
+ /**
53
+ * The credited rows on the stack, as ranges: entries that hold a document line and
54
+ * whose text has been confirmed. This is the ledger — derived, not maintained
55
+ * alongside.
56
+ */
57
+ function creditedRows(stack, next) {
58
+ return mergeRanges(stack
59
+ .filter(row => row.credited)
60
+ .filter((row) => row.document !== undefined && row.text !== undefined)
61
+ .filter(row => next === undefined || (next[row.document] ?? '') === row.text)
62
+ .map(row => ({ start: row.document, end: row.document + 1 })));
63
+ }
64
+ /**
65
+ * The rows that are still credited, for the pending set.
66
+ *
67
+ * Three states, and they are not interchangeable. A row the frame shows with the
68
+ * same text is still credited. A row the frame shows with **different** text has
69
+ * been rewritten, so the terminal's commit was about a row that no longer exists
70
+ * and the credit is dropped. A row the frame cannot speak for — a window that
71
+ * does not contain it, or a frame that declares no body range — is **unknown**,
72
+ * and an unknown row keeps its credit: discarding it would mean the terminal's own
73
+ * commit is forgotten and the row is sent again.
74
+ *
75
+ * The lookup goes through the frame's declared range, so a browse window that
76
+ * *does* contain the row is checked at the window's own offset rather than at an
77
+ * absolute document index.
78
+ */
79
+ /**
80
+ * Where a document row's text sits in this frame, or `undefined` when the frame
81
+ * cannot speak for that row. A frame with no declared range says nothing about
82
+ * what it is showing; a frame that declares one speaks only for the rows inside
83
+ * it, since `next` also holds chrome.
84
+ */
85
+ function frameTextIndex(frame, next, document) {
86
+ const range = frame?.documentRows;
87
+ if (range === undefined || range === null)
88
+ return undefined;
89
+ if (document < range.documentStart || document >= range.documentEnd)
90
+ return undefined;
91
+ const index = document - range.documentStart + range.frameStart;
92
+ return index < 0 || index >= next.length ? undefined : index;
93
+ }
94
+ /**
95
+ * How many rows a grow pulled back out of history: the number of new rows it added,
96
+ * when the cursor sat at the bottom, and nothing otherwise. A grow whose cursor is
97
+ * not at the bottom only stands blanks at the foot of the screen.
98
+ */
99
+ function pulledBackFor(oldHeight, newHeight, cursorRow) {
100
+ if (newHeight <= oldHeight)
101
+ return 0;
102
+ return cursorRow >= oldHeight - 1 ? newHeight - oldHeight : 0;
103
+ }
104
+ /**
105
+ * The union of a list of committed ranges: adjacent or overlapping ones collapse
106
+ * into a single span, and two ranges with a gap between them stay apart. The gap is
107
+ * rows the terminal did not commit and the renderer may still owe, so merging
108
+ * across it would claim them.
109
+ */
110
+ function mergeRanges(ranges) {
111
+ const sorted = [...ranges].sort((a, b) => a.start - b.start);
112
+ const merged = [];
113
+ for (const range of sorted) {
114
+ const last = merged[merged.length - 1];
115
+ if (last !== undefined && range.start <= last.end)
116
+ last.end = Math.max(last.end, range.end);
117
+ else
118
+ merged.push({ ...range });
119
+ }
120
+ return merged;
121
+ }
122
+ /**
123
+ * `[from, to)` minus the ranges this frame has already committed by other means, as the
124
+ * spans it still owes.
125
+ */
126
+ function subtractFromRange(from, to, committed) {
127
+ const spans = [];
128
+ let at = from;
129
+ for (const range of [...committed].sort((a, b) => a.start - b.start)) {
130
+ if (range.end <= at || range.start >= to)
131
+ continue;
132
+ if (range.start > at)
133
+ spans.push([at, Math.min(range.start, to)]);
134
+ at = Math.max(at, range.end);
135
+ }
136
+ if (at < to)
137
+ spans.push([at, to]);
138
+ return spans;
139
+ }
140
+ /**
141
+ * Where a block that was reshaped maps to, given the row offsets of the same
142
+ * blocks before and after.
143
+ *
144
+ * A block's span runs from its own start to the next block's. Block indices are
145
+ * stable within a document, so the span survives a reshape that another block
146
+ * made. The span to trust is the one the frozen boundary is *inside*: the last
147
+ * block that starts before it, whose next start is at or beyond it. When that
148
+ * block's span is the same length before and after, the boundary moves with it;
149
+ * when it changed, the block's new end is where the reshaped content stops, and
150
+ * everything from there on belongs to the reshape rather than to the reader's
151
+ * frozen prefix.
152
+ *
153
+ * Returns `undefined` when there is no such block — fewer blocks than the
154
+ * boundary, or offsets that do not describe one.
155
+ */
156
+ export function mapBlockSpan(input) {
157
+ const { oldStarts, newStarts, oldBody, newBody, physical, oldTail, newTail } = input;
158
+ if (newStarts.length < oldStarts.length)
159
+ return undefined;
160
+ const index = oldStarts.findLastIndex(row => oldBody + row < physical);
161
+ if (index < 0)
162
+ return undefined;
163
+ const oldStart = oldBody + oldStarts[index];
164
+ const newStart = newBody + newStarts[index];
165
+ const oldEnd = oldBody + (oldStarts[index + 1] ?? oldTail - oldBody);
166
+ let nextIndex = index + 1;
167
+ while (nextIndex < newStarts.length && newStarts[nextIndex] === newStarts[index])
168
+ nextIndex += 1;
169
+ const newEnd = newBody + (newStarts[nextIndex] ?? newTail - newBody);
170
+ return oldEnd - oldStart === newEnd - newStart
171
+ ? newStart + physical - oldStart
172
+ : newEnd;
173
+ }
39
174
  /** Render frames into the terminal main screen while preserving native history. */
40
175
  export class MainScreenRenderer {
41
176
  #sink;
42
177
  #synchronized;
43
- #canClearScrollback;
44
178
  #alternateScreenOverlays;
45
179
  #width;
46
180
  #height;
@@ -48,11 +182,37 @@ export class MainScreenRenderer {
48
182
  #hasFrame = false;
49
183
  #reanchor = false;
50
184
  #newEpoch = false;
51
- #fullReplayOnNextEpoch = true;
52
- #clearScrollbackOnNextRender = false;
185
+ #epochReplay = 'full';
53
186
  #adoptPhysicalAfterTransient = false;
187
+ /** First frame row a fold reshaped since the last followed frame, if any. */
188
+ #reflowFrom;
54
189
  /** First logical row not frozen above the screen in the current epoch. */
55
190
  #physical = 0;
191
+ /** Last followed document, retained for mapping a reshape's frozen boundary. */
192
+ #followRows = [];
193
+ /** End of transcript rows; changing live chrome must not break suffix matching. */
194
+ #followEnd = 0;
195
+ #followTranscript;
196
+ /** A reshape can bring frozen rows back into the viewport without thawing them. */
197
+ #frozenViewport = false;
198
+ /**
199
+ * First document row the previous painted frame showed. A resize makes the
200
+ * terminal commit rows from the top of *that* screen, which after a reflow is
201
+ * not the frozen boundary — the boundary deliberately sits above the screen
202
+ * top, so the two have to be tracked separately.
203
+ */
204
+ #screenSource = null;
205
+ #screenSourceKnown = false;
206
+ /** The rows the last painted frame showed on the main screen. */
207
+ #screenRows = [];
208
+ /** Document row each physical row of the last frame showed, if any. */
209
+ #screenRowSource = [];
210
+ /**
211
+ * Blank padding rows the previous frame showed above its content. The
212
+ * terminal pushes padding up when the window shrinks, and a pushed blank row
213
+ * is not a committed document row — treating it as one skips real rows that
214
+ * have never been shown.
215
+ */
56
216
  /** Exact visible rows expected on the physical screen. */
57
217
  #screen;
58
218
  #transient = false;
@@ -61,6 +221,108 @@ export class MainScreenRenderer {
61
221
  #cursorRow = 0;
62
222
  #cursorCol = 0;
63
223
  #cursorVisible = true;
224
+ /**
225
+ * The main screen's own resize transition, parked while an overlay is up.
226
+ *
227
+ * A terminal resizes both buffers, so a resize that arrives with an overlay
228
+ * frame also moves the hidden main screen and puts rows into its history. The
229
+ * overlay branch reconciles only its own layout, so this is kept — along with
230
+ * the main screen's cursor and source snapshot as of before the move — for the
231
+ * frame that returns to the main screen to settle. Parked rather than stored on
232
+ * `#resize`, because that field is the *active* buffer's transition and the two
233
+ * must not consume each other.
234
+ */
235
+ /**
236
+ * The parked move and the main screen as it stood before it, held in one
237
+ * record so that settling the comparison never has to block painting: the live
238
+ * fields are free to follow the frames the user is asking for, and this record
239
+ * stays put until a frame can finish reading it.
240
+ */
241
+ /**
242
+ * Every main-screen move made while an overlay was up, in the order they
243
+ * happened. One per move, not one per stay: an overlay can be resized more than
244
+ * once, and each resize moves the hidden screen by its own amount.
245
+ */
246
+ #mainResizeRecords = [];
247
+ /** What the parked move committed on its own, for the flush to subtract. */
248
+ /**
249
+ * The rows the terminal has pushed into native history, in the order it pushed
250
+ * them, newest last — one entry per physical row, with `undefined` wherever the
251
+ * row held no document line (chrome: a marker, padding, the composer).
252
+ *
253
+ * This is the shape a withdrawal needs. Growing pulls rows back off the **top of
254
+ * history**, which is the newest end here, so a pull takes the last entries and
255
+ * leaves the rest — and a pulled row that was chrome takes nothing with it. A set
256
+ * of document numbers can express neither: it loses the push order, has no place
257
+ * for chrome, and bans a row forever instead of withdrawing one commit.
258
+ */
259
+ #pushed = [];
260
+ /**
261
+ * Whether native history has moved in a way this account did not follow. From then
262
+ * on the tail describes positions that were pushed, not necessarily the newest ones,
263
+ * so a pull may not take its end for the real end of history. Only knowing what the
264
+ * terminal actually did can clear this.
265
+ */
266
+ #tailStale = false;
267
+ /**
268
+ * Instances this account knows will be on the main screen when a protection scroll runs,
269
+ * with the text and source they had. Collected from the moves that put them there — a
270
+ * pulled instance keeps its identity even when the layout after the move is unknown.
271
+ *
272
+ * The protection consumes this rather than the screen memory: `#screen` holds what this
273
+ * renderer last *painted*, which after an overlay stay is not what the terminal shows.
274
+ */
275
+ /**
276
+ * The physical state of the **normal** buffer, kept apart from the pending text queue so a
277
+ * retiring record cannot erase it.
278
+ *
279
+ * `undefined` means no normal paint has happened yet; `'unknown'` means a move left the
280
+ * layout unaccounted for and stays that way until a real paint rebuilds it. There is no
281
+ * fall back to the live fields: restoring trust from them would turn an unaccounted move
282
+ * back into a fact.
283
+ */
284
+ #normalLayout;
285
+ #knownOnScreen = [];
286
+ /**
287
+ * Instances a protection scroll created but could not confirm, kept by identity so a later
288
+ * normal frame can settle them. A protection scroll is one physical commit: its copies stay
289
+ * in `#pushed` whatever happens, and until one is confirmed or resolved they may not be
290
+ * deducted — which is exactly why they must be held rather than dropped when the move that
291
+ * created them stops being compared.
292
+ */
293
+ #unconfirmedProtection = [];
294
+ /**
295
+ * How many of the **newest** positions are known to be in physical order, counted from a real
296
+ * flush. A pull inside this range consumes order and decreases the count; a pull that reaches
297
+ * past it has left the proven order and is unknown, however much of the collection is left. The
298
+ * collection and this count must describe the same newest end.
299
+ */
300
+ #provenTail = 0;
301
+ /**
302
+ * The newest positions whose existence was really sent but whose **arrangement** is unknown: a
303
+ * protection scroll pushes `#height` of them. They are counted, never named. A pull that reaches
304
+ * into this range knows how many rows it took without knowing which they were.
305
+ */
306
+ #unknownFront = 0;
307
+ /**
308
+ * A pull that reached into the unknown front and is waiting to be undone. Those rows are known
309
+ * to be on the screen top but cannot be named, so the honest restoration is to scroll them back
310
+ * down before the next overwrite, by count, in screen order.
311
+ */
312
+ #pendingReverse;
313
+ /**
314
+ * Set while a caller has already written the alternate-buffer exit as part of its own plan, so
315
+ * the paint functions must not emit a second one. The exit belongs to exactly one level.
316
+ */
317
+ #exitOwnedByCaller = false;
318
+ /**
319
+ * Whether a move may have brought rows onto the screen that this account never painted,
320
+ * because it asked for more than the tail could account for. The exact count is not
321
+ * knowable; this records only that the situation may exist.
322
+ */
323
+ #unknownOnScreen = false;
324
+ /** The move this frame fixed and applied, for whichever draw path runs. */
325
+ #pendingEvent;
64
326
  constructor(sink, options) {
65
327
  this.#sink = sink;
66
328
  this.#width = options.width ?? 0;
@@ -68,7 +330,6 @@ export class MainScreenRenderer {
68
330
  this.#screen = this.#blankScreen();
69
331
  this.#altScreen = this.#blankScreen();
70
332
  this.#synchronized = options.synchronized === true;
71
- this.#canClearScrollback = options.clearScrollback !== false;
72
333
  this.#alternateScreenOverlays = options.alternateScreenOverlays === true;
73
334
  }
74
335
  /** Record terminal geometry; the terminal has already performed the resize. */
@@ -76,9 +337,21 @@ export class MainScreenRenderer {
76
337
  const nextHeight = Math.max(1, height);
77
338
  if (width === this.#width && nextHeight === this.#height)
78
339
  return;
79
- const transition = this.#resize ?? { oldHeight: this.#height, widthChanged: false };
80
- transition.widthChanged ||= width !== this.#width;
81
- this.#resize = transition;
340
+ const transition = this.#resize
341
+ ?? { oldHeight: this.#height, widthChanged: false, narrowestWidth: this.#width, intermediateHeights: [] };
342
+ if (transition !== this.#resize) {
343
+ this.#resize = transition;
344
+ }
345
+ else {
346
+ // Remember the height being left behind, so a burst that returns to where
347
+ // it started still records that the window moved in between.
348
+ if (this.#height !== transition.oldHeight)
349
+ transition.intermediateHeights.push(this.#height);
350
+ }
351
+ if (width !== this.#width) {
352
+ transition.widthChanged = true;
353
+ transition.narrowestWidth = Math.min(transition.narrowestWidth, this.#width, width);
354
+ }
82
355
  this.#width = width;
83
356
  this.#height = nextHeight;
84
357
  }
@@ -86,11 +359,32 @@ export class MainScreenRenderer {
86
359
  reset() {
87
360
  this.#reanchor = true;
88
361
  }
89
- /** Replace native history with a new logical transcript on the next paint. */
362
+ /**
363
+ * The document changed shape in place from `fromRow` down: a run folded or
364
+ * opened, or a call or thought was opened.
365
+ *
366
+ * The frozen boundary is a row index, and a fold moves every row after it.
367
+ * A reshape below the boundary is ordinary: those rows were never frozen, and
368
+ * the next paint commits them in their new shape. A reshape above it moved
369
+ * rows the terminal already holds, so the index stops naming the row it
370
+ * froze — a shrink showed only the rows past the stale index over a blank
371
+ * screen, a larger one read as a replaced document and replayed the whole
372
+ * transcript, and a growth pushed rows already in history a second time. For
373
+ * that case the renderer repaints the screen with the document's tail and
374
+ * maps the frozen boundary into its new row indices. Frozen rows may reappear
375
+ * in the viewport without being committed again. What history holds stays
376
+ * as it was shown: a snapshot, which is the contract, not a defect.
377
+ *
378
+ * The mark survives frames that browse history and applies on the next frame
379
+ * that follows the tail.
380
+ */
381
+ reflow(fromRow = 0) {
382
+ this.#reflowFrom = Math.min(this.#reflowFrom ?? fromRow, fromRow);
383
+ }
384
+ /** Adopt a new logical transcript while retaining native history. */
90
385
  startEpoch(options = {}) {
91
386
  this.#newEpoch = true;
92
- this.#fullReplayOnNextEpoch = options.replay !== 'pinned';
93
- this.#clearScrollbackOnNextRender = true;
387
+ this.#epochReplay = options.replay ?? 'full';
94
388
  this.#reanchor = true;
95
389
  this.#adoptPhysicalAfterTransient = false;
96
390
  }
@@ -114,24 +408,193 @@ export class MainScreenRenderer {
114
408
  }
115
409
  /** Render a frame, appending only finalized rows during a stable geometry epoch. */
116
410
  render(frame) {
411
+ // The event slot is per-frame. A frame that prepares no move must not inherit the last
412
+ // one's record: the confirmation steps are written against "the move this frame made",
413
+ // and a leftover slot would let them check against rows a previous frame moved. A move
414
+ // that still needs confirming lives in the pending queue, which is what the retry reads —
415
+ // that lifecycle is explicit and not a side effect of this field.
416
+ this.#pendingEvent = undefined;
117
417
  const next = frame.lines.map(line => sanitizeDisplayLine(String(line)));
118
418
  const liveStart = Math.max(0, Math.min(frame.liveStart ?? 0, next.length));
119
419
  const livePinned = frame.livePinned !== false;
120
420
  const cursor = frame.cursor ?? { row: next.length, column: 0 };
121
421
  const cursorVisible = frame.cursorVisible !== false;
422
+ // Settle a main-screen resize that was parked while an overlay was up, but
423
+ // only on a frame that actually comes **back** to the main screen. An overlay
424
+ // repaint is not that frame: restoring there would check the parked snapshot
425
+ // against the *overlay's* lines, find no matching document rows, credit
426
+ // nothing, and drop the pending move — so the rows the terminal committed
427
+ // would be sent a second time when the overlay is finally dismissed. Both
428
+ // paint paths can be the returning frame, a following transcript and a scroll
429
+ // browse alike, so this runs in `render` rather than in either of them.
430
+ // Settles on the frame that comes **back** to the main screen, and afterwards
431
+ // on any further frame that can finish an open comparison. The retry cannot
432
+ // depend on the overlay still being up: after the real exit it is not, and a
433
+ // record left open then would never be read again.
434
+ // Any frame that draws the normal screen may settle an open comparison. The test is the
435
+ // **same predicate the draw dispatch uses**, not a name from the frame: a frame with no
436
+ // `liveStart` goes to the alternate buffer whenever overlays are enabled and it is not a
437
+ // scroll, while a frame naming `overlay` with overlays disabled stays on the normal
438
+ // screen — so `surface`
439
+ // alone would settle records on a frame that painted the other buffer, and refuse to on
440
+ // one that painted this one. An alternate frame may still apply a normal resize event;
441
+ // what it must not do is confirm main-screen sources from overlay text.
442
+ const drawsAlternate = liveStart === 0
443
+ && this.#alternateScreenOverlays
444
+ && frame.transientSurface !== 'scroll';
445
+ // Fixed and applied once, after the target buffer is known and before the queue is read, so
446
+ // this frame's own move is part of its own comparison. The draw paths receive the result
447
+ // instead of looking for a field that has already been cleared.
448
+ const pendingTransition = this.#resize;
449
+ if (pendingTransition !== undefined)
450
+ this.#resize = undefined;
451
+ this.#pendingEvent = pendingTransition === undefined
452
+ ? undefined
453
+ : this.#prepareNormalMove(pendingTransition);
454
+ if (this.#pendingEvent !== undefined)
455
+ this.#mainResizeRecords.push(this.#pendingEvent);
456
+ // A normal frame settles the outstanding protection copies before it decides what to
457
+ // commit, so a copy confirmed here takes part in this frame's difference. An alternate
458
+ // frame must not: its lines are not main-screen text and cannot confirm anything.
459
+ let restoreBytes = '';
460
+ const restore = drawsAlternate ? undefined : this.#pendingReverse;
461
+ if (restore !== undefined) {
462
+ // The instances this pull took were already collected by `#applyMove` when the risk stood,
463
+ // so they are not pushed again here: the same objects entering the list twice would let one
464
+ // physical instance be confirmed twice.
465
+ //
466
+ // The rows coming back are again positions beyond the proven suffix — native history holds
467
+ // them once more — so the counted front grows by exactly what this move returns.
468
+ this.#unknownFront += restore.count;
469
+ // The reverse move is this event's answer to the risk its own pull raised, so that risk is
470
+ // spent: leaving it standing would make the next frame scroll the whole window out over
471
+ // rows that have already been put back.
472
+ this.#unknownOnScreen = false;
473
+ this.#pendingReverse = undefined;
474
+ }
475
+ if (!drawsAlternate)
476
+ this.#settleProtection(next, frame);
477
+ const returning = !drawsAlternate;
478
+ if (returning && this.#mainResizeRecords.length > 0) {
479
+ // Every move in the stay is settled, oldest first, because each was made
480
+ // against the screen the previous one left.
481
+ const records = this.#mainResizeRecords;
482
+ this.#mainResizeRecords = [];
483
+ // Records are settled oldest first, and each was made against the screen the
484
+ // previous one left. Once one cannot be checked the rest have lost their
485
+ // context too, so the unresolved remainder is kept for a later frame rather
486
+ // than being thrown away.
487
+ let unresolvedFrom;
488
+ for (let at = 0; at < records.length; at += 1) {
489
+ const record = records[at];
490
+ // Three separate questions, kept apart because conflating any two of them
491
+ // produces a different defect:
492
+ //
493
+ // 1. **May the main screen be painted?** Always, once the frame asks for it.
494
+ // Settling the accounting must never swallow the frame: the user asked
495
+ // for this window, and leaving the overlay up because a document
496
+ // comparison is still open is a display bug, not bookkeeping.
497
+ // 2. **What did the terminal move on its own?** The credit, checked against
498
+ // the snapshot's rows and the returning frame's text.
499
+ // 3. **Is the comparison complete?** A frame may confirm part of the
500
+ // snapshot and leave the rest unchecked — a browse window that covers
501
+ // only the first of two committed rows, or a frame that declares no body
502
+ // range. "Unchecked" is not "committed nothing", so the unconfirmed
503
+ // remainder is kept for a later frame.
504
+ const check = this.#creditAgainst(next, frame, record);
505
+ // Every confirmed range is **added**, not assigned. A credit that has been
506
+ // confirmed but not yet retired is still owed its subtraction: going back
507
+ // into the overlay and shrinking again yields a *second* natural commit,
508
+ // and replacing the first with the second would drop rows the terminal had
509
+ // already committed — which then get sent a second time.
510
+ // The move's physical effect on history is applied **once**, from its own
511
+ // geometry and row mapping — not from whether the text check happened to
512
+ // finish. The terminal moved those rows a single time, however many frames it
513
+ // takes to work out what they held; re-applying on a retry would record one
514
+ // move as several, and skipping it because a browse could not read one row
515
+ // would lose it entirely.
516
+ // A confirmation edits the **existing** entries; it never adds to the stack.
517
+ // A row the frame could not read stays unconfirmed and is re-checked on a
518
+ // later frame, without the move being recorded twice.
519
+ for (const row of check.confirmed) {
520
+ const entry = record.pushedRows?.find(item => item.document === row.start && item.text === undefined);
521
+ if (entry !== undefined) {
522
+ entry.text = row.text;
523
+ entry.credited = true;
524
+ }
525
+ }
526
+ if (check.remaining !== undefined && unresolvedFrom === undefined)
527
+ unresolvedFrom = at;
528
+ // A confirmed credit is subtracted from what the flush owes rather than
529
+ // folded into the boundary, because the rows between the two are still owed:
530
+ // with `liveStart` 8, only 0..7 are committed, and a natural commit of
531
+ // [20,22) still leaves [8,20) owed. Advancing the boundary to the end of
532
+ // the commit would mark all of it done and those rows would never be sent.
533
+ // A row that touches the frozen boundary is settled by moving the boundary over
534
+ // it; the rest stays on the stack for the flush to subtract.
535
+ this.#settleBoundary();
536
+ // The snapshot lives in its own record, separate from `#resize` and from
537
+ // the live screen memory, so it survives a frame that could not finish the
538
+ // comparison without holding up the paint. A transition that arrived after
539
+ // this record was parked belongs to a **later** move and is left alone:
540
+ // consuming it here would settle a resize the record never described.
541
+ }
542
+ if (unresolvedFrom !== undefined)
543
+ this.#mainResizeRecords = records.slice(unresolvedFrom);
544
+ }
545
+ // The reverse move belongs to the normal buffer, so the plan exits the alternate buffer
546
+ // itself — in its own position, before the scroll — and the paint is told not to emit that
547
+ // exit again. The buffer state is read here, before any paint has had the chance to change it.
548
+ if (restore !== undefined && !drawsAlternate && this.#altActive) {
549
+ restoreBytes = EXIT_ALT_SCREEN
550
+ + csi(this.#height - 1, 0)
551
+ + '\n'.repeat(restore.count);
552
+ // Only the ownership flag is set here. Clearing `#altActive` here would make the paint's own
553
+ // `leavingAlt` false, changing which baseline it picks and skipping the forced repaint that
554
+ // leaving the buffer requires; the buffer is left for the paint to close as it always did,
555
+ // with its exit suppressed.
556
+ this.#exitOwnedByCaller = true;
557
+ }
558
+ else if (restore !== undefined && !drawsAlternate) {
559
+ restoreBytes = csi(this.#height - 1, 0) + '\n'.repeat(restore.count);
560
+ }
122
561
  const paint = liveStart === 0
123
- ? this.#paintTransient(next, cursor, cursorVisible, frame.transientSurface)
124
- : this.#paintFollow(next, liveStart, livePinned, cursor, cursorVisible);
125
- if (paint !== '')
126
- this.#sink.write(paint);
562
+ ? this.#paintTransient(next, cursor, cursorVisible, frame.transientSurface, frame)
563
+ : this.#paintFollow(next, liveStart, livePinned, cursor, cursorVisible, frame);
564
+ // The reverse move is a prefix of this frame's output and is emitted before any of it can
565
+ // overwrite the rows: position at the bottom of the normal screen, then scroll down by the
566
+ // exact count that was taken there. It is put in front of the frame body rather than into a
567
+ // separate write so nothing drawn later can displace it.
568
+ // The reverse move belongs to the normal buffer. This prefix is correct for a frame that is
569
+ // already on it, which is the case the current regression covers.
570
+ //
571
+ // Known gap, not solved here: `paint` is `#finishPaint`'s output and is already packaged by
572
+ // `#wrap`, so the `1049` exit a leaving-alt frame carries is **not** at its front — searching
573
+ // for it there matches nothing and the prefix still runs while the alternate buffer is in
574
+ // effect. Ordering this correctly needs the exit, the reverse move, the baseline and the main
575
+ // paint to be sequenced as a structured plan **before** wrapping, with one owner for the exit;
576
+ // it cannot be repaired by inspecting the packaged string. Until that exists, a frame that
577
+ // leaves the alternate buffer does not run the reverse move on the right buffer.
578
+ this.#exitOwnedByCaller = false;
579
+ // One packaging for the whole frame: the reverse prefix and the body are wrapped together
580
+ // rather than the body being wrapped on its own and the prefix left outside it.
581
+ const frameBytes = restoreBytes + paint;
582
+ if (frameBytes !== '')
583
+ this.#sink.write(this.#wrap(frameBytes));
127
584
  }
128
- #paintTransient(next, cursor, cursorVisible, surface) {
585
+ #paintTransient(next, cursor, cursorVisible, surface, frame) {
129
586
  const target = this.#target(next, 0, 'top');
587
+ // The transition is fixed here, before the buffer is chosen, so the alternate branch and
588
+ // the baseline path below consume the same event instead of each looking for it later —
589
+ // by which time one of them would already have cleared the shared field.
590
+ const transitionForAlt = this.#pendingEvent?.transition;
130
591
  // Browsing history is not an overlay: the alternate buffer would hide the
131
592
  // terminal's own scrollback, which is exactly what the user is scrolling
132
593
  // through, so a scroll frame repaints the main screen instead.
133
594
  if (this.#alternateScreenOverlays && surface !== 'scroll') {
134
- const clear = !this.#altActive || this.#resize !== undefined;
595
+ // A transition present here is the overlay's own: the main screen's is parked
596
+ // on `#mainResizeRecord`, so a restore never re-arms this clear.
597
+ const clear = !this.#altActive || transitionForAlt !== undefined;
135
598
  let body = this.#altActive ? '' : ENTER_ALT_SCREEN;
136
599
  body += this.#paintScreen(target.rows, clear ? this.#blankScreen() : this.#altScreen, clear);
137
600
  const targetRow = this.#screenRow(cursor.row, target, next.length);
@@ -140,15 +603,79 @@ export class MainScreenRenderer {
140
603
  this.#altActive = true;
141
604
  this.#altScreen = target.rows;
142
605
  this.#cursorVisible = cursorVisible;
143
- // The clear above already reconciled the resize and the re-anchor; leaving
144
- // them pending would clear the overlay again on every following frame.
606
+ // The clear above already reconciled the overlay's own layout; leaving
607
+ // its re-anchor pending would clear the overlay again on every following
608
+ // frame.
609
+ //
610
+ // A resize arriving with this frame also moved the *main* screen: a
611
+ // terminal resizes both buffers, and the hidden one is not exempt. So the
612
+ // transition is parked on `#mainResizeRecord` rather than dropped, together with
613
+ // the main screen's own cursor and source snapshot. The overlay settles its
614
+ // layout and leaves the main screen to be reconciled by the frame that comes
615
+ // back to it.
616
+ // Park only a real transition, and never overwrite one that is already
617
+ // parked. An ordinary overlay repaint arrives with no new transition, and
618
+ // assigning unconditionally would replace a pending main-screen move with
619
+ // `undefined` — losing the move and its snapshot entirely. The main screen
620
+ // may have been resized while this overlay was up and again while it stayed
621
+ // up; the first move is the one the boundary has to account for, and a later
622
+ // one cannot be folded into it.
623
+ // Every move in this overlay stay gets its own record. Keeping only the
624
+ // first would leave the second resize's rows unaccounted for, and they would
625
+ // be sent a second time when the overlay finally comes down.
626
+ //
627
+ // The snapshot is **advanced** past any earlier unsettled move first. No
628
+ // frame repainted the main screen in between, so the live fields still show
629
+ // the screen as it was before the earlier move; taking them as they stand
630
+ // would describe the wrong rows. A push shifts the screen up by exactly the
631
+ // rows it pushed, so the earlier move's own geometry says how far to shift.
632
+ // Only advance from the queue when nothing repainted the main screen since.
633
+ // A record can outlive its stay — kept because its comparison is unfinished —
634
+ // while the main screen has meanwhile been painted for real. Its snapshot then
635
+ // describes a screen that no longer exists, and advancing from it invents
636
+ // committed rows that were never committed. The live fields are accurate
637
+ // exactly in that case, because a real paint wrote them.
638
+ if (transitionForAlt !== undefined) {
639
+ // Only advance from an earlier record when nothing repainted the main screen
640
+ // since. A record can outlive its stay — kept because its comparison is
641
+ // unfinished — while the main screen has meanwhile been painted for real. Its
642
+ // snapshot then describes a screen that no longer exists, and the move's own
643
+ // `after` is not built at all, so the following move inherits an unaccounted
644
+ // layout rather than a guess at one.
645
+ // The starting layout no longer lives in this branch: it is the shared
646
+ // `#normalLayout`, which records whichever physical action established the normal
647
+ // buffer last. Keeping a second answer here — the queue's own `after`, or the live
648
+ // fields — is what let the two timelines disagree about the same move.
649
+ // A grow takes back **exactly the rows the terminal pulls down**, and no
650
+ // more. Verified against @xterm/headless 6.0.0: ten rows showing `line-20`
651
+ // to `line-29` over history `line-0..7`, shrunk to six, commits
652
+ // `line-20..23`; growing back to seven with the cursor at the bottom pulls
653
+ // **only `line-23`** down again, leaving `line-20..22` in history. So the
654
+ // withdrawal is the rows the stack holds at its newest end — the positions the
655
+ // terminal pushed last, which are not the same thing as the highest document
656
+ // numbers, and include rows holding no document line at all. A grow whose
657
+ // cursor is not at the bottom pulls back nothing.
658
+ //
659
+ // Withdrawing the whole ledger instead re-sends rows that are still in
660
+ // history, and history cannot be rewritten once it has scrolled.
661
+ // The rows taken back are recorded on the grow's own move. The move's effect
662
+ // on history is applied when it is **parked** (see `#applyMove`), not here:
663
+ // a following move in the same stay has to see the tail this one leaves.
664
+ // The cursor the move starts from comes from the shared layout, not the live
665
+ // field: during a stay the live cursor still holds the main screen's position from
666
+ // *before* the stay, so it cannot decide whether the hidden screen sat at the
667
+ // bottom.
668
+ // The hidden buffer's move goes through the same preparation as the main screen's,
669
+ // so one normal physical timeline covers both and a later frame returning to the
670
+ // main screen reads the state this move leaves rather than an older snapshot.
671
+ }
145
672
  this.#resize = undefined;
146
673
  this.#reanchor = false;
147
- return this.#wrap(body);
674
+ return body;
148
675
  }
149
676
  const leavingAlt = this.#altActive;
150
- const exitAlt = leavingAlt ? EXIT_ALT_SCREEN : '';
151
- const resized = this.#takeResizeBaseline();
677
+ const exitAlt = leavingAlt && !this.#exitOwnedByCaller ? EXIT_ALT_SCREEN : '';
678
+ const resized = this.#takeResizeBaseline(transitionForAlt);
152
679
  // Leaving the alternate buffer restores whatever the main screen held before
153
680
  // it was entered, so that snapshot is the baseline to diff against.
154
681
  const baseline = leavingAlt ? this.#screen : resized.rows;
@@ -156,13 +683,44 @@ export class MainScreenRenderer {
156
683
  this.#altActive = false;
157
684
  this.#altScreen = this.#blankScreen();
158
685
  }
159
- const body = this.#paintScreen(target.rows, baseline, this.#reanchor || resized.clear || leavingAlt);
686
+ // A frame that comes back to the main screen also has to settle what the
687
+ // terminal committed while the overlay covered it. A browse window repaints
688
+ // itself whole and commits nothing, so the rows between the frozen boundary
689
+ // and the parked commit are owed here — and the parked rows must not be sent
690
+ // again. Both paths need it, so it runs whichever of them this frame takes.
691
+ // A browse window is not the document, so it cannot settle a document-level
692
+ // commit: the rows it shows are a window, and crediting against it would
693
+ // compare the wrong text. The credit stays parked for the next frame that
694
+ // carries the document — and the rows between the boundary and the commit
695
+ // are still owed, so the boundary does not move either.
696
+ // A transient frame covers the main screen too, so it consumes the same protection
697
+ // risk before it paints. `leavingAlt` is handled above by the caller having already
698
+ // exited the alternate buffer: the scroll has to act on the main screen.
699
+ const protect = leavingAlt || !this.#altActive
700
+ ? this.#planProtection(next, frame)
701
+ : { lead: '', confirmed: [] };
702
+ const flush = '';
703
+ // The scroll blanked the window, so the protected baseline is not what is on screen
704
+ // any more: diffing against it would skip every row that now reads blank and record
705
+ // lines the terminal does not hold. A protection scroll therefore forces the repaint
706
+ // exactly as a clear does.
707
+ const body = protect.lead
708
+ + this.#paintScreen(target.rows, protect.lead === '' ? baseline : this.#blankScreen(), this.#reanchor || resized.clear || leavingAlt || protect.lead !== '');
160
709
  this.#reanchor = false;
161
710
  this.#transient = true;
162
- return this.#finishPaint(exitAlt + body, target, next.length, cursor, cursorVisible);
711
+ // Every frame painted on the main screen replaces what the main screen
712
+ // showed — a browse, but also a main-screen overlay, which is a different
713
+ // thing entirely and must clear the document range rather than leave the
714
+ // previous one standing. An alternate-buffer frame is a different buffer and
715
+ // is filtered inside the recorder, not here.
716
+ const header = this.#paintStickyHeader(frame, target);
717
+ this.#screenRows = target.rows;
718
+ this.#recordSource(frame, target);
719
+ return this.#finishPaint(exitAlt + body + flush + header, target, next.length, cursor, cursorVisible);
163
720
  }
164
- #paintFollow(next, liveStart, livePinned, cursor, cursorVisible) {
165
- const exitAlt = this.#altActive ? EXIT_ALT_SCREEN : '';
721
+ #paintFollow(next, liveStart, livePinned, cursor, cursorVisible, frame) {
722
+ const transcript = frame.transcript;
723
+ const exitAlt = this.#altActive && !this.#exitOwnedByCaller ? EXIT_ALT_SCREEN : '';
166
724
  if (this.#altActive) {
167
725
  this.#altActive = false;
168
726
  this.#altScreen = this.#blankScreen();
@@ -173,37 +731,205 @@ export class MainScreenRenderer {
173
731
  let target = this.#target(next, effectiveStart, 'bottom');
174
732
  let body = '';
175
733
  if (!this.#hasFrame || this.#newEpoch) {
176
- // An explicit replacement follows ED3, so it must rebuild the complete
177
- // logical frame. Durable logs can end with an orphaned streaming/tool
178
- // block; applying the ordinary pending seam here would silently omit its
179
- // off-screen middle from the restored transcript.
180
- const replayPhysical = this.#newEpoch && this.#fullReplayOnNextEpoch ? viewStart : candidatePhysical;
181
- body = this.#paintFlush(next, 0, replayPhysical, viewStart, true);
734
+ // Document replacement replays its snapshot; a projection refresh only
735
+ // adopts its viewport. Both need a fresh row index, but a refresh must
736
+ // not append a second copy of the document to native history.
737
+ const replayPhysical = this.#newEpoch && this.#epochReplay === 'full' ? viewStart : candidatePhysical;
738
+ body = this.#newEpoch && this.#epochReplay === 'viewport'
739
+ ? this.#paintScreen(target.rows, this.#screen, true)
740
+ : this.#paintFlush(next, 0, replayPhysical, viewStart, true);
182
741
  this.#physical = replayPhysical;
742
+ this.#frozenViewport = false;
183
743
  this.#newEpoch = false;
184
- this.#fullReplayOnNextEpoch = true;
744
+ this.#epochReplay = 'full';
185
745
  this.#resize = undefined;
186
746
  }
187
- else if (this.#resize !== undefined) {
188
- const transition = this.#resize;
189
- const baseline = this.#resizedScreen(transition.oldHeight);
190
- body = this.#paintScreen(target.rows, baseline, transition.widthChanged);
191
- // The terminal has already moved rows between screen and scrollback.
192
- // Adopt that physical boundary rather than replaying those rows.
193
- this.#physical = viewStart;
747
+ else if (this.#pendingEvent !== undefined
748
+ || (!this.#tailStale && creditedRows(this.#pushed, next).length > 0)) {
749
+ const transition = this.#pendingEvent?.transition;
750
+ const baseline = transition === undefined
751
+ ? { rows: this.#screen.slice(0, this.#height), clear: true }
752
+ : this.#resizedScreen(transition);
753
+ body = this.#paintScreen(target.rows, baseline.rows, transition === undefined ? true : transition.widthChanged || baseline.clear);
754
+ // A height change moves rows between screen and scrollback by itself, but
755
+ // that is a screen-level event, not a document one, and it does not move
756
+ // the frozen boundary at all. A taller window brings back what scrollback
757
+ // holds — snapshots of how rows looked when they were pushed there, not
758
+ // the current document's rows — so those rows say nothing about where
759
+ // this document's boundary is. Moving the boundary by the height delta
760
+ // instead stepped it *past* frozen rows and unfroze them, and the next
761
+ // growth committed those rows to history a second time. Deriving it from
762
+ // `viewStart` was the original defect: that discards the boundary
763
+ // outright and hands the whole frozen head to the next flush.
764
+ //
765
+ // A reshape may be pending in the same frame, and this branch runs before
766
+ // the reflow one. Deferring it is not an option: the next frame records
767
+ // the rows this document now has, so the mapping would compare the new
768
+ // shape against itself and skip the rows between the stale boundary and
769
+ // the real one — rows that had never been shown and never committed.
770
+ // So the mapping happens here, and this frame counts as the reflow.
771
+ const reshaped = this.#reflowFrom !== undefined && this.#reflowFrom < this.#physical;
772
+ // The move's physical effect on history happens **once**, before the branches
773
+ // decide anything, and whichever branch this frame then takes works from the
774
+ // same history. Applying it inside one branch meant a frame that took another
775
+ // branch never recorded the push at all — a visible grow's pull, for instance,
776
+ // was written in the fallback branch while a frame with credits pending went
777
+ // to the branch above it, so the row the terminal took back kept its credit and
778
+ // the flush skipped it.
779
+ // The move for this frame was fixed and applied by the single producer in `render`, whose
780
+ // record carries the trust it was applied under. Re-judging it here from the live screen
781
+ // would interpret the same event a second time, against a screen that has already moved.
782
+ if (reshaped) {
783
+ this.#physical = Math.max(candidatePhysical, this.#reshapedBoundary(next, Math.max(liveStart, candidatePhysical), transcript));
784
+ this.#frozenViewport = this.#physical > candidatePhysical;
785
+ this.#reflowFrom = undefined;
786
+ this.#reanchor = false;
787
+ this.#adoptPhysicalAfterTransient = false;
788
+ }
789
+ else if (candidatePhysical > this.#physical || (!this.#tailStale && creditedRows(this.#pushed, next).length > 0)) {
790
+ // What still needs committing, as a set difference rather than one
791
+ // number. The frozen boundary is the floor — an earlier frame already
792
+ // committed everything above it. The terminal's own move removes a
793
+ // *range* from what is left, and that range need not touch the floor:
794
+ // a browse window that starts past it leaves a gap between the two that
795
+ // a single `max` would silently drop. So: commit the part of
796
+ // [floor, candidate) below the committed range, and the part above it.
797
+ // The comparison for this frame's move already ran: the single producer in `render` kept
798
+ // the event, and the central queue confirmed it before any draw path was chosen. Doing it
799
+ // again here would give one move two ledgers to be checked against, and the second one is
800
+ // built from a screen the first has already changed.
801
+ // A visible resize's confirmations go into the same ledger the parked ones
802
+ // use, **before** anything is retired. A confirmation that reaches past the
803
+ // candidate has not been settled by this frame — with `liveStart` 8 and a
804
+ // commit of [20,22), a frame whose candidate is 10 owes nothing of it — and
805
+ // dropping it here loses the fact that those rows are in history, so a later
806
+ // frame that does reach them sends them again.
807
+ // Re-checked at the moment of use: a credit confirmed against text that has
808
+ // since been rewritten is no longer evidence about this document, while one
809
+ // the frame cannot see stays credited.
810
+ // A credited position is only usable as a deduction while the tail still describes
811
+ // real history: an unfollowed move may have pulled that very instance back off it,
812
+ // and skipping a row the terminal no longer holds would drop it entirely rather
813
+ // than merely sending it twice.
814
+ const committed = this.#tailStale ? [] : creditedRows(this.#pushed, next);
815
+ const floor = Math.min(candidatePhysical, Math.max(0, this.#physical));
816
+ // Rows this flush actually settled stop being deductible — their positions
817
+ // stay, because the terminal still holds them and a grow pulls from exactly
818
+ // there. A row past the candidate has not been reached at all and keeps its
819
+ // claim for the frame that does reach it.
820
+ for (const row of this.#pushed) {
821
+ if (row.credited && row.document !== undefined && row.document < candidatePhysical) {
822
+ row.credited = false;
823
+ // Same rule as the boundary settlement: retiring settles the position, so its
824
+ // confirmation is closed while the position itself stays.
825
+ row.confirmationClosed = true;
826
+ }
827
+ }
828
+ // The plan itself is not gated on there being rows to commit: a protection scroll
829
+ // covers the main screen whether or not this frame owes anything, and the target
830
+ // viewport has to be drawn again afterwards either way.
831
+ const protect = this.#planProtection(next, frame);
832
+ const owed = floor < candidatePhysical
833
+ ? subtractFromRange(floor, candidatePhysical, [...protect.confirmed, ...committed])
834
+ : [];
835
+ if (owed.some(([from, to]) => from < to)) {
836
+ // One pass, so the two halves cannot clear each other's output.
837
+ body = protect.lead + this.#paintFlushRanges(next, owed, viewStart);
838
+ }
839
+ else if (protect.lead !== '') {
840
+ // Nothing left to commit, but the scroll just blanked the window: paint the
841
+ // target viewport so the frame shows what it says it shows. Leaving the
842
+ // newlines alone left an empty screen that `#finishPaint` then recorded as the
843
+ // target — a frame wrong on screen and wrong in memory for the next diff.
844
+ body = protect.lead + this.#paintScreen(this.#target(next, viewStart, 'bottom').rows, this.#blankScreen(), true);
845
+ }
846
+ // This boundary has already been moved down where a real pull took instances back out of
847
+ // history: the shared entry records what it actually took, so the proof is settled before
848
+ // this point. What is left is a commit frame — the rows it sent end at the candidate — so
849
+ // the boundary may advance, and it may not retreat. A smaller candidate is not evidence
850
+ // that rows left the terminal, and a requested pull is a limit, not a result.
851
+ this.#physical = Math.max(this.#physical, candidatePhysical);
852
+ this.#frozenViewport = this.#physical > candidatePhysical;
853
+ }
854
+ else if (this.#physical > candidatePhysical) {
855
+ this.#physical = Math.max(0, Math.min(this.#physical, next.length));
856
+ this.#frozenViewport = true;
857
+ }
858
+ else {
859
+ // The candidate has not moved, yet the viewport now reaches further back
860
+ // into the document than it did. How many of those rows the terminal
861
+ // already moved into native history is a **count**, not a yes/no, and it
862
+ // is not the height difference: the terminal removes blanks from the
863
+ // bottom first and only pushes what is left over off the top. For a
864
+ // same-width shrink the count is `oldCursorRow + 1 - newHeight`, floored
865
+ // at zero, and every one of the three shapes is real. Checked against
866
+ // @xterm/headless 6.0.0, all on a 5-row screen holding `[p1..p5]` over
867
+ // history `[a,b]` shrinking to 2 rows:
868
+ //
869
+ // - cursor on row 0: history stays `[a,b]`, nothing is pushed;
870
+ // - cursor on row 3: history becomes `[a,b,p1,p2]`, two rows pushed;
871
+ // - cursor on row 4: history becomes `[a,b,p1,p2,p3]`, all three pushed.
872
+ //
873
+ // Treating it as "at the bottom or not" gets the middle case wrong, and
874
+ // both endpoint answers do too: crediting `viewStart` when the count is
875
+ // zero freezes rows that never entered history, and holding at the
876
+ // candidate when the count is positive flushes rows the terminal already
877
+ // moved, duplicating them.
878
+ //
879
+ // `#cursorRow` still holds the position from before the resize, since the
880
+ // frame that would move it has not been painted yet, and that is the same
881
+ // signal the terminal itself uses.
882
+ //
883
+ // A **width** change is not a case of "no rows moved". Verified against
884
+ // @xterm/headless 6.0.0: a 5-row screen holding `[p1..p5]` over history
885
+ // `[a,b]` with the cursor on the last row, resized to 100 columns and 2
886
+ // rows, ends on `[p4,p5]` with `[a,b,p1,p2,p3]` in history — the short
887
+ // lines do not wrap, so the same three rows are committed as in a
888
+ // same-width shrink. Treating a width change as a known zero would credit
889
+ // nothing and make the next flush commit those rows a second time.
890
+ //
891
+ // The same source-aware accounting as every other resize branch. A count
892
+ // of pushed rows says how many left the screen, not *which document rows*
893
+ // they were: with `liveStart` 8 and a ten-row window showing 20..29, the
894
+ // count is two but the rows the terminal committed are 20 and 21 — not the
895
+ // 8 and 9 that lie next to the frozen boundary. Advancing the boundary by
896
+ // the count would claim 8 and 9 as committed, and they would never be sent
897
+ // by any later frame.
898
+ // The event prepared for this frame, not an object rebuilt beside it: a substitute
899
+ // mixing live fields with the event's trust describes a move that never happened,
900
+ // and the confirmation would be about rows the application never touched.
901
+ // Confirmed centrally, for the same reason as above: this path reads the ledger the
902
+ // comparison left behind and never confirms an instance of its own.
903
+ // Only the rows touching the boundary are settled here; the rest stay on the
904
+ // stack for the flush to subtract.
905
+ this.#settleBoundary();
906
+ this.#frozenViewport = false;
907
+ }
908
+ // No re-queue here: the move this frame made was already kept by the single producer in
909
+ // `render`, before the comparison ran. Keeping a second copy from the follow path would
910
+ // leave two records for one move and two chances to confirm it.
194
911
  this.#resize = undefined;
195
912
  }
913
+ else if (this.#reflowFrom !== undefined && this.#reflowFrom < this.#physical) {
914
+ body = this.#paintScreen(target.rows, this.#screen, true);
915
+ // Preserve unchanged frozen rows in their new index space. A shrink may
916
+ // bring them onto screen; repainting them does not make them unfrozen.
917
+ this.#physical = Math.max(candidatePhysical, this.#reshapedBoundary(next, Math.max(liveStart, candidatePhysical), transcript));
918
+ this.#frozenViewport = this.#physical > candidatePhysical;
919
+ this.#reanchor = false;
920
+ this.#adoptPhysicalAfterTransient = false;
921
+ }
196
922
  else if (this.#reanchor) {
197
923
  if (candidatePhysical > this.#physical) {
198
924
  body = this.#paintFlush(next, this.#physical, candidatePhysical, viewStart, true);
199
925
  this.#physical = candidatePhysical;
200
926
  }
201
- else if (next.length <= this.#physical) {
927
+ else if (!this.#frozenViewport && next.length <= this.#physical) {
202
928
  body = this.#paintFlush(next, 0, candidatePhysical, viewStart, true);
203
929
  this.#physical = candidatePhysical;
204
930
  }
205
931
  else {
206
- effectiveStart = Math.max(this.#physical, viewStart);
932
+ effectiveStart = this.#frozenViewport ? viewStart : Math.max(this.#physical, viewStart);
207
933
  target = this.#target(next, effectiveStart, 'bottom');
208
934
  body = this.#paintScreen(target.rows, this.#screen, true);
209
935
  }
@@ -211,8 +937,33 @@ export class MainScreenRenderer {
211
937
  }
212
938
  else if (this.#transient) {
213
939
  if (this.#adoptPhysicalAfterTransient) {
214
- body = this.#paintScreen(target.rows, this.#screen, true);
215
- this.#physical = viewStart;
940
+ // Returning from an overlay or a browse. A boundary already mapped past the candidate
941
+ // is kept: the frozen rows are still in native history, and a grow pulls exactly those
942
+ // instances back — which is what `#pulledFromHistory` accounts for, not a reason to
943
+ // claim they never were frozen. A boundary **below** the candidate has rows that were
944
+ // never sent at all, and those are committed for real before the boundary follows them.
945
+ // A repaint on its own proves nothing and may not move it.
946
+ const mapped = this.#physical > candidatePhysical;
947
+ if (mapped) {
948
+ this.#physical = Math.max(0, Math.min(this.#physical, next.length));
949
+ this.#frozenViewport = true;
950
+ body = this.#paintScreen(target.rows, this.#screen, true);
951
+ }
952
+ else if (candidatePhysical > this.#physical) {
953
+ // The flush emits the owed range and the live window together, so the target is
954
+ // already drawn; adding a screen paint after it would clear and repaint the same rows
955
+ // without proving anything new. Rows below the candidate were never sent, and only
956
+ // now that they are does the boundary follow them.
957
+ body = this.#paintFlush(next, this.#physical, candidatePhysical, viewStart, false);
958
+ this.#physical = candidatePhysical;
959
+ this.#frozenViewport = false;
960
+ }
961
+ else {
962
+ // Nothing to send and nothing proven to move: the frame paints, the boundary stays
963
+ // where the last real commit left it.
964
+ body = this.#paintScreen(target.rows, this.#screen, true);
965
+ this.#frozenViewport = false;
966
+ }
216
967
  this.#adoptPhysicalAfterTransient = false;
217
968
  }
218
969
  else if (candidatePhysical > this.#physical) {
@@ -220,38 +971,129 @@ export class MainScreenRenderer {
220
971
  this.#physical = candidatePhysical;
221
972
  }
222
973
  else {
223
- effectiveStart = candidatePhysical < this.#physical && next.length > this.#physical
974
+ effectiveStart = !this.#frozenViewport && candidatePhysical < this.#physical && next.length > this.#physical
224
975
  ? Math.max(this.#physical, viewStart)
225
976
  : viewStart;
226
977
  target = this.#target(next, effectiveStart, 'bottom');
227
978
  body = this.#paintScreen(target.rows, this.#screen, true);
228
979
  }
229
980
  }
230
- else if (next.length <= this.#physical) {
981
+ else if (!this.#frozenViewport && next.length <= this.#physical) {
231
982
  // A logical clear/replace that was not explicitly signalled still needs
232
983
  // an independent index space. Keep old history and append the new epoch.
233
984
  body = this.#paintFlush(next, 0, candidatePhysical, viewStart, true);
234
985
  this.#physical = candidatePhysical;
235
986
  }
236
987
  else if (candidatePhysical > this.#physical) {
237
- body = this.#paintFlush(next, this.#physical, candidatePhysical, viewStart, false);
988
+ // The draw plan: settle the protection's own history event first, take its rows out
989
+ // of what this frame still owes, and only then generate the bytes.
990
+ const protect = this.#planProtection(next, frame);
991
+ const owed = subtractFromRange(this.#physical, candidatePhysical, protect.confirmed);
992
+ body = protect.lead + (owed.length === 1
993
+ ? this.#paintFlush(next, owed[0][0], owed[0][1], viewStart, false)
994
+ : this.#paintFlushRanges(next, owed, viewStart));
238
995
  this.#physical = candidatePhysical;
239
996
  }
240
997
  else {
241
- if (candidatePhysical < this.#physical)
998
+ if (!this.#frozenViewport && candidatePhysical < this.#physical)
242
999
  effectiveStart = Math.max(this.#physical, viewStart);
243
1000
  target = this.#target(next, effectiveStart, 'bottom');
244
1001
  body = this.#paintScreen(target.rows, this.#screen, false);
245
1002
  }
246
1003
  this.#transient = false;
247
- return this.#finishPaint(exitAlt + body, target, next.length, cursor, cursorVisible);
1004
+ const header = this.#paintStickyHeader(frame, target);
1005
+ this.#screenRows = target.rows;
1006
+ this.#recordSource(frame, target);
1007
+ this.#reflowFrom = undefined;
1008
+ if (candidatePhysical >= this.#physical)
1009
+ this.#frozenViewport = false;
1010
+ this.#followRows = next;
1011
+ this.#followEnd = Math.max(liveStart, this.#physical);
1012
+ this.#followTranscript = transcript;
1013
+ return this.#finishPaint(exitAlt + body + header, target, next.length, cursor, cursorVisible);
1014
+ }
1015
+ #stickyHeader(frame, target) {
1016
+ const range = frame.documentRows;
1017
+ if (range === undefined || range === null || target.offset > 0)
1018
+ return undefined;
1019
+ const document = range.documentStart + Math.max(0, target.start - range.frameStart);
1020
+ return frame.stickyHeaders?.findLast(header => document >= header.start && document < header.end)?.text;
1021
+ }
1022
+ /** Paint after document flushes so the label never becomes a transcript row. */
1023
+ #paintStickyHeader(frame, target) {
1024
+ const header = this.#stickyHeader(frame, target);
1025
+ if (header === undefined)
1026
+ return '';
1027
+ target.rows[0] = sanitizeDisplayLine(header);
1028
+ return this.#writeRun(target.rows, 0, 0);
1029
+ }
1030
+ /** Map a frozen row index across a changed span, keeping its suffix aligned. */
1031
+ #reshapedBoundary(next, end, transcript) {
1032
+ const before = this.#followRows;
1033
+ let prefix = Math.min(this.#reflowFrom ?? 0, this.#followEnd, end);
1034
+ while (prefix < this.#followEnd && prefix < end && before[prefix] === next[prefix])
1035
+ prefix += 1;
1036
+ if (this.#physical <= prefix)
1037
+ return this.#physical;
1038
+ // Block indices remain stable within a document, even if new turns arrive
1039
+ // during browsing. They keep appended content out of the reshaped span.
1040
+ const oldStarts = this.#followTranscript?.blockDrawStarts ?? this.#followTranscript?.blockStarts;
1041
+ const newStarts = transcript?.blockDrawStarts ?? transcript?.blockStarts;
1042
+ if (oldStarts !== undefined && newStarts !== undefined) {
1043
+ const mapped = mapBlockSpan({
1044
+ oldStarts, newStarts,
1045
+ oldBody: this.#followTranscript?.bodyRow ?? 0,
1046
+ newBody: transcript?.bodyRow ?? 0,
1047
+ physical: this.#physical,
1048
+ oldTail: this.#followEnd,
1049
+ newTail: end,
1050
+ });
1051
+ if (mapped !== undefined)
1052
+ return mapped;
1053
+ }
1054
+ let oldEnd = this.#followEnd;
1055
+ let newEnd = end;
1056
+ while (oldEnd > prefix && newEnd > prefix && before[oldEnd - 1] === next[newEnd - 1]) {
1057
+ oldEnd -= 1;
1058
+ newEnd -= 1;
1059
+ }
1060
+ // Changed frozen rows retain their old snapshot. Only the unchanged suffix
1061
+ // has a precise correspondence; never replay the reshaped span itself.
1062
+ return this.#physical < oldEnd ? newEnd : newEnd + this.#physical - oldEnd;
248
1063
  }
249
1064
  /** Emit finalized rows followed by the bounded live tail. */
1065
+ /**
1066
+ * Commit several disjoint stretches in one paint, then the live viewport.
1067
+ *
1068
+ * The stretches exist because what the terminal committed on its own is a
1069
+ * range that need not touch the frozen boundary: a browse window that starts
1070
+ * past it leaves a gap between the two, and a single upper bound would drop
1071
+ * that gap. They go out together because each pass starts by clearing the
1072
+ * screen, so two passes would erase the first one's rows.
1073
+ */
1074
+ #paintFlushRanges(next, ranges, viewStart) {
1075
+ const rows = [
1076
+ ...ranges.flatMap(([from, to]) => next.slice(from, to)),
1077
+ ...next.slice(viewStart, viewStart + this.#height),
1078
+ ];
1079
+ this.#noteFlushed(next, ranges.flatMap(([from, to]) => Array.from({ length: Math.max(0, to - from) }, (_, i) => from + i)));
1080
+ let out = CLEAR_SCREEN;
1081
+ if (rows.length < this.#height)
1082
+ out += csi(this.#height - rows.length, 0);
1083
+ for (let i = 0; i < rows.length; i += 1) {
1084
+ if (i > 0)
1085
+ out += '\r\n';
1086
+ // EL is required before a shorter final row scrolls into history.
1087
+ out += CLEAR_LINE + (rows[i] ?? '');
1088
+ }
1089
+ return out;
1090
+ }
250
1091
  #paintFlush(next, start, committedEnd, viewStart, clearScreen) {
251
1092
  const rows = [
252
1093
  ...next.slice(start, committedEnd),
253
1094
  ...next.slice(viewStart, viewStart + this.#height),
254
1095
  ];
1096
+ this.#noteFlushed(next, Array.from({ length: Math.max(0, committedEnd - start) }, (_, i) => start + i));
255
1097
  let out = clearScreen ? CLEAR_SCREEN : csi(0, 0);
256
1098
  if (rows.length < this.#height)
257
1099
  out += csi(this.#height - rows.length, 0);
@@ -305,24 +1147,772 @@ export class MainScreenRenderer {
305
1147
  rows[offset + i] = content[i] ?? '';
306
1148
  return { rows, start, offset };
307
1149
  }
308
- #takeResizeBaseline() {
309
- if (this.#resize === undefined)
1150
+ #takeResizeBaseline(transition) {
1151
+ if (transition === undefined)
310
1152
  return { rows: this.#screen, clear: false };
311
- const transition = this.#resize;
312
- this.#resize = undefined;
313
1153
  this.#adoptPhysicalAfterTransient = true;
314
- return {
315
- rows: transition.widthChanged ? this.#blankScreen() : this.#resizedScreen(transition.oldHeight),
316
- clear: transition.widthChanged,
1154
+ // The baseline is computed **before** the transition is dropped. It needs the
1155
+ // heights the window passed through, and reading them off `this.#resize`
1156
+ // after clearing it found nothing there — so a 5 -> 2 -> 5 burst looked like
1157
+ // a window that had never moved and the browse and transient paths reused a
1158
+ // screen the terminal had already replaced. The transition is an input to
1159
+ // this call, not something to look up later.
1160
+ const baseline = transition.widthChanged
1161
+ ? { rows: this.#blankScreen(), clear: true }
1162
+ : this.#resizedScreen(transition);
1163
+ // This helper computes a baseline and nothing else. The move itself was fixed and applied by
1164
+ // the single producer in `render`, before any draw path was chosen, so preparing here would
1165
+ // run a second move off a layout the first one has already changed — and queue a second
1166
+ // record that never held the instances the real move created.
1167
+ return baseline;
1168
+ }
1169
+ /**
1170
+ * Note which document rows the main screen now shows, in document
1171
+ * coordinates, clipped to the rows the screen actually displays.
1172
+ *
1173
+ * The three states are kept apart on purpose. A frame that drew no body clears
1174
+ * the range; a frame whose source is unknown marks it unknown, which is not
1175
+ * the same as having none and must not be read as such. An alternate-buffer
1176
+ * frame is a different buffer entirely and leaves the main screen's record
1177
+ * alone.
1178
+ */
1179
+ #recordSource(frame, target) {
1180
+ if (this.#altActive)
1181
+ return;
1182
+ const range = frame.documentRows;
1183
+ if (range === null) {
1184
+ this.#screenSource = null;
1185
+ this.#screenSourceKnown = true;
1186
+ return;
1187
+ }
1188
+ if (range === undefined) {
1189
+ this.#screenSource = null;
1190
+ this.#screenSourceKnown = false;
1191
+ return;
1192
+ }
1193
+ // A physical row is a document row only through the frame's own mapping:
1194
+ // physical row `at` is frame row `target.start + at - target.offset`, and
1195
+ // frame row `f` is document row `documentStart + (f - frameStart)` when
1196
+ // that lands inside the declared range. Padding above the content and the
1197
+ // overflow markers around it map to no document row, which is what keeps a
1198
+ // terminal that pushed one of them from being credited with a document row.
1199
+ this.#screenRowSource = [];
1200
+ let lowest;
1201
+ let highest;
1202
+ const pinned = this.#stickyHeader(frame, target) !== undefined;
1203
+ for (let at = 0; at < target.rows.length; at += 1) {
1204
+ const frameRow = target.start + at - target.offset;
1205
+ const document = range.documentStart + (frameRow - range.frameStart);
1206
+ const inside = !(at === 0 && pinned)
1207
+ && frameRow >= range.frameStart && document >= range.documentStart && document < range.documentEnd;
1208
+ this.#screenRowSource[at] = inside ? document : undefined;
1209
+ if (!inside)
1210
+ continue;
1211
+ lowest = lowest === undefined ? document : Math.min(lowest, document);
1212
+ highest = highest === undefined ? document : Math.max(highest, document) + 1;
1213
+ }
1214
+ this.#screenSource = lowest === undefined || highest === undefined
1215
+ ? { start: range.documentStart, end: range.documentStart }
1216
+ : { start: lowest, end: highest };
1217
+ this.#screenSourceKnown = true;
1218
+ }
1219
+ /**
1220
+ * The document rows the terminal committed by itself when it moved rows off
1221
+ * the top of the last screen, as a half-open interval, or `undefined` when it
1222
+ * committed none.
1223
+ *
1224
+ * Three things this has to get right, each of which was wrong at least once.
1225
+ * A window that commits nothing must say so rather than naming its first
1226
+ * row: crediting that would skip everything between the frozen boundary and
1227
+ * A row that is not a document row — padding, an overflow marker — does not by
1228
+ * itself end the run, and it does not extend it either. The terminal pushes
1229
+ * `pushed` rows from the top whatever they hold, so the credit is exactly the
1230
+ * document rows among those `pushed` physical rows. Checked against
1231
+ * @xterm/headless 6.0.0, on 5 rows `['','','a','composer','footer']` shrinking
1232
+ * to 1, counting **raw** history rows and `baseY` rather than the rows that
1233
+ * happen to have text:
1234
+ *
1235
+ * - cursor on the last row: `baseY` goes 0 -> 4, raw history is
1236
+ * `['','','a','composer']`. Two padding rows went first and did not stop the
1237
+ * push, so the content rows committed are `['a','composer']`.
1238
+ * - cursor on row 2: `baseY` goes 0 -> 2, raw history is `['','']` — two rows
1239
+ * really were pushed — and the *content* rows committed are `[]`. The run
1240
+ * ended at the padding before it ever reached `a`.
1241
+ * - cursor on the last row of `[marker, line-1..line-4]` shrinking to 3: raw
1242
+ * history is `['marker','line-1']`. The marker went, and so did the row under
1243
+ * it.
1244
+ *
1245
+ * Reading those as "no rows moved" because none had text is the mistake this
1246
+ * count exists to prevent, so the numbers here are the physical ones: pushed
1247
+ * rows, `baseY`, and raw history.
1248
+ *
1249
+ * What the earlier code got wrong was not the `continue` past a non-document
1250
+ * row — that part was right and stayed. It was the scan *length*: the height
1251
+ * difference made the loop reach past the rows the terminal actually moved, so
1252
+ * it read `line-12` off the screen and credited a row that never left.
1253
+ *
1254
+ * How many rows were pushed is not the height difference. The terminal removes
1255
+ * blanks from the bottom first and pushes only the remainder, so the count is
1256
+ * `max(0, min(cursorRow + 1, oldHeight) - newHeight)` — one in the overflow
1257
+ * case, where the height difference would say two.
1258
+ *
1259
+ * And a pushed row counts
1260
+ * only while the document still reads the same at that position: new text
1261
+ * taking a position over means the old row entering history says nothing
1262
+ * about the new one, and the run of committed rows ends there.
1263
+ */
1264
+ /**
1265
+ * What a frame can confirm about a parked move, and what it cannot.
1266
+ *
1267
+ * `confirmed` is the part of the natural commit whose document text this frame
1268
+ * actually supplied. `remaining` is the rows the frame could not speak for: a
1269
+ * browse window that covers only the first of two committed rows, or a frame
1270
+ * that declares no body range at all. Keeping the two apart is the point — a
1271
+ * frame that confirmed nothing is not a frame that says the terminal moved
1272
+ * nothing, and treating the first as the second is how rows already in history
1273
+ * get sent again.
1274
+ *
1275
+ * `remaining` is `undefined` when the comparison is **complete**, which is a
1276
+ * different answer from "confirmed nothing": a terminal that pushed no rows is
1277
+ * complete with no range, and so is a frame whose text matched every row the
1278
+ * terminal moved.
1279
+ */
1280
+ /**
1281
+ * Advance the frozen boundary over every credited row that sits next to it, and
1282
+ * leave the rest on the stack. A row further along has not been settled by this
1283
+ * frame — the flush has not reached it — so it keeps its place for the frame that
1284
+ * does.
1285
+ */
1286
+ /**
1287
+ * Record that the renderer itself pushed these document rows into native history.
1288
+ *
1289
+ * They are not credits — the renderer sent them, so there is nothing to deduct —
1290
+ * but they do occupy the **tail**, and a later grow pulls from exactly there. A
1291
+ * ledger that only knows about deductible rows would pop the wrong position.
1292
+ */
1293
+ #noteFlushed(next, documents) {
1294
+ // A real flush appends positions in the order they were sent, so from here the newest
1295
+ // entries do describe the tail again; a pull may consume them.
1296
+ // A real flush appends its positions in the order they were sent, so the ordered suffix grows
1297
+ // by exactly that many; those positions are no longer part of the unknown front.
1298
+ if (documents.length > 0)
1299
+ this.#provenTail += documents.length;
1300
+ for (const document of documents) {
1301
+ this.#pushed.push({
1302
+ document,
1303
+ text: undefined,
1304
+ shown: next[document] ?? '',
1305
+ credited: false,
1306
+ });
1307
+ }
1308
+ }
1309
+ /**
1310
+ * Apply a move's physical effect on native history: the rows it pushed join the
1311
+ * tail, and the rows it pulled come back off it. Called once, where the move is
1312
+ * parked, so every later move in the same stay sees the history it actually left.
1313
+ */
1314
+ /**
1315
+ * Build and apply the move a resize transition describes for the normal buffer, once.
1316
+ *
1317
+ * The before-state follows the normal physical timeline, not the draw path this frame
1318
+ * happens to take: whatever a real paint or an earlier move left last is what this event
1319
+ * started from. An unfinished text comparison does not enter into it, and a layout that a
1320
+ * move left unknown stays unknown.
1321
+ */
1322
+ #prepareNormalMove(transition) {
1323
+ // Three states, kept apart. Never painted: nothing describes the buffer yet, so the live
1324
+ // fields are the only thing there is. Left unknown by a move: the live values are filled
1325
+ // in but **marked unusable** — `geometryTrusted`/`sourceUsable` are false and the source
1326
+ // is null, so no consumer may treat them as a starting point; the record carries no
1327
+ // trusted before-state rather than claiming the fields were never read. Known: the
1328
+ // snapshot is the event's before-state, including a null source, which is a real
1329
+ // statement about the document rather than a missing value to be replaced.
1330
+ const known = typeof this.#normalLayout === 'object' ? this.#normalLayout : undefined;
1331
+ const cursorRow = known?.cursorRow ?? this.#cursorRow;
1332
+ const record = this.#normalLayout === 'unknown'
1333
+ ? {
1334
+ transition,
1335
+ newHeight: this.#height,
1336
+ cursorRow: this.#cursorRow,
1337
+ // Not a proven zero. The geometry guard stops `#applyMove` before it can decide a
1338
+ // pull, so a zero here would be a number nothing established — and the risk of a
1339
+ // grow having brought unknown content onto the screen would go with it. The risk is
1340
+ // raised below from the transition itself instead.
1341
+ pulledBack: 0,
1342
+ screen: this.#screenRows,
1343
+ // Copied rather than cast: the snapshot is readonly and the record's field is not.
1344
+ rowSource: [...this.#screenRowSource],
1345
+ sourceUsable: false,
1346
+ geometryTrusted: false,
1347
+ outputTrusted: false,
1348
+ source: null,
1349
+ sourceKnown: false,
1350
+ }
1351
+ : {
1352
+ transition,
1353
+ newHeight: this.#height,
1354
+ cursorRow,
1355
+ pulledBack: pulledBackFor(transition.oldHeight, this.#height, cursorRow),
1356
+ screen: known?.screen ?? this.#screenRows,
1357
+ rowSource: known === undefined ? this.#screenRowSource : [...known.rowSource],
1358
+ sourceUsable: known !== undefined,
1359
+ geometryTrusted: known !== undefined,
1360
+ outputTrusted: false,
1361
+ source: known === undefined ? this.#screenSource : known.source,
1362
+ sourceKnown: known === undefined ? this.#screenSourceKnown : known.sourceKnown,
1363
+ };
1364
+ // A grow may have pulled unknown content back onto the screen, and that risk must not be
1365
+ // dropped along with the arithmetic when no exact push or pull can be applied. It is read
1366
+ // from the transition rather than guessed from an old cursor: a taller window, including
1367
+ // one that shrank on the way, may have taken rows out of native history.
1368
+ const mayHavePulled = this.#height > transition.oldHeight
1369
+ || transition.intermediateHeights.some(height => this.#height > height);
1370
+ // Applied once, before any draw path is chosen: a later move in the same frame has to
1371
+ // see the tail this one leaves, and the draw paths must not apply it again.
1372
+ this.#applyMove(record);
1373
+ record.outputTrusted = record.after !== undefined;
1374
+ // The risk is read off the **outcome**, not off the record's initial state: a move that
1375
+ // produced no `after` is one this account could not follow, whether it started unknown or
1376
+ // was stopped by its own geometry guard. Waiting for the next resize to notice would let
1377
+ // this frame clear rows the grow just brought back. The exact instances are still not
1378
+ // claimed here — only that the screen may now hold rows this account never painted.
1379
+ if (record.after === undefined && mayHavePulled)
1380
+ this.#unknownOnScreen = true;
1381
+ // The move does not change which document the rows came from, so the event's own source
1382
+ // state carries forward; an unaccounted result becomes explicitly unknown rather than
1383
+ // inheriting the input's trust.
1384
+ this.#normalLayout = record.after === undefined
1385
+ ? 'unknown'
1386
+ : {
1387
+ screen: [...record.after.screen],
1388
+ rowSource: [...record.after.rowSource],
1389
+ cursorRow: record.after.cursorRow,
1390
+ source: record.source,
1391
+ sourceKnown: record.sourceKnown,
1392
+ };
1393
+ return record;
1394
+ }
1395
+ #applyMove(record) {
1396
+ // Re-applying a record is a retry of the **same** event, so it must leave every other piece of
1397
+ // state alone. This check therefore comes before anything below, including the invalidation of
1398
+ // an unrelated pending plan.
1399
+ if (record.stackApplied)
1400
+ return;
1401
+ record.stackApplied = true;
1402
+ const pending = this.#pendingReverse;
1403
+ const heights = [record.transition.oldHeight, ...record.transition.intermediateHeights, record.newHeight];
1404
+ const growth = record.newHeight - record.transition.oldHeight;
1405
+ // Consecutive same-width grows prepend rows without disturbing their order. The
1406
+ // unknown front proves these additional rows exist even though their text is unknown.
1407
+ // Return the combined prefix when leaving the overlay, rather than forgetting that
1408
+ // its earlier rows were already committed before the terminal pulled them back.
1409
+ if (pending !== undefined && pending.height === record.transition.oldHeight
1410
+ && !record.transition.widthChanged && !this.#tailStale && this.#provenTail === 0
1411
+ && growth > 0 && growth <= this.#unknownFront
1412
+ && heights.every((height, index) => index === 0 || height >= heights[index - 1])) {
1413
+ this.#pendingReverse = { count: pending.count + growth, height: record.newHeight };
1414
+ this.#unknownFront -= growth;
1415
+ record.pulledBack = growth;
1416
+ record.pushedRows = [];
1417
+ record.after = undefined;
1418
+ return;
1419
+ }
1420
+ // A later move disturbs the rows a pending reverse move was waiting to return: what they are
1421
+ // cannot be shown to still sit at the top of the screen. The plan is dropped here, before the
1422
+ // geometry guard, because a move that cannot be followed at all is exactly the case where that
1423
+ // proof is gone. Its rows are **not** counted back into `#unknownFront`: no reverse move ran, so
1424
+ // nothing proves native history holds them again, and that count is a usable lower bound on
1425
+ // what history still holds — a different quantity from the positions a dropped plan was
1426
+ // waiting on.
1427
+ this.#pendingReverse = undefined;
1428
+ // An unaccounted start means the rows this move would push and pull are a guess, so
1429
+ // the ledger is left alone too — the check cannot come after the mutation, or a
1430
+ // guess has already moved the boundary and the tail by the time it runs.
1431
+ if (!record.geometryTrusted || this.#wraps(record.screen, record.transition)) {
1432
+ // The terminal moved history and this account did not follow it, so the tail no
1433
+ // longer describes the real end of native history. Saying so is what keeps a
1434
+ // later pull from taking an older position for the newest one.
1435
+ this.#tailStale = true;
1436
+ // The end of native history is now unproven, so neither quantity below still describes it.
1437
+ // The suffix's order is void, and the counted front — which was a **lower bound on what
1438
+ // history still holds** — is void for the same reason: a move this account could not follow
1439
+ // moved rows nobody tracked, so any number standing for that range is a leftover claim about
1440
+ // a range that no longer exists as described.
1441
+ this.#provenTail = 0;
1442
+ this.#unknownFront = 0;
1443
+ record.after = undefined;
1444
+ return;
1445
+ }
1446
+ const pushed = Math.max(0, Math.min(record.cursorRow + 1, record.transition.oldHeight) - record.newHeight);
1447
+ if (pushed > 0) {
1448
+ const rows = [];
1449
+ for (let at = 0; at < pushed; at += 1) {
1450
+ rows.push({
1451
+ document: record.rowSource[at],
1452
+ text: undefined,
1453
+ shown: record.screen[at] ?? '',
1454
+ credited: false,
1455
+ });
1456
+ }
1457
+ this.#pushed.push(...rows);
1458
+ // A push sends those rows out in order, so they extend the ordered suffix by that many.
1459
+ this.#provenTail += rows.length;
1460
+ record.pushedRows = rows;
1461
+ }
1462
+ const pulled = record.pulledBack ?? 0;
1463
+ if (pulled > 0) {
1464
+ // A pull that reached past everything this account recorded can have brought
1465
+ // unaccounted rows onto the screen; one it could fully account for is ordinary and
1466
+ // needs no protection. The count is only a proof while the tail is trusted: once
1467
+ // the real end of native history is unknown, a long array says nothing about
1468
+ // whether this move was covered, so the risk stands regardless of its length.
1469
+ //
1470
+ // Two decisions follow, and an untrusted tail separates them. It refuses the pulled
1471
+ // instances: those came off the stack this account can no longer prove the order
1472
+ // of, so they are not handed over. It says nothing about the rows the window was
1473
+ // already showing, which are this account's own painting — a grow does not disturb
1474
+ // them, so they survive it and remain evidence for a later protection scroll.
1475
+ if (pulled > this.#pushed.length || this.#tailStale) {
1476
+ this.#unknownOnScreen = true;
1477
+ // The evidence is the event's own before-state, not whatever the live fields hold
1478
+ // by the time this runs: after a stay in the alternate buffer, or a later move,
1479
+ // the live screen is not this move's starting point.
1480
+ //
1481
+ // Surviving the move and carrying a document identity are two separate things, and
1482
+ // a row number is not proof of the second. `#recordSource` returns early for a
1483
+ // frame that declares no rows, leaving the previous mapping in place while the
1484
+ // painted text has already moved on — so an event can hold the current text beside
1485
+ // a stale row number with `sourceKnown` false or `source` null. Trusting the
1486
+ // number there would offer a physical instance as a document row on the strength
1487
+ // of a mapping that was never re-established, and a later text comparison could
1488
+ // only show the text matched, never that this instance had that source.
1489
+ //
1490
+ // The identity therefore follows the event's own source state; where it is not
1491
+ // known, the row is not offered as document evidence at all. This is independent of
1492
+ // the tail's trust above: an untrusted tail is about the order of native history,
1493
+ // not about which document a shown row came from.
1494
+ const sourceNamed = record.sourceKnown && record.sourceUsable && record.source !== null;
1495
+ if (sourceNamed) {
1496
+ for (let at = 0; at < record.newHeight; at += 1) {
1497
+ const document = record.rowSource[at];
1498
+ const shown = record.screen[at];
1499
+ if (document === undefined || shown === undefined)
1500
+ continue;
1501
+ this.#knownOnScreen.push({ document, text: shown, shown, credited: true });
1502
+ }
1503
+ }
1504
+ }
1505
+ if (this.#tailStale) {
1506
+ record.after = undefined;
1507
+ return;
1508
+ }
1509
+ // Only the part beyond the proven suffix is unordered. A pull that fits inside it consumes
1510
+ // order; one that reaches past it has left the proven range for an arrangement no
1511
+ // collection can supply.
1512
+ // A pull takes the newest positions first. However much of the collection is unorderable,
1513
+ // the part inside the proven suffix is known: those instances are taken in their real
1514
+ // order, the suffix shortens, and their membership follows them onto the screen. Only the
1515
+ // remainder reaches past the proof and is unknown. Treating the whole move as unidentifiable
1516
+ // because a later part of it is would drop a movement that is entirely provable.
1517
+ const knownCount = Math.min(pulled, this.#provenTail);
1518
+ const knownTaken = knownCount > 0 && !this.#tailStale
1519
+ ? this.#pushed.splice(-knownCount, knownCount)
1520
+ : [];
1521
+ this.#provenTail = Math.max(0, this.#provenTail - knownTaken.length);
1522
+ const unknownCount = pulled - knownTaken.length;
1523
+ if (unknownCount > 0) {
1524
+ // The remainder is real only as far as the unknown front proves it: those positions were
1525
+ // sent, so the terminal can have handed that many back. Beyond the front there is no such
1526
+ // proof and the count would be a request rather than a fact, so only the proven part is
1527
+ // remembered for a reverse move, and the front shrinks by it.
1528
+ // The count is only as good as the front that proves it: the whole taken part must lie
1529
+ // inside positions whose existence was really sent. Where it does, the move is recorded
1530
+ // with the instances it also took, so the same event owns both halves.
1531
+ if (unknownCount <= this.#unknownFront) {
1532
+ // Two moves are two events. Adding to an unfinished one would merge their counts while
1533
+ // only the newer move's instances survived, and the older rows it was waiting to return
1534
+ // are not proven to be at the top of the screen any more once another pull has run. The
1535
+ // new count is therefore only kept where the pending one can still be consumed alone; a
1536
+ // move that arrives on top of another's rows leaves both as unknown counts rather than
1537
+ // claiming either.
1538
+ if (this.#pendingReverse === undefined) {
1539
+ this.#pendingReverse = { count: unknownCount, height: record.newHeight };
1540
+ }
1541
+ this.#unknownFront -= unknownCount;
1542
+ }
1543
+ }
1544
+ if (unknownCount > 0) {
1545
+ // That much of the move has no instance to name and no order to consume: the layout the
1546
+ // pull leaves is unknown, and the risk of clearing what came up stands.
1547
+ this.#unknownOnScreen = true;
1548
+ record.after = undefined;
1549
+ }
1550
+ // The part that was taken keeps going through the ordinary path below: its instances are
1551
+ // recorded, handed to the protection plan and applied to the boundary. Only `after` and the
1552
+ // risk above reflect that the move also reached into the unknown.
1553
+ // `record.pulledBack` keeps the event's original request: it is the allowed limit from
1554
+ // `pulledBackFor`, and the set difference is an un-covered remainder, not the number of
1555
+ // rows native history actually gave back. Writing it here made the same field mean two
1556
+ // things before and after the move, and a later reverse move could take the remainder as
1557
+ // fact. The provable count belongs to the pending reverse move, not to this field.
1558
+ const taken = knownTaken;
1559
+ // Keep the instances this pull brought back, even when the layout that follows is
1560
+ // unknown. They were in this account's hands a moment ago — with their text and
1561
+ // their source — and they are now on the screen. Dropping them here is what left a
1562
+ // later protection plan unable to say what it had scrolled out.
1563
+ record.pulledRows = taken;
1564
+ if (this.#unknownOnScreen)
1565
+ this.#knownOnScreen.push(...taken);
1566
+ this.#pulledFromHistory(taken);
1567
+ // Fewer positions than the move claims means the tail does not reach back that
1568
+ // far, so what came onto the screen is not known — say so rather than padding
1569
+ // the difference with blanks that look like content.
1570
+ if (taken.length < pulled) {
1571
+ // The tail does not reach back that far, so the screen this move leaves is not
1572
+ // accounted for. The **output** is unknown even though the start was trusted,
1573
+ // and that is what the next move inherits.
1574
+ record.after = undefined;
1575
+ return;
1576
+ }
1577
+ // The pulled positions come back onto the screen carrying their own text and
1578
+ // mapping: that is what makes the next move in this stay readable instead of a
1579
+ // guess. `undefined` entries are chrome and keep their place. The cursor moves
1580
+ // **down** with them — it was at the bottom, and rows just arrived above it.
1581
+ record.after = {
1582
+ screen: [...taken.map(row => row.shown), ...record.screen].slice(0, record.newHeight),
1583
+ rowSource: [...taken.map(row => row.document), ...record.rowSource].slice(0, record.newHeight),
1584
+ cursorRow: Math.min(record.cursorRow + taken.length, Math.max(0, record.newHeight - 1)),
1585
+ };
1586
+ return;
1587
+ }
1588
+ // No pull. A push takes rows off the top; a grow stands the extra rows at the
1589
+ // bottom, and those really are blank. Either way the result describes the whole
1590
+ // window, so the next move is measured against a complete layout — but only if
1591
+ // this move started from one that was accounted for.
1592
+ record.after = {
1593
+ screen: [
1594
+ ...record.screen.slice(pushed),
1595
+ ...Array.from({ length: Math.max(0, record.newHeight - (record.screen.length - pushed)) }, () => ''),
1596
+ ].slice(0, record.newHeight),
1597
+ rowSource: [
1598
+ ...record.rowSource.slice(pushed),
1599
+ ...Array.from({ length: Math.max(0, record.newHeight - (record.rowSource.length - pushed)) }, () => undefined),
1600
+ ].slice(0, record.newHeight),
1601
+ cursorRow: Math.max(0, Math.min(record.cursorRow - pushed, record.newHeight - 1)),
317
1602
  };
318
1603
  }
319
- #resizedScreen(oldHeight) {
320
- if (this.#height < oldHeight)
321
- return this.#screen.slice(oldHeight - this.#height);
322
- if (this.#height > oldHeight) {
323
- return [...Array.from({ length: this.#height - oldHeight }, () => ''), ...this.#screen];
1604
+ /**
1605
+ * Rows the terminal pulled back out of history are no longer committed, so the
1606
+ * frozen boundary must stop claiming them: they are owed again and the next flush
1607
+ * has to send them. Retreating is exact — it moves to the lowest row actually
1608
+ * pulled, not by any height difference — and it never advances.
1609
+ *
1610
+ * Every pulled position counts, not just the ones the terminal was credited for. A
1611
+ * row the renderer put into history with its own flush is exactly as gone once a
1612
+ * grow takes it back.
1613
+ */
1614
+ /**
1615
+ * Whether a width change could have re-wrapped the rows shown.
1616
+ *
1617
+ * Calibrated, and **narrow on purpose**: with the short rows of this fixture, 80 -> 100
1618
+ * columns leaves history and the screen row for row identical, so the mapping holds
1619
+ * and the sources can be kept. A row wider than every column count in force is treated
1620
+ * as re-wrapped and the layout as unknown.
1621
+ *
1622
+ * That test is not a general proof. A row of 80 cells under an 80-column window may
1623
+ * already have soft-wrapped into 80 + 40, and both halves are `<= 80` while a 100-column
1624
+ * window would re-flow them to 100 + 20. `screen: string[]` carries no `isWrapped`, and
1625
+ * scanning the visible screen says nothing about the history tail either — xterm's own
1626
+ * `BufferReflow` groups rows by `isWrapped` for exactly this reason. So this rule is
1627
+ * sound only for inputs established not to have soft-wrapped; promoting it to a general
1628
+ * "width did not matter" claim needs a no-soft-wrap source invariant first.
1629
+ */
1630
+ #wraps(screen, transition = this.#resize) {
1631
+ if (transition === undefined || !transition.widthChanged)
1632
+ return false;
1633
+ if (screen === undefined)
1634
+ return true;
1635
+ const tooWide = (row) => row !== undefined && visibleWidth(row) > transition.narrowestWidth;
1636
+ if (screen.some(tooWide))
1637
+ return true;
1638
+ // A short visible screen is not enough. Native history is re-flowed on a width change
1639
+ // too, so a long row already pushed there can re-wrap while the screen still looks
1640
+ // narrow, and this account would go on using the old positions. Every tracked row is
1641
+ // checked as well. History the account never watched cannot be examined at all, so
1642
+ // that part stays an assumption — the guard is partial, not general.
1643
+ return this.#pushed.some(row => tooWide(row.shown));
1644
+ }
1645
+ /**
1646
+ * Plan the protection scroll for this frame and settle the history event it causes.
1647
+ *
1648
+ * It scrolls the **whole visible window** out: the count of unaccounted rows is not
1649
+ * knowable, and scrolling everything preserves the screen's actual content without
1650
+ * reading or replaying any of it. The instances this account can identify among what
1651
+ * moves go on the ledger — the flush must not send them again — while anything it
1652
+ * cannot identify stays unaccounted rather than being pretended into a known tail.
1653
+ *
1654
+ * Which instances those are comes from the moves that put them on the screen, not from
1655
+ * `#screen`: after an overlay stay the renderer's screen memory holds what it last
1656
+ * painted, not what the terminal shows.
1657
+ */
1658
+ /**
1659
+ * Settle the copies a protection scroll could not confirm, by identity and before any commit
1660
+ * range is chosen. A copy still in the stack whose document reads the same again is
1661
+ * confirmed; one whose text has changed is resolved without credit, because the old shown
1662
+ * instance stays in history while the new text still owes its own commit; one the frame
1663
+ * cannot locate stays held for the next frame that can. Leaving the stack and being retired are
1664
+ * two different things — retirement keeps the position and closes confirmation — so a copy is
1665
+ * dropped only when it is genuinely no longer tracked, and is skipped while its confirmation
1666
+ * is closed or already granted.
1667
+ */
1668
+ #settleProtection(next, frame) {
1669
+ if (this.#unconfirmedProtection.length === 0)
1670
+ return;
1671
+ const kept = [];
1672
+ // While the tail's order is unknown, knowing a row's document does not say the instance is
1673
+ // still in native history: the event that made this copy is evidence about what it was, not
1674
+ // about where it now sits, and being present in the array only says it is still tracked. A
1675
+ // matching string is therefore not a deduction. Confirming from identity alone would conflate
1676
+ // "this is the row that was sent" with "this row is still committed", so the copies stay held
1677
+ // until a frame can prove the membership again.
1678
+ if (this.#tailStale)
1679
+ return;
1680
+ for (const copy of this.#unconfirmedProtection) {
1681
+ // Only an instance still tracked may be edited. Being dropped from the stack is not the
1682
+ // same as being retired: a retired copy stays and is filtered by its closed confirmation
1683
+ // below, while one that is gone stands for nothing at all.
1684
+ // Presence on the stack is not eligibility: retirement keeps the position. A copy whose
1685
+ // confirmation has been closed, or one already confirmed, must not be credited again.
1686
+ if (!this.#pushed.includes(copy))
1687
+ continue;
1688
+ if (copy.confirmationClosed === true || copy.credited)
1689
+ continue;
1690
+ if (copy.document === undefined)
1691
+ continue;
1692
+ const at = frameTextIndex(frame, next, copy.document);
1693
+ if (at === undefined) {
1694
+ kept.push(copy);
1695
+ continue;
1696
+ }
1697
+ // A row whose text differs is settled: this comparison is over and grants no credit.
1698
+ if ((next[at] ?? '') === copy.shown) {
1699
+ copy.text = copy.shown;
1700
+ copy.credited = true;
1701
+ }
1702
+ }
1703
+ this.#unconfirmedProtection = kept;
1704
+ }
1705
+ #planProtection(next, frame) {
1706
+ // The trigger is the **outstanding risk**, not the presence of identifiable rows: a
1707
+ // screen carrying only unaccounted external content still has to be preserved, and
1708
+ // there is nothing of this account's own to point at in that case.
1709
+ if (!this.#unknownOnScreen || this.#height <= 0) {
1710
+ return { lead: '', confirmed: [] };
1711
+ }
1712
+ this.#unknownOnScreen = false;
1713
+ // The scroll really sent `#height` positions into history, so the newest that many become
1714
+ // unknown in arrangement: an empty confirmed set is still a tail nobody can order.
1715
+ this.#unknownFront = this.#height;
1716
+ this.#provenTail = 0;
1717
+ // The scroll pushes `#height` positions into history whether or not any own instance was
1718
+ // identifiable, so the whole tail becomes unordered here: an empty confirmed set is still a
1719
+ // tail nobody can order. The collection and the physical range are held separately.
1720
+ this.#provenTail = 0;
1721
+ const rows = this.#knownOnScreen;
1722
+ this.#knownOnScreen = [];
1723
+ const confirmed = [];
1724
+ for (const row of rows) {
1725
+ if (row.document === undefined)
1726
+ continue;
1727
+ // The instance is saved either way — it is what the scroll really moved — but it may
1728
+ // only be **deducted** from this frame if the document still reads the same there.
1729
+ // A row rewritten since the move is new text and still owes its own commit; a row
1730
+ // the frame cannot locate is unknown, and unknown is not a deduction either.
1731
+ const at = frameTextIndex(frame, next, row.document);
1732
+ const matches = at !== undefined && (next[at] ?? '') === row.shown;
1733
+ const copy = {
1734
+ ...row,
1735
+ text: matches ? row.shown : undefined,
1736
+ credited: matches,
1737
+ settledByProtection: true,
1738
+ // This scroll is a new physical commit and this copy is a new instance. Whatever
1739
+ // happened to the row it was read from — retired, closed, pulled — says nothing about
1740
+ // this one, so its own confirmation state starts open and is decided by this result.
1741
+ confirmationClosed: false,
1742
+ };
1743
+ this.#pushed.push(copy);
1744
+ if (matches) {
1745
+ confirmed.push({ start: row.document, end: row.document + 1 });
1746
+ }
1747
+ else if (at === undefined) {
1748
+ // The frame could not locate the row at all, so nothing has been decided: keep the same
1749
+ // instance for a later frame. A row that **was** located and reads differently is a
1750
+ // different case — that comparison is over, and it grants no credit.
1751
+ this.#unconfirmedProtection.push(copy);
1752
+ }
1753
+ }
1754
+ // Park on the last row so the scroll really pushes the window out, then walk it out.
1755
+ // `csi` is zero-based and adds one, so the last row is `height - 1`, column 0 —
1756
+ // sending `height` would rely on the terminal clamping an out-of-range position.
1757
+ return { lead: csi(this.#height - 1, 0) + '\n'.repeat(this.#height), confirmed };
1758
+ }
1759
+ #pulledFromHistory(positions) {
1760
+ for (const position of positions) {
1761
+ if (position.document === undefined)
1762
+ continue;
1763
+ this.#physical = Math.min(this.#physical, position.document);
1764
+ }
1765
+ }
1766
+ #settleBoundary() {
1767
+ // A credited row may only claim the frozen boundary while the tail still describes
1768
+ // real history. Advancing the boundary is a claim that the terminal has already
1769
+ // committed those rows, and after an unfollowed move that membership is unproven —
1770
+ // the rows may be back on the screen, and stepping the boundary over them drops
1771
+ // them for good. The same trust range governs this path as governs deduction.
1772
+ if (this.#tailStale)
1773
+ return;
1774
+ for (const row of creditedRows(this.#pushed)) {
1775
+ if (row.start > this.#physical)
1776
+ continue;
1777
+ this.#physical = Math.max(this.#physical, row.end);
1778
+ // Retired, not removed. The row is still in native history and still occupies
1779
+ // the tail; only its right to be deducted from a flush is gone.
1780
+ for (const item of this.#pushed) {
1781
+ // The **whole** range this credit stands for, not the row it happens to start at.
1782
+ // A merge means the flush settled every physical row between its ends, so leaving the
1783
+ // interior positions credited would let a later frame treat an already-settled range as
1784
+ // still owed — and the surviving claim then pushes the boundary back over it.
1785
+ if (item.document !== undefined
1786
+ && item.document >= row.start
1787
+ && item.document < row.end) {
1788
+ item.credited = false;
1789
+ // Retiring settles the position for good, so a pending confirmation for it is closed:
1790
+ // the instance keeps its place on the stack, but it may not be granted credit again by
1791
+ // a later frame that happens to read the same text.
1792
+ item.confirmationClosed = true;
1793
+ }
1794
+ }
324
1795
  }
325
- return this.#screen.slice(0, this.#height);
1796
+ }
1797
+ #creditAgainst(next, frame, record) {
1798
+ // An accounted grow can prove it pushed nothing without knowing the rows it pulled.
1799
+ if (record.pushedRows?.length === 0)
1800
+ return { confirmed: [], remaining: undefined };
1801
+ // Everything the comparison needs comes from the record: the cursor as it stood
1802
+ // before the terminal's move, and the source as it stood then. Reading the live
1803
+ // fields instead is how a parked move became unreadable — an overlay or a browse
1804
+ // window has already replaced them, and a live source of `null` is exactly what
1805
+ // a browse records.
1806
+ //
1807
+ // Without an accounted empty push above, check the source before inferring a
1808
+ // push count from geometry: an untracked move may have intermediate shrinks.
1809
+ // A record whose rows cannot be tied to document rows at all is **unresolved**,
1810
+ // not resolved-to-nothing. An empty mapping that reads as "known, nothing to
1811
+ // credit" would settle the record and let the flush send rows the terminal has
1812
+ // already committed.
1813
+ if (!record.sourceKnown || record.source === null || !record.sourceUsable) {
1814
+ return { confirmed: [], remaining: 'unresolved' };
1815
+ }
1816
+ if (record.newHeight >= record.transition.oldHeight) {
1817
+ return { confirmed: [], remaining: undefined };
1818
+ }
1819
+ const pushed = Math.max(0, Math.min(record.cursorRow + 1, record.transition.oldHeight) - record.newHeight);
1820
+ // Nothing was pushed: a complete answer, not an unknown one. Without this the
1821
+ // renderer would sit on an open comparison forever and never clear the park.
1822
+ if (pushed <= 0)
1823
+ return { confirmed: [], remaining: undefined };
1824
+ // Each pushed row is settled on its own and the answers collected, rather than
1825
+ // treated as one run. A row whose text has been replaced is *not* a reason to
1826
+ // stop: the rows after it may be unchanged, and the terminal committed those
1827
+ // just as much as this one. Reporting a single min/max interval would swallow
1828
+ // the mismatched row into the middle of it and claim a commit the terminal
1829
+ // never made.
1830
+ const confirmed = [];
1831
+ // One entry per pushed **physical** row, in the order they left the screen, with
1832
+ // `undefined` for the ones holding no document line. The stack needs that order
1833
+ // and those placeholders; a list of document numbers alone cannot say which row
1834
+ // a grow takes back.
1835
+ const order = [];
1836
+ for (let at = 0; at < pushed; at += 1) {
1837
+ const shown = record.screen[at];
1838
+ if (shown === undefined)
1839
+ break;
1840
+ const document = record.rowSource[at];
1841
+ order.push(document);
1842
+ if (document === undefined)
1843
+ continue;
1844
+ const at2 = frameTextIndex(frame, next, document);
1845
+ // A row the frame cannot speak for leaves the rest unverified. It is not
1846
+ // evidence that the terminal committed nothing.
1847
+ if (at2 === undefined)
1848
+ return { confirmed, remaining: 'unresolved', pushed: order };
1849
+ // Unchanged text: the terminal's commit of this row still stands, so it is
1850
+ // credited. Replaced text: the old row entering history says nothing about
1851
+ // the new one, so this row is simply not credited — and that is a complete
1852
+ // answer, not an open question.
1853
+ if (shown.replace(/\s+$/u, '') === (next[at2] ?? '').replace(/\s+$/u, '')) {
1854
+ // The text it matched is stored with the credit. A confirmation is
1855
+ // evidence about *that* document, and the document can still change before
1856
+ // the credit is spent: a row confirmed here and rewritten before the flush
1857
+ // arrives is new text, and subtracting the old credit for it would drop it
1858
+ // from history entirely. Re-checked at use time, the stale credit simply
1859
+ // stops applying.
1860
+ confirmed.push({ start: document, end: document + 1, text: next[at2] ?? '' });
1861
+ }
1862
+ }
1863
+ return { confirmed, remaining: undefined, pushed: order };
1864
+ }
1865
+ /**
1866
+ * Where a document row's text sits in this frame, or `undefined` when the frame
1867
+ * cannot speak for that row. A frame with no declared range says nothing about
1868
+ * what it is showing; a frame that declares one speaks only for the rows inside
1869
+ * it, since `next` also holds chrome.
1870
+ */
1871
+ /**
1872
+ * The **physical** screen as the terminal now holds it, given only a height
1873
+ * change — together with whether that is knowable at all.
1874
+ *
1875
+ * Every height change here has two possible shapes, and this renderer keeps no
1876
+ * model of the terminal's scrollback, so it cannot tell which one happened or
1877
+ * what the moved rows contain. A **grow** either appends blanks at the bottom
1878
+ * (no history) or pulls history back into the top, shifting every row down. A
1879
+ * **shrink** either drops rows from the bottom (cursor high, rows below it
1880
+ * blank) or pushes rows off the top into history (cursor at the bottom), which
1881
+ * keeps the *last* rows rather than the first. All four checked against
1882
+ * @xterm/headless 6.0.0: `a..e` over no history grows to
1883
+ * `['a','b','c','d','e','','']`; a 3-row screen `[a,b,c]` over history
1884
+ * `[p0,p1]` grows to `['p0','p1','a','b','c']`; a 5-row `[a..e]` with the
1885
+ * cursor on row 0 shrinks to `['a','b','c']` with history untouched; the same
1886
+ * screen with the cursor at the bottom shrinks to `['c','d','e']` and pushes
1887
+ * `[a,b]` into history.
1888
+ *
1889
+ * Guessing the shape is what broke this repeatedly. Prepending on grow made
1890
+ * the baseline *identical* to the bottom-anchored target, so the diff found
1891
+ * nothing to do and emitted a bare CUP. Appending on grow read as "the top rows
1892
+ * are unchanged" and stranded whatever the terminal had pulled back. And
1893
+ * `slice(oldHeight - this.#height)` on shrink drops the **top** rows and keeps
1894
+ * `[d,e]` — the opposite end of the screen from the `[a,b,c]` the terminal
1895
+ * actually kept when the cursor was high. Each of those left rows unwritten
1896
+ * while `#finishPaint` recorded the target as drawn.
1897
+ *
1898
+ * A plain empty string is not a stand-in for "unknown" either: it matches a
1899
+ * target blank exactly, which is how a row that needs repainting gets skipped.
1900
+ * So a resize reports its baseline as **not knowable** and repaints the
1901
+ * viewport. Only a height change that moves nothing leaves the screen memory
1902
+ * usable.
1903
+ */
1904
+ #resizedScreen(transition) {
1905
+ // A burst of resizes collapses into one transition that remembers only the
1906
+ // first old height and the final one, so equal heights do **not** mean the
1907
+ // screen never moved: 5 -> 2 -> 5 with nothing painted in between leaves the
1908
+ // terminal on `['b','p1','p2','p3','p4']` with history `['a']`, not on the
1909
+ // original `['p1'..'p5']` (verified against @xterm/headless 6.0.0). The
1910
+ // transition records that a resize happened, so only a resize that was never
1911
+ // more than one height keeps the screen memory usable.
1912
+ if (this.#height === transition.oldHeight && transition.intermediateHeights.length === 0) {
1913
+ return { rows: this.#screen.slice(0, this.#height), clear: false };
1914
+ }
1915
+ return { rows: this.#blankScreen(), clear: true };
326
1916
  }
327
1917
  #finishPaint(body, target, length, cursor, cursorVisible) {
328
1918
  const targetRow = this.#screenRow(cursor.row, target, length);
@@ -332,14 +1922,27 @@ export class MainScreenRenderer {
332
1922
  const moved = targetRow !== this.#cursorRow || targetCol !== this.#cursorCol;
333
1923
  const visibilityChanged = cursorVisible !== this.#cursorVisible;
334
1924
  const cursorOut = body !== '' || moved || visibilityChanged ? csi(targetRow, targetCol) : '';
335
- const clearScrollback = this.#clearScrollbackOnNextRender && this.#canClearScrollback;
336
- this.#clearScrollbackOnNextRender = false;
337
1925
  this.#cursorRow = targetRow;
338
1926
  this.#cursorCol = targetCol;
339
1927
  this.#cursorVisible = cursorVisible;
340
1928
  this.#screen = target.rows;
1929
+ // Every path that paints the main screen ends here — a following transcript, a
1930
+ // browse, a reset — and the alternate branch returns before it. So this is
1931
+ // where the live screen memory becomes authoritative again, and where a parked
1932
+ // record stops describing what the terminal holds.
1933
+ // The snapshot is taken here, not while `#recordSource` runs: only now are the target
1934
+ // row, the source and the cursor final. Reading them earlier pairs this frame's screen
1935
+ // with the previous frame's cursor, and a later shrink would then count pushes from a
1936
+ // row the terminal had already left.
1937
+ this.#normalLayout = {
1938
+ screen: [...target.rows],
1939
+ rowSource: [...this.#screenRowSource],
1940
+ cursorRow: this.#cursorRow,
1941
+ source: this.#screenSource,
1942
+ sourceKnown: this.#screenSourceKnown,
1943
+ };
341
1944
  this.#hasFrame = true;
342
- return this.#wrap((clearScrollback ? CLEAR_SCROLLBACK : '') + body + cursorOut + hide + show);
1945
+ return body + cursorOut + hide + show;
343
1946
  }
344
1947
  #screenRow(logicalRow, target, length) {
345
1948
  const clamped = Math.max(0, Math.min(Math.max(0, length - 1), logicalRow));