ink 6.7.0 → 7.0.0

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 (132) hide show
  1. package/build/ansi-tokenizer.d.ts +38 -0
  2. package/build/ansi-tokenizer.js +316 -0
  3. package/build/ansi-tokenizer.js.map +1 -0
  4. package/build/components/AnimationContext.d.ts +9 -0
  5. package/build/components/AnimationContext.js +13 -0
  6. package/build/components/AnimationContext.js.map +1 -0
  7. package/build/components/App.d.ts +5 -2
  8. package/build/components/App.js +192 -41
  9. package/build/components/App.js.map +1 -1
  10. package/build/components/AppContext.d.ts +33 -3
  11. package/build/components/AppContext.js +2 -1
  12. package/build/components/AppContext.js.map +1 -1
  13. package/build/components/Box.d.ts +16 -3
  14. package/build/components/ErrorBoundary.d.ts +2 -2
  15. package/build/components/ErrorOverview.js +6 -6
  16. package/build/components/ErrorOverview.js.map +1 -1
  17. package/build/components/Static.js.map +1 -1
  18. package/build/components/StdinContext.d.ts +7 -1
  19. package/build/components/StdinContext.js +1 -0
  20. package/build/components/StdinContext.js.map +1 -1
  21. package/build/components/Text.d.ts +1 -1
  22. package/build/components/Text.js +1 -1
  23. package/build/components/Text.js.map +1 -1
  24. package/build/components/Transform.d.ts +1 -1
  25. package/build/devtools-window-polyfill.js +7 -4
  26. package/build/devtools-window-polyfill.js.map +1 -1
  27. package/build/devtools.js +31 -6
  28. package/build/devtools.js.map +1 -1
  29. package/build/dom.d.ts +5 -1
  30. package/build/dom.js +25 -5
  31. package/build/dom.js.map +1 -1
  32. package/build/hooks/use-animation.d.ts +49 -0
  33. package/build/hooks/use-animation.js +87 -0
  34. package/build/hooks/use-animation.js.map +1 -0
  35. package/build/hooks/use-app.d.ts +5 -2
  36. package/build/hooks/use-app.js +1 -1
  37. package/build/hooks/use-box-metrics.d.ts +59 -0
  38. package/build/hooks/use-box-metrics.js +88 -0
  39. package/build/hooks/use-box-metrics.js.map +1 -0
  40. package/build/hooks/use-cursor.d.ts +1 -1
  41. package/build/hooks/use-cursor.js +1 -1
  42. package/build/hooks/use-focus-manager.d.ts +17 -2
  43. package/build/hooks/use-focus-manager.js +2 -1
  44. package/build/hooks/use-focus-manager.js.map +1 -1
  45. package/build/hooks/use-focus.d.ts +2 -1
  46. package/build/hooks/use-focus.js +5 -4
  47. package/build/hooks/use-focus.js.map +1 -1
  48. package/build/hooks/use-input.d.ts +2 -1
  49. package/build/hooks/use-input.js +82 -80
  50. package/build/hooks/use-input.js.map +1 -1
  51. package/build/hooks/use-is-screen-reader-enabled.d.ts +2 -1
  52. package/build/hooks/use-is-screen-reader-enabled.js +2 -1
  53. package/build/hooks/use-is-screen-reader-enabled.js.map +1 -1
  54. package/build/hooks/use-paste.d.ts +35 -0
  55. package/build/hooks/use-paste.js +62 -0
  56. package/build/hooks/use-paste.js.map +1 -0
  57. package/build/hooks/use-stderr.d.ts +1 -1
  58. package/build/hooks/use-stderr.js +1 -1
  59. package/build/hooks/use-stdin.d.ts +4 -2
  60. package/build/hooks/use-stdin.js +2 -1
  61. package/build/hooks/use-stdin.js.map +1 -1
  62. package/build/hooks/use-stdout.d.ts +1 -1
  63. package/build/hooks/use-stdout.js +1 -1
  64. package/build/hooks/use-window-size.d.ts +18 -0
  65. package/build/hooks/use-window-size.js +22 -0
  66. package/build/hooks/use-window-size.js.map +1 -0
  67. package/build/index.d.ts +10 -1
  68. package/build/index.js +5 -0
  69. package/build/index.js.map +1 -1
  70. package/build/ink.d.ts +55 -6
  71. package/build/ink.js +433 -162
  72. package/build/ink.js.map +1 -1
  73. package/build/input-parser.d.ts +10 -0
  74. package/build/input-parser.js +194 -0
  75. package/build/input-parser.js.map +1 -0
  76. package/build/log-update.d.ts +1 -0
  77. package/build/log-update.js +13 -1
  78. package/build/log-update.js.map +1 -1
  79. package/build/measure-element.d.ts +4 -0
  80. package/build/measure-element.js +4 -0
  81. package/build/measure-element.js.map +1 -1
  82. package/build/output.d.ts +1 -0
  83. package/build/output.js +63 -5
  84. package/build/output.js.map +1 -1
  85. package/build/parse-keypress.d.ts +1 -3
  86. package/build/parse-keypress.js +19 -17
  87. package/build/parse-keypress.js.map +1 -1
  88. package/build/reconciler.js +48 -19
  89. package/build/reconciler.js.map +1 -1
  90. package/build/render-border.js +29 -18
  91. package/build/render-border.js.map +1 -1
  92. package/build/render-to-string.d.ts +38 -0
  93. package/build/render-to-string.js +116 -0
  94. package/build/render-to-string.js.map +1 -0
  95. package/build/render.d.ts +69 -3
  96. package/build/render.js +18 -11
  97. package/build/render.js.map +1 -1
  98. package/build/sanitize-ansi.d.ts +2 -0
  99. package/build/sanitize-ansi.js +27 -0
  100. package/build/sanitize-ansi.js.map +1 -0
  101. package/build/squash-text-nodes.js +2 -1
  102. package/build/squash-text-nodes.js.map +1 -1
  103. package/build/styles.d.ts +78 -16
  104. package/build/styles.js +102 -31
  105. package/build/styles.js.map +1 -1
  106. package/build/utils.d.ts +9 -0
  107. package/build/utils.js +19 -0
  108. package/build/utils.js.map +1 -0
  109. package/build/wrap-text.js +7 -0
  110. package/build/wrap-text.js.map +1 -1
  111. package/build/write-synchronized.d.ts +1 -1
  112. package/build/write-synchronized.js +4 -2
  113. package/build/write-synchronized.js.map +1 -1
  114. package/package.json +40 -101
  115. package/readme.md +674 -56
  116. package/build/apply-styles.js +0 -175
  117. package/build/build-layout.js +0 -77
  118. package/build/calculate-wrapped-text.js +0 -53
  119. package/build/components/Color.js +0 -62
  120. package/build/experimental/apply-style.js +0 -140
  121. package/build/experimental/dom.js +0 -123
  122. package/build/experimental/output.js +0 -91
  123. package/build/experimental/reconciler.js +0 -141
  124. package/build/experimental/renderer.js +0 -81
  125. package/build/hooks/useInput.js +0 -38
  126. package/build/instance.js +0 -205
  127. package/build/options.d.ts +0 -52
  128. package/build/options.js +0 -2
  129. package/build/options.js.map +0 -1
  130. package/build/screen-reader-update.d.ts +0 -13
  131. package/build/screen-reader-update.js +0 -38
  132. package/build/screen-reader-update.js.map +0 -1
