@floegence/floeterm-terminal-web 0.16.4 → 0.16.5

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.
@@ -1,49 +1,23 @@
1
- import { isStructuralSemanticHistoryError, SemanticHistoryError, validateHistoryViewport, } from './presentation.js';
1
+ import { isStructuralSemanticHistoryError, SemanticHistoryError, validateHistoryWindow, validateHistoryViewport, } from './presentation.js';
2
2
  import { runSemanticHistoryRequest } from './historyRequestScheduler.js';
3
- const MAX_HISTORY_CACHE_BYTES = 4 * 1024 * 1024;
4
- const MAX_GLOBAL_HISTORY_CACHE_BYTES = 16 * 1024 * 1024;
5
- const globalHistoryCache = new class {
6
- constructor() {
7
- this.entries = new Map();
8
- this.nextOwner = 0;
9
- }
10
- owner() {
11
- this.nextOwner += 1;
12
- return this.nextOwner;
13
- }
14
- put(entry) {
15
- this.entries.set(this.entryKey(entry.owner, entry.key), entry);
16
- this.enforce();
17
- }
18
- remove(owner, key) {
19
- this.entries.delete(this.entryKey(owner, key));
20
- }
21
- clear(owner) {
22
- for (const [key, entry] of this.entries) {
23
- if (entry.owner === owner)
24
- this.entries.delete(key);
25
- }
26
- }
27
- enforce() {
28
- let total = [...this.entries.values()].reduce((sum, entry) => sum + entry.bytes, 0);
29
- while (total > MAX_GLOBAL_HISTORY_CACHE_BYTES) {
30
- const candidate = [...this.entries.values()]
31
- .filter(entry => entry.evictable())
32
- .sort((left, right) => Number(right.hidden()) - Number(left.hidden()) || left.touched - right.touched)[0];
33
- if (!candidate)
34
- return;
35
- candidate.evict();
36
- total = [...this.entries.values()].reduce((sum, entry) => sum + entry.bytes, 0);
37
- }
38
- }
39
- entryKey(owner, key) {
40
- return `${owner}:${key}`;
41
- }
42
- }();
3
+ function emitSemanticDebugTrace(event) {
4
+ const target = globalThis;
5
+ target.__floetermSemanticTrace?.(event);
6
+ }
7
+ // History is owned by a single terminal session. Keep the budget local to the
8
+ // controller so one busy session cannot evict another session's history.
9
+ const MAX_SESSION_HISTORY_CACHE_BYTES = 4 * 1024 * 1024;
10
+ const HISTORY_WINDOW_CACHE_TARGET_BYTES = Math.floor(MAX_SESSION_HISTORY_CACHE_BYTES * 0.85);
11
+ const HISTORY_WINDOW_BASE_MULTIPLIER = 10;
12
+ const HISTORY_WINDOW_FAST_MULTIPLIER = 20;
13
+ const HISTORY_WINDOW_MAX_ROWS = 4000;
14
+ const HISTORY_SCROLL_BURST_GAP_MS = 250;
15
+ const HISTORY_SCROLL_BURST_DECAY_MS = 500;
16
+ const HISTORY_SCROLL_BURST_VIEWPORTS = 2;
17
+ const HISTORY_WINDOW_FORWARD_BIAS = 0.7;
43
18
  export class HistoryViewportController {
44
19
  constructor(options) {
45
20
  this.options = options;
46
- this.cacheOwner = globalHistoryCache.owner();
47
21
  this.latest = null;
48
22
  this.visible = null;
49
23
  this.frontier = null;
@@ -51,23 +25,28 @@ export class HistoryViewportController {
51
25
  this.cacheBytes = 0;
52
26
  this.historyAnchor = null;
53
27
  this.desiredOffset = null;
54
- this.prefetchOffset = null;
55
28
  this.lane = null;
56
29
  this.epoch = 0;
57
30
  this.transportGeneration = null;
58
31
  this.touch = 0;
59
32
  this.disposed = false;
60
- this.viewVisible = true;
61
33
  this.error = null;
62
34
  this.wheelResidualRows = 0;
63
35
  this.wheelFrame = null;
64
- this.lastDirection = 0;
36
+ this.handlingWheelFrame = false;
37
+ this.preferExactViewport = false;
38
+ this.scrollDirection = 0;
39
+ this.scrollBurstDirection = 0;
40
+ this.scrollBurstRows = 0;
41
+ this.scrollBurstAt = 0;
42
+ this.estimatedHistoryRowBytes = 0;
65
43
  this.requestFrame = options.requestAnimationFrame
66
44
  ?? globalThis.requestAnimationFrame?.bind(globalThis)
67
45
  ?? (callback => globalThis.setTimeout(() => callback(performance.now()), 0));
68
46
  this.cancelFrame = options.cancelAnimationFrame
69
47
  ?? globalThis.cancelAnimationFrame?.bind(globalThis)
70
48
  ?? (handle => globalThis.clearTimeout(handle));
49
+ this.now = options.now ?? (() => performance.now());
71
50
  }
72
51
  apply(presentation) {
73
52
  if (this.disposed)
@@ -88,9 +67,8 @@ export class HistoryViewportController {
88
67
  const rows = Math.trunc(deltaRows);
89
68
  if (rows === 0)
90
69
  return;
91
- const current = this.desiredOffset ?? this.visible?.offset ?? this.latest.frame.history.screenStartOffset;
70
+ const current = this.currentIntentOffset();
92
71
  const target = clamp(current + rows, 0, this.latest.frame.history.screenStartOffset);
93
- this.lastDirection = rows < 0 ? -1 : 1;
94
72
  this.showOffset(target);
95
73
  }
96
74
  handleWheel(delta, deltaMode) {
@@ -103,63 +81,109 @@ export class HistoryViewportController {
103
81
  ? delta * viewportRows
104
82
  : delta / this.options.renderer.getCellMetrics().cellHeightCssPx;
105
83
  this.wheelResidualRows += rows;
84
+ if (Math.abs(rows) >= viewportRows)
85
+ this.preferExactViewport = true;
106
86
  if (this.wheelFrame !== null)
107
87
  return;
108
88
  this.wheelFrame = this.requestFrame(() => {
109
89
  this.wheelFrame = null;
110
- const wholeRows = this.wheelResidualRows < 0
111
- ? Math.ceil(this.wheelResidualRows)
112
- : Math.floor(this.wheelResidualRows);
113
- this.wheelResidualRows -= wholeRows;
114
- this.scrollByRows(wholeRows);
90
+ this.handlingWheelFrame = true;
91
+ try {
92
+ const wholeRows = this.wheelResidualRows < 0
93
+ ? Math.ceil(this.wheelResidualRows)
94
+ : Math.floor(this.wheelResidualRows);
95
+ this.wheelResidualRows -= wholeRows;
96
+ this.scrollByRows(wholeRows);
97
+ }
98
+ finally {
99
+ this.handlingWheelFrame = false;
100
+ }
115
101
  });
116
102
  }
117
103
  showStart() {
118
- this.lastDirection = -1;
119
104
  this.showOffset(0);
120
105
  }
121
106
  showLatest() {
122
107
  if (!this.latest || this.disposed)
123
108
  return;
109
+ // Invalidate any history request that was started before returning to the
110
+ // live frontier. Its response must never re-enter the visible surface.
111
+ this.epoch += 1;
124
112
  this.desiredOffset = null;
125
- this.prefetchOffset = null;
113
+ this.preferExactViewport = false;
126
114
  this.visible = null;
127
- this.frontier = null;
128
- this.clearCache();
129
- this.historyAnchor = null;
130
115
  this.error = null;
131
- this.options.renderer.project(null);
116
+ this.projectFrame(null);
117
+ this.evictCache();
132
118
  this.emitState();
133
119
  }
134
120
  showOffset(offset) {
135
121
  if (!this.latest || this.disposed || !Number.isFinite(offset))
136
122
  return;
137
123
  const target = clamp(Math.trunc(offset), 0, this.latest.frame.history.screenStartOffset);
124
+ const current = this.currentIntentOffset();
125
+ this.recordScrollIntent(target - current);
126
+ emitSemanticDebugTrace({ kind: 'history-cache-check', at: performance.now(), target,
127
+ frontierOffset: this.frontier?.offset ?? null, frontierRows: this.frontier?.rows ?? null,
128
+ cacheEntries: this.cache.size, cacheBytes: this.cacheBytes,
129
+ cacheExtraBytes: this.cacheExtraBytes() });
138
130
  if (target === this.latest.frame.history.screenStartOffset) {
139
131
  this.showLatest();
140
132
  return;
141
133
  }
142
134
  const cached = this.findCachedViewport(target);
143
- if (cached && this.isCompatible(cached)) {
135
+ if (cached) {
144
136
  this.desiredOffset = null;
145
137
  this.display(cached);
146
138
  return;
147
139
  }
140
+ // The active frontier is the hottest window. Keep it usable even if the
141
+ // bounded LRU has evicted its map entry between adjacent wheel frames.
142
+ const frontierWindow = this.frontier;
143
+ if (frontierWindow && this.isReusableWindow(frontierWindow, target, this.latest.geometry.rows)) {
144
+ this.desiredOffset = null;
145
+ this.display(sliceHistoryWindow(frontierWindow, target, this.latest.geometry.rows));
146
+ return;
147
+ }
148
+ const window = this.findCachedWindow(target, this.latest.geometry.rows);
149
+ if (window) {
150
+ this.desiredOffset = null;
151
+ this.putCache(window);
152
+ this.display(sliceHistoryWindow(window, target, this.latest.geometry.rows));
153
+ return;
154
+ }
148
155
  this.desiredOffset = target;
149
156
  this.error = null;
150
157
  this.startLane();
151
158
  this.emitState();
152
159
  }
153
- setVisible(visible) {
154
- if (this.disposed || this.viewVisible === visible)
155
- return;
156
- this.viewVisible = visible;
157
- if (!visible) {
158
- this.prefetchOffset = null;
159
- this.evictCache(true);
160
+ currentIntentOffset() {
161
+ if (!this.latest)
162
+ return 0;
163
+ const liveOffset = this.latest.frame.history.screenStartOffset;
164
+ if (this.desiredOffset !== null)
165
+ return clamp(this.desiredOffset, 0, liveOffset);
166
+ if (!this.visible)
167
+ return liveOffset;
168
+ // A bounded runtime scrollback may evict old rows while an immutable
169
+ // history snapshot remains visible. Its old absolute offset can then sit
170
+ // beyond the new live frontier. Preserve the viewport's distance from the
171
+ // tail before applying the next user delta; clamping the stale absolute
172
+ // coordinate directly would incorrectly jump the user back to live.
173
+ if (this.visible.screenStartOffset > liveOffset) {
174
+ const distanceFromLive = this.visible.screenStartOffset - this.visible.offset;
175
+ return clamp(liveOffset - distanceFromLive, 0, liveOffset);
160
176
  }
161
- else if (this.visible)
162
- this.schedulePrefetch(this.visible);
177
+ return clamp(this.visible.offset, 0, liveOffset);
178
+ }
179
+ setVisible(_visible) {
180
+ if (this.disposed)
181
+ return;
182
+ // Keep this session's bounded history cache warm while another workbench
183
+ // view is active. Every reuse still passes the live revision, content,
184
+ // geometry, and transport-generation checks; retaining the immutable
185
+ // window therefore removes the first-scroll cold RPC without permitting
186
+ // stale terminal content to be projected after returning to this view.
163
187
  }
164
188
  reset() {
165
189
  if (this.disposed)
@@ -204,7 +228,6 @@ export class HistoryViewportController {
204
228
  throw new SemanticHistoryError('snapshot_superseded', 'semantic history viewport does not match the live surface');
205
229
  }
206
230
  this.desiredOffset = null;
207
- this.prefetchOffset = null;
208
231
  this.frontier = null;
209
232
  this.clearCache();
210
233
  this.historyAnchor = null;
@@ -231,7 +254,6 @@ export class HistoryViewportController {
231
254
  this.visible = null;
232
255
  this.frontier = null;
233
256
  this.desiredOffset = null;
234
- this.prefetchOffset = null;
235
257
  }
236
258
  startLane() {
237
259
  if (this.lane || this.disposed)
@@ -240,38 +262,56 @@ export class HistoryViewportController {
240
262
  this.lane = this.runLane(epoch).finally(() => {
241
263
  this.lane = null;
242
264
  this.emitState();
243
- if (!this.disposed && (this.desiredOffset !== null || this.prefetchOffset !== null))
265
+ if (!this.disposed && this.desiredOffset !== null)
244
266
  this.startLane();
245
267
  });
246
268
  }
247
269
  async runLane(epoch) {
248
- let activeWasPrefetch = false;
249
270
  try {
250
271
  while (!this.disposed && this.epoch === epoch) {
251
- const target = this.desiredOffset ?? this.prefetchOffset;
252
- const isPrefetch = this.desiredOffset === null && this.prefetchOffset !== null;
253
- activeWasPrefetch = isPrefetch;
272
+ const target = this.desiredOffset;
254
273
  if (target === null)
255
274
  return;
256
- if (isPrefetch)
257
- this.prefetchOffset = null;
258
275
  const cached = this.findCachedViewport(target);
259
- if (cached && this.isCompatible(cached)) {
260
- if (!isPrefetch && this.desiredOffset === target) {
276
+ if (cached) {
277
+ const isCurrentTarget = this.desiredOffset === target;
278
+ const desiredOffset = this.desiredOffset;
279
+ if (isCurrentTarget) {
261
280
  this.desiredOffset = null;
281
+ }
282
+ // A completed, validated viewport is still useful while a newer
283
+ // wheel target is in flight, but only while it moves toward that
284
+ // target. Never project an older response after a direction change.
285
+ if (isCurrentTarget || this.shouldDisplayIntermediate(cached.offset, desiredOffset)) {
262
286
  this.display(cached);
263
287
  }
264
288
  continue;
265
289
  }
290
+ const cachedWindow = this.findCachedWindow(target, this.latest?.geometry.rows ?? 0);
291
+ if (cachedWindow && this.latest) {
292
+ const isCurrentTarget = this.desiredOffset === target;
293
+ const desiredOffset = this.desiredOffset;
294
+ if (isCurrentTarget) {
295
+ this.desiredOffset = null;
296
+ }
297
+ this.putCache(cachedWindow);
298
+ const viewport = sliceHistoryWindow(cachedWindow, target, this.latest.geometry.rows);
299
+ if (isCurrentTarget || this.shouldDisplayIntermediate(viewport.offset, desiredOffset)) {
300
+ this.display(viewport);
301
+ }
302
+ continue;
303
+ }
266
304
  const viewport = await this.fetchOffset(target);
267
305
  if (this.disposed || this.epoch !== epoch)
268
306
  return;
269
- this.frontier = viewport;
270
307
  this.cacheViewport(viewport);
271
- if (!isPrefetch && this.desiredOffset === target) {
308
+ const isCurrentTarget = this.desiredOffset === target;
309
+ const desiredOffset = this.desiredOffset;
310
+ if (isCurrentTarget) {
272
311
  this.desiredOffset = null;
312
+ }
313
+ if (isCurrentTarget || this.shouldDisplayIntermediate(viewport.offset, desiredOffset)) {
273
314
  this.display(viewport);
274
- this.schedulePrefetch(viewport);
275
315
  }
276
316
  }
277
317
  }
@@ -281,61 +321,231 @@ export class HistoryViewportController {
281
321
  this.error = cause instanceof Error ? cause : new Error(String(cause));
282
322
  this.frontier = null;
283
323
  this.desiredOffset = null;
284
- this.prefetchOffset = null;
285
- if (activeWasPrefetch)
286
- this.error = null;
287
- else if (isStructuralSemanticHistoryError(this.error))
324
+ if (isStructuralSemanticHistoryError(this.error))
288
325
  this.resetHistory(true, this.error);
289
326
  }
290
327
  }
291
328
  async fetchOffset(target) {
329
+ try {
330
+ return await this.fetchOffsetAttempt(target);
331
+ }
332
+ catch (cause) {
333
+ if (!isRecoverableHistoryLineageError(cause))
334
+ throw cause;
335
+ this.frontier = null;
336
+ this.clearCache();
337
+ this.historyAnchor = null;
338
+ return await this.fetchOffsetAttempt(this.desiredOffset ?? target);
339
+ }
340
+ }
341
+ async fetchOffsetAttempt(target) {
292
342
  if (!this.latest)
293
343
  throw new Error('terminal history requires a live Presentation');
294
344
  const rows = this.latest.geometry.rows;
345
+ const totalRows = this.latest.frame.history.totalRows;
346
+ const revision = this.latest.frame.history.revision;
347
+ const preferExactViewport = this.preferExactViewport;
348
+ // A large wheel delta needs one exact target request, but must not poison
349
+ // the rest of the session. Subsequent small deltas should use a reusable
350
+ // history window again.
351
+ this.preferExactViewport = false;
352
+ const windowRows = preferExactViewport ? rows : this.adaptiveWindowRows(rows, totalRows);
353
+ if (windowRows <= rows)
354
+ return this.fetchExactFromBoundary(target, rows);
355
+ const cachedWindow = this.findCachedWindow(target, rows);
356
+ if (cachedWindow)
357
+ return sliceHistoryWindow(cachedWindow, target, rows);
295
358
  let frontier = this.frontier;
296
- if (!frontier || !this.isCompatible(frontier)) {
297
- frontier = validateHistoryViewport(await runSemanticHistoryRequest(() => this.options.request({
298
- lane: 'viewport',
299
- direction: target === 0 ? 'start' : 'end',
300
- viewportRows: rows,
301
- })));
302
- this.assertTransportGeneration(frontier);
303
- this.acceptLineage(frontier, true);
359
+ if (frontier && (frontier.revision !== revision || frontier.totalRows !== totalRows)) {
360
+ this.frontier = null;
361
+ frontier = null;
362
+ }
363
+ if (!frontier || !this.isReusableWindow(frontier, target, rows)) {
364
+ frontier = await this.fetchWindow(target, rows, windowRows);
365
+ }
366
+ if (frontier.window !== true)
367
+ return frontier;
368
+ return sliceHistoryWindow(frontier, target, rows);
369
+ }
370
+ async fetchExactFromBoundary(target, rows) {
371
+ // A boundary request already supports exact targeting in one RPC and
372
+ // atomically establishes its own native lineage. Reusing an old exact
373
+ // frontier offers no round-trip saving, but it opens a race where live
374
+ // output evicts the anchor after the client checks its revision and before
375
+ // the runtime processes the anchored request.
376
+ const frontier = validateHistoryViewport(await this.fetchBoundary(target === 0 ? 'start' : 'end', target, rows));
377
+ this.assertTransportGeneration(frontier);
378
+ this.acceptLineage(frontier, true);
379
+ this.frontier = frontier;
380
+ return this.fetchExactOffset(frontier, target, rows);
381
+ }
382
+ async fetchExactOffset(frontier, target, rows) {
383
+ if (frontier.offset === target)
384
+ return frontier;
385
+ const direction = target < frontier.offset ? 'backward' : 'forward';
386
+ const next = validateHistoryViewport(await runSemanticHistoryRequest(() => this.options.request({
387
+ lane: 'viewport',
388
+ direction,
389
+ anchor: frontier.anchor,
390
+ snapshotId: frontier.snapshotId,
391
+ offset: frontier.offset,
392
+ targetOffset: target,
393
+ viewportRows: rows,
394
+ })));
395
+ this.assertTransportGeneration(next);
396
+ this.acceptLineage(next);
397
+ this.frontier = next;
398
+ return next;
399
+ }
400
+ async fetchWindow(target, rows, windowRows) {
401
+ const windowStart = this.windowStart(target, rows, windowRows, this.scrollDirection);
402
+ let current = this.frontier;
403
+ if (current && this.isCompatibleWindow(current)
404
+ && current.offset === windowStart && !this.isReusableWindow(current, target, rows)) {
405
+ current = null;
406
+ }
407
+ if (!current || !this.isCompatibleWindow(current)) {
408
+ // A start/end boundary request always replaces the server-side view for
409
+ // this lane and therefore always establishes a new anchor. This is also
410
+ // how an exact viewport is upgraded to a reusable window. Clear every
411
+ // cache entry from the old lineage before accepting that replacement.
412
+ const allowLineageReplacement = true;
413
+ const response = await this.fetchBoundary(target === 0 ? 'start' : 'end', windowStart, windowRows, rows);
414
+ // Keep the old exact-viewport contract as a fallback for older runtimes
415
+ // while the fast-debug runtime is being rolled out.
416
+ if (response.window !== true) {
417
+ current = validateHistoryViewport(response);
418
+ this.assertTransportGeneration(current);
419
+ this.acceptLineage(current, allowLineageReplacement);
420
+ this.frontier = current;
421
+ return this.fetchExactOffset(current, this.desiredOffset ?? target, rows);
422
+ }
423
+ current = validateHistoryWindow(response);
424
+ this.assertTransportGeneration(current);
425
+ this.acceptLineage(current, allowLineageReplacement);
426
+ this.cacheWindow(current);
427
+ // This boundary response is already one validated, immutable snapshot
428
+ // captured for the requested target. Live output may advance `latest`
429
+ // while the RPC and payload chunks are in flight, so revision equality
430
+ // is no longer a valid reason to navigate away from this fresh window.
431
+ // Doing so used to synthesize a zero-distance `forward` request when the
432
+ // response started exactly at windowStart; the runtime correctly rejected
433
+ // that request as an invalid anchor and continuous agent output could make
434
+ // the single recovery attempt repeat the same race.
435
+ this.frontier = current;
436
+ if (this.windowCovers(current, target, rows))
437
+ return current;
438
+ // Bounded native scrollback can legitimately move past the requested
439
+ // absolute offset while the boundary capture is running. In that case,
440
+ // project the nearest viewport that is actually present in this atomic
441
+ // snapshot instead of issuing a directionally invalid anchored request.
442
+ const nearest = clamp(target, current.offset, current.offset + current.rows - rows);
443
+ return sliceHistoryWindow(current, nearest, rows);
304
444
  }
305
- let current = frontier;
306
445
  if (!current)
307
- throw new Error('terminal history frontier is unavailable');
308
- const intended = this.desiredOffset ?? target;
309
- if (current.offset === intended)
310
- return current;
311
- const direction = intended < current.offset ? 'backward' : 'forward';
312
- const scrollDeltaRows = Math.abs(current.offset - intended);
313
- if (scrollDeltaRows <= 0)
446
+ throw new Error('terminal history window is unavailable');
447
+ if (this.isReusableWindow(current, target, rows)) {
448
+ this.frontier = current;
314
449
  return current;
450
+ }
451
+ const direction = windowStart < current.offset ? 'backward' : 'forward';
315
452
  const offset = current.offset;
316
- const next = validateHistoryViewport(await runSemanticHistoryRequest(() => this.options.request({
453
+ const next = validateHistoryWindow(await runSemanticHistoryRequest(() => this.options.request({
317
454
  lane: 'viewport',
318
455
  direction,
319
456
  anchor: current.anchor,
320
457
  snapshotId: current.snapshotId,
321
458
  offset,
322
- targetOffset: intended,
323
- viewportRows: rows,
459
+ targetOffset: windowStart,
460
+ viewportRows: windowRows,
461
+ windowRows,
324
462
  })));
325
463
  this.assertTransportGeneration(next);
326
464
  this.acceptLineage(next);
327
465
  if (next.offset === offset || (direction === 'backward' && next.offset > offset)
328
466
  || (direction === 'forward' && next.offset < offset)) {
329
- throw new SemanticHistoryError('malformed_snapshot', 'semantic history request did not advance toward its target');
467
+ throw new SemanticHistoryError('malformed_snapshot', 'semantic history window did not advance toward its target');
330
468
  }
331
- current = next;
332
- return current;
469
+ this.frontier = next;
470
+ this.cacheWindow(next);
471
+ return next;
472
+ }
473
+ async fetchBoundary(direction, target, viewportRows, legacyViewportRows = viewportRows) {
474
+ try {
475
+ return await runSemanticHistoryRequest(() => this.options.request({
476
+ lane: 'viewport',
477
+ direction,
478
+ targetOffset: target,
479
+ viewportRows,
480
+ ...(viewportRows === legacyViewportRows ? {} : { windowRows: viewportRows }),
481
+ }));
482
+ }
483
+ catch (cause) {
484
+ if (!isLegacyBoundaryTargetRejection(cause))
485
+ throw cause;
486
+ return await runSemanticHistoryRequest(() => this.options.request({
487
+ lane: 'viewport',
488
+ direction,
489
+ viewportRows: legacyViewportRows,
490
+ }));
491
+ }
492
+ }
493
+ windowStart(target, rows, windowRows, direction) {
494
+ if (!this.latest)
495
+ return target;
496
+ const totalRows = this.latest.frame.history.totalRows;
497
+ const maximum = Math.max(0, totalRows - windowRows);
498
+ const bufferRows = Math.max(0, windowRows - rows);
499
+ const rowsBeforeTarget = direction < 0
500
+ ? Math.round(bufferRows * HISTORY_WINDOW_FORWARD_BIAS)
501
+ : direction > 0
502
+ ? Math.round(bufferRows * (1 - HISTORY_WINDOW_FORWARD_BIAS))
503
+ : Math.round(bufferRows / 2);
504
+ return clamp(target - rowsBeforeTarget, 0, maximum);
505
+ }
506
+ adaptiveWindowRows(rows, totalRows) {
507
+ if (!this.latest)
508
+ return rows;
509
+ const now = this.now();
510
+ const fast = this.scrollBurstDirection !== 0
511
+ && now - this.scrollBurstAt <= HISTORY_SCROLL_BURST_DECAY_MS
512
+ && this.scrollBurstRows >= rows * HISTORY_SCROLL_BURST_VIEWPORTS;
513
+ const multiplier = fast ? HISTORY_WINDOW_FAST_MULTIPLIER : HISTORY_WINDOW_BASE_MULTIPLIER;
514
+ const liveRowBytes = estimateFrameBytes(this.latest.frame) / Math.max(1, this.latest.frame.height);
515
+ const rowBytes = Math.max(256, this.estimatedHistoryRowBytes || liveRowBytes);
516
+ const cacheLimitedRows = Math.max(rows, Math.floor(HISTORY_WINDOW_CACHE_TARGET_BYTES / rowBytes));
517
+ const windowRows = Math.max(rows, Math.min(totalRows, HISTORY_WINDOW_MAX_ROWS, rows * multiplier, cacheLimitedRows));
518
+ emitSemanticDebugTrace({
519
+ kind: 'history-window-plan', at: now, direction: this.scrollDirection,
520
+ multiplier, viewportRows: rows, windowRows, cacheLimitedRows,
521
+ estimatedHistoryRowBytes: rowBytes, scrollBurstRows: this.scrollBurstRows,
522
+ });
523
+ return windowRows;
524
+ }
525
+ recordScrollIntent(deltaRows) {
526
+ if (deltaRows === 0)
527
+ return;
528
+ const direction = Math.sign(deltaRows);
529
+ const now = this.now();
530
+ if (direction === this.scrollBurstDirection
531
+ && now - this.scrollBurstAt <= HISTORY_SCROLL_BURST_GAP_MS) {
532
+ this.scrollBurstRows += Math.abs(deltaRows);
533
+ }
534
+ else {
535
+ this.scrollBurstDirection = direction;
536
+ this.scrollBurstRows = Math.abs(deltaRows);
537
+ }
538
+ this.scrollDirection = direction;
539
+ this.scrollBurstAt = now;
540
+ }
541
+ windowCovers(window, target, rows) {
542
+ return target >= window.offset && target + rows <= window.offset + window.rows;
333
543
  }
334
544
  display(viewport) {
335
545
  this.visible = viewport;
336
546
  this.error = null;
337
547
  this.putCache(viewport);
338
- this.options.renderer.project(viewport.frame);
548
+ this.projectFrame(viewport.frame);
339
549
  this.evictCache();
340
550
  this.emitState();
341
551
  }
@@ -343,33 +553,34 @@ export class HistoryViewportController {
343
553
  this.putCache(viewport);
344
554
  this.evictCache();
345
555
  }
346
- evictCache(dropHiddenExtras = false) {
347
- const viewportCells = this.latest ? this.latest.geometry.cols * this.latest.geometry.rows : 0;
348
- const maxExtraCells = Math.min(2 * viewportCells, 12288);
556
+ projectFrame(frame) {
557
+ if (this.handlingWheelFrame && this.options.renderer.projectInCurrentAnimationFrame) {
558
+ this.options.renderer.projectInCurrentAnimationFrame(frame);
559
+ return;
560
+ }
561
+ this.options.renderer.project(frame);
562
+ }
563
+ evictCache() {
349
564
  for (;;) {
350
565
  const candidates = [...this.cache.entries()]
351
566
  .filter(([, item]) => item.viewport !== this.visible)
352
567
  .sort((left, right) => left[1].touched - right[1].touched);
353
568
  const extraBytes = candidates.reduce((sum, candidate) => sum + candidate[1].bytes, 0);
354
- const extraCells = candidates.reduce((sum, candidate) => sum + candidate[1].cells, 0);
355
- if (!dropHiddenExtras && extraBytes <= MAX_HISTORY_CACHE_BYTES && extraCells <= maxExtraCells)
569
+ if (extraBytes <= MAX_SESSION_HISTORY_CACHE_BYTES)
356
570
  break;
357
571
  const oldest = candidates[0];
358
572
  if (!oldest)
359
573
  break;
360
574
  this.removeCacheEntry(oldest[0]);
361
575
  }
576
+ emitSemanticDebugTrace({ kind: 'history-cache-state', at: performance.now(),
577
+ cacheEntries: this.cache.size, cacheBytes: this.cacheBytes,
578
+ cacheExtraBytes: this.cacheExtraBytes() });
362
579
  }
363
- schedulePrefetch(viewport) {
364
- if (!this.viewVisible || this.lastDirection === 0)
365
- return;
366
- const rows = viewport.rows;
367
- const target = clamp(viewport.offset + this.lastDirection * rows, 0, viewport.screenStartOffset);
368
- if (target === viewport.offset || this.findCachedViewport(target))
369
- return;
370
- this.prefetchOffset = target;
371
- if (!this.lane)
372
- this.startLane();
580
+ cacheExtraBytes() {
581
+ return [...this.cache.values()]
582
+ .filter(item => item.viewport !== this.visible)
583
+ .reduce((sum, item) => sum + item.bytes, 0);
373
584
  }
374
585
  isCompatible(viewport) {
375
586
  return Boolean(this.latest
@@ -379,7 +590,32 @@ export class HistoryViewportController {
379
590
  && (viewport.lane ?? 'viewport') === 'viewport'
380
591
  && (this.historyAnchor === null || viewport.anchor === this.historyAnchor)
381
592
  && viewport.cols === this.latest.geometry.cols
382
- && viewport.rows === this.latest.geometry.rows);
593
+ && viewport.rows === this.latest.geometry.rows
594
+ && viewport.window !== true);
595
+ }
596
+ isReusableViewport(viewport) {
597
+ return Boolean(this.latest
598
+ && this.isCompatible(viewport)
599
+ && viewport.totalRows === this.latest.frame.history.totalRows
600
+ && viewport.revision === this.latest.frame.history.revision);
601
+ }
602
+ isCompatibleWindow(viewport) {
603
+ return Boolean(this.latest
604
+ && viewport.window === true
605
+ && viewport.contentEpoch === (this.latest.state.contentEpoch ?? 0)
606
+ && viewport.geometryGeneration === this.latest.geometry.generation
607
+ && (this.transportGeneration === null || viewport.transportGeneration === this.transportGeneration)
608
+ && (viewport.lane ?? 'viewport') === 'viewport'
609
+ && (this.historyAnchor === null || viewport.anchor === this.historyAnchor)
610
+ && viewport.cols === this.latest.geometry.cols
611
+ && viewport.rows >= this.latest.geometry.rows);
612
+ }
613
+ isReusableWindow(viewport, target, rows) {
614
+ return Boolean(this.latest
615
+ && this.isCompatibleWindow(viewport)
616
+ && viewport.totalRows === this.latest.frame.history.totalRows
617
+ && this.windowCovers(viewport, target, rows)
618
+ && viewport.revision === this.latest.frame.history.revision);
383
619
  }
384
620
  assertTransportGeneration(viewport) {
385
621
  if (this.transportGeneration === null) {
@@ -396,20 +632,57 @@ export class HistoryViewportController {
396
632
  this.frontier = null;
397
633
  this.visible = null;
398
634
  this.desiredOffset = null;
399
- this.prefetchOffset = null;
635
+ this.preferExactViewport = false;
636
+ this.scrollDirection = 0;
637
+ this.scrollBurstDirection = 0;
638
+ this.scrollBurstRows = 0;
639
+ this.scrollBurstAt = 0;
640
+ this.estimatedHistoryRowBytes = 0;
400
641
  this.historyAnchor = null;
401
642
  this.error = error;
402
643
  if (projectLatest)
403
644
  this.options.renderer.project(null);
404
645
  this.emitState();
405
646
  }
647
+ shouldDisplayIntermediate(target, desiredOffset) {
648
+ if (desiredOffset === null || this.visible === null)
649
+ return false;
650
+ const currentOffset = this.visible.offset;
651
+ const minimum = Math.min(currentOffset, desiredOffset);
652
+ const maximum = Math.max(currentOffset, desiredOffset);
653
+ return target >= minimum && target <= maximum
654
+ && Math.abs(desiredOffset - target) < Math.abs(desiredOffset - currentOffset);
655
+ }
406
656
  findCachedViewport(offset) {
407
657
  for (const item of this.cache.values()) {
408
- if (item.viewport.offset === offset && this.isCompatible(item.viewport))
658
+ if (item.viewport.offset === offset && this.isReusableViewport(item.viewport))
409
659
  return item.viewport;
410
660
  }
411
661
  return null;
412
662
  }
663
+ findCachedWindow(offset, rows) {
664
+ if (rows <= 0)
665
+ return null;
666
+ let candidate = null;
667
+ for (const item of this.cache.values()) {
668
+ if (this.isReusableWindow(item.viewport, offset, rows)
669
+ && (!candidate || item.touched > candidate.touched))
670
+ candidate = item;
671
+ }
672
+ return candidate?.viewport ?? null;
673
+ }
674
+ cacheWindow(viewport) {
675
+ if (viewport.window !== true)
676
+ return;
677
+ const observedRowBytes = estimateViewportBytes(viewport) / Math.max(1, viewport.rows);
678
+ this.estimatedHistoryRowBytes = this.estimatedHistoryRowBytes === 0
679
+ ? observedRowBytes
680
+ : observedRowBytes > this.estimatedHistoryRowBytes
681
+ ? observedRowBytes
682
+ : this.estimatedHistoryRowBytes * 0.75 + observedRowBytes * 0.25;
683
+ this.putCache(viewport);
684
+ this.evictCache();
685
+ }
413
686
  putCache(viewport) {
414
687
  if ((viewport.lane ?? 'viewport') !== 'viewport') {
415
688
  throw new SemanticHistoryError('malformed_snapshot', 'semantic history viewport belongs to another lane');
@@ -419,33 +692,26 @@ export class HistoryViewportController {
419
692
  }
420
693
  if (this.historyAnchor === null)
421
694
  this.historyAnchor = viewport.anchor;
422
- const key = `${viewport.snapshotId}:${viewport.offset}`;
695
+ // Keep a sliced viewport separate from its source window. Reusing the same
696
+ // key would replace the reusable window as soon as it is displayed once.
697
+ const key = `${viewport.snapshotId}:${viewport.offset}:${viewport.window === true ? 'window' : 'viewport'}`;
698
+ const previous = this.cache.get(key);
699
+ this.touch += 1;
700
+ if (previous?.viewport === viewport) {
701
+ this.cache.set(key, { ...previous, touched: this.touch });
702
+ return;
703
+ }
423
704
  const bytes = estimateViewportBytes(viewport);
424
705
  const cells = viewport.cols * viewport.rows;
425
- const previous = this.cache.get(key);
426
706
  if (previous)
427
707
  this.cacheBytes -= previous.bytes;
428
- this.touch += 1;
429
708
  const item = { viewport, touched: this.touch, bytes, cells };
430
709
  this.cache.set(key, item);
431
710
  this.cacheBytes += bytes;
432
- globalHistoryCache.put({
433
- owner: this.cacheOwner,
434
- key,
435
- bytes,
436
- touched: item.touched,
437
- hidden: () => !this.viewVisible,
438
- evictable: () => {
439
- const current = this.cache.get(key)?.viewport;
440
- return Boolean(current && current !== this.visible);
441
- },
442
- evict: () => this.removeCacheEntry(key),
443
- });
444
711
  }
445
712
  clearCache() {
446
713
  this.cache.clear();
447
714
  this.cacheBytes = 0;
448
- globalHistoryCache.clear(this.cacheOwner);
449
715
  }
450
716
  removeCacheEntry(key) {
451
717
  const item = this.cache.get(key);
@@ -455,7 +721,6 @@ export class HistoryViewportController {
455
721
  this.cacheBytes -= item.bytes;
456
722
  if (this.frontier === item.viewport)
457
723
  this.frontier = null;
458
- globalHistoryCache.remove(this.cacheOwner, key);
459
724
  }
460
725
  acceptLineage(viewport, allowReplacement = false) {
461
726
  if ((viewport.lane ?? 'viewport') !== 'viewport') {
@@ -476,12 +741,91 @@ export class HistoryViewportController {
476
741
  function clamp(value, minimum, maximum) {
477
742
  return Math.max(minimum, Math.min(maximum, value));
478
743
  }
744
+ function isLegacyBoundaryTargetRejection(cause) {
745
+ if (!(cause instanceof Error))
746
+ return false;
747
+ const code = Number(cause.code);
748
+ return code === 400 && cause.message.toLowerCase().includes('failed to read semantic history');
749
+ }
750
+ function isRecoverableHistoryLineageError(cause) {
751
+ if (!(cause instanceof SemanticHistoryError)
752
+ || (cause.kind !== 'anchor_invalid' && cause.kind !== 'snapshot_superseded'))
753
+ return false;
754
+ // The RPC adapter normally preserves the 409/412 code as `cause`, but a
755
+ // transport implementation is allowed to surface the typed semantic error
756
+ // directly. The semantic kind is the authoritative contract here; making
757
+ // recovery depend on an adapter-specific numeric code exposed the user to
758
+ // stale-anchor errors whenever that code was wrapped or omitted.
759
+ const rpcCause = cause.cause;
760
+ // A local `snapshot_superseded` means two validated responses disagreed on
761
+ // lineage and must remain a hard failure. Only an unwrapped anchor error is
762
+ // safe to recover from without an adapter code: native history can expire
763
+ // an anchor while the transport is still healthy.
764
+ if (!rpcCause)
765
+ return cause.kind === 'anchor_invalid';
766
+ const code = rpcCause instanceof Error
767
+ ? Number(rpcCause.code)
768
+ : Number.NaN;
769
+ return code === 409 || code === 412 || rpcCause instanceof SemanticHistoryError;
770
+ }
479
771
  function estimateViewportBytes(viewport) {
480
- let bytes = 256;
481
- for (const row of viewport.frame.rows) {
772
+ return 256 + estimateFrameBytes(viewport.frame);
773
+ }
774
+ function estimateFrameBytes(frame) {
775
+ let bytes = 0;
776
+ for (const row of frame.rows) {
482
777
  for (const cell of row.cells)
483
778
  bytes += 16 + cell.text.length * 2 + (cell.hyperlink?.length ?? 0) * 2;
484
779
  }
485
780
  return bytes;
486
781
  }
782
+ function sliceHistoryWindow(window, offset, rows) {
783
+ if (window.window !== true || offset < window.offset || offset + rows > window.offset + window.rows) {
784
+ throw new SemanticHistoryError('malformed_snapshot', 'semantic history window does not cover its target viewport');
785
+ }
786
+ const rowStart = offset - window.offset;
787
+ const frame = window.frame;
788
+ const end = rowStart + rows;
789
+ const screenStartOffset = window.totalRows - rows;
790
+ const cursorVisible = frame.cursor.visible && frame.cursor.y >= rowStart && frame.cursor.y < end;
791
+ const placements = frame.graphics.placements
792
+ .filter(placement => !placement.visible || (placement.viewportRow + placement.gridRows > rowStart && placement.viewportRow < end))
793
+ .map(placement => ({ ...placement, viewportRow: placement.viewportRow - rowStart }));
794
+ const slicedFrame = {
795
+ ...frame,
796
+ height: rows,
797
+ rows: frame.rows.slice(rowStart, end),
798
+ cursor: {
799
+ ...frame.cursor,
800
+ y: cursorVisible ? frame.cursor.y - rowStart : 0,
801
+ visible: cursorVisible,
802
+ },
803
+ history: {
804
+ revision: window.revision,
805
+ totalRows: window.totalRows,
806
+ screenStartOffset: window.totalRows - rows,
807
+ },
808
+ graphics: { ...frame.graphics, placements },
809
+ };
810
+ return validateHistoryViewport({
811
+ snapshotId: `${window.snapshotId}:${offset}`,
812
+ lane: window.lane,
813
+ revision: window.revision,
814
+ transportGeneration: window.transportGeneration,
815
+ contentEpoch: window.contentEpoch,
816
+ geometryGeneration: window.geometryGeneration,
817
+ cols: window.cols,
818
+ rows,
819
+ anchor: window.anchor,
820
+ firstAvailable: window.firstAvailable,
821
+ lastAvailable: window.lastAvailable,
822
+ screenStart: window.screenStart,
823
+ offset,
824
+ totalRows: window.totalRows,
825
+ screenStartOffset,
826
+ hasPrevious: offset > 0,
827
+ hasNext: offset < screenStartOffset,
828
+ frame: slicedFrame,
829
+ });
830
+ }
487
831
  //# sourceMappingURL=HistoryViewportController.js.map