ink 6.8.0 → 7.0.1

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 (128) hide show
  1. package/build/components/AnimationContext.d.ts +9 -0
  2. package/build/components/AnimationContext.js +13 -0
  3. package/build/components/AnimationContext.js.map +1 -0
  4. package/build/components/App.d.ts +4 -1
  5. package/build/components/App.js +142 -22
  6. package/build/components/App.js.map +1 -1
  7. package/build/components/AppContext.d.ts +23 -3
  8. package/build/components/AppContext.js +7 -4
  9. package/build/components/AppContext.js.map +1 -1
  10. package/build/components/Box.d.ts +16 -3
  11. package/build/components/ErrorBoundary.d.ts +2 -2
  12. package/build/components/ErrorOverview.js +6 -6
  13. package/build/components/ErrorOverview.js.map +1 -1
  14. package/build/components/Static.js.map +1 -1
  15. package/build/components/StdinContext.d.ts +7 -1
  16. package/build/components/StdinContext.js +1 -0
  17. package/build/components/StdinContext.js.map +1 -1
  18. package/build/components/Text.d.ts +1 -1
  19. package/build/components/Text.js +1 -1
  20. package/build/components/Text.js.map +1 -1
  21. package/build/components/Transform.d.ts +1 -1
  22. package/build/devtools-window-polyfill.js +7 -4
  23. package/build/devtools-window-polyfill.js.map +1 -1
  24. package/build/devtools.js +31 -6
  25. package/build/devtools.js.map +1 -1
  26. package/build/dom.d.ts +5 -1
  27. package/build/dom.js +20 -1
  28. package/build/dom.js.map +1 -1
  29. package/build/hooks/use-animation.d.ts +49 -0
  30. package/build/hooks/use-animation.js +87 -0
  31. package/build/hooks/use-animation.js.map +1 -0
  32. package/build/hooks/use-app.d.ts +1 -1
  33. package/build/hooks/use-app.js +1 -1
  34. package/build/hooks/use-box-metrics.d.ts +59 -0
  35. package/build/hooks/use-box-metrics.js +88 -0
  36. package/build/hooks/use-box-metrics.js.map +1 -0
  37. package/build/hooks/use-cursor.d.ts +1 -1
  38. package/build/hooks/use-cursor.js +1 -1
  39. package/build/hooks/use-focus-manager.d.ts +17 -2
  40. package/build/hooks/use-focus-manager.js +2 -1
  41. package/build/hooks/use-focus-manager.js.map +1 -1
  42. package/build/hooks/use-focus.d.ts +2 -1
  43. package/build/hooks/use-focus.js +5 -4
  44. package/build/hooks/use-focus.js.map +1 -1
  45. package/build/hooks/use-input.d.ts +2 -1
  46. package/build/hooks/use-input.js +82 -80
  47. package/build/hooks/use-input.js.map +1 -1
  48. package/build/hooks/use-is-screen-reader-enabled.d.ts +2 -1
  49. package/build/hooks/use-is-screen-reader-enabled.js +2 -1
  50. package/build/hooks/use-is-screen-reader-enabled.js.map +1 -1
  51. package/build/hooks/use-paste.d.ts +35 -0
  52. package/build/hooks/use-paste.js +62 -0
  53. package/build/hooks/use-paste.js.map +1 -0
  54. package/build/hooks/use-stderr.d.ts +1 -1
  55. package/build/hooks/use-stderr.js +1 -1
  56. package/build/hooks/use-stdin.d.ts +4 -2
  57. package/build/hooks/use-stdin.js +2 -1
  58. package/build/hooks/use-stdin.js.map +1 -1
  59. package/build/hooks/use-stdout.d.ts +1 -1
  60. package/build/hooks/use-stdout.js +1 -1
  61. package/build/hooks/use-window-size.d.ts +18 -0
  62. package/build/hooks/use-window-size.js +22 -0
  63. package/build/hooks/use-window-size.js.map +1 -0
  64. package/build/index.d.ts +8 -1
  65. package/build/index.js +4 -0
  66. package/build/index.js.map +1 -1
  67. package/build/ink.d.ts +48 -3
  68. package/build/ink.js +325 -155
  69. package/build/ink.js.map +1 -1
  70. package/build/input-parser.d.ts +4 -1
  71. package/build/input-parser.js +70 -30
  72. package/build/input-parser.js.map +1 -1
  73. package/build/log-update.d.ts +1 -0
  74. package/build/log-update.js +13 -1
  75. package/build/log-update.js.map +1 -1
  76. package/build/measure-element.d.ts +4 -0
  77. package/build/measure-element.js +4 -0
  78. package/build/measure-element.js.map +1 -1
  79. package/build/output.js +25 -0
  80. package/build/output.js.map +1 -1
  81. package/build/parse-keypress.d.ts +1 -3
  82. package/build/parse-keypress.js +19 -17
  83. package/build/parse-keypress.js.map +1 -1
  84. package/build/reconciler.js +46 -27
  85. package/build/reconciler.js.map +1 -1
  86. package/build/render-border.js +29 -18
  87. package/build/render-border.js.map +1 -1
  88. package/build/render-to-string.js +2 -1
  89. package/build/render-to-string.js.map +1 -1
  90. package/build/render.d.ts +57 -2
  91. package/build/render.js +18 -11
  92. package/build/render.js.map +1 -1
  93. package/build/styles.d.ts +78 -16
  94. package/build/styles.js +102 -31
  95. package/build/styles.js.map +1 -1
  96. package/build/utils.d.ts +9 -2
  97. package/build/utils.js +18 -3
  98. package/build/utils.js.map +1 -1
  99. package/build/wrap-text.js +7 -0
  100. package/build/wrap-text.js.map +1 -1
  101. package/build/write-synchronized.d.ts +1 -1
  102. package/build/write-synchronized.js +4 -2
  103. package/build/write-synchronized.js.map +1 -1
  104. package/package.json +34 -98
  105. package/readme.md +554 -48
  106. package/build/apply-styles.js +0 -175
  107. package/build/build-layout.js +0 -77
  108. package/build/calculate-wrapped-text.js +0 -53
  109. package/build/components/Color.js +0 -62
  110. package/build/components/Cursor.d.ts +0 -83
  111. package/build/components/Cursor.js +0 -53
  112. package/build/components/Cursor.js.map +0 -1
  113. package/build/experimental/apply-style.js +0 -140
  114. package/build/experimental/dom.js +0 -123
  115. package/build/experimental/output.js +0 -91
  116. package/build/experimental/reconciler.js +0 -141
  117. package/build/experimental/renderer.js +0 -81
  118. package/build/hooks/useInput.js +0 -38
  119. package/build/instance.js +0 -205
  120. package/build/layout.d.ts +0 -7
  121. package/build/layout.js +0 -33
  122. package/build/layout.js.map +0 -1
  123. package/build/options.d.ts +0 -52
  124. package/build/options.js +0 -2
  125. package/build/options.js.map +0 -1
  126. package/build/screen-reader-update.d.ts +0 -13
  127. package/build/screen-reader-update.js +0 -38
  128. package/build/screen-reader-update.js.map +0 -1
