ink 6.6.0 → 6.8.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 (73) 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/App.d.ts +8 -49
  5. package/build/components/App.js +293 -228
  6. package/build/components/App.js.map +1 -1
  7. package/build/components/AppContext.d.ts +5 -1
  8. package/build/components/AppContext.js.map +1 -1
  9. package/build/components/Cursor.d.ts +83 -0
  10. package/build/components/Cursor.js +53 -0
  11. package/build/components/Cursor.js.map +1 -0
  12. package/build/components/CursorContext.d.ts +11 -0
  13. package/build/components/CursorContext.js +8 -0
  14. package/build/components/CursorContext.js.map +1 -0
  15. package/build/components/ErrorBoundary.d.ts +18 -0
  16. package/build/components/ErrorBoundary.js +23 -0
  17. package/build/components/ErrorBoundary.js.map +1 -0
  18. package/build/cursor-helpers.d.ts +38 -0
  19. package/build/cursor-helpers.js +56 -0
  20. package/build/cursor-helpers.js.map +1 -0
  21. package/build/dom.js +5 -4
  22. package/build/dom.js.map +1 -1
  23. package/build/hooks/use-cursor.d.ts +12 -0
  24. package/build/hooks/use-cursor.js +29 -0
  25. package/build/hooks/use-cursor.js.map +1 -0
  26. package/build/hooks/use-input.d.ts +30 -0
  27. package/build/hooks/use-input.js +31 -2
  28. package/build/hooks/use-input.js.map +1 -1
  29. package/build/index.d.ts +6 -0
  30. package/build/index.js +3 -0
  31. package/build/index.js.map +1 -1
  32. package/build/ink.d.ts +39 -3
  33. package/build/ink.js +377 -49
  34. package/build/ink.js.map +1 -1
  35. package/build/input-parser.d.ts +7 -0
  36. package/build/input-parser.js +154 -0
  37. package/build/input-parser.js.map +1 -0
  38. package/build/kitty-keyboard.d.ts +23 -0
  39. package/build/kitty-keyboard.js +32 -0
  40. package/build/kitty-keyboard.js.map +1 -0
  41. package/build/layout.d.ts +7 -0
  42. package/build/layout.js +33 -0
  43. package/build/layout.js.map +1 -0
  44. package/build/log-update.d.ts +6 -1
  45. package/build/log-update.js +163 -40
  46. package/build/log-update.js.map +1 -1
  47. package/build/output.d.ts +1 -0
  48. package/build/output.js +38 -5
  49. package/build/output.js.map +1 -1
  50. package/build/parse-keypress.d.ts +8 -0
  51. package/build/parse-keypress.js +270 -2
  52. package/build/parse-keypress.js.map +1 -1
  53. package/build/reconciler.js +23 -3
  54. package/build/reconciler.js.map +1 -1
  55. package/build/render-to-string.d.ts +38 -0
  56. package/build/render-to-string.js +115 -0
  57. package/build/render-to-string.js.map +1 -0
  58. package/build/render.d.ts +34 -1
  59. package/build/render.js +7 -2
  60. package/build/render.js.map +1 -1
  61. package/build/sanitize-ansi.d.ts +2 -0
  62. package/build/sanitize-ansi.js +27 -0
  63. package/build/sanitize-ansi.js.map +1 -0
  64. package/build/squash-text-nodes.js +2 -1
  65. package/build/squash-text-nodes.js.map +1 -1
  66. package/build/utils.d.ts +2 -0
  67. package/build/utils.js +4 -0
  68. package/build/utils.js.map +1 -0
  69. package/build/write-synchronized.d.ts +4 -0
  70. package/build/write-synchronized.js +7 -0
  71. package/build/write-synchronized.js.map +1 -0
  72. package/package.json +27 -21
  73. package/readme.md +292 -14
package/build/ink.js CHANGED
@@ -6,25 +6,95 @@ import isInCi from 'is-in-ci';
6
6
  import autoBind from 'auto-bind';
