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