package/build/ink.js CHANGED
@@ -9,11 +9,11 @@ import patchConsole from 'patch-console';
9
9
  import { LegacyRoot, ConcurrentRoot } from 'react-reconciler/constants.js';
10
10
  import Yoga from 'yoga-layout';
11
11
  import wrapAnsi from 'wrap-ansi';
12
- import terminalSize from 'terminal-size';
13
- import { isDev } from './utils.js';
12
+ import { getWindowSize } from './utils.js';
14
13
  import reconciler from './reconciler.js';
15
14
  import render from './renderer.js';
16
15
  import * as dom from './dom.js';
16
+ import { hideCursorEscape, showCursorEscape } from './cursor-helpers.js';
17
17
  import logUpdate from './log-update.js';
18
18
  import { bsu, esu, shouldSynchronize } from './write-synchronized.js';
19
19
  import instances from './instances.js';
@@ -21,6 +21,10 @@ import App from './components/App.js';
21
21
  import { accessibilityContext as AccessibilityContext } from './components/AccessibilityContext.js';
22
22
  import { resolveFlags, } from './kitty-keyboard.js';
23
23
  const noop = () => { };
24
+ const textEncoder = new TextEncoder();
25
+ const yieldImmediate = async () => new Promise(resolve => {
26
+ setImmediate(resolve);
27
+ });
24
28
  const kittyQueryEscapeByte = 0x1b;
25
29
  const kittyQueryOpenBracketByte = 0x5b;
26
30
  const kittyQueryQuestionMarkByte = 0x3f;
@@ -76,10 +80,51 @@ const stripKittyQueryResponsesAndTrailingPartial = (buffer) => {
76
80
  }
77
81
  return keptBytes;
78
82
  };
83
+ const shouldClearTerminalForFrame = ({ isTty, viewportRows, previousOutputHeight, nextOutputHeight, isUnmounting, }) => {
84
+ if (!isTty) {
85
+ return false;
86
+ }
87
+ const hadPreviousFrame = previousOutputHeight > 0;
88
+ const wasFullscreen = previousOutputHeight >= viewportRows;
89
+ const wasOverflowing = previousOutputHeight > viewportRows;
90
+ const isOverflowing = nextOutputHeight > viewportRows;
91
+ const isLeavingFullscreen = wasFullscreen && nextOutputHeight < viewportRows;
92
+ const shouldClearOnUnmount = isUnmounting && wasFullscreen;
93
+ return (
94
+ // Overflowing frames still need full clear fallback.
95
+ wasOverflowing ||
96
+ (isOverflowing && hadPreviousFrame) ||
97
+ // Clear when shrinking from fullscreen to non-fullscreen output.
98
+ isLeavingFullscreen ||
99
+ // Preserve legacy unmount behavior for fullscreen frames: final teardown
100
+ // render should clear once to avoid leaving a scrolled viewport state.
101
+ shouldClearOnUnmount);
102
+ };
79
103
  const isErrorInput = (value) => {
80
104
  return (value instanceof Error ||
81
105
  Object.prototype.toString.call(value) === '[object Error]');
82
106
  };
