@nuvin/ink 7.6.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.
package/build/ink.js CHANGED
@@ -16,72 +16,19 @@ import render from './renderer.js';
16
16
  import * as dom from './dom.js';
17
17
  import { hideCursorEscape, showCursorEscape } from './cursor-helpers.js';
18
18
  import logUpdate from './log-update.js';
19
+ import { createExplicitWidthEncoder } from './explicit-width.js';
20
+ import { ExplicitWidthDetection, canOverwriteFirstCell, } from './explicit-width-detection.js';
19
21
  import { bsu, esu, shouldSynchronize } from './write-synchronized.js';
22
+ import { isOwnedSeekViewport, isSeekViewport, seekViewportReset, selectPaintStrategy, } from './terminal-paint.js';
20
23
  import instances from './instances.js';
21
24
  import { TextSelectionController } from './text-selection-controller.js';
22
25
  import App from './components/App.js';
23
26
  import { accessibilityContext as AccessibilityContext } from './components/AccessibilityContext.js';
24
27
  import { resolveFlags, } from './kitty-keyboard.js';
25
28
  const noop = () => { };
26
- const textEncoder = new TextEncoder();
27
29
  const yieldImmediate = async () => new Promise(resolve => {
28
30
  setImmediate(resolve);
29
31
  });
