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