107
+ const getWritableStreamState = (stdout) => {
108
+ const canWriteToStdout = !stdout.destroyed && !stdout.writableEnded && (stdout.writable ?? true);
109
+ const hasWritableState = stdout._writableState !== undefined || stdout.writableLength !== undefined;
110
+ return {
111
+ canWriteToStdout,
112
+ hasWritableState,
113
+ };
114
+ };
115
+ const settleThrottle = (throttled, canWriteToStdout) => {
116
+ if (!throttled ||
117
+ typeof throttled.flush !== 'function') {
118
+ return;
119
+ }
120
+ const throttledValue = throttled;
121
+ if (canWriteToStdout) {
122
+ throttledValue.flush();
123
+ }
124
+ else if (typeof throttledValue.cancel === 'function') {
125
+ throttledValue.cancel();
126
+ }
127
+ };
83
128
  export default class Ink {
84
129
  /**
85
130
  Whether this instance is using concurrent rendering mode.
@@ -90,6 +135,9 @@ export default class Ink {
90
135
  cursorPosition;
91
136
  throttledLog;
92
137
  isScreenReaderEnabled;
138
+ interactive;
139
+ renderThrottleMs;
140
+ alternateScreen;
93
141
  // Ignore last render after unmounting a tree to prevent empty output before exit
94
142
  isUnmounted;
95
143
  isUnmounting;
@@ -111,6 +159,7 @@ export default class Ink {
111
159
  hasPendingThrottledRender = false;
112
160
  kittyProtocolEnabled = false;
113
161
  cancelKittyDetection;
162
+ nextRenderCommit;
114
163
  constructor(options) {
115
164
  autoBind(this);
116
165
  this.options = options;
@@ -119,9 +168,18 @@ export default class Ink {
119
168
  this.isScreenReaderEnabled =
120
169
  options.isScreenReaderEnabled ??
121
170
  process.env['INK_SCREEN_READER'] === 'true';
171
+ // CI detection takes precedence: even a TTY stdout in CI defaults to non-interactive.
172
+ // Using Boolean(isTTY) (rather than an 'in' guard) correctly handles piped streams
173
+ // where the property is absent (e.g. `node app.js | cat`).
174
+ this.interactive = this.resolveInteractiveOption(options.interactive);
175
+ this.alternateScreen = false;
122
176
  const unthrottled = options.debug || this.isScreenReaderEnabled;
123
177
  const maxFps = options.maxFps ?? 30;
178
+ // Treat non-positive maxFps as an internal fallback case, not a supported
179
+ // "disable throttling" mode. Keep animation scheduling on a normal cadence
180
+ // so future changes don't accidentally reintroduce zero-delay loops.
124
181
  const renderThrottleMs = maxFps > 0 ? Math.max(1, Math.ceil(1000 / maxFps)) : 0;
182
+ this.renderThrottleMs = unthrottled ? 0 : renderThrottleMs;
125
183
  if (unthrottled) {
126
184
  this.rootNode.onRender = this.onRender;
127
185
  this.throttledOnRender = undefined;
@@ -146,7 +204,7 @@ export default class Ink {
146
204
  ? this.log
147
205
  : throttle((output) => {
148
206
  const shouldWrite = this.log.willRender(output);
149
- const sync = shouldSynchronize(this.options.stdout);
207
+ const sync = this.shouldSync();
150
208
  if (sync && shouldWrite) {
151
209
  this.options.stdout.write(bsu);
152
210
  }
@@ -167,7 +225,7 @@ export default class Ink {
167
225
  this.lastOutput = '';
168
226
  this.lastOutputToRender = '';
169
227
  this.lastOutputHeight = 0;
170
- this.lastTerminalWidth = this.getTerminalWidth();
228
+ this.lastTerminalWidth = getWindowSize(this.options.stdout).columns;
171
229
  // This variable is used only in debug mode to store full static output
172
230
  // so that it's rerendered every time, not just new static parts, like in non-debug mode
173
231
  this.fullStaticOutput = '';
@@ -177,32 +235,31 @@ export default class Ink {
177
235
  this.container = reconciler.createContainer(this.rootNode, rootTag, null, false, null, 'id', () => { }, () => { }, () => { }, () => { });
178
236
  // Unmount when process exits
179
237
  this.unsubscribeExit = signalExit(this.unmount, { alwaysLast: false });
180
- if (isDev()) {
238
+ this.setAlternateScreen(Boolean(options.alternateScreen));
239
+ if (process.env['DEV'] === 'true') {
181
240
  // @ts-expect-error outdated types
182
241
  reconciler.injectIntoDevTools();
183
242
  }
184
243
  if (options.patchConsole) {
185
244
  this.patchConsole();
186
245
  }
187
- if (!isInCi) {
246
+ if (this.interactive) {
188
247
  options.stdout.on('resize', this.resized);
189
248
  this.unsubscribeResize = () => {
190
249
  options.stdout.off('resize', this.resized);
191
250
  };
192
251
  }
193
252
  this.initKittyKeyboard();
253
+ this.exitPromise = new Promise((resolve, reject) => {
254
+ this.resolveExitPromise = resolve;
255
+ this.rejectExitPromise = reject;
256
+ });
257
+ // Prevent global unhandled-rejection crashes when app code exits with an
258
+ // error but consumers never call waitUntilExit().
259
+ void this.exitPromise.catch(noop);
194
260
  }
195
- getTerminalWidth = () => {
196
- // The 'columns' property can be undefined or 0 when not using a TTY.
197
- // Use terminal-size as a fallback for piped processes, then default to 80.
198
- if (this.options.stdout.columns) {
199
- return this.options.stdout.columns;
200
- }
201
- const size = terminalSize();
202
- return size?.columns ?? 80;
203
- };
204
261
  resized = () => {
205
- const currentWidth = this.getTerminalWidth();
262
+ const currentWidth = getWindowSize(this.options.stdout).columns;
206
263
  if (currentWidth < this.lastTerminalWidth) {
207
264
  // We clear the screen when decreasing terminal width to prevent duplicate overlapping re-renders.
208
265
  this.log.clear();
@@ -232,13 +289,16 @@ export default class Ink {
232
289
  this.log.setCursorPosition(position);
233
290
  };
234
291
  restoreLastOutput = () => {
292
+ if (!this.interactive) {
293
+ return;
294
+ }
235
295
  // Clear() resets log-update's cursor state, so replay the latest cursor intent
236
296
  // before restoring output after external stdout/stderr writes.
237
297
  this.log.setCursorPosition(this.cursorPosition);
238
298
  this.log(this.lastOutputToRender || this.lastOutput + '\n');
239
299
  };
240
300
  calculateLayout = () => {
241
- const terminalWidth = this.getTerminalWidth();
301
+ const terminalWidth = getWindowSize(this.options.stdout).columns;
242
302
  this.rootNode.yogaNode.setWidth(terminalWidth);
243
303
  this.rootNode.yogaNode.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
244
304
  };
@@ -247,6 +307,10 @@ export default class Ink {
247
307
  if (this.isUnmounted) {
248
308
  return;
249
309
  }
310
+ if (this.nextRenderCommit) {
311
+ this.nextRenderCommit.resolve();
312
+ this.nextRenderCommit = undefined;
313
+ }
250
314
  const startTime = performance.now();
251
315
  const { output, outputHeight, staticOutput } = render(this.rootNode, this.isScreenReaderEnabled);
252
316
  this.options.onRender?.({ renderTime: performance.now() - startTime });
@@ -256,10 +320,13 @@ export default class Ink {
256
320
  if (hasStaticOutput) {
257
321
  this.fullStaticOutput += staticOutput;
258
322
  }
323
+ this.lastOutput = output;
324
+ this.lastOutputToRender = output;
325
+ this.lastOutputHeight = outputHeight;
259
326
  this.options.stdout.write(this.fullStaticOutput + output);
260
327
  return;
261
328
  }
262
- if (isInCi) {
329
+ if (!this.interactive) {
263
330
  if (hasStaticOutput) {
264
331
  this.options.stdout.write(staticOutput);
265
332
  }
@@ -269,7 +336,7 @@ export default class Ink {
269
336
  return;
270
337
  }
271
338
  if (this.isScreenReaderEnabled) {
272
- const sync = shouldSynchronize(this.options.stdout);
339
+ const sync = this.shouldSync();
273
340
  if (sync) {
274
341
  this.options.stdout.write(bsu);
275
342
  }
@@ -288,7 +355,7 @@ export default class Ink {
288
355
  }
289
356
  return;
290
357
  }
291
- const terminalWidth = this.getTerminalWidth();
358
+ const terminalWidth = getWindowSize(this.options.stdout).columns;
292
359
  const wrappedOutput = wrapAnsi(output, terminalWidth, {
293
360
  trim: false,
294
361
  hard: true,
@@ -315,49 +382,11 @@ export default class Ink {
315
382
  if (hasStaticOutput) {
316
383
  this.fullStaticOutput += staticOutput;
317
384
  }
318
- // Detect fullscreen: output fills or exceeds terminal height.
319
- // Only apply when writing to a real TTY — piped output always gets trailing newlines.
320
- const isFullscreen = this.options.stdout.isTTY && outputHeight >= this.options.stdout.rows;
321
- const outputToRender = isFullscreen ? output : output + '\n';
322
- if (this.lastOutputHeight >= this.options.stdout.rows) {
323
- const sync = shouldSynchronize(this.options.stdout);
324
- if (sync) {
325
- this.options.stdout.write(bsu);
326
- }
327
- this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + output);
328
- this.lastOutput = output;
329
- this.lastOutputToRender = outputToRender;
330
- this.lastOutputHeight = outputHeight;
331
- this.log.sync(outputToRender);
332
- if (sync) {
333
- this.options.stdout.write(esu);
334
- }
335
- return;
336
- }
337
- // To ensure static output is cleanly rendered before main output, clear main output first
338
- if (hasStaticOutput) {
339
- const sync = shouldSynchronize(this.options.stdout);
340
- if (sync) {
341
- this.options.stdout.write(bsu);
342
- }
343
- this.log.clear();
344
- this.options.stdout.write(staticOutput);
345
- this.log(outputToRender);
346
- if (sync) {
347
- this.options.stdout.write(esu);
348
- }
349
- }
350
- else if (output !== this.lastOutput || this.log.isCursorDirty()) {
351
- // ThrottledLog manages its own bsu/esu at actual write time
352
- this.throttledLog(outputToRender);
353
- }
354
- this.lastOutput = output;
355
- this.lastOutputToRender = outputToRender;
356
- this.lastOutputHeight = outputHeight;
385
+ this.renderInteractiveFrame(output, outputHeight, hasStaticOutput ? staticOutput : '');
357
386
  };
358
387
  render(node) {
359
388
  const tree = (React.createElement(AccessibilityContext.Provider, { value: { isScreenReaderEnabled: this.isScreenReaderEnabled } },
360
- React.createElement(App, { stdin: this.options.stdin, stdout: this.options.stdout, stderr: this.options.stderr, exitOnCtrlC: this.options.exitOnCtrlC, writeToStdout: this.writeToStdout, writeToStderr: this.writeToStderr, setCursorPosition: this.setCursorPosition, onExit: this.handleAppExit }, node)));
389
+ 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, onExit: this.handleAppExit, onWaitUntilRenderFlush: this.waitUntilRenderFlush }, node)));
361
390
  if (this.options.concurrent) {
362
391
  // Concurrent mode: use updateContainer (async scheduling)
363
392
  reconciler.updateContainer(tree, this.container, null, noop);
@@ -376,11 +405,11 @@ export default class Ink {
376
405
  this.options.stdout.write(data + this.fullStaticOutput + this.lastOutput);
377
406
  return;
378
407
  }
379
- if (isInCi) {
408
+ if (!this.interactive) {
380
409
  this.options.stdout.write(data);
381
410
  return;
382
411
  }
383
- const sync = shouldSynchronize(this.options.stdout);
412
+ const sync = this.shouldSync();
384
413
  if (sync) {
385
414
  this.options.stdout.write(bsu);
386
415
  }
@@ -400,11 +429,11 @@ export default class Ink {
400
429
  this.options.stdout.write(this.fullStaticOutput + this.lastOutput);
401
430
  return;
402
431
  }
403
- if (isInCi) {
432
+ if (!this.interactive) {
404
433
  this.options.stderr.write(data);
405
434
  return;
406
435
  }
407
- const sync = shouldSynchronize(this.options.stdout);
436
+ const sync = this.shouldSync();
408
437
  if (sync) {
409
438
  this.options.stdout.write(bsu);
410
439
  }
@@ -415,7 +444,7 @@ export default class Ink {
415
444
  this.options.stdout.write(esu);
416
445
  }
417
446
  }
418
- // eslint-disable-next-line @typescript-eslint/ban-types
447
+ // eslint-disable-next-line @typescript-eslint/no-restricted-types
419
448
  unmount(error) {
420
449
  if (this.isUnmounted || this.isUnmounting) {
421
450
  return;
@@ -426,21 +455,10 @@ export default class Ink {
426
455
  this.beforeExitHandler = undefined;
427
456
  }
428
457
  const stdout = this.options.stdout;
429
- const canWriteToStdout = !stdout.destroyed && !stdout.writableEnded && (stdout.writable ?? true);
430
- const settleThrottle = (throttled) => {
431
- if (typeof throttled.flush !== 'function') {
432
- return;
433
- }
434
- if (canWriteToStdout) {
435
- throttled.flush();
436
- }
437
- else if (typeof throttled.cancel === 'function') {
438
- throttled.cancel();
439
- }
440
- };
458
+ const { canWriteToStdout, hasWritableState } = getWritableStreamState(stdout);
441
459
  // Clear any pending throttled render timer on unmount. When stdout is writable,
442
460
  // flush so the final frame is emitted; otherwise cancel to avoid delayed callbacks.
443
- settleThrottle(this.throttledOnRender ?? {});
461
+ settleThrottle(this.throttledOnRender, canWriteToStdout);
444
462
  if (canWriteToStdout) {
445
463
  // If throttling is enabled and there is already a pending render, flushing above
446
464
  // is sufficient. Also avoid calling onRender() again when static output already
@@ -456,84 +474,95 @@ export default class Ink {
456
474
  // that could re-enter exit() via synchronous write callbacks.
457
475
  this.isUnmounted = true;
458
476
  this.unsubscribeExit();
477
+ // Flush any pending throttled log writes if possible, otherwise cancel to
478
+ // prevent delayed callbacks from writing to a closed stream.
479
+ settleThrottle(this.throttledLog, canWriteToStdout);
459
480
  if (typeof this.restoreConsole === 'function') {
481
+ // Once unmount starts, Ink stops trying to manage teardown-time
482
+ // console output. Restoring the native console before React cleanup keeps
483
+ // unmount behavior simple and avoids special-case handling for custom
484
+ // streams, fullscreen frames, and alternate-screen teardown.
460
485
  this.restoreConsole();
461
486
  }
462
- if (typeof this.unsubscribeResize === 'function') {
463
- this.unsubscribeResize();
464
- }
465
- // Cancel any in-progress auto-detection before checking protocol state
466
- if (this.cancelKittyDetection) {
467
- this.cancelKittyDetection();
468
- }
469
- // Flush any pending throttled log writes if possible, otherwise cancel to
470
- // prevent delayed callbacks from writing to a closed stream.
471
- const throttledLog = this.throttledLog;
472
- settleThrottle(throttledLog);
473
- if (canWriteToStdout) {
474
- if (this.kittyProtocolEnabled) {
475
- try {
476
- this.options.stdout.write('\u001B[<u');
487
+ const finishUnmount = () => {
488
+ if (typeof this.unsubscribeResize === 'function') {
489
+ this.unsubscribeResize();
490
+ }
491
+ // Cancel any in-progress auto-detection before checking protocol state
492
+ if (this.cancelKittyDetection) {
493
+ this.cancelKittyDetection();
494
+ }
495
+ if (canWriteToStdout) {
496
+ if (this.kittyProtocolEnabled) {
497
+ this.writeBestEffort(this.options.stdout, '\u001B[<u');
498
+ }
499
+ // Alternate-screen content is disposable by design. We intentionally
500
+ // leave it active until React cleanup finishes, then restore the
501
+ // primary buffer without replaying prior frames, hook writes, or
502
+ // diagnostics onto it. Trying to preserve teardown output across the
503
+ // buffer switch adds fragile lifecycle-specific behavior, so Ink keeps
504
+ // alternate-screen teardown intentionally simple and best-effort.
505
+ if (this.alternateScreen) {
506
+ this.writeBestEffort(this.options.stdout, ansiEscapes.exitAlternativeScreen);
507
+ this.writeBestEffort(this.options.stdout, showCursorEscape);
508
+ this.alternateScreen = false;
509
+ }
510
+ if (!this.interactive) {
511
+ // Non-interactive environments don't handle erasing ansi escapes well.
512
+ // In debug mode, each render already writes to stdout, so only a trailing
513
+ // newline is needed. In non-debug mode, write the last frame now (it was
514
+ // deferred during rendering).
515
+ this.options.stdout.write(this.options.debug ? '\n' : this.lastOutput + '\n');
477
516
  }
478
- catch {
479
- // Best-effort: stdout may already be destroyed during shutdown
517
+ else if (!this.options.debug) {
518
+ this.log.done();
480
519
  }
481
520
  }
482
- // CIs don't handle erasing ansi escapes well, so it's better to
483
- // only render last frame of non-static output
484
- if (isInCi) {
485
- this.options.stdout.write(this.lastOutput + '\n');
521
+ this.kittyProtocolEnabled = false;
522
+ instances.delete(this.options.stdout);
523
+ // Ensure all queued writes have been processed before resolving the
524
+ // exit promise. For real writable streams, queue an empty write as a
525
+ // barrier — its callback fires only after all prior writes complete.
526
+ // For non-stream objects (e.g. test spies), resolve on next tick.
527
+ //
528
+ // When called from signal-exit during process shutdown (error is a
529
+ // number or null rather than undefined/Error), resolve synchronously
530
+ // because the event loop is draining and async callbacks won't fire.
531
+ const { exitResult } = this;
532
+ const resolveOrReject = () => {
533
+ if (isErrorInput(error)) {
534
+ this.rejectExitPromise(error);
535
+ }
536
+ else {
537
+ this.resolveExitPromise(exitResult);
538
+ }
539
+ };
540
+ const isProcessExiting = error !== undefined && !isErrorInput(error);
541
+ if (isProcessExiting) {
542
+ resolveOrReject();
486
543
  }
487
- else if (!this.options.debug) {
488
- this.log.done();
544
+ else if (canWriteToStdout && hasWritableState) {
545
+ this.options.stdout.write('', resolveOrReject);
489
546
  }
490
- }
491
- this.kittyProtocolEnabled = false;
547
+ else {
548
+ setImmediate(resolveOrReject);
549
+ }
550
+ };
551
+ const concurrentReconciler = reconciler;
492
552
  if (this.options.concurrent) {
493
- // Concurrent mode: use updateContainer (async scheduling)
494
- reconciler.updateContainer(null, this.container, null, noop);
553
+ reconciler.updateContainerSync(null, this.container, null, noop);
554
+ reconciler.flushSyncWork();
555
+ concurrentReconciler.flushPassiveEffects?.();
556
+ finishUnmount();
495
557
  }
496
558
  else {
497
559
  // Legacy mode: use updateContainerSync + flushSyncWork (sync)
498
560
  reconciler.updateContainerSync(null, this.container, null, noop);
499
561
  reconciler.flushSyncWork();
500
- }
501
- instances.delete(this.options.stdout);
502
- // Ensure all queued writes have been processed before resolving the
503
- // exit promise. For real writable streams, queue an empty write as a
504
- // barrier — its callback fires only after all prior writes complete.
505
- // For non-stream objects (e.g. test spies), resolve on next tick.
506
- //
507
- // When called from signal-exit during process shutdown (error is a
508
- // number or null rather than undefined/Error), resolve synchronously
509
- // because the event loop is draining and async callbacks won't fire.
510
- const { exitResult } = this;
511
- const resolveOrReject = () => {
512
- if (isErrorInput(error)) {
513
- this.rejectExitPromise(error);
514
- }
515
- else {
516
- this.resolveExitPromise(exitResult);
517
- }
518
- };
519
- const isProcessExiting = error !== undefined && !isErrorInput(error);
520
- const hasWritableState = stdout._writableState !== undefined ||
521
- stdout.writableLength !== undefined;
522
- if (isProcessExiting) {
523
- resolveOrReject();
524
- }
525
- else if (canWriteToStdout && hasWritableState) {
526
- this.options.stdout.write('', resolveOrReject);
527
- }
528
- else {
529
- setImmediate(resolveOrReject);
562
+ finishUnmount();
530
563
  }
531
564
  }
532
565
  async waitUntilExit() {
533
- this.exitPromise ||= new Promise((resolve, reject) => {
534
- this.resolveExitPromise = resolve;
535
- this.rejectExitPromise = reject;
536
- });
537
566
  if (!this.beforeExitHandler) {
538
567
  this.beforeExitHandler = () => {
539
568
  this.unmount();
@@ -542,8 +571,46 @@ export default class Ink {
542
571
  }
543
572
  return this.exitPromise;
544
573
  }
574
+ async waitUntilRenderFlush() {
575
+ if (this.isUnmounted || this.isUnmounting) {
576
+ await this.awaitExit();
577
+ return;
578
+ }
579
+ // Yield to the macrotask queue so that React's scheduler has a chance to
580
+ // fire passive effects and process any work they enqueued.
581
+ await yieldImmediate();
582
+ if (this.isUnmounted || this.isUnmounting) {
583
+ await this.awaitExit();
584
+ return;
585
+ }
586
+ // In concurrent mode, React's scheduler may still be mid-render after
587
+ // the yield. Wait for the next render commit instead of polling.
588
+ if (this.isConcurrent && this.hasPendingConcurrentWork()) {
589
+ await Promise.race([this.awaitNextRender(), this.awaitExit()]);
590
+ if (this.isUnmounted || this.isUnmounting) {
591
+ this.nextRenderCommit = undefined;
592
+ await this.awaitExit();
593
+ return;
594
+ }
595
+ }
596
+ reconciler.flushSyncWork();
597
+ const stdout = this.options.stdout;
598
+ const { canWriteToStdout, hasWritableState } = getWritableStreamState(stdout);
599
+ // Flush pending throttled render/log timers so their output is included in this wait.
600
+ settleThrottle(this.throttledOnRender, canWriteToStdout);
601
+ settleThrottle(this.throttledLog, canWriteToStdout);
602
+ if (canWriteToStdout && hasWritableState) {
603
+ await new Promise(resolve => {
604
+ this.options.stdout.write('', () => {
605
+ resolve();
606
+ });
607
+ });
608
+ return;
609
+ }
610
+ await yieldImmediate();
611
+ }
545
612
  clear() {
546
- if (!isInCi && !this.options.debug) {
613
+ if (this.interactive && !this.options.debug) {
547
614
  this.log.clear();
548
615
  // Sync lastOutput so that unmount's final onRender
549
616
  // sees it as unchanged and log-update skips it
@@ -566,6 +633,106 @@ export default class Ink {
566
633
  }
567
634
  });
568
635
  }
636
+ setAlternateScreen(enabled) {
637
+ this.alternateScreen = this.resolveAlternateScreenOption(enabled, this.interactive);
638
+ if (this.alternateScreen) {
639
+ this.writeBestEffort(this.options.stdout, ansiEscapes.enterAlternativeScreen);
640
+ this.writeBestEffort(this.options.stdout, hideCursorEscape);
641
+ }
642
+ }
643
+ resolveInteractiveOption(interactive) {
644
+ return interactive ?? (!isInCi && Boolean(this.options.stdout.isTTY));
645
+ }
646
+ resolveAlternateScreenOption(alternateScreen, interactive) {
647
+ return (Boolean(alternateScreen) &&
648
+ interactive &&
649
+ Boolean(this.options.stdout.isTTY));
650
+ }
651
+ shouldSync() {
652
+ return shouldSynchronize(this.options.stdout, this.interactive);
653
+ }
654
+ // Best-effort write: streams may already be destroyed during shutdown.
655
+ writeBestEffort(stream, data) {
656
+ try {
657
+ stream.write(data);
658
+ }
659
+ catch { }
660
+ }
661
+ // Waits for the exit promise to settle, suppressing any rejection.
662
+ // Errors are surfaced via waitUntilExit() instead.
663
+ async awaitExit() {
664
+ try {
665
+ await this.exitPromise;
666
+ }
667
+ catch { }
668
+ }
669
+ hasPendingConcurrentWork() {
670
+ const concurrentContainer = this.container;
671
+ return ((concurrentContainer.pendingLanes ?? 0) !== 0 &&
672
+ concurrentContainer.callbackNode !== undefined &&
673
+ concurrentContainer.callbackNode !== null);
674
+ }
675
+ async awaitNextRender() {
676
+ if (!this.nextRenderCommit) {
677
+ let resolveRender;
678
+ const promise = new Promise(resolve => {
679
+ resolveRender = resolve;
680
+ });
681
+ this.nextRenderCommit = { promise, resolve: resolveRender };
682
+ }
683
+ return this.nextRenderCommit.promise;
684
+ }
685
+ renderInteractiveFrame(output, outputHeight, staticOutput) {
686
+ const hasStaticOutput = staticOutput !== '';
687
+ const isTty = this.options.stdout.isTTY;
688
+ // Detect fullscreen: output fills or exceeds terminal height.
689
+ // Only apply when writing to a real TTY — piped output always gets trailing newlines.
690
+ const viewportRows = isTty ? getWindowSize(this.options.stdout).rows : 24;
691
+ const isFullscreen = isTty && outputHeight >= viewportRows;
692
+ const outputToRender = isFullscreen ? output : output + '\n';
693
+ const shouldClearTerminal = shouldClearTerminalForFrame({
694
+ isTty,
695
+ viewportRows,
696
+ previousOutputHeight: this.lastOutputHeight,
697
+ nextOutputHeight: outputHeight,
698
+ isUnmounting: this.isUnmounting,
699
+ });
700
+ if (shouldClearTerminal) {
701
+ const sync = this.shouldSync();
702
+ if (sync) {
703
+ this.options.stdout.write(bsu);
704
+ }
705
+ this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + output);
706
+ this.lastOutput = output;
707
+ this.lastOutputToRender = outputToRender;
708
+ this.lastOutputHeight = outputHeight;
709
+ this.log.sync(outputToRender);
710
+ if (sync) {
711
+ this.options.stdout.write(esu);
712
+ }
713
+ return;
714
+ }
715
+ // To ensure static output is cleanly rendered before main output, clear main output first
716
+ if (hasStaticOutput) {
717
+ const sync = this.shouldSync();
718
+ if (sync) {
719
+ this.options.stdout.write(bsu);
720
+ }
721
+ this.log.clear();
722
+ this.options.stdout.write(staticOutput);
723
+ this.log(outputToRender);
724
+ if (sync) {
725
+ this.options.stdout.write(esu);
726
+ }
727
+ }
728
+ else if (output !== this.lastOutput || this.log.isCursorDirty()) {
729
+ // ThrottledLog manages its own bsu/esu at actual write time
730
+ this.throttledLog(outputToRender);
731
+ }
732
+ this.lastOutput = output;
733
+ this.lastOutputToRender = outputToRender;
734
+ this.lastOutputHeight = outputHeight;
735
+ }
569
736
  initKittyKeyboard() {
570
737
  // Protocol is opt-in: if kittyKeyboard is not specified, do nothing
571
738
  if (!this.options.kittyKeyboard) {
@@ -573,26 +740,29 @@ export default class Ink {
573
740
  }
574
741
  const opts = this.options.kittyKeyboard;
575
742
  const mode = opts.mode ?? 'auto';
576
- if (mode === 'disabled' ||
577
- !this.options.stdin.isTTY ||
578
- !this.options.stdout.isTTY) {
743
+ if (mode === 'disabled') {
579
744
  return;
580
745
  }
581
746
  const flags = opts.flags ?? ['disambiguateEscapeCodes'];
747
+ // 'enabled' force-enables the protocol as long as both streams are TTYs,
748
+ // regardless of the interactive setting (e.g. even in CI).
582
749
  if (mode === 'enabled') {
583
- this.enableKittyProtocol(flags);
750
+ if (this.options.stdin.isTTY && this.options.stdout.isTTY) {
751
+ this.enableKittyProtocol(flags);
752
+ }
584
753
  return;
585
754
  }
586
- // Auto mode: use heuristic precheck, then confirm with protocol query
587
- const term = process.env['TERM'] ?? '';
588
- const termProgram = process.env['TERM_PROGRAM'] ?? '';
589
- const isKnownSupportingTerminal = 'KITTY_WINDOW_ID' in process.env ||
590
- term === 'xterm-kitty' ||
591
- termProgram === 'WezTerm' ||
592
- termProgram === 'ghostty';
593
- if (!isInCi && isKnownSupportingTerminal) {
594
- this.confirmKittySupport(flags);
755
+ // Auto mode: require interactive + TTY
756
+ if (!this.interactive ||
757
+ !this.options.stdin.isTTY ||
758
+ !this.options.stdout.isTTY) {
759
+ return;
595
760
  }
761
+ // Auto mode: query the terminal for kitty keyboard protocol support.
762
+ // The CSI ? u query is safe to send to any terminal — unsupporting
763
+ // terminals simply won't respond, and the 200ms timeout handles that.
764
+ // This avoids maintaining a hardcoded whitelist of terminal names.
765
+ this.confirmKittySupport(flags);
596
766
  }
597
767
  confirmKittySupport(flags) {
598
768
  const { stdin, stdout } = this.options;
@@ -607,11 +777,11 @@ export default class Ink {
607
777
  const remaining = stripKittyQueryResponsesAndTrailingPartial(responseBuffer);
608
778
  responseBuffer = [];
609
779
  if (remaining.length > 0) {
610
- stdin.unshift(Buffer.from(remaining));
780
+ stdin.unshift(Uint8Array.from(remaining));
611
781
  }
612
782
  };
613
783
  const onData = (data) => {
614
- const chunk = typeof data === 'string' ? Buffer.from(data) : data;
784
+ const chunk = typeof data === 'string' ? textEncoder.encode(data) : data;
615
785
  for (const byte of chunk) {
616
786
  responseBuffer.push(byte);
617
787
  }