package/build/ink.js CHANGED
@@ -9,10 +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';
12
+ import { getWindowSize } from './utils.js';
13
13
  import reconciler from './reconciler.js';
14
14
  import render from './renderer.js';
15
15
  import * as dom from './dom.js';
16
+ import { hideCursorEscape, showCursorEscape } from './cursor-helpers.js';
16
17
  import logUpdate from './log-update.js';
17
18
  import { bsu, esu, shouldSynchronize } from './write-synchronized.js';
18
19
  import instances from './instances.js';
@@ -20,6 +21,110 @@ import App from './components/App.js';
20
21
  import { accessibilityContext as AccessibilityContext } from './components/AccessibilityContext.js';
21
22
  import { resolveFlags, } from './kitty-keyboard.js';
22
23
  const noop = () => { };
24
+ const textEncoder = new TextEncoder();
25
+ const yieldImmediate = async () => new Promise(resolve => {
26
+ setImmediate(resolve);
27
+ });
28
+ const kittyQueryEscapeByte = 0x1b;
29
+ const kittyQueryOpenBracketByte = 0x5b;
30
+ const kittyQueryQuestionMarkByte = 0x3f;
31
+ const kittyQueryLetterByte = 0x75;
32
+ const zeroByte = 0x30;
33
+ const nineByte = 0x39;
34
+ const isDigitByte = (byte) => byte >= zeroByte && byte <= nineByte;
35
+ const matchKittyQueryResponse = (buffer, startIndex) => {
36
+ if (buffer[startIndex] !== kittyQueryEscapeByte ||
37
+ buffer[startIndex + 1] !== kittyQueryOpenBracketByte ||
38
+ buffer[startIndex + 2] !== kittyQueryQuestionMarkByte) {
39
+ return undefined;
40
+ }
41
+ let index = startIndex + 3;
42
+ const digitsStartIndex = index;
43
+ while (index < buffer.length && isDigitByte(buffer[index])) {
44
+ index++;
45
+ }
46
+ if (index === digitsStartIndex) {
47
+ return undefined;
48
+ }
49
+ if (index === buffer.length) {
50
+ return { state: 'partial' };
51
+ }
52
+ if (buffer[index] === kittyQueryLetterByte) {
53
+ return { state: 'complete', endIndex: index };
54
+ }
55
+ return undefined;
56
+ };
57
+ const hasCompleteKittyQueryResponse = (buffer) => {
58
+ for (let index = 0; index < buffer.length; index++) {
59
+ const match = matchKittyQueryResponse(buffer, index);
60
+ if (match?.state === 'complete') {
61
+ return true;
62
+ }
63
+ }
64
+ return false;
65
+ };
66
+ const stripKittyQueryResponsesAndTrailingPartial = (buffer) => {
67
+ const keptBytes = [];
68
+ let index = 0;
69
+ while (index < buffer.length) {
70
+ const match = matchKittyQueryResponse(buffer, index);
71
+ if (match?.state === 'complete') {
72
+ index = match.endIndex + 1;
73
+ continue;
74
+ }
75
+ if (match?.state === 'partial') {
76
+ break;
77
+ }
78
+ keptBytes.push(buffer[index]);
79
+ index++;
80
+ }
81
+ return keptBytes;
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
+ };
103
+ const isErrorInput = (value) => {
104
+ return (value instanceof Error ||
105
+ Object.prototype.toString.call(value) === '[object Error]');
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
+ };
23
128
  export default class Ink {
24
129
  /**
25
130
  Whether this instance is using concurrent rendering mode.
@@ -30,8 +135,12 @@ export default class Ink {
30
135
  cursorPosition;
31
136
  throttledLog;
32
137
  isScreenReaderEnabled;
138
+ interactive;
139
+ renderThrottleMs;
140
+ alternateScreen;
33
141
  // Ignore last render after unmounting a tree to prevent empty output before exit
34
142
  isUnmounted;
143
+ isUnmounting;
35
144
  lastOutput;
36
145
  lastOutputToRender;
37
146
  lastOutputHeight;
@@ -42,12 +151,15 @@ export default class Ink {
42
151
  // so that it's rerendered every time, not just new static parts, like in non-debug mode
43
152
  fullStaticOutput;
44
153
  exitPromise;
154
+ exitResult;
45
155
  beforeExitHandler;
46
156
  restoreConsole;
47
157
  unsubscribeResize;
48
158
  throttledOnRender;
159
+ hasPendingThrottledRender = false;
49
160
  kittyProtocolEnabled = false;
50
161
  cancelKittyDetection;
162
+ nextRenderCommit;
51
163
  constructor(options) {
52
164
  autoBind(this);
53
165
  this.options = options;
@@ -56,9 +168,18 @@ export default class Ink {
56
168
  this.isScreenReaderEnabled =
57
169
  options.isScreenReaderEnabled ??
58
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;
59
176
  const unthrottled = options.debug || this.isScreenReaderEnabled;
60
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.
61
181
  const renderThrottleMs = maxFps > 0 ? Math.max(1, Math.ceil(1000 / maxFps)) : 0;
182
+ this.renderThrottleMs = unthrottled ? 0 : renderThrottleMs;
62
183
  if (unthrottled) {
63
184
  this.rootNode.onRender = this.onRender;
64
185
  this.throttledOnRender = undefined;
@@ -68,7 +189,10 @@ export default class Ink {
68
189
  leading: true,
69
190
  trailing: true,
70
191
  });
71
- this.rootNode.onRender = throttled;
192
+ this.rootNode.onRender = () => {
193
+ this.hasPendingThrottledRender = true;
194
+ throttled();
195
+ };
72
196
  this.throttledOnRender = throttled;
73
197
  }
74
198
  this.rootNode.onImmediateRender = this.onRender;
@@ -80,7 +204,7 @@ export default class Ink {
80
204
  ? this.log
81
205
  : throttle((output) => {
82
206
  const shouldWrite = this.log.willRender(output);
83
- const sync = shouldSynchronize(this.options.stdout);
207
+ const sync = this.shouldSync();
84
208
  if (sync && shouldWrite) {
85
209
  this.options.stdout.write(bsu);
86
210
  }
@@ -94,13 +218,14 @@ export default class Ink {
94
218
  });
95
219
  // Ignore last render after unmounting a tree to prevent empty output before exit
96
220
  this.isUnmounted = false;
221
+ this.isUnmounting = false;
97
222
  // Store concurrent mode setting
98
223
  this.isConcurrent = options.concurrent ?? false;
99
224
  // Store last output to only rerender when needed
100
225
  this.lastOutput = '';
101
226
  this.lastOutputToRender = '';
102
227
  this.lastOutputHeight = 0;
103
- this.lastTerminalWidth = this.getTerminalWidth();
228
+ this.lastTerminalWidth = getWindowSize(this.options.stdout).columns;
104
229
  // This variable is used only in debug mode to store full static output
105
230
  // so that it's rerendered every time, not just new static parts, like in non-debug mode
106
231
  this.fullStaticOutput = '';
@@ -110,37 +235,31 @@ export default class Ink {
110
235
  this.container = reconciler.createContainer(this.rootNode, rootTag, null, false, null, 'id', () => { }, () => { }, () => { }, () => { });
111
236
  // Unmount when process exits
112
237
  this.unsubscribeExit = signalExit(this.unmount, { alwaysLast: false });
238
+ this.setAlternateScreen(Boolean(options.alternateScreen));
113
239
  if (process.env['DEV'] === 'true') {
114
- reconciler.injectIntoDevTools({
115
- bundleType: 0,
116
- // Reporting React DOM's version, not Ink's
117
- // See https://github.com/facebook/react/issues/16666#issuecomment-532639905
118
- version: '16.13.1',
119
- rendererPackageName: 'ink',
120
- });
240
+ // @ts-expect-error outdated types
241
+ reconciler.injectIntoDevTools();
121
242
  }
122
243
  if (options.patchConsole) {
123
244
  this.patchConsole();
124
245
  }
125
- if (!isInCi) {
246
+ if (this.interactive) {
126
247
  options.stdout.on('resize', this.resized);
127
248
  this.unsubscribeResize = () => {
128
249
  options.stdout.off('resize', this.resized);
129
250
  };
130
251
  }
131
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);
132
260
  }
133
- getTerminalWidth = () => {
134
- // The 'columns' property can be undefined or 0 when not using a TTY.
135
- // Use terminal-size as a fallback for piped processes, then default to 80.
136
- if (this.options.stdout.columns) {
137
- return this.options.stdout.columns;
138
- }
139
- const size = terminalSize();
140
- return size?.columns ?? 80;
141
- };
142
261
  resized = () => {
143
- const currentWidth = this.getTerminalWidth();
262
+ const currentWidth = getWindowSize(this.options.stdout).columns;
144
263
  if (currentWidth < this.lastTerminalWidth) {
145
264
  // We clear the screen when decreasing terminal width to prevent duplicate overlapping re-renders.
146
265
  this.log.clear();
@@ -154,25 +273,44 @@ export default class Ink {
154
273
  resolveExitPromise = () => { };
155
274
  rejectExitPromise = () => { };
156
275
  unsubscribeExit = () => { };
276
+ handleAppExit = (errorOrResult) => {
277
+ if (this.isUnmounted || this.isUnmounting) {
278
+ return;
279
+ }
280
+ if (isErrorInput(errorOrResult)) {
281
+ this.unmount(errorOrResult);
282
+ return;
283
+ }
284
+ this.exitResult = errorOrResult;
285
+ this.unmount();
286
+ };
157
287
  setCursorPosition = (position) => {
158
288
  this.cursorPosition = position;
159
289
  this.log.setCursorPosition(position);
160
290
  };
161
291
  restoreLastOutput = () => {
292
+ if (!this.interactive) {
293
+ return;
294
+ }
162
295
  // Clear() resets log-update's cursor state, so replay the latest cursor intent
163
296
  // before restoring output after external stdout/stderr writes.
164
297
  this.log.setCursorPosition(this.cursorPosition);
165
298
  this.log(this.lastOutputToRender || this.lastOutput + '\n');
166
299
  };
167
300
  calculateLayout = () => {
168
- const terminalWidth = this.getTerminalWidth();
301
+ const terminalWidth = getWindowSize(this.options.stdout).columns;
169
302
  this.rootNode.yogaNode.setWidth(terminalWidth);
170
303
  this.rootNode.yogaNode.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
171
304
  };
172
305
  onRender = () => {
306
+ this.hasPendingThrottledRender = false;
173
307
  if (this.isUnmounted) {
174
308
  return;
175
309
  }
310
+ if (this.nextRenderCommit) {
311
+ this.nextRenderCommit.resolve();
312
+ this.nextRenderCommit = undefined;
313
+ }
176
314
  const startTime = performance.now();
177
315
  const { output, outputHeight, staticOutput } = render(this.rootNode, this.isScreenReaderEnabled);
178
316
  this.options.onRender?.({ renderTime: performance.now() - startTime });
@@ -182,10 +320,13 @@ export default class Ink {
182
320
  if (hasStaticOutput) {
183
321
  this.fullStaticOutput += staticOutput;
184
322
  }
323
+ this.lastOutput = output;
324
+ this.lastOutputToRender = output;
325
+ this.lastOutputHeight = outputHeight;
185
326
  this.options.stdout.write(this.fullStaticOutput + output);
186
327
  return;
187
328
  }
188
- if (isInCi) {
329
+ if (!this.interactive) {
189
330
  if (hasStaticOutput) {
190
331
  this.options.stdout.write(staticOutput);
191
332
  }
@@ -195,7 +336,7 @@ export default class Ink {
195
336
  return;
196
337
  }
197
338
  if (this.isScreenReaderEnabled) {
198
- const sync = shouldSynchronize(this.options.stdout);
339
+ const sync = this.shouldSync();
199
340
  if (sync) {
200
341
  this.options.stdout.write(bsu);
201
342
  }
@@ -214,7 +355,7 @@ export default class Ink {
214
355
  }
215
356
  return;
216
357
  }
217
- const terminalWidth = this.getTerminalWidth();
358
+ const terminalWidth = getWindowSize(this.options.stdout).columns;
218
359
  const wrappedOutput = wrapAnsi(output, terminalWidth, {
219
360
  trim: false,
220
361
  hard: true,
@@ -241,49 +382,11 @@ export default class Ink {
241
382
  if (hasStaticOutput) {
242
383
  this.fullStaticOutput += staticOutput;
243
384
  }
244
- // Detect fullscreen: output fills or exceeds terminal height.
245
- // Only apply when writing to a real TTY — piped output always gets trailing newlines.
246
- const isFullscreen = this.options.stdout.isTTY && outputHeight >= this.options.stdout.rows;
247
- const outputToRender = isFullscreen ? output : output + '\n';
248
- if (this.lastOutputHeight >= this.options.stdout.rows) {
249
- const sync = shouldSynchronize(this.options.stdout);
250
- if (sync) {
251
- this.options.stdout.write(bsu);
252
- }
253
- this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + output);
254
- this.lastOutput = output;
255
- this.lastOutputToRender = outputToRender;
256
- this.lastOutputHeight = outputHeight;
257
- this.log.sync(outputToRender);
258
- if (sync) {
259
- this.options.stdout.write(esu);
260
- }
261
- return;
262
- }
263
- // To ensure static output is cleanly rendered before main output, clear main output first
264
- if (hasStaticOutput) {
265
- const sync = shouldSynchronize(this.options.stdout);
266
- if (sync) {
267
- this.options.stdout.write(bsu);
268
- }
269
- this.log.clear();
270
- this.options.stdout.write(staticOutput);
271
- this.log(outputToRender);
272
- if (sync) {
273
- this.options.stdout.write(esu);
274
- }
275
- }
276
- else if (output !== this.lastOutput || this.log.isCursorDirty()) {
277
- // ThrottledLog manages its own bsu/esu at actual write time
278
- this.throttledLog(outputToRender);
279
- }
280
- this.lastOutput = output;
281
- this.lastOutputToRender = outputToRender;
282
- this.lastOutputHeight = outputHeight;
385
+ this.renderInteractiveFrame(output, outputHeight, hasStaticOutput ? staticOutput : '');
283
386
  };
284
387
  render(node) {
285
388
  const tree = (React.createElement(AccessibilityContext.Provider, { value: { isScreenReaderEnabled: this.isScreenReaderEnabled } },
286
- 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.unmount }, 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)));
287
390
  if (this.options.concurrent) {
288
391
  // Concurrent mode: use updateContainer (async scheduling)
289
392
  reconciler.updateContainer(tree, this.container, null, noop);
@@ -302,11 +405,11 @@ export default class Ink {
302
405
  this.options.stdout.write(data + this.fullStaticOutput + this.lastOutput);
303
406
  return;
304
407
  }
305
- if (isInCi) {
408
+ if (!this.interactive) {
306
409
  this.options.stdout.write(data);
307
410
  return;
308
411
  }
309
- const sync = shouldSynchronize(this.options.stdout);
412
+ const sync = this.shouldSync();
310
413
  if (sync) {
311
414
  this.options.stdout.write(bsu);
312
415
  }
@@ -326,11 +429,11 @@ export default class Ink {
326
429
  this.options.stdout.write(this.fullStaticOutput + this.lastOutput);
327
430
  return;
328
431
  }
329
- if (isInCi) {
432
+ if (!this.interactive) {
330
433
  this.options.stderr.write(data);
331
434
  return;
332
435
  }
333
- const sync = shouldSynchronize(this.options.stdout);
436
+ const sync = this.shouldSync();
334
437
  if (sync) {
335
438
  this.options.stdout.write(bsu);
336
439
  }
@@ -341,98 +444,125 @@ export default class Ink {
341
444
  this.options.stdout.write(esu);
342
445
  }
343
446
  }
344
- // eslint-disable-next-line @typescript-eslint/ban-types
447
+ // eslint-disable-next-line @typescript-eslint/no-restricted-types
345
448
  unmount(error) {
346
- if (this.isUnmounted) {
449
+ if (this.isUnmounted || this.isUnmounting) {
347
450
  return;
348
451
  }
452
+ this.isUnmounting = true;
349
453
  if (this.beforeExitHandler) {
350
454
  process.off('beforeExit', this.beforeExitHandler);
351
455
  this.beforeExitHandler = undefined;
352
456
  }
353
- // Flush any pending throttled render to ensure the final frame is rendered
354
- if (this.throttledOnRender) {
355
- this.throttledOnRender.flush();
457
+ const stdout = this.options.stdout;
458
+ const { canWriteToStdout, hasWritableState } = getWritableStreamState(stdout);
459
+ // Clear any pending throttled render timer on unmount. When stdout is writable,
460
+ // flush so the final frame is emitted; otherwise cancel to avoid delayed callbacks.
461
+ settleThrottle(this.throttledOnRender, canWriteToStdout);
462
+ if (canWriteToStdout) {
463
+ // If throttling is enabled and there is already a pending render, flushing above
464
+ // is sufficient. Also avoid calling onRender() again when static output already
465
+ // exists, as that can duplicate <Static> children output on exit (see issue #397).
466
+ const shouldRenderFinalFrame = !this.throttledOnRender ||
467
+ (!this.hasPendingThrottledRender && this.fullStaticOutput === '');
468
+ if (shouldRenderFinalFrame) {
469
+ this.calculateLayout();
470
+ this.onRender();
471
+ }
356
472
  }
357
- this.calculateLayout();
358
- this.onRender();
473
+ // Mark as unmounted after the final render but before stdout writes
474
+ // that could re-enter exit() via synchronous write callbacks.
475
+ this.isUnmounted = true;
359
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);
360
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.
361
485
  this.restoreConsole();
362
486
  }
363
- if (typeof this.unsubscribeResize === 'function') {
364
- this.unsubscribeResize();
365
- }
366
- // Flush any pending throttled log writes
367
- const throttledLog = this.throttledLog;
368
- if (typeof throttledLog.flush === 'function') {
369
- throttledLog.flush();
370
- }
371
- // Cancel any in-progress auto-detection before checking protocol state
372
- if (this.cancelKittyDetection) {
373
- this.cancelKittyDetection();
374
- }
375
- if (this.kittyProtocolEnabled) {
376
- try {
377
- this.options.stdout.write('\u001B[<u');
487
+ const finishUnmount = () => {
488
+ if (typeof this.unsubscribeResize === 'function') {
489
+ this.unsubscribeResize();
378
490
  }
379
- catch {
380
- // Best-effort: stdout may already be destroyed during shutdown
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');
516
+ }
517
+ else if (!this.options.debug) {
518
+ this.log.done();
519
+ }
381
520
  }
382
521
  this.kittyProtocolEnabled = false;
383
- }
384
- // CIs don't handle erasing ansi escapes well, so it's better to
385
- // only render last frame of non-static output
386
- if (isInCi) {
387
- this.options.stdout.write(this.lastOutput + '\n');
388
- }
389
- else if (!this.options.debug) {
390
- this.log.done();
391
- }
392
- this.isUnmounted = true;
393
- if (this.options.concurrent) {
394
- // Concurrent mode: use updateContainer (async scheduling)
395
- reconciler.updateContainer(null, this.container, null, noop);
396
- }
397
- else {
398
- // Legacy mode: use updateContainerSync + flushSyncWork (sync)
399
- reconciler.updateContainerSync(null, this.container, null, noop);
400
- reconciler.flushSyncWork();
401
- }
402
- instances.delete(this.options.stdout);
403
- // Ensure all queued writes have been processed before resolving the
404
- // exit promise. For real writable streams, queue an empty write as a
405
- // barrier its callback fires only after all prior writes complete.
406
- // For non-stream objects (e.g. test spies), resolve on next tick.
407
- //
408
- // When called from signal-exit during process shutdown (error is a
409
- // number or null rather than undefined/Error), resolve synchronously
410
- // because the event loop is draining and async callbacks won't fire.
411
- const resolveOrReject = () => {
412
- if (error instanceof Error) {
413
- this.rejectExitPromise(error);
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();
543
+ }
544
+ else if (canWriteToStdout && hasWritableState) {
545
+ this.options.stdout.write('', resolveOrReject);
414
546
  }
415
547
  else {
416
- this.resolveExitPromise();
548
+ setImmediate(resolveOrReject);
417
549
  }
418
550
  };
419
- const isProcessExiting = error !== undefined && !(error instanceof Error);
420
- if (isProcessExiting) {
421
- resolveOrReject();
422
- }
423
- else if (this.options.stdout._writableState !== undefined ||
424
- this.options.stdout.writableLength !== undefined) {
425
- this.options.stdout.write('', resolveOrReject);
551
+ const concurrentReconciler = reconciler;
552
+ if (this.options.concurrent) {
553
+ reconciler.updateContainerSync(null, this.container, null, noop);
554
+ reconciler.flushSyncWork();
555
+ concurrentReconciler.flushPassiveEffects?.();
556
+ finishUnmount();
426
557
  }
427
558
  else {
428
- setImmediate(resolveOrReject);
559
+ // Legacy mode: use updateContainerSync + flushSyncWork (sync)
560
+ reconciler.updateContainerSync(null, this.container, null, noop);
561
+ reconciler.flushSyncWork();
562
+ finishUnmount();
429
563
  }
430
564
  }
431
565
  async waitUntilExit() {
432
- this.exitPromise ||= new Promise((resolve, reject) => {
433
- this.resolveExitPromise = resolve;
434
- this.rejectExitPromise = reject;
435
- });
436
566
  if (!this.beforeExitHandler) {
437
567
  this.beforeExitHandler = () => {
438
568
  this.unmount();
@@ -441,8 +571,46 @@ export default class Ink {
441
571
  }
442
572
  return this.exitPromise;
443
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
+ }
444
612
  clear() {
445
- if (!isInCi && !this.options.debug) {
613
+ if (this.interactive && !this.options.debug) {
446
614
  this.log.clear();
447
615
  // Sync lastOutput so that unmount's final onRender
448
616
  // sees it as unchanged and log-update skips it
@@ -465,6 +633,106 @@ export default class Ink {
465
633
  }
466
634
  });
467
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
+ }
468
736
  initKittyKeyboard() {
469
737
  // Protocol is opt-in: if kittyKeyboard is not specified, do nothing
470
738
  if (!this.options.kittyKeyboard) {
@@ -472,30 +740,33 @@ export default class Ink {
472
740
  }
473
741
  const opts = this.options.kittyKeyboard;
474
742
  const mode = opts.mode ?? 'auto';
475
- if (mode === 'disabled' ||
476
- !this.options.stdin.isTTY ||
477
- !this.options.stdout.isTTY) {
743
+ if (mode === 'disabled') {
478
744
  return;
479
745
  }
480
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).
481
749
  if (mode === 'enabled') {
482
- this.enableKittyProtocol(flags);
750
+ if (this.options.stdin.isTTY && this.options.stdout.isTTY) {
751
+ this.enableKittyProtocol(flags);
752
+ }
483
753
  return;
484
754
  }
485
- // Auto mode: use heuristic precheck, then confirm with protocol query
486
- const term = process.env['TERM'] ?? '';
487
- const termProgram = process.env['TERM_PROGRAM'] ?? '';
488
- const isKnownSupportingTerminal = 'KITTY_WINDOW_ID' in process.env ||
489
- term === 'xterm-kitty' ||
490
- termProgram === 'WezTerm' ||
491
- termProgram === 'ghostty';
492
- if (!isInCi && isKnownSupportingTerminal) {
493
- 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;
494
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);
495
766
  }
496
767
  confirmKittySupport(flags) {
497
768
  const { stdin, stdout } = this.options;
498
- let responseBuffer = '';
769
+ let responseBuffer = [];
499
770
  const cleanup = () => {
500
771
  this.cancelKittyDetection = undefined;
501
772
  clearTimeout(timer);
@@ -503,18 +774,18 @@ export default class Ink {
503
774
  // Re-emit any buffered data that wasn't the protocol response,
504
775
  // so it isn't lost from Ink's normal input pipeline.
505
776
  // Clear responseBuffer afterwards to make cleanup idempotent.
506
- // eslint-disable-next-line no-control-regex
507
- const remaining = responseBuffer.replace(/\u001B\[\?\d+u/, '');
508
- responseBuffer = '';
509
- if (remaining) {
510
- stdin.unshift(Buffer.from(remaining));
777
+ const remaining = stripKittyQueryResponsesAndTrailingPartial(responseBuffer);
778
+ responseBuffer = [];
779
+ if (remaining.length > 0) {
780
+ stdin.unshift(Uint8Array.from(remaining));
511
781
  }
512
782
  };
513
783
  const onData = (data) => {
514
- responseBuffer +=
515
- typeof data === 'string' ? data : Buffer.from(data).toString();
516
- // eslint-disable-next-line no-control-regex
517
- if (/\u001B\[\?\d+u/.test(responseBuffer)) {
784
+ const chunk = typeof data === 'string' ? textEncoder.encode(data) : data;
785
+ for (const byte of chunk) {
786
+ responseBuffer.push(byte);
787
+ }
788
+ if (hasCompleteKittyQueryResponse(responseBuffer)) {
518
789
  cleanup();
519
790
  if (!this.isUnmounted) {
520
791
  this.enableKittyProtocol(flags);