@nuvin/ink 7.5.0-alpha → 7.7.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/build/allocated-seek.d.ts +21 -0
  2. package/build/allocated-seek.js +273 -0
  3. package/build/allocated-seek.js.map +1 -0
  4. package/build/ansi-tokenizer.js.map +1 -1
  5. package/build/components/App.d.ts +5 -1
  6. package/build/components/App.js +48 -10
  7. package/build/components/App.js.map +1 -1
  8. package/build/components/Box.d.ts +95 -0
  9. package/build/components/Box.js +176 -140
  10. package/build/components/Box.js.map +1 -1
  11. package/build/components/Transform.js +1 -1
  12. package/build/components/Transform.js.map +1 -1
  13. package/build/components/VLBox.d.ts +87 -0
  14. package/build/components/VLBox.js +5 -0
  15. package/build/components/VLBox.js.map +1 -0
  16. package/build/dom.d.ts +45 -1
  17. package/build/dom.js +5 -0
  18. package/build/dom.js.map +1 -1
  19. package/build/explicit-width-detection.d.ts +45 -0
  20. package/build/explicit-width-detection.js +172 -0
  21. package/build/explicit-width-detection.js.map +1 -0
  22. package/build/explicit-width.d.ts +1 -0
  23. package/build/explicit-width.js +114 -0
  24. package/build/explicit-width.js.map +1 -0
  25. package/build/hooks/use-input.js +3 -3
  26. package/build/hooks/use-input.js.map +1 -1
  27. package/build/index.d.ts +2 -0
  28. package/build/index.js +1 -0
  29. package/build/index.js.map +1 -1
  30. package/build/ink.d.ts +45 -2
  31. package/build/ink.js +691 -164
  32. package/build/ink.js.map +1 -1
  33. package/build/input-parser.d.ts +14 -1
  34. package/build/input-parser.js +74 -4
  35. package/build/input-parser.js.map +1 -1
  36. package/build/layout-metadata.d.ts +28 -0
  37. package/build/layout-metadata.js +302 -0
  38. package/build/layout-metadata.js.map +1 -0
  39. package/build/log-update.d.ts +16 -8
  40. package/build/log-update.js +439 -37
  41. package/build/log-update.js.map +1 -1
  42. package/build/measure-text.d.ts +1 -1
  43. package/build/measure-text.js +2 -2
  44. package/build/measure-text.js.map +1 -1
  45. package/build/output.d.ts +1 -0
  46. package/build/output.js +69 -28
  47. package/build/output.js.map +1 -1
  48. package/build/reconciler.js +84 -10
  49. package/build/reconciler.js.map +1 -1
  50. package/build/render-background.d.ts +2 -2
  51. package/build/render-background.js +33 -5
  52. package/build/render-background.js.map +1 -1
  53. package/build/render-border.d.ts +2 -2
  54. package/build/render-border.js +78 -34
  55. package/build/render-border.js.map +1 -1
  56. package/build/render-node-to-output.d.ts +7 -1
  57. package/build/render-node-to-output.js +118 -27
  58. package/build/render-node-to-output.js.map +1 -1
  59. package/build/render-to-string.js +2 -1
  60. package/build/render-to-string.js.map +1 -1
  61. package/build/render.d.ts +11 -0
  62. package/build/render.js.map +1 -1
  63. package/build/renderer.d.ts +1 -0
  64. package/build/renderer.js +10 -2
  65. package/build/renderer.js.map +1 -1
  66. package/build/styles.js.map +1 -1
  67. package/build/terminal-control.d.ts +6 -0
  68. package/build/terminal-control.js +124 -0
  69. package/build/terminal-control.js.map +1 -0
  70. package/build/terminal-paint.d.ts +38 -0
  71. package/build/terminal-paint.js +47 -0
  72. package/build/terminal-paint.js.map +1 -0
  73. package/build/wrap-text.d.ts +1 -1
  74. package/build/wrap-text.js +2 -2
  75. package/build/wrap-text.js.map +1 -1
  76. package/package.json +3 -1
  77. package/readme.md +98 -0
  78. package/build/reflow.d.ts +0 -12
  79. package/build/reflow.js +0 -47
  80. package/build/reflow.js.map +0 -1
