@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
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;
@@ -116,7 +63,15 @@ const getReflowedLineCount = (output, columns) => {
116
63
  wordWrap: false,
117
64
  }).split('\n').length;
118
65
  };
119
- const isOutputSoftWrapped = (output, columns) => output !== '' && widestLine(output) > columns;
66
+ const isOutputSoftWrapped = (output, columns, maxVisualWidth) => {
67
+ if (output === '') {
68
+ return false;
69
+ }
70
+ if (maxVisualWidth !== undefined) {
71
+ return maxVisualWidth > columns;
72
+ }
73
+ return widestLine(output) > columns;
74
+ };
120
75
  const isErrorInput = (value) => {
121
76
  return (value instanceof Error ||
122
77
  Object.prototype.toString.call(value) === '[object Error]');
@@ -158,6 +113,7 @@ export default class Ink {
158
113
  // Ignore last render after unmounting a tree to prevent empty output before exit
159
114
  isUnmounted;
160
115
  isUnmounting;
116
+ outputError;
161
117
  lastOutput;
162
118
  lastOutputToRender;
163
119
  lastOutputHeight;
@@ -181,6 +137,22 @@ export default class Ink {
181
137
  cancelKittyDetection;
182
138
  nextRenderCommit;
183
139
  textSelection;
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;
184
156
  constructor(options) {
185
157
  autoBind(this);
186
158
  this.options = options;
@@ -199,6 +171,41 @@ export default class Ink {
199
171
  // Using Boolean(isTTY) (rather than an 'in' guard) correctly handles piped streams
200
172
  // where the property is absent (e.g. `node app.js | cat`).
201
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
+ }
202
209
  this.alternateScreen = false;
203
210
  const unthrottled = options.debug || this.isScreenReaderEnabled;
204
211
  const maxFps = options.maxFps ?? 30;
@@ -225,27 +232,24 @@ export default class Ink {
225
232
  this.rootNode.onImmediateRender = this.onRender;
226
233
  this.log = logUpdate.create(options.stdout, {
227
234
  incremental: options.incrementalRendering,
235
+ transformOutput: this.widthDetection
236
+ ? this.encodeTerminalOutput
237
+ : this.transformOutput,
228
238
  });
229
239
  this.cursorPosition = undefined;
240
+ const writePaintRequest = (request) => {
241
+ this.writePaintRequest(request);
242
+ };
230
243
  this.throttledLog = unthrottled
231
- ? this.log
232
- : throttle((output) => {
233
- const shouldWrite = this.log.willRender(output);
234
- const sync = this.shouldSync();
235
- if (sync && shouldWrite) {
236
- this.options.stdout.write(bsu);
237
- }
238
- this.log(output);
239
- if (sync && shouldWrite) {
240
- this.options.stdout.write(esu);
241
- }
242
- }, undefined, {
244
+ ? writePaintRequest
245
+ : throttle(writePaintRequest, undefined, {
243
246
  leading: true,
244
247
  trailing: true,
245
248
  });
246
249
  // Ignore last render after unmounting a tree to prevent empty output before exit
247
250
  this.isUnmounted = false;
248
251
  this.isUnmounting = false;
252
+ this.outputError = undefined;
249
253
  // Store concurrent mode setting
250
254
  this.isConcurrent = options.concurrent ?? false;
251
255
  // Store last output to only rerender when needed
@@ -256,6 +260,7 @@ export default class Ink {
256
260
  this.lastPhysicalFrameWasSoftWrapped = false;
257
261
  this.lastViewportRows = getWindowSize(this.options.stdout).rows;
258
262
  this.lastTerminalWidth = getWindowSize(this.options.stdout).columns;
263
+ this.lastLayoutRows = this.lastViewportRows;
259
264
  // This variable is used only in debug mode to store full static output
260
265
  // so that it's rerendered every time, not just new static parts, like in non-debug mode
261
266
  this.fullStaticOutput = '';
@@ -279,6 +284,7 @@ export default class Ink {
279
284
  options.stdout.off('resize', this.resized);
280
285
  };
281
286
  }
287
+ options.stdout.on('error', this.handleStdoutError);
282
288
  this.initKittyKeyboard();
283
289
  this.exitPromise = new Promise((resolve, reject) => {
284
290
  this.resolveExitPromise = resolve;
@@ -289,6 +295,9 @@ export default class Ink {
289
295
  void this.exitPromise.catch(noop);
290
296
  }
291
297
  resized = () => {
298
+ if (this.deferUntilExplicitWidthSettlement(this.resized))
299
+ return;
300
+ settleThrottle(this.throttledLog, false);
292
301
  const currentWidth = getWindowSize(this.options.stdout).columns;
293
302
  this.calculateLayout();
294
303
  this.onRender();
@@ -308,6 +317,24 @@ export default class Ink {
308
317
  this.exitResult = errorOrResult;
309
318
  this.unmount();
310
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
+ }
311
338
  setCursorPosition = (position) => {
312
339
  this.cursorPosition = position;
313
340
  this.log.setCursorPosition(position);
@@ -320,16 +347,49 @@ export default class Ink {
320
347
  // before restoring output after external stdout/stderr writes.
321
348
  this.log.setCursorPosition(this.cursorPosition);
322
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;
323
374
  this.log(outputToRestore);
324
375
  this.hasPhysicalFrame = true;
325
376
  this.lastPhysicalFrameWasSoftWrapped =
326
377
  Boolean(this.options.stdout.isTTY) &&
327
- isOutputSoftWrapped(outputToRestore, getWindowSize(this.options.stdout).columns);
378
+ isOutputSoftWrapped(outputToRestore, size.columns);
328
379
  };
329
380
  calculateLayout = () => {
330
- const terminalWidth = getWindowSize(this.options.stdout).columns;
331
- this.rootNode.yogaNode.setWidth(terminalWidth);
332
- this.rootNode.yogaNode.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
381
+ const { columns: terminalWidth, rows: terminalRows } = getWindowSize(this.options.stdout);
382
+ const yoga = this.rootNode.yogaNode;
383
+ const widthChanged = this.lastLayoutWidth !== terminalWidth;
384
+ if (widthChanged) {
385
+ yoga.setWidth(terminalWidth);
386
+ this.lastLayoutWidth = terminalWidth;
387
+ }
388
+ this.lastLayoutRows = terminalRows;
389
+ if (!widthChanged && !yoga.isDirty())
390
+ return;
391
+ yoga.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
392
+ dom.incrementLayoutEpoch(this.rootNode);
333
393
  };
334
394
  onRender = () => {
335
395
  this.hasPendingThrottledRender = false;
@@ -341,7 +401,7 @@ export default class Ink {
341
401
  this.nextRenderCommit = undefined;
342
402
  }
343
403
  const startTime = performance.now();
344
- const { output, outputHeight, staticOutput } = render(this.rootNode, this.isScreenReaderEnabled,
404
+ const { output, outputHeight, staticOutput, maxVisualWidth } = render(this.rootNode, this.isScreenReaderEnabled,
345
405
  // Screen-reader rendering bypasses Output, so selection is skipped.
346
406
  this.isScreenReaderEnabled ? undefined : this.textSelection);
347
407
  this.options.onRender?.({
@@ -414,14 +474,52 @@ export default class Ink {
414
474
  }
415
475
  return;
416
476
  }
417
- if (hasStaticOutput) {
418
- 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);
419
507
  }
420
- this.renderInteractiveFrame(output, outputHeight, hasStaticOutput ? staticOutput : '');
421
508
  };
422
509
  render(node) {
510
+ if (this.widthDetection?.publicationBlocked) {
511
+ this.deferredWidthRenders.push(node);
512
+ this.widthDetection.cancel();
513
+ return;
514
+ }
423
515
  const tree = (React.createElement(AccessibilityContext.Provider, { value: { isScreenReaderEnabled: this.isScreenReaderEnabled } },
424
- 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)));
425
523
  if (this.options.concurrent) {
426
524
  // Concurrent mode: use updateContainer (async scheduling)
427
525
  reconciler.updateContainer(tree, this.container, null, noop);
@@ -436,6 +534,8 @@ export default class Ink {
436
534
  if (this.isUnmounted) {
437
535
  return;
438
536
  }
537
+ if (this.deferUntilExplicitWidthSettlement(() => this.writeToStdout(data)))
538
+ return;
439
539
  if (this.options.debug) {
440
540
  this.options.stdout.write(data + this.fullStaticOutput + this.lastOutput);
441
541
  return;
@@ -444,21 +544,14 @@ export default class Ink {
444
544
  this.options.stdout.write(data);
445
545
  return;
446
546
  }
447
- const sync = this.shouldSync();
448
- if (sync) {
449
- this.options.stdout.write(bsu);
450
- }
451
- this.log.clear(this.getPhysicalEraseOptions());
452
- this.options.stdout.write(data);
453
- this.restoreLastOutput();
454
- if (sync) {
455
- this.options.stdout.write(esu);
456
- }
547
+ this.writeExternalOutput(data, this.options.stdout);
457
548
  }
458
549
  writeToStderr(data) {
459
550
  if (this.isUnmounted) {
460
551
  return;
461
552
  }
553
+ if (this.deferUntilExplicitWidthSettlement(() => this.writeToStderr(data)))
554
+ return;
462
555
  if (this.options.debug) {
463
556
  this.options.stderr.write(data);
464
557
  this.options.stdout.write(this.fullStaticOutput + this.lastOutput);
@@ -468,22 +561,15 @@ export default class Ink {
468
561
  this.options.stderr.write(data);
469
562
  return;
470
563
  }
471
- const sync = this.shouldSync();
472
- if (sync) {
473
- this.options.stdout.write(bsu);
474
- }
475
- this.log.clear(this.getPhysicalEraseOptions());
476
- this.options.stderr.write(data);
477
- this.restoreLastOutput();
478
- if (sync) {
479
- this.options.stdout.write(esu);
480
- }
564
+ this.writeExternalOutput(data, this.options.stderr);
481
565
  }
482
566
  // eslint-disable-next-line @typescript-eslint/no-restricted-types
483
567
  unmount(error) {
484
568
  if (this.isUnmounted || this.isUnmounting) {
485
569
  return;
486
570
  }
571
+ if (this.deferUntilExplicitWidthSettlement(() => this.unmount(error)))
572
+ return;
487
573
  this.isUnmounting = true;
488
574
  if (this.beforeExitHandler) {
489
575
  process.off('beforeExit', this.beforeExitHandler);
@@ -504,14 +590,16 @@ export default class Ink {
504
590
  this.calculateLayout();
505
591
  this.onRender();
506
592
  }
593
+ // Accept pending paints from the controlled final render before locking.
594
+ settleThrottle(this.throttledLog, true);
507
595
  }
508
596
  // Mark as unmounted after the final render but before stdout writes
509
597
  // that could re-enter exit() via synchronous write callbacks.
510
598
  this.isUnmounted = true;
511
599
  this.unsubscribeExit();
512
- // Flush any pending throttled log writes if possible, otherwise cancel to
513
- // prevent delayed callbacks from writing to a closed stream.
514
- 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();
515
603
  if (typeof this.restoreConsole === 'function') {
516
604
  // Once unmount starts, Ink stops trying to manage teardown-time
517
605
  // console output. Restoring the native console before React cleanup keeps
@@ -527,7 +615,12 @@ export default class Ink {
527
615
  if (this.cancelKittyDetection) {
528
616
  this.cancelKittyDetection();
529
617
  }
618
+ this.log.restoreTerminalModes();
619
+ const seekTeardown = this.log.getPhysicalFrame?.()?.context.strategy === 'seek-viewport';
530
620
  if (canWriteToStdout) {
621
+ if (this.interactive && !this.options.debug && seekTeardown) {
622
+ this.log.done();
623
+ }
531
624
  if (this.kittyProtocolEnabled) {
532
625
  this.writeBestEffort(this.options.stdout, '\u001B[<u');
533
626
  }
@@ -537,6 +630,9 @@ export default class Ink {
537
630
  // diagnostics onto it. Trying to preserve teardown output across the
538
631
  // buffer switch adds fragile lifecycle-specific behavior, so Ink keeps
539
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.
540
636
  if (this.alternateScreen) {
541
637
  this.writeBestEffort(this.options.stdout, ansiEscapes.exitAlternativeScreen);
542
638
  this.writeBestEffort(this.options.stdout, showCursorEscape);
@@ -549,10 +645,16 @@ export default class Ink {
549
645
  // deferred during rendering).
550
646
  this.options.stdout.write(this.options.debug ? '\n' : this.lastOutput + '\n');
551
647
  }
552
- else if (!this.options.debug) {
648
+ else if (!this.options.debug && !seekTeardown) {
553
649
  this.log.done();
554
650
  }
555
651
  }
652
+ else if (this.interactive && !this.options.debug) {
653
+ try {
654
+ this.log.done();
655
+ }
656
+ catch { }
657
+ }
556
658
  this.kittyProtocolEnabled = false;
557
659
  instances.delete(this.options.stdout);
558
660
  // Ensure all queued writes have been processed before resolving the
@@ -565,8 +667,10 @@ export default class Ink {
565
667
  // because the event loop is draining and async callbacks won't fire.
566
668
  const { exitResult } = this;
567
669
  const resolveOrReject = () => {
568
- if (isErrorInput(error)) {
569
- 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);
570
674
  }
571
675
  else {
572
676
  this.resolveExitPromise(exitResult);
@@ -577,7 +681,17 @@ export default class Ink {
577
681
  resolveOrReject();
578
682
  }
579
683
  else if (canWriteToStdout && hasWritableState) {
580
- 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
+ }
581
695
  }
582
696
  else {
583
697
  setImmediate(resolveOrReject);
@@ -629,6 +743,14 @@ export default class Ink {
629
743
  }
630
744
  }
631
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
+ }
632
754
  const stdout = this.options.stdout;
633
755
  const { canWriteToStdout, hasWritableState } = getWritableStreamState(stdout);
634
756
  // Flush pending throttled render/log timers so their output is included in this wait.
@@ -636,35 +758,46 @@ export default class Ink {
636
758
  settleThrottle(this.throttledLog, canWriteToStdout);
637
759
  if (canWriteToStdout && hasWritableState) {
638
760
  await new Promise(resolve => {
639
- 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
+ }
640
771
  resolve();
641
- });
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
+ }
642
787
  });
643
788
  return;
644
789
  }
645
790
  await yieldImmediate();
646
791
  }
647
792
  clear() {
793
+ if (this.deferUntilExplicitWidthSettlement(this.clear))
794
+ return;
648
795
  if (this.interactive && !this.options.debug) {
649
- this.log.clear(this.getPhysicalEraseOptions());
796
+ this.clearPhysicalFrame();
650
797
  this.hasPhysicalFrame = false;
651
798
  this.lastPhysicalFrameWasSoftWrapped = false;
652
799
  }
653
800
  }
654
- getPhysicalEraseOptions() {
655
- if (!this.options.stdout.isTTY ||
656
- !this.hasPhysicalFrame ||
657
- this.lastOutputToRender === '') {
658
- return undefined;
659
- }
660
- const terminalWidth = getWindowSize(this.options.stdout).columns;
661
- if (!isOutputSoftWrapped(this.lastOutputToRender, terminalWidth)) {
662
- return undefined;
663
- }
664
- return {
665
- eraseLineCount: getReflowedLineCount(this.lastOutputToRender, terminalWidth),
666
- };
667
- }
668
801
  patchConsole() {
669
802
  if (this.options.debug) {
670
803
  return;
@@ -681,6 +814,23 @@ export default class Ink {
681
814
  }
682
815
  });
683
816
  }
817
+ getPhysicalEraseOptions() {
818
+ if (isOwnedSeekViewport(this.log.getPhysicalFrame?.())) {
819
+ return undefined;
820
+ }
821
+ if (!this.options.stdout.isTTY ||
822
+ !this.hasPhysicalFrame ||
823
+ this.lastOutputToRender === '') {
824
+ return undefined;
825
+ }
826
+ const terminalWidth = getWindowSize(this.options.stdout).columns;
827
+ if (!isOutputSoftWrapped(this.lastOutputToRender, terminalWidth)) {
828
+ return undefined;
829
+ }
830
+ return {
831
+ eraseLineCount: getReflowedLineCount(this.lastOutputToRender, terminalWidth),
832
+ };
833
+ }
684
834
  setAlternateScreen(enabled) {
685
835
  this.alternateScreen = this.resolveAlternateScreenOption(enabled, this.interactive);
686
836
  if (this.alternateScreen) {
@@ -730,9 +880,127 @@ export default class Ink {
730
880
  }
731
881
  return this.nextRenderCommit.promise;
732
882
  }
733
- renderInteractiveFrame(output, outputHeight, staticOutput) {
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);
734
1001
  const hasStaticOutput = staticOutput !== '';
735
1002
  const isTty = this.options.stdout.isTTY;
1003
+ const { output, outputHeight, maxVisualWidth } = frame;
736
1004
  const { columns: terminalWidth, rows: terminalRows } = getWindowSize(this.options.stdout);
737
1005
  // Detect fullscreen: output fills or exceeds terminal height.
738
1006
  // Only apply when writing to a real TTY — piped output always gets trailing newlines.
@@ -746,7 +1014,7 @@ export default class Ink {
746
1014
  const outputWillRender = hasStaticOutput || this.log.willRender(outputToRender);
747
1015
  const shouldClassifyNextFrame = Boolean(isTty) && (columnsDecreased || outputWillRender);
748
1016
  const nextFrameIsSoftWrapped = shouldClassifyNextFrame &&
749
- isOutputSoftWrapped(outputToRender, terminalWidth);
1017
+ isOutputSoftWrapped(outputToRender, terminalWidth, maxVisualWidth);
750
1018
  const shouldRepairReflow = Boolean(isTty) &&
751
1019
  hasCachedPhysicalOutput &&
752
1020
  (columnsDecreased ||
@@ -778,7 +1046,9 @@ export default class Ink {
778
1046
  if (sync) {
779
1047
  this.options.stdout.write(bsu);
780
1048
  }
781
- 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));
782
1052
  this.lastOutput = output;
783
1053
  this.lastOutputToRender = outputToRender;
784
1054
  this.lastOutputHeight = outputHeight;
@@ -788,7 +1058,8 @@ export default class Ink {
788
1058
  this.hasPhysicalFrame = true;
789
1059
  this.lastPhysicalFrameWasSoftWrapped = shouldClassifyNextFrame
790
1060
  ? nextFrameIsSoftWrapped
791
- : Boolean(isTty) && isOutputSoftWrapped(outputToRender, terminalWidth);
1061
+ : Boolean(isTty) &&
1062
+ isOutputSoftWrapped(outputToRender, terminalWidth, maxVisualWidth);
792
1063
  if (sync) {
793
1064
  this.options.stdout.write(esu);
794
1065
  }
@@ -802,7 +1073,7 @@ export default class Ink {
802
1073
  this.options.stdout.write(bsu);
803
1074
  }
804
1075
  this.log.clear(reflowEraseOptions);
805
- this.options.stdout.write(staticOutput);
1076
+ this.options.stdout.write(this.transformOutput?.(staticOutput) ?? staticOutput);
806
1077
  this.log(outputToRender);
807
1078
  didWriteFrame = true;
808
1079
  if (sync) {
@@ -823,7 +1094,10 @@ export default class Ink {
823
1094
  else if (outputToRender !== this.lastOutputToRender ||
824
1095
  this.log.isCursorDirty()) {
825
1096
  // ThrottledLog manages its own bsu/esu at actual write time
826
- this.throttledLog(outputToRender);
1097
+ this.throttledLog({
1098
+ frame,
1099
+ outputToRender,
1100
+ });
827
1101
  didWriteFrame = true;
828
1102
  }
829
1103
  this.lastOutput = output;
@@ -833,11 +1107,290 @@ export default class Ink {
833
1107
  if (didWriteFrame) {
834
1108
  this.lastPhysicalFrameWasSoftWrapped = shouldClassifyNextFrame
835
1109
  ? nextFrameIsSoftWrapped
836
- : Boolean(isTty) && isOutputSoftWrapped(outputToRender, terminalWidth);
1110
+ : Boolean(isTty) &&
1111
+ isOutputSoftWrapped(outputToRender, terminalWidth, maxVisualWidth);
837
1112
  }
838
1113
  this.lastViewportRows = viewportRows;
839
1114
  this.lastTerminalWidth = terminalWidth;
840
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
+ }
841
1394
  initKittyKeyboard() {
842
1395
  // Protocol is opt-in: if kittyKeyboard is not specified, do nothing
843
1396
  if (!this.options.kittyKeyboard) {
@@ -863,46 +1416,20 @@ export default class Ink {
863
1416
  !this.options.stdout.isTTY) {
864
1417
  return;
865
1418
  }
866
- // Auto mode: query the terminal for kitty keyboard protocol support.
867
- // The CSI ? u query is safe to send to any terminal — unsupporting
868
- // terminals simply won't respond, and the 200ms timeout handles that.
869
- // This avoids maintaining a hardcoded whitelist of terminal names.
870
- this.confirmKittySupport(flags);
871
- }
872
- confirmKittySupport(flags) {
873
- const { stdin, stdout } = this.options;
874
- 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;
875
1423
  const cleanup = () => {
876
- this.cancelKittyDetection = undefined;
877
1424
  clearTimeout(timer);
878
- stdin.removeListener('data', onData);
879
- // Re-emit any buffered data that wasn't the protocol response,
880
- // so it isn't lost from Ink's normal input pipeline.
881
- // Clear responseBuffer afterwards to make cleanup idempotent.
882
- const remaining = stripKittyQueryResponsesAndTrailingPartial(responseBuffer);
883
- responseBuffer = [];
884
- if (remaining.length > 0) {
885
- stdin.unshift(Uint8Array.from(remaining));
886
- }
887
- };
888
- const onData = (data) => {
889
- const chunk = typeof data === 'string' ? textEncoder.encode(data) : data;
890
- for (const byte of chunk) {
891
- responseBuffer.push(byte);
892
- }
893
- if (hasCompleteKittyQueryResponse(responseBuffer)) {
894
- cleanup();
895
- if (!this.isUnmounted) {
896
- this.enableKittyProtocol(flags);
897
- }
898
- }
1425
+ this.cancelKittyDetection = undefined;
1426
+ this.kittyAutoFlags = undefined;
1427
+ this.resumePendingInput?.();
899
1428
  };
900
- // Attach listener before writing the query so that synchronous
901
- // or immediate responses are not missed.
902
- stdin.on('data', onData);
903
1429
  const timer = setTimeout(cleanup, 200);
904
1430
  this.cancelKittyDetection = cleanup;
905
- stdout.write('\u001B[?u');
1431
+ if (this.rawInputReady)
1432
+ this.handleInputReady(true, this.resumePendingInput);
906
1433
  }
907
1434
  enableKittyProtocol(flags) {
908
1435
  this.options.stdout.write(`\u001B[>${resolveFlags(flags)}u`);