7
7
  import signalExit from 'signal-exit';
8
8
  import patchConsole from 'patch-console';
9
- import { LegacyRoot } from 'react-reconciler/constants.js';
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
14
  import reconciler from './reconciler.js';
13
15
  import render from './renderer.js';
14
16
  import * as dom from './dom.js';
15
17
  import logUpdate from './log-update.js';
18
+ import { bsu, esu, shouldSynchronize } from './write-synchronized.js';
16
19
  import instances from './instances.js';
17
20
  import App from './components/App.js';
18
21
  import { accessibilityContext as AccessibilityContext } from './components/AccessibilityContext.js';
22
+ import { resolveFlags, } from './kitty-keyboard.js';
19
23
  const noop = () => { };
24
+ const kittyQueryEscapeByte = 0x1b;
25
+ const kittyQueryOpenBracketByte = 0x5b;
26
+ const kittyQueryQuestionMarkByte = 0x3f;
27
+ const kittyQueryLetterByte = 0x75;
28
+ const zeroByte = 0x30;
29
+ const nineByte = 0x39;
30
+ const isDigitByte = (byte) => byte >= zeroByte && byte <= nineByte;
31
+ const matchKittyQueryResponse = (buffer, startIndex) => {
32
+ if (buffer[startIndex] !== kittyQueryEscapeByte ||
33
+ buffer[startIndex + 1] !== kittyQueryOpenBracketByte ||
34
+ buffer[startIndex + 2] !== kittyQueryQuestionMarkByte) {
35
+ return undefined;
36
+ }
37
+ let index = startIndex + 3;
38
+ const digitsStartIndex = index;
39
+ while (index < buffer.length && isDigitByte(buffer[index])) {
40
+ index++;
41
+ }
42
+ if (index === digitsStartIndex) {
43
+ return undefined;
44
+ }
45
+ if (index === buffer.length) {
46
+ return { state: 'partial' };
47
+ }
48
+ if (buffer[index] === kittyQueryLetterByte) {
49
+ return { state: 'complete', endIndex: index };
50
+ }
51
+ return undefined;
52
+ };
53
+ const hasCompleteKittyQueryResponse = (buffer) => {
54
+ for (let index = 0; index < buffer.length; index++) {
55
+ const match = matchKittyQueryResponse(buffer, index);
56
+ if (match?.state === 'complete') {
57
+ return true;
58
+ }
59
+ }
60
+ return false;
61
+ };
62
+ const stripKittyQueryResponsesAndTrailingPartial = (buffer) => {
63
+ const keptBytes = [];
64
+ let index = 0;
65
+ while (index < buffer.length) {
66
+ const match = matchKittyQueryResponse(buffer, index);
67
+ if (match?.state === 'complete') {
68
+ index = match.endIndex + 1;
69
+ continue;
70
+ }
71
+ if (match?.state === 'partial') {
72
+ break;
73
+ }
74
+ keptBytes.push(buffer[index]);
75
+ index++;
76
+ }
77
+ return keptBytes;
78
+ };
79
+ const isErrorInput = (value) => {
80
+ return (value instanceof Error ||
81
+ Object.prototype.toString.call(value) === '[object Error]');
82
+ };
20
83
  export default class Ink {
84
+ /**
85
+ Whether this instance is using concurrent rendering mode.
86
+ */
87
+ isConcurrent;
21
88
  options;
22
89
  log;
90
+ cursorPosition;
23
91
  throttledLog;
24
92
  isScreenReaderEnabled;
25
93
  // Ignore last render after unmounting a tree to prevent empty output before exit
26
94
  isUnmounted;
95
+ isUnmounting;
27
96
  lastOutput;
97
+ lastOutputToRender;
28
98
  lastOutputHeight;
29
99
  lastTerminalWidth;
30
100
  container;
@@ -33,8 +103,14 @@ export default class Ink {
33
103
  // so that it's rerendered every time, not just new static parts, like in non-debug mode
34
104
  fullStaticOutput;
35
105
  exitPromise;
106
+ exitResult;
107
+ beforeExitHandler;
36
108
  restoreConsole;
37
109
  unsubscribeResize;
110
+ throttledOnRender;
111
+ hasPendingThrottledRender = false;
112
+ kittyProtocolEnabled = false;
113
+ cancelKittyDetection;
38
114
  constructor(options) {
39
115
  autoBind(this);
40
116
  this.options = options;
@@ -46,43 +122,64 @@ export default class Ink {
46
122
  const unthrottled = options.debug || this.isScreenReaderEnabled;
47
123
  const maxFps = options.maxFps ?? 30;
48
124
  const renderThrottleMs = maxFps > 0 ? Math.max(1, Math.ceil(1000 / maxFps)) : 0;
49
- this.rootNode.onRender = unthrottled
50
- ? this.onRender
51
- : throttle(this.onRender, renderThrottleMs, {
125
+ if (unthrottled) {
126
+ this.rootNode.onRender = this.onRender;
127
+ this.throttledOnRender = undefined;
128
+ }
129
+ else {
130
+ const throttled = throttle(this.onRender, renderThrottleMs, {
52
131
  leading: true,
53
132
  trailing: true,
54
133
  });
134
+ this.rootNode.onRender = () => {
135
+ this.hasPendingThrottledRender = true;
136
+ throttled();
137
+ };
138
+ this.throttledOnRender = throttled;
139
+ }
55
140
  this.rootNode.onImmediateRender = this.onRender;
56
141
  this.log = logUpdate.create(options.stdout, {
57
142
  incremental: options.incrementalRendering,
58
143
  });
144
+ this.cursorPosition = undefined;
59
145
  this.throttledLog = unthrottled
60
146
  ? this.log
61
- : throttle(this.log, undefined, {
147
+ : throttle((output) => {
148
+ const shouldWrite = this.log.willRender(output);
149
+ const sync = shouldSynchronize(this.options.stdout);
150
+ if (sync && shouldWrite) {
151
+ this.options.stdout.write(bsu);
152
+ }
153
+ this.log(output);
154
+ if (sync && shouldWrite) {
155
+ this.options.stdout.write(esu);
156
+ }
157
+ }, undefined, {
62
158
  leading: true,
63
159
  trailing: true,
64
160
  });
65
161
  // Ignore last render after unmounting a tree to prevent empty output before exit
66
162
  this.isUnmounted = false;
163
+ this.isUnmounting = false;
164
+ // Store concurrent mode setting
165
+ this.isConcurrent = options.concurrent ?? false;
67
166
  // Store last output to only rerender when needed
68
167
  this.lastOutput = '';
168
+ this.lastOutputToRender = '';
69
169
  this.lastOutputHeight = 0;
70
170
  this.lastTerminalWidth = this.getTerminalWidth();
71
171
  // This variable is used only in debug mode to store full static output
72
172
  // so that it's rerendered every time, not just new static parts, like in non-debug mode
73
173
  this.fullStaticOutput = '';
174
+ // Use ConcurrentRoot for concurrent mode, LegacyRoot for legacy mode
175
+ const rootTag = options.concurrent ? ConcurrentRoot : LegacyRoot;
74
176
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
75
- this.container = reconciler.createContainer(this.rootNode, LegacyRoot, null, false, null, 'id', () => { }, () => { }, () => { }, () => { }, null);
177
+ this.container = reconciler.createContainer(this.rootNode, rootTag, null, false, null, 'id', () => { }, () => { }, () => { }, () => { });
76
178
  // Unmount when process exits
77
179
  this.unsubscribeExit = signalExit(this.unmount, { alwaysLast: false });
78
- if (process.env['DEV'] === 'true') {
79
- reconciler.injectIntoDevTools({
80
- bundleType: 0,
81
- // Reporting React DOM's version, not Ink's
82
- // See https://github.com/facebook/react/issues/16666#issuecomment-532639905
83
- version: '16.13.1',
84
- rendererPackageName: 'ink',
85
- });
180
+ if (isDev()) {
181
+ // @ts-expect-error outdated types
182
+ reconciler.injectIntoDevTools();
86
183
  }
87
184
  if (options.patchConsole) {
88
185
  this.patchConsole();
@@ -93,11 +190,16 @@ export default class Ink {
93
190
  options.stdout.off('resize', this.resized);
94
191
  };
95
192
  }
193
+ this.initKittyKeyboard();
96
194
  }
97
195
  getTerminalWidth = () => {
98
196
  // The 'columns' property can be undefined or 0 when not using a TTY.
99
- // In that case we fall back to 80.
100
- return this.options.stdout.columns || 80;
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;
101
203
  };
102
204
  resized = () => {
103
205
  const currentWidth = this.getTerminalWidth();
@@ -105,6 +207,7 @@ export default class Ink {
105
207
  // We clear the screen when decreasing terminal width to prevent duplicate overlapping re-renders.
106
208
  this.log.clear();
107
209
  this.lastOutput = '';
210
+ this.lastOutputToRender = '';
108
211
  }
109
212
  this.calculateLayout();
110
213
  this.onRender();
@@ -113,12 +216,34 @@ export default class Ink {
113
216
  resolveExitPromise = () => { };
114
217
  rejectExitPromise = () => { };
115
218
  unsubscribeExit = () => { };
219
+ handleAppExit = (errorOrResult) => {
220
+ if (this.isUnmounted || this.isUnmounting) {
221
+ return;
222
+ }
223
+ if (isErrorInput(errorOrResult)) {
224
+ this.unmount(errorOrResult);
225
+ return;
226
+ }
227
+ this.exitResult = errorOrResult;
228
+ this.unmount();
229
+ };
230
+ setCursorPosition = (position) => {
231
+ this.cursorPosition = position;
232
+ this.log.setCursorPosition(position);
233
+ };
234
+ restoreLastOutput = () => {
235
+ // Clear() resets log-update's cursor state, so replay the latest cursor intent
236
+ // before restoring output after external stdout/stderr writes.
237
+ this.log.setCursorPosition(this.cursorPosition);
238
+ this.log(this.lastOutputToRender || this.lastOutput + '\n');
239
+ };
116
240
  calculateLayout = () => {
117
241
  const terminalWidth = this.getTerminalWidth();
118
242
  this.rootNode.yogaNode.setWidth(terminalWidth);
119
243
  this.rootNode.yogaNode.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
120
244
  };
121
245
  onRender = () => {
246
+ this.hasPendingThrottledRender = false;
122
247
  if (this.isUnmounted) {
123
248
  return;
124
249
  }
@@ -139,10 +264,15 @@ export default class Ink {
139
264
  this.options.stdout.write(staticOutput);
140
265
  }
141
266
  this.lastOutput = output;
267
+ this.lastOutputToRender = output + '\n';
142
268
  this.lastOutputHeight = outputHeight;
143
269
  return;
144
270
  }
145
271
  if (this.isScreenReaderEnabled) {
272
+ const sync = shouldSynchronize(this.options.stdout);
273
+ if (sync) {
274
+ this.options.stdout.write(bsu);
275
+ }
146
276
  if (hasStaticOutput) {
147
277
  // We need to erase the main output before writing new static output
148
278
  const erase = this.lastOutputHeight > 0
@@ -153,9 +283,12 @@ export default class Ink {
153
283
  this.lastOutputHeight = 0;
154
284
  }
155
285
  if (output === this.lastOutput && !hasStaticOutput) {
286
+ if (sync) {
287
+ this.options.stdout.write(esu);
288
+ }
156
289
  return;
157
290
  }
158
- const terminalWidth = this.options.stdout.columns || 80;
291
+ const terminalWidth = this.getTerminalWidth();
159
292
  const wrappedOutput = wrapAnsi(output, terminalWidth, {
160
293
  trim: false,
161
294
  hard: true,
@@ -171,41 +304,69 @@ export default class Ink {
171
304
  this.options.stdout.write(erase + wrappedOutput);
172
305
  }
173
306
  this.lastOutput = output;
307
+ this.lastOutputToRender = wrappedOutput;
174
308
  this.lastOutputHeight =
175
309
  wrappedOutput === '' ? 0 : wrappedOutput.split('\n').length;
310
+ if (sync) {
311
+ this.options.stdout.write(esu);
312
+ }
176
313
  return;
177
314
  }
178
315
  if (hasStaticOutput) {
179
316
  this.fullStaticOutput += staticOutput;
180
317
  }
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';
181
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
+ }
182
327
  this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + output);
183
328
  this.lastOutput = output;
329
+ this.lastOutputToRender = outputToRender;
184
330
  this.lastOutputHeight = outputHeight;
185
- this.log.sync(output);
331
+ this.log.sync(outputToRender);
332
+ if (sync) {
333
+ this.options.stdout.write(esu);
334
+ }
186
335
  return;
187
336
  }
188
337
  // To ensure static output is cleanly rendered before main output, clear main output first
189
338
  if (hasStaticOutput) {
339
+ const sync = shouldSynchronize(this.options.stdout);
340
+ if (sync) {
341
+ this.options.stdout.write(bsu);
342
+ }
190
343
  this.log.clear();
191
344
  this.options.stdout.write(staticOutput);
192
- this.log(output);
345
+ this.log(outputToRender);
346
+ if (sync) {
347
+ this.options.stdout.write(esu);
348
+ }
193
349
  }
194
- if (!hasStaticOutput && output !== this.lastOutput) {
195
- this.throttledLog(output);
350
+ else if (output !== this.lastOutput || this.log.isCursorDirty()) {
351
+ // ThrottledLog manages its own bsu/esu at actual write time
352
+ this.throttledLog(outputToRender);
196
353
  }
197
354
  this.lastOutput = output;
355
+ this.lastOutputToRender = outputToRender;
198
356
  this.lastOutputHeight = outputHeight;
199
357
  };
200
358
  render(node) {
201
359
  const tree = (React.createElement(AccessibilityContext.Provider, { value: { isScreenReaderEnabled: this.isScreenReaderEnabled } },
202
- React.createElement(App, { stdin: this.options.stdin, stdout: this.options.stdout, stderr: this.options.stderr, writeToStdout: this.writeToStdout, writeToStderr: this.writeToStderr, exitOnCtrlC: this.options.exitOnCtrlC, onExit: this.unmount }, node)));
203
- // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
204
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call
205
- reconciler.updateContainerSync(tree, this.container, null, noop);
206
- // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
207
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call
208
- reconciler.flushSyncWork();
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)));
361
+ if (this.options.concurrent) {
362
+ // Concurrent mode: use updateContainer (async scheduling)
363
+ reconciler.updateContainer(tree, this.container, null, noop);
364
+ }
365
+ else {
366
+ // Legacy mode: use updateContainerSync + flushSyncWork (sync)
367
+ reconciler.updateContainerSync(tree, this.container, null, noop);
368
+ reconciler.flushSyncWork();
369
+ }
209
370
  }
210
371
  writeToStdout(data) {
211
372
  if (this.isUnmounted) {
@@ -219,9 +380,16 @@ export default class Ink {
219
380
  this.options.stdout.write(data);
220
381
  return;
221
382
  }
383
+ const sync = shouldSynchronize(this.options.stdout);
384
+ if (sync) {
385
+ this.options.stdout.write(bsu);
386
+ }
222
387
  this.log.clear();
223
388
  this.options.stdout.write(data);
224
- this.log(this.lastOutput);
389
+ this.restoreLastOutput();
390
+ if (sync) {
391
+ this.options.stdout.write(esu);
392
+ }
225
393
  }
226
394
  writeToStderr(data) {
227
395
  if (this.isUnmounted) {
@@ -236,17 +404,57 @@ export default class Ink {
236
404
  this.options.stderr.write(data);
237
405
  return;
238
406
  }
407
+ const sync = shouldSynchronize(this.options.stdout);
408
+ if (sync) {
409
+ this.options.stdout.write(bsu);
410
+ }
239
411
  this.log.clear();
240
412
  this.options.stderr.write(data);
241
- this.log(this.lastOutput);
413
+ this.restoreLastOutput();
414
+ if (sync) {
415
+ this.options.stdout.write(esu);
416
+ }
242
417
  }
243
418
  // eslint-disable-next-line @typescript-eslint/ban-types
244
419
  unmount(error) {
245
- if (this.isUnmounted) {
420
+ if (this.isUnmounted || this.isUnmounting) {
246
421
  return;
247
422
  }
248
- this.calculateLayout();
249
- this.onRender();
423
+ this.isUnmounting = true;
424
+ if (this.beforeExitHandler) {
425
+ process.off('beforeExit', this.beforeExitHandler);
426
+ this.beforeExitHandler = undefined;
427
+ }
428
+ 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
+ };
441
+ // Clear any pending throttled render timer on unmount. When stdout is writable,
442
+ // flush so the final frame is emitted; otherwise cancel to avoid delayed callbacks.
443
+ settleThrottle(this.throttledOnRender ?? {});
444
+ if (canWriteToStdout) {
445
+ // If throttling is enabled and there is already a pending render, flushing above
446
+ // is sufficient. Also avoid calling onRender() again when static output already
447
+ // exists, as that can duplicate <Static> children output on exit (see issue #397).
448
+ const shouldRenderFinalFrame = !this.throttledOnRender ||
449
+ (!this.hasPendingThrottledRender && this.fullStaticOutput === '');
450
+ if (shouldRenderFinalFrame) {
451
+ this.calculateLayout();
452
+ this.onRender();
453
+ }
454
+ }
455
+ // Mark as unmounted after the final render but before stdout writes
456
+ // that could re-enter exit() via synchronous write callbacks.
457
+ this.isUnmounted = true;
250
458
  this.unsubscribeExit();
251
459
  if (typeof this.restoreConsole === 'function') {
252
460
  this.restoreConsole();
@@ -254,27 +462,71 @@ export default class Ink {
254
462
  if (typeof this.unsubscribeResize === 'function') {
255
463
  this.unsubscribeResize();
256
464
  }
257
- // CIs don't handle erasing ansi escapes well, so it's better to
258
- // only render last frame of non-static output
259
- if (isInCi) {
260
- this.options.stdout.write(this.lastOutput + '\n');
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');
477
+ }
478
+ catch {
479
+ // Best-effort: stdout may already be destroyed during shutdown
480
+ }
481
+ }
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');
486
+ }
487
+ else if (!this.options.debug) {
488
+ this.log.done();
489
+ }
261
490
  }
262
- else if (!this.options.debug) {
263
- this.log.done();
491
+ this.kittyProtocolEnabled = false;
492
+ if (this.options.concurrent) {
493
+ // Concurrent mode: use updateContainer (async scheduling)
494
+ reconciler.updateContainer(null, this.container, null, noop);
495
+ }
496
+ else {
497
+ // Legacy mode: use updateContainerSync + flushSyncWork (sync)
498
+ reconciler.updateContainerSync(null, this.container, null, noop);
499
+ reconciler.flushSyncWork();
264
500
  }
265
- this.isUnmounted = true;
266
- // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
267
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call
268
- reconciler.updateContainerSync(null, this.container, null, noop);
269
- // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
270
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call
271
- reconciler.flushSyncWork();
272
501
  instances.delete(this.options.stdout);
273
- if (error instanceof Error) {
274
- this.rejectExitPromise(error);
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);
275
527
  }
276
528
  else {
277
- this.resolveExitPromise();
529
+ setImmediate(resolveOrReject);
278
530
  }
279
531
  }
280
532
  async waitUntilExit() {
@@ -282,11 +534,20 @@ export default class Ink {
282
534
  this.resolveExitPromise = resolve;
283
535
  this.rejectExitPromise = reject;
284
536
  });
537
+ if (!this.beforeExitHandler) {
538
+ this.beforeExitHandler = () => {
539
+ this.unmount();
540
+ };
541
+ process.once('beforeExit', this.beforeExitHandler);
542
+ }
285
543
  return this.exitPromise;
286
544
  }
287
545
  clear() {
288
546
  if (!isInCi && !this.options.debug) {
289
547
  this.log.clear();
548
+ // Sync lastOutput so that unmount's final onRender
549
+ // sees it as unchanged and log-update skips it
550
+ this.log.sync(this.lastOutputToRender || this.lastOutput + '\n');
290
551
  }
291
552
  }
292
553
  patchConsole() {
@@ -305,5 +566,72 @@ export default class Ink {
305
566
  }
306
567
  });
307
568
  }
569
+ initKittyKeyboard() {
570
+ // Protocol is opt-in: if kittyKeyboard is not specified, do nothing
571
+ if (!this.options.kittyKeyboard) {
572
+ return;
573
+ }
574
+ const opts = this.options.kittyKeyboard;
575
+ const mode = opts.mode ?? 'auto';
576
+ if (mode === 'disabled' ||
577
+ !this.options.stdin.isTTY ||
578
+ !this.options.stdout.isTTY) {
579
+ return;
580
+ }
581
+ const flags = opts.flags ?? ['disambiguateEscapeCodes'];
582
+ if (mode === 'enabled') {
583
+ this.enableKittyProtocol(flags);
584
+ return;
585
+ }
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);
595
+ }
596
+ }
597
+ confirmKittySupport(flags) {
598
+ const { stdin, stdout } = this.options;
599
+ let responseBuffer = [];
600
+ const cleanup = () => {
601
+ this.cancelKittyDetection = undefined;
602
+ clearTimeout(timer);
603
+ stdin.removeListener('data', onData);
604
+ // Re-emit any buffered data that wasn't the protocol response,
605
+ // so it isn't lost from Ink's normal input pipeline.
606
+ // Clear responseBuffer afterwards to make cleanup idempotent.
607
+ const remaining = stripKittyQueryResponsesAndTrailingPartial(responseBuffer);
608
+ responseBuffer = [];
609
+ if (remaining.length > 0) {
610
+ stdin.unshift(Buffer.from(remaining));
611
+ }
612
+ };
613
+ const onData = (data) => {
614
+ const chunk = typeof data === 'string' ? Buffer.from(data) : data;
615
+ for (const byte of chunk) {
616
+ responseBuffer.push(byte);
617
+ }
618
+ if (hasCompleteKittyQueryResponse(responseBuffer)) {
619
+ cleanup();
620
+ if (!this.isUnmounted) {
621
+ this.enableKittyProtocol(flags);
622
+ }
623
+ }
624
+ };
625
+ // Attach listener before writing the query so that synchronous
626
+ // or immediate responses are not missed.
627
+ stdin.on('data', onData);
628
+ const timer = setTimeout(cleanup, 200);
629
+ this.cancelKittyDetection = cleanup;
630
+ stdout.write('\u001B[?u');
631
+ }
632
+ enableKittyProtocol(flags) {
633
+ this.options.stdout.write(`\u001B[>${resolveFlags(flags)}u`);
634
+ this.kittyProtocolEnabled = true;
635
+ }
308
636
  }
309
637
  //# sourceMappingURL=ink.js.map