@@ -1,6 +1,9 @@
1
+ import process from 'node:process';
1
2
  import ansiEscapes from 'ansi-escapes';
2
3
  import cliCursor from 'cli-cursor';
3
4
  import { cursorPositionChanged, getOutputCursorRow, buildCursorSuffix, buildCursorOnlySequence, buildReturnToBottomPrefix, hideCursorEscape, showCursorEscape, } from './cursor-helpers.js';
5
+ import { createSeekPreparer } from './allocated-seek.js';
6
+ import { isSeekViewport, clampSeekCursor, buildSeekCursorSequence, encodeSeekRows, wrapSeekPaint, decawmOn, eraseDisplayHome, } from './terminal-paint.js';
4
7
  // Count visible lines in a string, ignoring the trailing empty element
5
8
  // that `split('\n')` produces when the string ends with '\n'.
6
9
  const visibleLineCount = (lines, str) => str.endsWith('\n') ? lines.length - 1 : lines.length;
@@ -10,7 +13,103 @@ const getEraseLineCount = (cachedLineCount, options) => {
10
13
  }
11
14
  return options?.eraseLineCount ?? cachedLineCount;
12
15
  };
13
- const createStandard = (stream, { showCursor = false } = {}) => {
16
+ // Maximum shift distance (rows) considered by scroll-region detection. Real
17
+ // wheel/keyboard scrolls move a handful of rows per commit; larger jumps
18
+ // (PageUp/PageDown) rarely share half the frame and fall through to the
19
+ // ordinary diff anyway.
20
+ const maxScrollShift = 32;
21
+ // Minimum fraction of the frame that must be explained by a pure vertical
22
+ // shift for the scroll-region fast path to engage. Updates that merely resemble
23
+ // a shift (streaming appends, selection highlights, spinner ticks) stay below
24
+ // the threshold and use the ordinary diff.
25
+ const minShiftedCoreRatio = 0.5;
26
+ const setScrollRegion = (start, end) => `\u001B[${start + 1};${end}r`;
27
+ const resetScrollRegion = '\u001B[r';
28
+ const cursorToRow = (row) => `\u001B[${row + 1};1H`;
29
+ const blankLines = (count) => Array.from({ length: count }, () => '');
30
+ const prefixUnchanged = (previousLines, nextLines, start) => {
31
+ for (let index = 0; index < start; index++) {
32
+ if (nextLines[index] !== previousLines[index]) {
33
+ return false;
34
+ }
35
+ }
36
+ return true;
37
+ };
38
+ const suffixUnchanged = (previousLines, nextLines, end) => {
39
+ for (let index = end; index < nextLines.length; index++) {
40
+ if (nextLines[index] !== previousLines[index])
41
+ return false;
42
+ }
43
+ return true;
44
+ };
45
+ // Detect whether `nextLines` is `previousLines` shifted vertically by k rows
46
+ // with at most a small band of changed edge lines. Only exact whole-line
47
+ // equality counts (same semantics as the existing positional diff).
48
+ const detectScrollShift = (previousLines, nextLines) => {
49
+ const height = nextLines.length;
50
+ if (height < 4 || height !== previousLines.length) {
51
+ return undefined;
52
+ }
53
+ const required = Math.ceil(height * minShiftedCoreRatio);
54
+ for (let rows = 1; rows <= Math.min(maxScrollShift, height - 1); rows++) {
55
+ // Content moved up: nextLines[i] === previousLines[i + rows] over the
56
+ // overlap [0, height - rows); the bottom `rows` lines are new.
57
+ let up = 0;
58
+ let upFirst = -1;
59
+ let upLast = -1;
60
+ for (let i = 0; i < height - rows; i++) {
61
+ if (nextLines[i] !== previousLines[i + rows]) {
62
+ continue;
63
+ }
64
+ up++;
65
+ if (nextLines[i] === previousLines[i]) {
66
+ continue;
67
+ }
68
+ upFirst = upFirst < 0 ? i : upFirst;
69
+ upLast = i;
70
+ }
71
+ const upEnd = upLast + rows + 1;
72
+ if (up >= required &&
73
+ upFirst >= 0 &&
74
+ upLast - upFirst + 1 >= required &&
75
+ upEnd <= height &&
76
+ upEnd - upFirst > rows &&
77
+ prefixUnchanged(previousLines, nextLines, upFirst) &&
78
+ suffixUnchanged(previousLines, nextLines, upEnd)) {
79
+ return { rows, start: upFirst, end: upEnd };
80
+ }
81
+ // Content moved down: nextLines[i] === previousLines[i - rows]; the top
82
+ // `rows` lines of the shifted band are new.
83
+ let down = 0;
84
+ let downFirst = -1;
85
+ let downLast = -1;
86
+ for (let i = rows; i < height; i++) {
87
+ if (nextLines[i] !== previousLines[i - rows]) {
88
+ continue;
89
+ }
90
+ down++;
91
+ if (nextLines[i] === previousLines[i]) {
92
+ continue;
93
+ }
94
+ downFirst = downFirst < 0 ? i : downFirst;
95
+ downLast = i;
96
+ }
97
+ const downStart = downFirst - rows;
98
+ const downEnd = downLast + 1;
99
+ if (down >= required &&
100
+ downFirst >= 0 &&
101
+ downLast - downFirst + 1 >= required &&
102
+ downStart >= 0 &&
103
+ downEnd <= height &&
104
+ downEnd - downStart > rows &&
105
+ prefixUnchanged(previousLines, nextLines, downStart) &&
106
+ suffixUnchanged(previousLines, nextLines, downEnd)) {
107
+ return { rows: -rows, start: downStart, end: downEnd };
108
+ }
109
+ }
110
+ return undefined;
111
+ };
112
+ const createStandard = (stream, { showCursor = false, transformOutput } = {}) => {
14
113
  let previousLineCount = 0;
15
114
  let previousOutput = '';
16
115
  let hasHiddenCursor = false;
@@ -54,7 +153,7 @@ const createStandard = (stream, { showCursor = false } = {}) => {
54
153
  const returnPrefix = buildReturnToBottomPrefix(cursorWasShown, previousLineCount, previousCursorPosition);
55
154
  stream.write(returnPrefix +
56
155
  ansiEscapes.eraseLines(getEraseLineCount(previousLineCount, options)) +
57
- str +
156
+ (transformOutput ? transformOutput(str) : str) +
58
157
  cursorSuffix);
59
158
  previousLineCount = lines.length;
60
159
  }
@@ -62,8 +161,8 @@ const createStandard = (stream, { showCursor = false } = {}) => {
62
161
  cursorWasShown = activeCursor !== undefined;
63
162
  return true;
64
163
  };
65
- const render = (str) => writeFrame(str, false);
66
- render.repaint = (str, options) => writeFrame(str, true, options);
164
+ const render = (str, _context) => writeFrame(str, false);
165
+ render.repaint = (str, options, _context) => writeFrame(str, true, options);
67
166
  render.clear = (options) => {
68
167
  const prefix = buildReturnToBottomPrefix(cursorWasShown, previousLineCount, previousCursorPosition);
69
168
  stream.write(prefix +
@@ -98,7 +197,7 @@ const createStandard = (stream, { showCursor = false } = {}) => {
98
197
  cursorWasShown = false;
99
198
  cursorDirty = false;
100
199
  };
101
- render.sync = (str) => {
200
+ render.sync = (str, _physical) => {
102
201
  const activeCursor = cursorPosition;
103
202
  cursorDirty = false;
104
203
  const lines = str.split('\n');
@@ -118,10 +217,14 @@ const createStandard = (stream, { showCursor = false } = {}) => {
118
217
  cursorDirty = true;
119
218
  };
120
219
  render.isCursorDirty = () => cursorDirty;
121
- render.willRender = (str) => hasChanges(str, getActiveCursor());
220
+ render.willRender = (str, _context) => hasChanges(str, getActiveCursor());
221
+ render.getPhysicalFrame = () => undefined;
222
+ render.restoreTerminalModes = () => {
223
+ // Standard rendering never acquires seek terminal modes.
224
+ };
122
225
  return render;
123
226
  };
124
- const createIncremental = (stream, { showCursor = false } = {}) => {
227
+ const createIncremental = (stream, { showCursor = false, transformOutput } = {}) => {
125
228
  let previousLines = [];
126
229
  let previousOutput = '';
127
230
  let hasHiddenCursor = false;
@@ -129,12 +232,221 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
129
232
  let cursorDirty = false;
130
233
  let previousCursorPosition;
131
234
  let cursorWasShown = false;
235
+ let physicalFrame;
236
+ let previousIdentities = [];
237
+ let ownsDecawm = false;
238
+ let writeGeneration = 0;
239
+ let paintedSeekViewport = false;
240
+ const seekPreparer = createSeekPreparer();
241
+ let streamErrorHandler;
242
+ let handlingAsyncWriteError = false;
132
243
  const getActiveCursor = () => cursorPosition;
133
244
  const hasChanges = (str, activeCursor) => {
134
245
  const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
135
246
  return str !== previousOutput || cursorChanged;
136
247
  };
137
- const render = (str) => {
248
+ const bumpGeneration = () => {
249
+ writeGeneration++;
250
+ };
251
+ const invalidatePhysical = () => {
252
+ if (physicalFrame === undefined) {
253
+ return;
254
+ }
255
+ physicalFrame = {
256
+ ...physicalFrame,
257
+ valid: false,
258
+ ownsViewport: false,
259
+ };
260
+ };
261
+ const canWriteStream = () => {
262
+ const candidate = stream;
263
+ return (!candidate.destroyed &&
264
+ !candidate.writableEnded &&
265
+ (candidate.writable ?? true));
266
+ };
267
+ const restoreTerminalModes = () => {
268
+ if (!ownsDecawm) {
269
+ return;
270
+ }
271
+ if (canWriteStream()) {
272
+ try {
273
+ stream.write(decawmOn);
274
+ }
275
+ catch {
276
+ // Best-effort autowrap restore.
277
+ }
278
+ }
279
+ ownsDecawm = false;
280
+ };
281
+ const handleAsyncWriteError = () => {
282
+ if (handlingAsyncWriteError) {
283
+ return;
284
+ }
285
+ handlingAsyncWriteError = true;
286
+ bumpGeneration();
287
+ invalidatePhysical();
288
+ previousIdentities = [];
289
+ previousOutput = '';
290
+ previousLines = [];
291
+ // A queued successful write can establish the next ordered diff base,
292
+ // but an async error invalidates it; it is not proof that a terminal
293
+ // displayed the frame.
294
+ if (paintedSeekViewport) {
295
+ ownsDecawm = true;
296
+ }
297
+ restoreTerminalModes();
298
+ };
299
+ const detachStreamErrorListener = () => {
300
+ if (!streamErrorHandler) {
301
+ return;
302
+ }
303
+ stream.off('error', streamErrorHandler);
304
+ streamErrorHandler = undefined;
305
+ };
306
+ const attachStreamErrorListener = () => {
307
+ if (streamErrorHandler) {
308
+ return;
309
+ }
310
+ streamErrorHandler = () => {
311
+ handleAsyncWriteError();
312
+ };
313
+ stream.on('error', streamErrorHandler);
314
+ };
315
+ const copyContext = (context) => ({
316
+ strategy: context.strategy,
317
+ columns: context.columns,
318
+ rows: context.rows,
319
+ });
320
+ const seekHasChanges = (str, context, activeCursor) => {
321
+ if (physicalFrame === undefined ||
322
+ !physicalFrame.valid ||
323
+ physicalFrame.context.strategy !== context.strategy ||
324
+ physicalFrame.context.columns !== context.columns ||
325
+ physicalFrame.context.rows !== context.rows ||
326
+ str !== previousOutput) {
327
+ return true;
328
+ }
329
+ return cursorPositionChanged(activeCursor, previousCursorPosition);
330
+ };
331
+ const submitSeekWrite = (payload, { decawm, onAccept, }) => {
332
+ const generation = ++writeGeneration;
333
+ handlingAsyncWriteError = false;
334
+ if (decawm) {
335
+ ownsDecawm = true;
336
+ // Seek cells may already be on the stream; keep teardown ownership
337
+ // through throw/reentry. Physical/diff still publish only on accept.
338
+ paintedSeekViewport = true;
339
+ }
340
+ attachStreamErrorListener();
341
+ try {
342
+ stream.write(payload, error => {
343
+ if (error) {
344
+ handleAsyncWriteError();
345
+ }
346
+ });
347
+ if (decawm) {
348
+ ownsDecawm = false;
349
+ }
350
+ // A queued successful write can establish the next ordered diff base,
351
+ // but an async error invalidates it; it is not proof that a terminal
352
+ // displayed the frame.
353
+ if (generation === writeGeneration) {
354
+ onAccept();
355
+ }
356
+ return true;
357
+ }
358
+ catch (error) {
359
+ invalidatePhysical();
360
+ previousIdentities = [];
361
+ previousOutput = '';
362
+ previousLines = [];
363
+ if (decawm) {
364
+ ownsDecawm = true;
365
+ }
366
+ restoreTerminalModes();
367
+ throw error;
368
+ }
369
+ };
370
+ const writeSeekFrame = (str, context, force) => {
371
+ const activeCursor = getActiveCursor();
372
+ cursorDirty = false;
373
+ if (!force && !seekHasChanges(str, context, activeCursor)) {
374
+ return false;
375
+ }
376
+ const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
377
+ const dimensionsChanged = physicalFrame === undefined ||
378
+ !physicalFrame.valid ||
379
+ physicalFrame.context.columns !== context.columns ||
380
+ physicalFrame.context.rows !== context.rows ||
381
+ physicalFrame.context.strategy !== context.strategy;
382
+ if (!force &&
383
+ !dimensionsChanged &&
384
+ str === previousOutput &&
385
+ cursorChanged) {
386
+ const payload = buildSeekCursorSequence(activeCursor, context.columns, context.rows, cursorWasShown);
387
+ return submitSeekWrite(payload, {
388
+ decawm: false,
389
+ onAccept() {
390
+ previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
391
+ cursorWasShown = activeCursor !== undefined;
392
+ if (physicalFrame?.valid) {
393
+ physicalFrame = {
394
+ ...physicalFrame,
395
+ cursor: clampSeekCursor(activeCursor, context.columns, context.rows),
396
+ };
397
+ }
398
+ },
399
+ });
400
+ }
401
+ const prepared = seekPreparer.prepareFrame(str, context.columns);
402
+ const viewportCount = Math.min(prepared.length, context.rows);
403
+ const viewportPrepared = prepared.slice(0, viewportCount);
404
+ const paintAll = force || dimensionsChanged;
405
+ const indexes = [];
406
+ if (paintAll) {
407
+ for (let index = 0; index < viewportCount; index++) {
408
+ indexes.push(index);
409
+ }
410
+ }
411
+ else {
412
+ for (let index = 0; index < viewportCount; index++) {
413
+ if (viewportPrepared[index].identity !== previousIdentities[index]) {
414
+ indexes.push(index);
415
+ }
416
+ }
417
+ }
418
+ const encodedRows = encodeSeekRows(viewportPrepared, indexes, context.columns);
419
+ const cursorSuffix = buildSeekCursorSequence(activeCursor, context.columns, context.rows, cursorWasShown);
420
+ const payload = encodedRows.length > 0
421
+ ? wrapSeekPaint(encodedRows + cursorSuffix)
422
+ : cursorSuffix;
423
+ if (payload.length === 0) {
424
+ return false;
425
+ }
426
+ return submitSeekWrite(payload, {
427
+ decawm: encodedRows.length > 0,
428
+ onAccept() {
429
+ previousOutput = str;
430
+ previousLines = str.split('\n');
431
+ previousIdentities = viewportPrepared.map(row => row.identity);
432
+ previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
433
+ cursorWasShown = activeCursor !== undefined;
434
+ paintedSeekViewport = true;
435
+ physicalFrame = {
436
+ context: copyContext(context),
437
+ ownsViewport: true,
438
+ valid: true,
439
+ logicalRows: viewportPrepared.map(row => row.logical),
440
+ allocatedWidths: viewportPrepared.map(row => row.allocatedWidth),
441
+ cursor: clampSeekCursor(activeCursor, context.columns, context.rows),
442
+ };
443
+ },
444
+ });
445
+ };
446
+ const render = (str, context) => {
447
+ if (isSeekViewport(context)) {
448
+ return writeSeekFrame(str, context, false);
449
+ }
138
450
  if (!showCursor && !hasHiddenCursor) {
139
451
  cliCursor.hide(stream);
140
452
  hasHiddenCursor = true;
@@ -169,7 +481,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
169
481
  const cursorSuffix = buildCursorSuffix(outputCursorRow, activeCursor);
170
482
  stream.write(returnPrefix +
171
483
  ansiEscapes.eraseLines(previousLines.length) +
172
- str +
484
+ (transformOutput ? transformOutput(str) : str) +
173
485
  cursorSuffix);
174
486
  cursorWasShown = activeCursor !== undefined;
175
487
  previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
@@ -181,8 +493,33 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
181
493
  // We aggregate all chunks for incremental rendering into a buffer, and then write them to stdout at the end.
182
494
  const buffer = [];
183
495
  buffer.push(returnPrefix);
184
- // Clear extra lines if the current content's line count is lower than the previous.
185
- if (visibleCount < previousVisible) {
496
+ const prevVisible = previousLines.slice(0, previousVisible);
497
+ const nextVisible = nextLines.slice(0, visibleCount);
498
+ const shift = process.env['NUVIN_INK_NO_SCROLL_OPT'] === '1' ||
499
+ activeCursor !== undefined ||
500
+ hasTrailingNewline ||
501
+ previousOutput.endsWith('\n') ||
502
+ visibleCount !== previousVisible
503
+ ? undefined
504
+ : detectScrollShift(prevVisible, nextVisible);
505
+ let diffPrevious = previousLines;
506
+ let loopStart = 0;
507
+ if (shift) {
508
+ const k = Math.abs(shift.rows);
509
+ const { start, end } = shift;
510
+ const prefix = prevVisible.slice(0, start);
511
+ const band = prevVisible.slice(start, end);
512
+ const suffix = prevVisible.slice(end);
513
+ const shiftedBand = shift.rows > 0
514
+ ? [...band.slice(k), ...blankLines(k)]
515
+ : [...blankLines(k), ...band.slice(0, band.length - k)];
516
+ const shiftedPrevious = [...prefix, ...shiftedBand, ...suffix];
517
+ buffer.push(setScrollRegion(start, end), cursorToRow(start), shift.rows > 0 ? `\u001B[${k}M` : `\u001B[${k}L`, resetScrollRegion, cursorToRow(start));
518
+ diffPrevious = shiftedPrevious;
519
+ loopStart = start;
520
+ }
521
+ else if (visibleCount < previousVisible) {
522
+ // Clear extra lines if the current content's line count is lower than the previous.
186
523
  const previousHadTrailingNewline = previousOutput.endsWith('\n');
187
524
  const extraSlot = previousHadTrailingNewline ? 1 : 0;
188
525
  buffer.push(ansiEscapes.eraseLines(previousVisible - visibleCount + extraSlot), ansiEscapes.cursorUp(visibleCount));
@@ -190,10 +527,10 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
190
527
  else {
191
528
  buffer.push(ansiEscapes.cursorUp(previousLines.length - 1));
192
529
  }
193
- for (let i = 0; i < visibleCount; i++) {
530
+ for (let i = loopStart; i < visibleCount; i++) {
194
531
  const isLastLine = i === visibleCount - 1;
195
532
  // We do not write lines if the contents are the same. This prevents flickering during renders.
196
- if (nextLines[i] === previousLines[i]) {
533
+ if (nextLines[i] === diffPrevious[i]) {
197
534
  // Don't move past the last line when there's no trailing newline,
198
535
  // otherwise the cursor overshoots the rendered block.
199
536
  if (!isLastLine || hasTrailingNewline) {
@@ -202,7 +539,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
202
539
  continue;
203
540
  }
204
541
  buffer.push(ansiEscapes.cursorTo(0) +
205
- nextLines[i] +
542
+ (transformOutput ? transformOutput(nextLines[i]) : nextLines[i]) +
206
543
  ansiEscapes.eraseEndLine +
207
544
  // Don't append newline after the last line when the input
208
545
  // has no trailing newline (fullscreen mode).
@@ -217,7 +554,10 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
217
554
  previousLines = nextLines;
218
555
  return true;
219
556
  };
220
- render.repaint = (str, options) => {
557
+ render.repaint = (str, options, context) => {
558
+ if (isSeekViewport(context)) {
559
+ return writeSeekFrame(str, context, true);
560
+ }
221
561
  if (!showCursor && !hasHiddenCursor) {
222
562
  cliCursor.hide(stream);
223
563
  hasHiddenCursor = true;
@@ -230,7 +570,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
230
570
  const cursorSuffix = buildCursorSuffix(outputCursorRow, activeCursor);
231
571
  stream.write(returnPrefix +
232
572
  ansiEscapes.eraseLines(getEraseLineCount(previousLines.length, options)) +
233
- str +
573
+ (transformOutput ? transformOutput(str) : str) +
234
574
  cursorSuffix);
235
575
  previousOutput = str;
236
576
  previousLines = nextLines;
@@ -238,46 +578,101 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
238
578
  cursorWasShown = activeCursor !== undefined;
239
579
  return true;
240
580
  };
241
- render.clear = (options) => {
242
- const prefix = buildReturnToBottomPrefix(cursorWasShown, previousLines.length, previousCursorPosition);
243
- stream.write(prefix +
244
- ansiEscapes.eraseLines(getEraseLineCount(previousLines.length, options)));
581
+ const clearSeekState = () => {
245
582
  previousOutput = '';
246
583
  previousLines = [];
584
+ previousIdentities = [];
247
585
  previousCursorPosition = undefined;
248
586
  cursorWasShown = false;
249
587
  cursorDirty = false;
588
+ paintedSeekViewport = false;
589
+ invalidatePhysical();
590
+ };
591
+ render.clear = (options) => {
592
+ bumpGeneration();
593
+ restoreTerminalModes();
594
+ if (paintedSeekViewport) {
595
+ stream.write(eraseDisplayHome);
596
+ clearSeekState();
597
+ return;
598
+ }
599
+ const prefix = buildReturnToBottomPrefix(cursorWasShown, previousLines.length, previousCursorPosition);
600
+ stream.write(prefix +
601
+ ansiEscapes.eraseLines(getEraseLineCount(previousLines.length, options)));
602
+ clearSeekState();
250
603
  };
251
604
  render.done = () => {
252
- const returnPrefix = buildReturnToBottomPrefix(cursorWasShown, previousLines.length, previousCursorPosition);
253
- if (returnPrefix || showCursor) {
254
- stream.write(returnPrefix + (showCursor ? showCursorEscape : ''));
605
+ bumpGeneration();
606
+ restoreTerminalModes();
607
+ detachStreamErrorListener();
608
+ try {
609
+ if (paintedSeekViewport) {
610
+ if (showCursor) {
611
+ stream.write(showCursorEscape);
612
+ }
613
+ clearSeekState();
614
+ cursorPosition = undefined;
615
+ if (!showCursor) {
616
+ cliCursor.show(stream);
617
+ hasHiddenCursor = false;
618
+ }
619
+ return;
620
+ }
621
+ const returnPrefix = buildReturnToBottomPrefix(cursorWasShown, previousLines.length, previousCursorPosition);
622
+ if (returnPrefix || showCursor) {
623
+ stream.write(returnPrefix + (showCursor ? showCursorEscape : ''));
624
+ }
625
+ clearSeekState();
626
+ cursorPosition = undefined;
627
+ if (!showCursor) {
628
+ cliCursor.show(stream);
629
+ hasHiddenCursor = false;
630
+ }
255
631
  }
256
- previousOutput = '';
257
- previousLines = [];
258
- previousCursorPosition = undefined;
259
- cursorWasShown = false;
260
- cursorPosition = undefined;
261
- cursorDirty = false;
262
- if (!showCursor) {
263
- cliCursor.show(stream);
264
- hasHiddenCursor = false;
632
+ catch {
633
+ clearSeekState();
634
+ cursorPosition = undefined;
265
635
  }
266
636
  };
267
637
  render.reset = () => {
268
638
  // Cache-only: the caller must have externally reset or replaced terminal contents.
639
+ bumpGeneration();
269
640
  previousOutput = '';
270
641
  previousLines = [];
642
+ previousIdentities = [];
271
643
  previousCursorPosition = undefined;
272
644
  cursorWasShown = false;
273
645
  cursorDirty = false;
646
+ invalidatePhysical();
274
647
  };
275
- render.sync = (str) => {
648
+ render.sync = (str, physical) => {
276
649
  const activeCursor = cursorPosition;
277
650
  cursorDirty = false;
278
651
  const lines = str.split('\n');
279
652
  previousOutput = str;
280
653
  previousLines = lines;
654
+ const matching = physical?.valid === true &&
655
+ physical.ownsViewport &&
656
+ physical.context.strategy === 'seek-viewport' &&
657
+ physical.logicalRows.join('\n') === str;
658
+ if (matching && physical) {
659
+ physicalFrame = {
660
+ context: copyContext(physical.context),
661
+ ownsViewport: true,
662
+ valid: true,
663
+ logicalRows: physical.logicalRows,
664
+ allocatedWidths: physical.allocatedWidths,
665
+ cursor: physical.cursor,
666
+ };
667
+ previousIdentities = [];
668
+ paintedSeekViewport = true;
669
+ previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
670
+ cursorWasShown = activeCursor !== undefined;
671
+ return;
672
+ }
673
+ previousIdentities = [];
674
+ paintedSeekViewport = false;
675
+ invalidatePhysical();
281
676
  if (!activeCursor && cursorWasShown) {
282
677
  stream.write(hideCursorEscape);
283
678
  }
@@ -292,14 +687,21 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
292
687
  cursorDirty = true;
293
688
  };
294
689
  render.isCursorDirty = () => cursorDirty;
295
- render.willRender = (str) => hasChanges(str, getActiveCursor());
690
+ render.willRender = (str, context) => {
691
+ if (isSeekViewport(context)) {
692
+ return seekHasChanges(str, context, getActiveCursor());
693
+ }
694
+ return hasChanges(str, getActiveCursor());
695
+ };
696
+ render.getPhysicalFrame = () => physicalFrame;
697
+ render.restoreTerminalModes = restoreTerminalModes;
296
698
  return render;
297
699
  };
298
- const create = (stream, { showCursor = false, incremental = false } = {}) => {
700
+ const create = (stream, { showCursor = false, incremental = false, transformOutput, } = {}) => {
299
701
  if (incremental) {
300
- return createIncremental(stream, { showCursor });
702
+ return createIncremental(stream, { showCursor, transformOutput });
301
703
  }
302
- return createStandard(stream, { showCursor });
704
+ return createStandard(stream, { showCursor, transformOutput });
303
705
  };
304
706
  const logUpdate = { create };
305
707
  export default logUpdate;