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.
- package/build/ansi-tokenizer.d.ts +38 -0
- package/build/ansi-tokenizer.js +316 -0
- package/build/ansi-tokenizer.js.map +1 -0
- package/build/components/App.d.ts +8 -49
- package/build/components/App.js +293 -228
- package/build/components/App.js.map +1 -1
- package/build/components/AppContext.d.ts +5 -1
- package/build/components/AppContext.js.map +1 -1
- package/build/components/Cursor.d.ts +83 -0
- package/build/components/Cursor.js +53 -0
- package/build/components/Cursor.js.map +1 -0
- package/build/components/CursorContext.d.ts +11 -0
- package/build/components/CursorContext.js +8 -0
- package/build/components/CursorContext.js.map +1 -0
- package/build/components/ErrorBoundary.d.ts +18 -0
- package/build/components/ErrorBoundary.js +23 -0
- package/build/components/ErrorBoundary.js.map +1 -0
- package/build/cursor-helpers.d.ts +38 -0
- package/build/cursor-helpers.js +56 -0
- package/build/cursor-helpers.js.map +1 -0
- package/build/dom.js +5 -4
- package/build/dom.js.map +1 -1
- package/build/hooks/use-cursor.d.ts +12 -0
- package/build/hooks/use-cursor.js +29 -0
- package/build/hooks/use-cursor.js.map +1 -0
- package/build/hooks/use-input.d.ts +30 -0
- package/build/hooks/use-input.js +31 -2
- package/build/hooks/use-input.js.map +1 -1
- package/build/index.d.ts +6 -0
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/build/ink.d.ts +39 -3
- package/build/ink.js +377 -49
- package/build/ink.js.map +1 -1
- package/build/input-parser.d.ts +7 -0
- package/build/input-parser.js +154 -0
- package/build/input-parser.js.map +1 -0
- package/build/kitty-keyboard.d.ts +23 -0
- package/build/kitty-keyboard.js +32 -0
- package/build/kitty-keyboard.js.map +1 -0
- package/build/layout.d.ts +7 -0
- package/build/layout.js +33 -0
- package/build/layout.js.map +1 -0
- package/build/log-update.d.ts +6 -1
- package/build/log-update.js +163 -40
- package/build/log-update.js.map +1 -1
- package/build/output.d.ts +1 -0
- package/build/output.js +38 -5
- package/build/output.js.map +1 -1
- package/build/parse-keypress.d.ts +8 -0
- package/build/parse-keypress.js +270 -2
- package/build/parse-keypress.js.map +1 -1
- package/build/reconciler.js +23 -3
- package/build/reconciler.js.map +1 -1
- package/build/render-to-string.d.ts +38 -0
- package/build/render-to-string.js +115 -0
- package/build/render-to-string.js.map +1 -0
- package/build/render.d.ts +34 -1
- package/build/render.js +7 -2
- package/build/render.js.map +1 -1
- package/build/sanitize-ansi.d.ts +2 -0
- package/build/sanitize-ansi.js +27 -0
- package/build/sanitize-ansi.js.map +1 -0
- package/build/squash-text-nodes.js +2 -1
- package/build/squash-text-nodes.js.map +1 -1
- package/build/utils.d.ts +2 -0
- package/build/utils.js +4 -0
- package/build/utils.js.map +1 -0
- package/build/write-synchronized.d.ts +4 -0
- package/build/write-synchronized.js +7 -0
- package/build/write-synchronized.js.map +1 -0
- package/package.json +27 -21
- 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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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(
|
|
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,
|
|
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 (
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
//
|
|
100
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
345
|
+
this.log(outputToRender);
|
|
346
|
+
if (sync) {
|
|
347
|
+
this.options.stdout.write(esu);
|
|
348
|
+
}
|
|
193
349
|
}
|
|
194
|
-
if (
|
|
195
|
-
|
|
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,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
249
|
-
this.
|
|
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
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|