30
- const kittyQueryEscapeByte = 0x1b;
31
- const kittyQueryOpenBracketByte = 0x5b;
32
- const kittyQueryQuestionMarkByte = 0x3f;
33
- const kittyQueryLetterByte = 0x75;
34
- const zeroByte = 0x30;
35
- const nineByte = 0x39;
36
- const isDigitByte = (byte) => byte >= zeroByte && byte <= nineByte;
37
- const matchKittyQueryResponse = (buffer, startIndex) => {
38
- if (buffer[startIndex] !== kittyQueryEscapeByte ||
39
- buffer[startIndex + 1] !== kittyQueryOpenBracketByte ||
40
- buffer[startIndex + 2] !== kittyQueryQuestionMarkByte) {
41
- return undefined;
42
- }
43
- let index = startIndex + 3;
44
- const digitsStartIndex = index;
45
- while (index < buffer.length && isDigitByte(buffer[index])) {
46
- index++;
47
- }
48
- if (index === digitsStartIndex) {
49
- return undefined;
50
- }
51
- if (index === buffer.length) {
52
- return { state: 'partial' };
53
- }
54
- if (buffer[index] === kittyQueryLetterByte) {
55
- return { state: 'complete', endIndex: index };
56
- }
57
- return undefined;
58
- };
59
- const hasCompleteKittyQueryResponse = (buffer) => {
60
- for (let index = 0; index < buffer.length; index++) {
61
- const match = matchKittyQueryResponse(buffer, index);
62
- if (match?.state === 'complete') {
63
- return true;
64
- }
65
- }
66
- return false;
67
- };
68
- const stripKittyQueryResponsesAndTrailingPartial = (buffer) => {
69
- const keptBytes = [];
70
- let index = 0;
71
- while (index < buffer.length) {
72
- const match = matchKittyQueryResponse(buffer, index);
73
- if (match?.state === 'complete') {
74
- index = match.endIndex + 1;
75
- continue;
76
- }
77
- if (match?.state === 'partial') {
78
- break;
79
- }
80
- keptBytes.push(buffer[index]);
81
- index++;
82
- }
83
- return keptBytes;
84
- };
85
32
  const shouldClearTerminalForFrame = ({ isTty, viewportRows, previousViewportRows, previousOutputHeight, nextOutputHeight, isUnmounting, }) => {
86
33
  if (!isTty) {
87
34
  return false;
@@ -166,6 +113,7 @@ export default class Ink {
166
113
  // Ignore last render after unmounting a tree to prevent empty output before exit
167
114
  isUnmounted;
168
115
  isUnmounting;
116
+ outputError;
169
117
  lastOutput;
170
118
  lastOutputToRender;
171
119
  lastOutputHeight;
@@ -190,6 +138,21 @@ export default class Ink {
190
138
  nextRenderCommit;
191
139
  textSelection;
192
140
  lastLayoutWidth;
141
+ lastLayoutRows;
142
+ pendingStaticOutput = '';
143
+ pendingHistoryAdvance = false;
144
+ transformOutput;
145
+ paintEligible = false;
146
+ paintCapability = 'raw';
147
+ widthDetection;
148
+ pendingFrame;
149
+ reservedFrame;
150
+ deferredWidthRenders = [];
151
+ deferredWidthLifecycleActions = [];
152
+ rawInputReady = false;
153
+ resumePendingInput;
154
+ kittyAutoFlags;
155
+ kittyQuerySent = false;
193
156
  constructor(options) {
194
157
  autoBind(this);
195
158
  this.options = options;
@@ -208,6 +171,41 @@ export default class Ink {
208
171
  // Using Boolean(isTTY) (rather than an 'in' guard) correctly handles piped streams
209
172
  // where the property is absent (e.g. `node app.js | cat`).
210
173
  this.interactive = this.resolveInteractiveOption(options.interactive);
174
+ const explicitWidth = process.env['INK_EXPLICIT_WIDTH'];
175
+ const canUseExplicitWidth = options.explicitWidth !== 'disabled' &&
176
+ this.interactive &&
177
+ Boolean(options.stdout.isTTY) &&
178
+ !options.debug &&
179
+ !this.isScreenReaderEnabled;
180
+ this.paintEligible = canUseExplicitWidth && explicitWidth !== '0';
181
+ if (canUseExplicitWidth && explicitWidth === '1') {
182
+ this.transformOutput = createExplicitWidthEncoder();
183
+ this.paintCapability = 'osc66';
184
+ }
185
+ else if (canUseExplicitWidth &&
186
+ explicitWidth !== '0' &&
187
+ options.stdin.isTTY &&
188
+ options.stdin.readable &&
189
+ typeof options.stdin.setRawMode === 'function' &&
190
+ typeof options.stdin.ref === 'function' &&
191
+ typeof options.stdin.unref === 'function') {
192
+ this.widthDetection = new ExplicitWidthDetection({
193
+ getSize: () => getWindowSize(this.options.stdout),
194
+ write: text => {
195
+ if (!getWritableStreamState(this.options.stdout)
196
+ .canWriteToStdout) {
197
+ throw new Error('Terminal output is unavailable');
198
+ }
199
+ this.options.stdout.write(text);
200
+ },
201
+ reserve: this.reserveExplicitWidthFrame,
202
+ onResult: this.settleExplicitWidth,
203
+ });
204
+ this.widthDetection.onSettled(this.finishExplicitWidthSettlement);
205
+ }
206
+ else if (this.paintEligible) {
207
+ this.paintCapability = 'fallback';
208
+ }
211
209
  this.alternateScreen = false;
212
210
  const unthrottled = options.debug || this.isScreenReaderEnabled;
213
211
  const maxFps = options.maxFps ?? 30;
@@ -234,27 +232,24 @@ export default class Ink {
234
232
  this.rootNode.onImmediateRender = this.onRender;
235
233
  this.log = logUpdate.create(options.stdout, {
236
234
  incremental: options.incrementalRendering,
235
+ transformOutput: this.widthDetection
236
+ ? this.encodeTerminalOutput
237
+ : this.transformOutput,
237
238
  });
238
239
  this.cursorPosition = undefined;
240
+ const writePaintRequest = (request) => {
241
+ this.writePaintRequest(request);
242
+ };
239
243
  this.throttledLog = unthrottled
240
- ? this.log
241
- : throttle((output) => {
242
- const shouldWrite = this.log.willRender(output);
243
- const sync = this.shouldSync();
244
- if (sync && shouldWrite) {
245
- this.options.stdout.write(bsu);
246
- }
247
- this.log(output);
248
- if (sync && shouldWrite) {
249
- this.options.stdout.write(esu);
250
- }
251
- }, undefined, {
244
+ ? writePaintRequest
245
+ : throttle(writePaintRequest, undefined, {
252
246
  leading: true,
253
247
  trailing: true,
254
248
  });
255
249
  // Ignore last render after unmounting a tree to prevent empty output before exit
256
250
  this.isUnmounted = false;
257
251
  this.isUnmounting = false;
252
+ this.outputError = undefined;
258
253
  // Store concurrent mode setting
259
254
  this.isConcurrent = options.concurrent ?? false;
260
255
  // Store last output to only rerender when needed
@@ -265,6 +260,7 @@ export default class Ink {
265
260
  this.lastPhysicalFrameWasSoftWrapped = false;
266
261
  this.lastViewportRows = getWindowSize(this.options.stdout).rows;
267
262
  this.lastTerminalWidth = getWindowSize(this.options.stdout).columns;
263
+ this.lastLayoutRows = this.lastViewportRows;
268
264
  // This variable is used only in debug mode to store full static output
269
265
  // so that it's rerendered every time, not just new static parts, like in non-debug mode
270
266
  this.fullStaticOutput = '';
@@ -288,6 +284,7 @@ export default class Ink {
288
284
  options.stdout.off('resize', this.resized);
289
285
  };
290
286
  }
287
+ options.stdout.on('error', this.handleStdoutError);
291
288
  this.initKittyKeyboard();
292
289
  this.exitPromise = new Promise((resolve, reject) => {
293
290
  this.resolveExitPromise = resolve;
@@ -298,6 +295,9 @@ export default class Ink {
298
295
  void this.exitPromise.catch(noop);
299
296
  }
300
297
  resized = () => {
298
+ if (this.deferUntilExplicitWidthSettlement(this.resized))
299
+ return;
300
+ settleThrottle(this.throttledLog, false);
301
301
  const currentWidth = getWindowSize(this.options.stdout).columns;
302
302
  this.calculateLayout();
303
303
  this.onRender();
@@ -317,6 +317,24 @@ export default class Ink {
317
317
  this.exitResult = errorOrResult;
318
318
  this.unmount();
319
319
  };
320
+ handleStdoutError = (error) => {
321
+ this.surfaceOutputError(error);
322
+ };
323
+ surfaceOutputError(error) {
324
+ this.log.restoreTerminalModes();
325
+ this.hasPhysicalFrame = false;
326
+ this.pendingHistoryAdvance = false;
327
+ const err = isErrorInput(error)
328
+ ? error
329
+ : new Error('Terminal output failed');
330
+ if (!this.outputError) {
331
+ this.outputError = err;
332
+ }
333
+ if (this.isUnmounted || this.isUnmounting) {
334
+ return;
335
+ }
336
+ this.unmount(err);
337
+ }
320
338
  setCursorPosition = (position) => {
321
339
  this.cursorPosition = position;
322
340
  this.log.setCursorPosition(position);
@@ -329,20 +347,45 @@ export default class Ink {
329
347
  // before restoring output after external stdout/stderr writes.
330
348
  this.log.setCursorPosition(this.cursorPosition);
331
349
  const outputToRestore = this.lastOutputToRender || this.lastOutput + '\n';
350
+ const size = getWindowSize(this.options.stdout);
351
+ const layoutStale = this.lastLayoutWidth !== size.columns ||
352
+ this.lastLayoutRows !== size.rows;
353
+ const context = this.getSeekPaintContext({
354
+ output: this.lastOutput,
355
+ outputHeight: this.lastOutputHeight,
356
+ staticOutput: '',
357
+ layoutColumns: this.lastLayoutWidth ?? size.columns,
358
+ layoutRows: this.lastLayoutRows ?? size.rows,
359
+ });
360
+ if (layoutStale && (context || this.pendingHistoryAdvance)) {
361
+ this.consumeHistoryAdvance(size.rows);
362
+ this.scheduleLayoutRender();
363
+ return;
364
+ }
365
+ if (context) {
366
+ this.consumeHistoryAdvance(context.rows);
367
+ this.log(outputToRestore, context);
368
+ const physical = this.log.getPhysicalFrame();
369
+ this.hasPhysicalFrame = Boolean(physical?.valid);
370
+ this.lastPhysicalFrameWasSoftWrapped = false;
371
+ return;
372
+ }
373
+ this.pendingHistoryAdvance = false;
332
374
  this.log(outputToRestore);
333
375
  this.hasPhysicalFrame = true;
334
376
  this.lastPhysicalFrameWasSoftWrapped =
335
377
  Boolean(this.options.stdout.isTTY) &&
336
- isOutputSoftWrapped(outputToRestore, getWindowSize(this.options.stdout).columns);
378
+ isOutputSoftWrapped(outputToRestore, size.columns);
337
379
  };
338
380
  calculateLayout = () => {
339
- const terminalWidth = getWindowSize(this.options.stdout).columns;
381
+ const { columns: terminalWidth, rows: terminalRows } = getWindowSize(this.options.stdout);
340
382
  const yoga = this.rootNode.yogaNode;
341
383
  const widthChanged = this.lastLayoutWidth !== terminalWidth;
342
384
  if (widthChanged) {
343
385
  yoga.setWidth(terminalWidth);
344
386
  this.lastLayoutWidth = terminalWidth;
345
387
  }
388
+ this.lastLayoutRows = terminalRows;
346
389
  if (!widthChanged && !yoga.isDirty())
347
390
  return;
348
391
  yoga.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
@@ -431,14 +474,52 @@ export default class Ink {
431
474
  }
432
475
  return;
433
476
  }
434
- if (hasStaticOutput) {
435
- this.fullStaticOutput += staticOutput;
477
+ const size = getWindowSize(this.options.stdout);
478
+ const frame = {
479
+ output,
480
+ outputHeight,
481
+ staticOutput: hasStaticOutput ? staticOutput : '',
482
+ maxVisualWidth,
483
+ layoutColumns: this.lastLayoutWidth ?? size.columns,
484
+ layoutRows: this.lastLayoutRows ?? size.rows,
485
+ };
486
+ if (this.widthDetection && !this.widthDetection.settled) {
487
+ this.pendingFrame = {
488
+ ...frame,
489
+ staticOutput: (this.pendingFrame?.staticOutput ?? '') + frame.staticOutput,
490
+ };
491
+ if (!this.widthDetection.pending &&
492
+ !frame.output &&
493
+ !frame.staticOutput) {
494
+ // No physical frame exists yet; keep the first nonempty frame eligible.
495
+ return;
496
+ }
497
+ else {
498
+ this.widthDetection.start();
499
+ }
500
+ return;
501
+ }
502
+ try {
503
+ this.commitInteractiveFrame(frame);
504
+ }
505
+ catch (error) {
506
+ this.surfaceOutputError(error);
436
507
  }
437
- this.renderInteractiveFrame(output, outputHeight, hasStaticOutput ? staticOutput : '', maxVisualWidth);
438
508
  };
439
509
  render(node) {
510
+ if (this.widthDetection?.publicationBlocked) {
511
+ this.deferredWidthRenders.push(node);
512
+ this.widthDetection.cancel();
513
+ return;
514
+ }
440
515
  const tree = (React.createElement(AccessibilityContext.Provider, { value: { isScreenReaderEnabled: this.isScreenReaderEnabled } },
441
- React.createElement(App, { stdin: this.options.stdin, stdout: this.options.stdout, stderr: this.options.stderr, exitOnCtrlC: this.options.exitOnCtrlC, interactive: this.interactive, renderThrottleMs: this.renderThrottleMs, writeToStdout: this.writeToStdout, writeToStderr: this.writeToStderr, setCursorPosition: this.setCursorPosition, textSelection: this.textSelection, onExit: this.handleAppExit, onWaitUntilRenderFlush: this.waitUntilRenderFlush }, node)));
516
+ React.createElement(App, { stdin: this.options.stdin, stdout: this.options.stdout, stderr: this.options.stderr, exitOnCtrlC: this.options.exitOnCtrlC, interactive: this.interactive, renderThrottleMs: this.renderThrottleMs, writeToStdout: this.writeToStdout, writeToStderr: this.writeToStderr, setCursorPosition: this.setCursorPosition, textSelection: this.textSelection, onExit: this.handleAppExit, onWaitUntilRenderFlush: this.waitUntilRenderFlush, onTerminalResponse: this.widthDetection || this.kittyAutoFlags
517
+ ? this.handleTerminalResponse
518
+ : undefined, isTerminalResponsePending: this.widthDetection || this.kittyAutoFlags
519
+ ? this.isTerminalResponsePending
520
+ : undefined, onInputReady: this.widthDetection || this.kittyAutoFlags
521
+ ? this.handleInputReady
522
+ : undefined }, node)));
442
523
  if (this.options.concurrent) {
443
524
  // Concurrent mode: use updateContainer (async scheduling)
444
525
  reconciler.updateContainer(tree, this.container, null, noop);
@@ -453,6 +534,8 @@ export default class Ink {
453
534
  if (this.isUnmounted) {
454
535
  return;
455
536
  }
537
+ if (this.deferUntilExplicitWidthSettlement(() => this.writeToStdout(data)))
538
+ return;
456
539
  if (this.options.debug) {
457
540
  this.options.stdout.write(data + this.fullStaticOutput + this.lastOutput);
458
541
  return;
@@ -461,21 +544,14 @@ export default class Ink {
461
544
  this.options.stdout.write(data);
462
545
  return;
463
546
  }
464
- const sync = this.shouldSync();
465
- if (sync) {
466
- this.options.stdout.write(bsu);
467
- }
468
- this.log.clear(this.getPhysicalEraseOptions());
469
- this.options.stdout.write(data);
470
- this.restoreLastOutput();
471
- if (sync) {
472
- this.options.stdout.write(esu);
473
- }
547
+ this.writeExternalOutput(data, this.options.stdout);
474
548
  }
475
549
  writeToStderr(data) {
476
550
  if (this.isUnmounted) {
477
551
  return;
478
552
  }
553
+ if (this.deferUntilExplicitWidthSettlement(() => this.writeToStderr(data)))
554
+ return;
479
555
  if (this.options.debug) {
480
556
  this.options.stderr.write(data);
481
557
  this.options.stdout.write(this.fullStaticOutput + this.lastOutput);
@@ -485,22 +561,15 @@ export default class Ink {
485
561
  this.options.stderr.write(data);
486
562
  return;
487
563
  }
488
- const sync = this.shouldSync();
489
- if (sync) {
490
- this.options.stdout.write(bsu);
491
- }
492
- this.log.clear(this.getPhysicalEraseOptions());
493
- this.options.stderr.write(data);
494
- this.restoreLastOutput();
495
- if (sync) {
496
- this.options.stdout.write(esu);
497
- }
564
+ this.writeExternalOutput(data, this.options.stderr);
498
565
  }
499
566
  // eslint-disable-next-line @typescript-eslint/no-restricted-types
500
567
  unmount(error) {
501
568
  if (this.isUnmounted || this.isUnmounting) {
502
569
  return;
503
570
  }
571
+ if (this.deferUntilExplicitWidthSettlement(() => this.unmount(error)))
572
+ return;
504
573
  this.isUnmounting = true;
505
574
  if (this.beforeExitHandler) {
506
575
  process.off('beforeExit', this.beforeExitHandler);
@@ -521,14 +590,16 @@ export default class Ink {
521
590
  this.calculateLayout();
522
591
  this.onRender();
523
592
  }
593
+ // Accept pending paints from the controlled final render before locking.
594
+ settleThrottle(this.throttledLog, true);
524
595
  }
525
596
  // Mark as unmounted after the final render but before stdout writes
526
597
  // that could re-enter exit() via synchronous write callbacks.
527
598
  this.isUnmounted = true;
528
599
  this.unsubscribeExit();
529
- // Flush any pending throttled log writes if possible, otherwise cancel to
530
- // prevent delayed callbacks from writing to a closed stream.
531
- settleThrottle(this.throttledLog, canWriteToStdout);
600
+ // Cancel leftover throttled log writes so no new frame starts after unmount.
601
+ settleThrottle(this.throttledLog, false);
602
+ this.log.restoreTerminalModes();
532
603
  if (typeof this.restoreConsole === 'function') {
533
604
  // Once unmount starts, Ink stops trying to manage teardown-time
534
605
  // console output. Restoring the native console before React cleanup keeps
@@ -544,7 +615,12 @@ export default class Ink {
544
615
  if (this.cancelKittyDetection) {
545
616
  this.cancelKittyDetection();
546
617
  }
618
+ this.log.restoreTerminalModes();
619
+ const seekTeardown = this.log.getPhysicalFrame?.()?.context.strategy === 'seek-viewport';
547
620
  if (canWriteToStdout) {
621
+ if (this.interactive && !this.options.debug && seekTeardown) {
622
+ this.log.done();
623
+ }
548
624
  if (this.kittyProtocolEnabled) {
549
625
  this.writeBestEffort(this.options.stdout, '\u001B[<u');
550
626
  }
@@ -554,6 +630,9 @@ export default class Ink {
554
630
  // diagnostics onto it. Trying to preserve teardown output across the
555
631
  // buffer switch adds fragile lifecycle-specific behavior, so Ink keeps
556
632
  // alternate-screen teardown intentionally simple and best-effort.
633
+ // Seek cursor/cache teardown must finish before this screen switch:
634
+ // after the switch, no seek done() path may reposition or erase the
635
+ // returned primary screen.
557
636
  if (this.alternateScreen) {
558
637
  this.writeBestEffort(this.options.stdout, ansiEscapes.exitAlternativeScreen);
559
638
  this.writeBestEffort(this.options.stdout, showCursorEscape);
@@ -566,10 +645,16 @@ export default class Ink {
566
645
  // deferred during rendering).
567
646
  this.options.stdout.write(this.options.debug ? '\n' : this.lastOutput + '\n');
568
647
  }
569
- else if (!this.options.debug) {
648
+ else if (!this.options.debug && !seekTeardown) {
570
649
  this.log.done();
571
650
  }
572
651
  }
652
+ else if (this.interactive && !this.options.debug) {
653
+ try {
654
+ this.log.done();
655
+ }
656
+ catch { }
657
+ }
573
658
  this.kittyProtocolEnabled = false;
574
659
  instances.delete(this.options.stdout);
575
660
  // Ensure all queued writes have been processed before resolving the
@@ -582,8 +667,10 @@ export default class Ink {
582
667
  // because the event loop is draining and async callbacks won't fire.
583
668
  const { exitResult } = this;
584
669
  const resolveOrReject = () => {
585
- if (isErrorInput(error)) {
586
- this.rejectExitPromise(error);
670
+ this.options.stdout.off('error', this.handleStdoutError);
671
+ const exitError = isErrorInput(error) ? error : this.outputError;
672
+ if (exitError) {
673
+ this.rejectExitPromise(exitError);
587
674
  }
588
675
  else {
589
676
  this.resolveExitPromise(exitResult);
@@ -594,7 +681,17 @@ export default class Ink {
594
681
  resolveOrReject();
595
682
  }
596
683
  else if (canWriteToStdout && hasWritableState) {
597
- this.options.stdout.write('', resolveOrReject);
684
+ try {
685
+ this.options.stdout.write('', errorFromBarrier => {
686
+ if (errorFromBarrier && !this.outputError) {
687
+ this.outputError = errorFromBarrier;
688
+ }
689
+ resolveOrReject();
690
+ });
691
+ }
692
+ catch {
693
+ resolveOrReject();
694
+ }
598
695
  }
599
696
  else {
600
697
  setImmediate(resolveOrReject);
@@ -646,6 +743,14 @@ export default class Ink {
646
743
  }
647
744
  }
648
745
  reconciler.flushSyncWork();
746
+ settleThrottle(this.throttledOnRender, getWritableStreamState(this.options.stdout)
747
+ .canWriteToStdout);
748
+ if (this.widthDetection?.pending)
749
+ await this.widthDetection.finished;
750
+ if (this.isUnmounted || this.isUnmounting) {
751
+ await this.awaitExit();
752
+ return;
753
+ }
649
754
  const stdout = this.options.stdout;
650
755
  const { canWriteToStdout, hasWritableState } = getWritableStreamState(stdout);
651
756
  // Flush pending throttled render/log timers so their output is included in this wait.
@@ -653,17 +758,42 @@ export default class Ink {
653
758
  settleThrottle(this.throttledLog, canWriteToStdout);
654
759
  if (canWriteToStdout && hasWritableState) {
655
760
  await new Promise(resolve => {
656
- this.options.stdout.write('', () => {
761
+ let settled = false;
762
+ const finish = (writeError) => {
763
+ if (settled) {
764
+ return;
765
+ }
766
+ settled = true;
767
+ this.options.stdout.off('error', onError);
768
+ if (writeError) {
769
+ this.surfaceOutputError(writeError);
770
+ }
657
771
  resolve();
658
- });
772
+ };
773
+ const onError = (writeError) => {
774
+ finish(writeError);
775
+ };
776
+ this.options.stdout.once('error', onError);
777
+ try {
778
+ this.options.stdout.write('', writeError => {
779
+ finish(writeError);
780
+ });
781
+ }
782
+ catch (writeError) {
783
+ finish(isErrorInput(writeError)
784
+ ? writeError
785
+ : new Error('Terminal output failed'));
786
+ }
659
787
  });
660
788
  return;
661
789
  }
662
790
  await yieldImmediate();
663
791
  }
664
792
  clear() {
793
+ if (this.deferUntilExplicitWidthSettlement(this.clear))
794
+ return;
665
795
  if (this.interactive && !this.options.debug) {
666
- this.log.clear(this.getPhysicalEraseOptions());
796
+ this.clearPhysicalFrame();
667
797
  this.hasPhysicalFrame = false;
668
798
  this.lastPhysicalFrameWasSoftWrapped = false;
669
799
  }
@@ -685,6 +815,9 @@ export default class Ink {
685
815
  });
686
816
  }
687
817
  getPhysicalEraseOptions() {
818
+ if (isOwnedSeekViewport(this.log.getPhysicalFrame?.())) {
819
+ return undefined;
820
+ }
688
821
  if (!this.options.stdout.isTTY ||
689
822
  !this.hasPhysicalFrame ||
690
823
  this.lastOutputToRender === '') {
@@ -747,9 +880,127 @@ export default class Ink {
747
880
  }
748
881
  return this.nextRenderCommit.promise;
749
882
  }
750
- renderInteractiveFrame(output, outputHeight, staticOutput, maxVisualWidth) {
883
+ renderInteractiveFrame(output, outputHeight, staticOutput, maxVisualWidth, layoutColumns, layoutRows) {
884
+ const size = getWindowSize(this.options.stdout);
885
+ this.renderPendingFrame({
886
+ output,
887
+ outputHeight,
888
+ staticOutput,
889
+ maxVisualWidth,
890
+ layoutColumns: layoutColumns ?? this.lastLayoutWidth ?? size.columns,
891
+ layoutRows: layoutRows ?? this.lastLayoutRows ?? size.rows,
892
+ });
893
+ }
894
+ renderPendingFrame(frame) {
895
+ const { columns: terminalWidth, rows: terminalRows } = getWindowSize(this.options.stdout);
896
+ if (frame.layoutColumns !== terminalWidth ||
897
+ frame.layoutRows !== terminalRows) {
898
+ this.deferStatic(frame);
899
+ this.scheduleLayoutRender();
900
+ return;
901
+ }
902
+ const previousPhysical = this.log.getPhysicalFrame?.();
903
+ const previousSeek = isOwnedSeekViewport(previousPhysical);
904
+ const seekContext = this.getSeekPaintContext(frame);
905
+ if (previousSeek &&
906
+ !seekContext &&
907
+ frame.outputHeight === previousPhysical.context.rows) {
908
+ this.deferStatic(frame);
909
+ return;
910
+ }
911
+ if (previousSeek || seekContext) {
912
+ this.renderSeekFrame(frame, seekContext, previousSeek);
913
+ return;
914
+ }
915
+ this.renderRawInteractiveFrame(frame);
916
+ }
917
+ renderSeekFrame(frame, seekContext, previousSeek) {
918
+ const isTty = this.options.stdout.isTTY;
919
+ const { rows: terminalRows } = getWindowSize(this.options.stdout);
920
+ const viewportRows = isTty ? terminalRows : 24;
921
+ const isFullscreen = isTty && frame.outputHeight >= viewportRows;
922
+ const outputToRender = isFullscreen ? frame.output : frame.output + '\n';
923
+ if (!seekContext) {
924
+ this.leaveSeekViewport();
925
+ this.renderRawInteractiveFrame(frame);
926
+ return;
927
+ }
928
+ const context = seekContext;
929
+ const staticOutput = this.takeStaticOutput(frame);
930
+ const previousPhysical = this.log.getPhysicalFrame?.();
931
+ const dimensionsChanged = !previousPhysical?.valid ||
932
+ previousPhysical.context.columns !== context.columns ||
933
+ previousPhysical.context.rows !== context.rows ||
934
+ previousPhysical.context.strategy !== context.strategy;
935
+ const entering = !previousSeek;
936
+ if (staticOutput !== '') {
937
+ const sync = this.shouldSync();
938
+ if (sync) {
939
+ this.options.stdout.write(bsu);
940
+ }
941
+ try {
942
+ this.clearPhysicalFrame();
943
+ this.hasPhysicalFrame = false;
944
+ this.options.stdout.write(this.transformOutput?.(staticOutput) ?? staticOutput);
945
+ this.pendingHistoryAdvance = true;
946
+ this.consumeHistoryAdvance(context.rows);
947
+ this.log(outputToRender, context);
948
+ }
949
+ catch (error) {
950
+ this.failExternalOutput(error);
951
+ }
952
+ finally {
953
+ if (sync) {
954
+ try {
955
+ this.options.stdout.write(esu);
956
+ }
957
+ catch { }
958
+ }
959
+ }
960
+ this.publishLogicalFrame(frame, outputToRender, true);
961
+ return;
962
+ }
963
+ if (entering || dimensionsChanged) {
964
+ const sync = this.shouldSync();
965
+ if (sync) {
966
+ this.options.stdout.write(bsu);
967
+ }
968
+ try {
969
+ if (this.pendingHistoryAdvance) {
970
+ this.consumeHistoryAdvance(context.rows);
971
+ }
972
+ else {
973
+ this.options.stdout.write(seekViewportReset);
974
+ this.log.reset();
975
+ }
976
+ this.log.repaint(outputToRender, undefined, context);
977
+ }
978
+ finally {
979
+ if (sync) {
980
+ try {
981
+ this.options.stdout.write(esu);
982
+ }
983
+ catch { }
984
+ }
985
+ }
986
+ this.publishLogicalFrame(frame, outputToRender, true);
987
+ return;
988
+ }
989
+ if (outputToRender !== this.lastOutputToRender ||
990
+ this.log.isCursorDirty()) {
991
+ this.throttledLog({
992
+ frame,
993
+ outputToRender,
994
+ });
995
+ return;
996
+ }
997
+ this.publishLogicalFrame(frame, outputToRender, false);
998
+ }
999
+ renderRawInteractiveFrame(frame) {
1000
+ const staticOutput = this.takeStaticOutput(frame);
751
1001
  const hasStaticOutput = staticOutput !== '';
752
1002
  const isTty = this.options.stdout.isTTY;
1003
+ const { output, outputHeight, maxVisualWidth } = frame;
753
1004
  const { columns: terminalWidth, rows: terminalRows } = getWindowSize(this.options.stdout);
754
1005
  // Detect fullscreen: output fills or exceeds terminal height.
755
1006
  // Only apply when writing to a real TTY — piped output always gets trailing newlines.
@@ -795,7 +1046,9 @@ export default class Ink {
795
1046
  if (sync) {
796
1047
  this.options.stdout.write(bsu);
797
1048
  }
798
- this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + outputToRender);
1049
+ const content = this.fullStaticOutput + outputToRender;
1050
+ this.options.stdout.write(ansiEscapes.clearTerminal +
1051
+ (this.transformOutput?.(content) ?? content));
799
1052
  this.lastOutput = output;
800
1053
  this.lastOutputToRender = outputToRender;
801
1054
  this.lastOutputHeight = outputHeight;
@@ -820,7 +1073,7 @@ export default class Ink {
820
1073
  this.options.stdout.write(bsu);
821
1074
  }
822
1075
  this.log.clear(reflowEraseOptions);
823
- this.options.stdout.write(staticOutput);
1076
+ this.options.stdout.write(this.transformOutput?.(staticOutput) ?? staticOutput);
824
1077
  this.log(outputToRender);
825
1078
  didWriteFrame = true;
826
1079
  if (sync) {
@@ -841,7 +1094,10 @@ export default class Ink {
841
1094
  else if (outputToRender !== this.lastOutputToRender ||
842
1095
  this.log.isCursorDirty()) {
843
1096
  // ThrottledLog manages its own bsu/esu at actual write time
844
- this.throttledLog(outputToRender);
1097
+ this.throttledLog({
1098
+ frame,
1099
+ outputToRender,
1100
+ });
845
1101
  didWriteFrame = true;
846
1102
  }
847
1103
  this.lastOutput = output;
@@ -857,6 +1113,284 @@ export default class Ink {
857
1113
  this.lastViewportRows = viewportRows;
858
1114
  this.lastTerminalWidth = terminalWidth;
859
1115
  }
1116
+ encodeTerminalOutput(output) {
1117
+ return this.transformOutput?.(output) ?? output;
1118
+ }
1119
+ commitInteractiveFrame(frame) {
1120
+ this.renderInteractiveFrame(frame.output, frame.outputHeight, frame.staticOutput, frame.maxVisualWidth, frame.layoutColumns, frame.layoutRows);
1121
+ }
1122
+ resolvePaintContext(frame) {
1123
+ const { columns, rows } = getWindowSize(this.options.stdout);
1124
+ return {
1125
+ strategy: selectPaintStrategy({
1126
+ eligible: this.paintEligible,
1127
+ capability: this.paintCapability,
1128
+ incremental: Boolean(this.options.incrementalRendering),
1129
+ outputHeight: frame.outputHeight,
1130
+ rows,
1131
+ }),
1132
+ columns,
1133
+ rows,
1134
+ };
1135
+ }
1136
+ getSeekPaintContext(frame) {
1137
+ const context = this.resolvePaintContext(frame);
1138
+ if (!isSeekViewport(context)) {
1139
+ return undefined;
1140
+ }
1141
+ const { rows } = getWindowSize(this.options.stdout);
1142
+ if (frame.outputHeight !== rows || context.rows !== rows) {
1143
+ return undefined;
1144
+ }
1145
+ return context;
1146
+ }
1147
+ clearPhysicalFrame() {
1148
+ const physical = this.log.getPhysicalFrame?.();
1149
+ if (isOwnedSeekViewport(physical)) {
1150
+ this.log.clear();
1151
+ return;
1152
+ }
1153
+ this.log.clear(this.getPhysicalEraseOptions());
1154
+ }
1155
+ leaveSeekViewport() {
1156
+ this.clearPhysicalFrame();
1157
+ this.log.restoreTerminalModes();
1158
+ this.log.reset();
1159
+ this.hasPhysicalFrame = false;
1160
+ this.lastPhysicalFrameWasSoftWrapped = false;
1161
+ }
1162
+ consumeHistoryAdvance(rows) {
1163
+ if (!this.pendingHistoryAdvance) {
1164
+ return;
1165
+ }
1166
+ this.pendingHistoryAdvance = false;
1167
+ if (rows > 0) {
1168
+ this.options.stdout.write('\r\n'.repeat(rows) + '\u001B[H');
1169
+ }
1170
+ }
1171
+ failExternalOutput(error) {
1172
+ this.log.restoreTerminalModes();
1173
+ this.hasPhysicalFrame = false;
1174
+ this.pendingHistoryAdvance = false;
1175
+ throw error;
1176
+ }
1177
+ writeExternalOutput(data, stream) {
1178
+ const sync = this.shouldSync();
1179
+ if (sync) {
1180
+ this.options.stdout.write(bsu);
1181
+ }
1182
+ try {
1183
+ const ownedSeek = isOwnedSeekViewport(this.log.getPhysicalFrame?.());
1184
+ const size = getWindowSize(this.options.stdout);
1185
+ const willSeek = Boolean(this.getSeekPaintContext({
1186
+ output: this.lastOutput,
1187
+ outputHeight: this.lastOutputHeight,
1188
+ staticOutput: '',
1189
+ layoutColumns: this.lastLayoutWidth ?? size.columns,
1190
+ layoutRows: this.lastLayoutRows ?? size.rows,
1191
+ }));
1192
+ this.clearPhysicalFrame();
1193
+ this.hasPhysicalFrame = false;
1194
+ stream.write(data);
1195
+ if (ownedSeek || willSeek) {
1196
+ this.pendingHistoryAdvance = true;
1197
+ }
1198
+ this.restoreLastOutput();
1199
+ }
1200
+ catch (error) {
1201
+ this.failExternalOutput(error);
1202
+ }
1203
+ finally {
1204
+ if (sync) {
1205
+ try {
1206
+ this.options.stdout.write(esu);
1207
+ }
1208
+ catch { }
1209
+ }
1210
+ }
1211
+ }
1212
+ writePaintRequest(request) {
1213
+ if (this.isUnmounted) {
1214
+ return;
1215
+ }
1216
+ try {
1217
+ const { columns, rows } = getWindowSize(this.options.stdout);
1218
+ if (request.frame.layoutColumns !== columns ||
1219
+ request.frame.layoutRows !== rows) {
1220
+ this.deferStatic(request.frame);
1221
+ this.scheduleLayoutRender();
1222
+ return;
1223
+ }
1224
+ const previousPhysical = this.log.getPhysicalFrame?.();
1225
+ const seekContext = this.getSeekPaintContext(request.frame);
1226
+ if (seekContext === undefined &&
1227
+ isOwnedSeekViewport(previousPhysical) &&
1228
+ request.frame.outputHeight === previousPhysical.context.rows) {
1229
+ this.deferStatic(request.frame);
1230
+ return;
1231
+ }
1232
+ const shouldWrite = this.log.willRender(request.outputToRender, seekContext);
1233
+ const sync = this.shouldSync();
1234
+ if (sync && shouldWrite) {
1235
+ this.options.stdout.write(bsu);
1236
+ }
1237
+ try {
1238
+ this.log(request.outputToRender, seekContext);
1239
+ }
1240
+ finally {
1241
+ if (sync && shouldWrite) {
1242
+ try {
1243
+ this.options.stdout.write(esu);
1244
+ }
1245
+ catch { }
1246
+ }
1247
+ }
1248
+ this.publishLogicalFrame(request.frame, request.outputToRender, shouldWrite);
1249
+ }
1250
+ catch (error) {
1251
+ this.surfaceOutputError(error);
1252
+ }
1253
+ }
1254
+ publishLogicalFrame(frame, outputToRender, didWrite) {
1255
+ this.lastOutput = frame.output;
1256
+ this.lastOutputToRender = outputToRender;
1257
+ this.lastOutputHeight = frame.outputHeight;
1258
+ const size = getWindowSize(this.options.stdout);
1259
+ this.lastViewportRows = size.rows;
1260
+ this.lastTerminalWidth = size.columns;
1261
+ const physical = this.log.getPhysicalFrame?.();
1262
+ if (isOwnedSeekViewport(physical)) {
1263
+ this.hasPhysicalFrame = true;
1264
+ this.lastPhysicalFrameWasSoftWrapped = false;
1265
+ return;
1266
+ }
1267
+ this.hasPhysicalFrame ||= didWrite;
1268
+ }
1269
+ takeStaticOutput(frame) {
1270
+ const staticOutput = (this.pendingStaticOutput ?? '') + frame.staticOutput;
1271
+ this.pendingStaticOutput = '';
1272
+ this.fullStaticOutput = (this.fullStaticOutput ?? '') + staticOutput;
1273
+ return staticOutput;
1274
+ }
1275
+ deferStatic(frame) {
1276
+ this.pendingStaticOutput =
1277
+ (this.pendingStaticOutput ?? '') + frame.staticOutput;
1278
+ }
1279
+ scheduleLayoutRender() {
1280
+ if (this.isUnmounted) {
1281
+ return;
1282
+ }
1283
+ this.calculateLayout();
1284
+ this.onRender();
1285
+ }
1286
+ reserveExplicitWidthFrame() {
1287
+ const frame = this.pendingFrame;
1288
+ if (!frame || !canOverwriteFirstCell(frame.staticOutput || frame.output))
1289
+ return false;
1290
+ this.reservedFrame = frame;
1291
+ this.pendingFrame = undefined;
1292
+ return true;
1293
+ }
1294
+ settleExplicitWidth(supported) {
1295
+ const reserved = this.reservedFrame;
1296
+ this.reservedFrame = undefined;
1297
+ if (!this.isUnmounted &&
1298
+ this.paintEligible &&
1299
+ this.paintCapability === 'raw') {
1300
+ if (supported) {
1301
+ this.transformOutput = createExplicitWidthEncoder();
1302
+ this.paintCapability = 'osc66';
1303
+ }
1304
+ else {
1305
+ this.paintCapability = 'fallback';
1306
+ }
1307
+ }
1308
+ try {
1309
+ if (this.isUnmounted ||
1310
+ !getWritableStreamState(this.options.stdout)
1311
+ .canWriteToStdout)
1312
+ return;
1313
+ if (reserved)
1314
+ this.commitInteractiveFrame(reserved);
1315
+ while (true) {
1316
+ settleThrottle(this.throttledOnRender, true);
1317
+ if (!this.pendingFrame)
1318
+ break;
1319
+ const pending = this.pendingFrame;
1320
+ this.pendingFrame = undefined;
1321
+ this.commitInteractiveFrame(pending);
1322
+ }
1323
+ }
1324
+ finally {
1325
+ this.pendingFrame = undefined;
1326
+ }
1327
+ }
1328
+ deferUntilExplicitWidthSettlement(action) {
1329
+ const detection = this.widthDetection;
1330
+ if (!detection || detection.settled)
1331
+ return false;
1332
+ this.deferredWidthLifecycleActions.push(action);
1333
+ detection.cancel();
1334
+ return true;
1335
+ }
1336
+ finishExplicitWidthSettlement() {
1337
+ this.resumePendingInput?.();
1338
+ for (const node of this.deferredWidthRenders.splice(0))
1339
+ this.render(node);
1340
+ const canWriteToStdout = getWritableStreamState(this.options.stdout).canWriteToStdout;
1341
+ settleThrottle(this.throttledOnRender, canWriteToStdout);
1342
+ settleThrottle(this.throttledLog, canWriteToStdout);
1343
+ for (const action of this.deferredWidthLifecycleActions.splice(0)) {
1344
+ settleThrottle(this.throttledLog, canWriteToStdout);
1345
+ action();
1346
+ }
1347
+ }
1348
+ isTerminalResponsePending() {
1349
+ return Boolean(this.widthDetection?.pending || this.cancelKittyDetection);
1350
+ }
1351
+ handleInputReady(ready, resumePendingInput, afterSettlement) {
1352
+ this.rawInputReady = ready;
1353
+ if (!ready) {
1354
+ const finishInputRelease = () => {
1355
+ this.cancelKittyDetection?.();
1356
+ this.resumePendingInput = undefined;
1357
+ afterSettlement?.();
1358
+ };
1359
+ if (this.deferUntilExplicitWidthSettlement(finishInputRelease))
1360
+ return;
1361
+ finishInputRelease();
1362
+ return;
1363
+ }
1364
+ if (this.isUnmounted || this.isUnmounting)
1365
+ return;
1366
+ this.resumePendingInput = resumePendingInput;
1367
+ this.widthDetection?.setInputReady(true);
1368
+ if (this.kittyAutoFlags &&
1369
+ !this.kittyQuerySent &&
1370
+ this.cancelKittyDetection) {
1371
+ this.kittyQuerySent = true;
1372
+ try {
1373
+ this.options.stdout.write('\u001B[?u');
1374
+ }
1375
+ catch {
1376
+ this.cancelKittyDetection?.();
1377
+ }
1378
+ }
1379
+ }
1380
+ handleTerminalResponse(response) {
1381
+ if (response.type === 'cursor-position') {
1382
+ this.widthDetection?.accept(response);
1383
+ return;
1384
+ }
1385
+ if (this.kittyQuerySent &&
1386
+ this.kittyAutoFlags &&
1387
+ this.cancelKittyDetection) {
1388
+ const flags = this.kittyAutoFlags;
1389
+ this.cancelKittyDetection();
1390
+ if (!this.isUnmounted && !this.isUnmounting)
1391
+ this.enableKittyProtocol(flags);
1392
+ }
1393
+ }
860
1394
  initKittyKeyboard() {
861
1395
  // Protocol is opt-in: if kittyKeyboard is not specified, do nothing
862
1396
  if (!this.options.kittyKeyboard) {
@@ -882,46 +1416,20 @@ export default class Ink {
882
1416
  !this.options.stdout.isTTY) {
883
1417
  return;
884
1418
  }
885
- // Auto mode: query the terminal for kitty keyboard protocol support.
886
- // The CSI ? u query is safe to send to any terminal — unsupporting
887
- // terminals simply won't respond, and the 200ms timeout handles that.
888
- // This avoids maintaining a hardcoded whitelist of terminal names.
889
- this.confirmKittySupport(flags);
890
- }
891
- confirmKittySupport(flags) {
892
- const { stdin, stdout } = this.options;
893
- let responseBuffer = [];
1419
+ if (this.options.debug || this.isScreenReaderEnabled)
1420
+ return;
1421
+ // Replies flow through App's one readable parser, without input replay.
1422
+ this.kittyAutoFlags = flags;
894
1423
  const cleanup = () => {
895
- this.cancelKittyDetection = undefined;
896
1424
  clearTimeout(timer);
897
- stdin.removeListener('data', onData);
898
- // Re-emit any buffered data that wasn't the protocol response,
899
- // so it isn't lost from Ink's normal input pipeline.
900
- // Clear responseBuffer afterwards to make cleanup idempotent.
901
- const remaining = stripKittyQueryResponsesAndTrailingPartial(responseBuffer);
902
- responseBuffer = [];
903
- if (remaining.length > 0) {
904
- stdin.unshift(Uint8Array.from(remaining));
905
- }
906
- };
907
- const onData = (data) => {
908
- const chunk = typeof data === 'string' ? textEncoder.encode(data) : data;
909
- for (const byte of chunk) {
910
- responseBuffer.push(byte);
911
- }
912
- if (hasCompleteKittyQueryResponse(responseBuffer)) {
913
- cleanup();
914
- if (!this.isUnmounted) {
915
- this.enableKittyProtocol(flags);
916
- }
917
- }
1425
+ this.cancelKittyDetection = undefined;
1426
+ this.kittyAutoFlags = undefined;
1427
+ this.resumePendingInput?.();
918
1428
  };
919
- // Attach listener before writing the query so that synchronous
920
- // or immediate responses are not missed.
921
- stdin.on('data', onData);
922
1429
  const timer = setTimeout(cleanup, 200);
923
1430
  this.cancelKittyDetection = cleanup;
924
- stdout.write('\u001B[?u');
1431
+ if (this.rawInputReady)
1432
+ this.handleInputReady(true, this.resumePendingInput);
925
1433
  }
926
1434
  enableKittyProtocol(flags) {
927
1435
  this.options.stdout.write(`\u001B[>${resolveFlags(flags)}u`);