@alchemy.run/sigil 0.0.0-alpha.3 → 0.0.0-alpha.4
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/README.md +2 -4
- package/THIRD_PARTY_NOTICES.md +31 -22
- package/dist/ansi.d.ts +17 -14
- package/dist/ansi.js +7 -2
- package/dist/{devtools-BhYGjb7h.js → devtools-DbthxoD1.js} +22 -23
- package/dist/index.d.ts +124 -288
- package/dist/index.js +2540 -2953
- package/dist/{truncate-CBiyyZzw.js → truncate-Cr6xVFMa.js} +447 -273
- package/package.json +3 -9
- package/src/ansi/chalk.ts +8 -51
- package/src/ansi/cursor.ts +2 -4
- package/src/ansi/east-asian-width.ts +44 -0
- package/src/ansi/escapes.ts +4 -26
- package/src/ansi/index.ts +12 -14
- package/src/ansi/sgr.ts +2 -5
- package/src/ansi/slice.ts +1 -1
- package/src/ansi/string-width.ts +91 -206
- package/src/ansi/strip.ts +13 -33
- package/src/ansi/supports-color.ts +8 -14
- package/src/ansi/tokenize.ts +94 -166
- package/src/ansi/truncate.ts +5 -3
- package/src/ansi/wrap.ts +13 -14
- package/src/ansi-tokenizer.ts +1 -1
- package/src/colorize.ts +1 -1
- package/src/components/App.tsx +13 -14
- package/src/components/BackgroundContext.ts +2 -2
- package/src/components/Box.tsx +50 -59
- package/src/components/CursorContext.ts +1 -1
- package/src/components/ErrorBoundary.tsx +1 -1
- package/src/components/ErrorOverview.tsx +8 -7
- package/src/components/Spacer.tsx +1 -1
- package/src/components/Static.tsx +1 -1
- package/src/components/StderrContext.ts +0 -2
- package/src/components/StdinContext.ts +0 -1
- package/src/components/StdoutContext.ts +1 -3
- package/src/components/Text.tsx +6 -6
- package/src/components/Transform.tsx +1 -1
- package/src/cursor-position.ts +1 -1
- package/src/devtools.ts +84 -24
- package/src/dom.ts +7 -7
- package/src/env.ts +12 -0
- package/src/get-max-width.ts +1 -1
- package/src/global.d.ts +3 -4
- package/src/{boxes.ts → glyphs.ts} +17 -18
- package/src/hooks/use-animation.ts +1 -1
- package/src/hooks/use-app.ts +1 -1
- package/src/hooks/use-box-metrics.ts +1 -1
- package/src/hooks/use-cursor.ts +2 -2
- package/src/hooks/use-focus-manager.ts +1 -1
- package/src/hooks/use-focus.ts +5 -6
- package/src/hooks/use-input.ts +4 -4
- package/src/hooks/use-is-screen-reader-enabled.ts +1 -1
- package/src/hooks/use-paste.ts +2 -2
- package/src/hooks/use-stderr.ts +1 -1
- package/src/hooks/use-stdin.ts +1 -1
- package/src/hooks/use-stdout.ts +1 -1
- package/src/hooks/use-window-size.ts +2 -2
- package/src/index.ts +44 -44
- package/src/ink.tsx +535 -720
- package/src/input-parser.ts +1 -1
- package/src/instances.ts +1 -1
- package/src/kitty-keyboard.ts +128 -0
- package/src/log-update.ts +4 -4
- package/src/measure-element.ts +1 -1
- package/src/measure-text.ts +2 -2
- package/src/output.ts +4 -4
- package/src/parse-keypress.ts +4 -5
- package/src/parse-stack-line.ts +0 -1
- package/src/patch-console.ts +44 -0
- package/src/reconciler.ts +13 -60
- package/src/render-background.ts +3 -3
- package/src/render-border.ts +6 -6
- package/src/render-node-to-output.ts +13 -11
- package/src/render-to-string.ts +4 -4
- package/src/render.ts +9 -10
- package/src/renderer.ts +3 -3
- package/src/sanitize-ansi.ts +1 -1
- package/src/signal-exit.ts +4 -5
- package/src/squash-text-nodes.ts +2 -2
- package/src/stream.ts +2 -4
- package/src/styles.ts +6 -6
- package/src/terminal-size.ts +9 -8
- package/src/types.ts +1 -6
- package/src/utils.ts +2 -2
- package/src/wrap-text.ts +4 -4
- package/src/yoga/config.ts +2 -2
- package/src/yoga/core/absoluteLayout.ts +15 -15
- package/src/yoga/core/baseline.ts +3 -3
- package/src/yoga/core/cache.ts +4 -4
- package/src/yoga/core/calculateLayout.ts +33 -27
- package/src/yoga/core/config.ts +1 -1
- package/src/yoga/core/flexLine.ts +3 -3
- package/src/yoga/core/helpers.ts +3 -3
- package/src/yoga/core/layoutResults.ts +4 -4
- package/src/yoga/core/node.ts +16 -16
- package/src/yoga/core/pixelGrid.ts +4 -4
- package/src/yoga/core/style.ts +11 -11
- package/src/yoga/core/types.ts +2 -2
- package/src/yoga/index.ts +6 -6
- package/src/yoga/node.ts +8 -8
- package/src/ansi/widest-line.ts +0 -12
- package/src/auto-bind.ts +0 -41
- package/src/devtools-window-polyfill.ts +0 -73
- package/src/indent-string.ts +0 -16
- package/src/is-in-ci.ts +0 -7
- package/src/write-synchronized.ts +0 -9
package/src/ink.tsx
CHANGED
|
@@ -1,122 +1,37 @@
|
|
|
1
1
|
/** @jsxImportSource react */
|
|
2
|
-
import
|
|
2
|
+
import { setImmediate as yieldImmediate } from "node:timers/promises";
|
|
3
|
+
import { isNativeError } from "node:util/types";
|
|
3
4
|
|
|
4
5
|
import { type ReactNode } from "react";
|
|
5
6
|
import { type FiberRoot } from "react-reconciler";
|
|
6
7
|
import { LegacyRoot, ConcurrentRoot } from "react-reconciler/constants.js";
|
|
7
8
|
|
|
8
|
-
import { ansiEscapes, bsu, esu } from "
|
|
9
|
-
import { wrapAnsi } from "
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
import { instances } from "
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
9
|
+
import { ansiEscapes, bsu, esu } from "#/ansi/escapes.ts";
|
|
10
|
+
import { wrapAnsi } from "#/ansi/wrap.ts";
|
|
11
|
+
import { accessibilityContext as AccessibilityContext } from "#/components/AccessibilityContext.ts";
|
|
12
|
+
import { App } from "#/components/App.tsx";
|
|
13
|
+
import { type TerminalSuspension } from "#/components/AppContext.ts";
|
|
14
|
+
import { hideCursorEscape, showCursorEscape } from "#/cursor-position.ts";
|
|
15
|
+
import * as dom from "#/dom.ts";
|
|
16
|
+
import { isSigilDev, isInCi, isScreenReader, isTty, isWindows } from "#/env.ts";
|
|
17
|
+
import { instances } from "#/instances.ts";
|
|
18
|
+
import {
|
|
19
|
+
type KittyKeyboardOptions,
|
|
20
|
+
type KittyFlagName,
|
|
21
|
+
resolveFlags,
|
|
22
|
+
detectKittySupport,
|
|
23
|
+
} from "#/kitty-keyboard.ts";
|
|
24
|
+
import { logUpdate, type LogUpdate, type CursorPosition } from "#/log-update.ts";
|
|
25
|
+
import { patchConsole, patchStreamWrite } from "#/patch-console.ts";
|
|
26
|
+
import { reconciler } from "#/reconciler.ts";
|
|
27
|
+
import { renderer } from "#/renderer.ts";
|
|
28
|
+
import { signalExit } from "#/signal-exit.ts";
|
|
29
|
+
import { type OutputStream } from "#/stream.ts";
|
|
30
|
+
import { throttle, type Throttled } from "#/throttle.ts";
|
|
31
|
+
import { getWindowSize } from "#/utils.ts";
|
|
32
|
+
import { Yoga } from "#/yoga/index.ts";
|
|
29
33
|
|
|
30
34
|
const noop = () => {};
|
|
31
|
-
const textEncoder = new TextEncoder();
|
|
32
|
-
|
|
33
|
-
const yieldImmediate = async () =>
|
|
34
|
-
new Promise<void>((resolve) => {
|
|
35
|
-
setImmediate(resolve);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
const kittyQueryEscapeByte = 0x1b;
|
|
39
|
-
const kittyQueryOpenBracketByte = 0x5b;
|
|
40
|
-
const kittyQueryQuestionMarkByte = 0x3f;
|
|
41
|
-
const kittyQueryLetterByte = 0x75;
|
|
42
|
-
const zeroByte = 0x30;
|
|
43
|
-
const nineByte = 0x39;
|
|
44
|
-
|
|
45
|
-
type KittyQueryResponseMatch = { state: "complete"; endIndex: number } | { state: "partial" };
|
|
46
|
-
|
|
47
|
-
const isDigitByte = (byte: number): boolean => byte >= zeroByte && byte <= nineByte;
|
|
48
|
-
|
|
49
|
-
const matchKittyQueryResponse = (
|
|
50
|
-
buffer: number[],
|
|
51
|
-
startIndex: number,
|
|
52
|
-
): KittyQueryResponseMatch | undefined => {
|
|
53
|
-
if (
|
|
54
|
-
buffer[startIndex] !== kittyQueryEscapeByte ||
|
|
55
|
-
buffer[startIndex + 1] !== kittyQueryOpenBracketByte ||
|
|
56
|
-
buffer[startIndex + 2] !== kittyQueryQuestionMarkByte
|
|
57
|
-
) {
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
let index = startIndex + 3;
|
|
62
|
-
const digitsStartIndex = index;
|
|
63
|
-
while (index < buffer.length && isDigitByte(buffer[index]!)) {
|
|
64
|
-
index++;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (index === digitsStartIndex) {
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (index === buffer.length) {
|
|
72
|
-
return { state: "partial" };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (buffer[index] === kittyQueryLetterByte) {
|
|
76
|
-
return { state: "complete", endIndex: index };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
const hasCompleteKittyQueryResponse = (buffer: number[]): boolean => {
|
|
83
|
-
for (let index = 0; index < buffer.length; index++) {
|
|
84
|
-
const match = matchKittyQueryResponse(buffer, index);
|
|
85
|
-
if (match?.state === "complete") {
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return false;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const stripKittyQueryResponsesAndTrailingPartial = (buffer: number[]): number[] => {
|
|
94
|
-
const keptBytes: number[] = [];
|
|
95
|
-
let index = 0;
|
|
96
|
-
while (index < buffer.length) {
|
|
97
|
-
const match = matchKittyQueryResponse(buffer, index);
|
|
98
|
-
if (match?.state === "complete") {
|
|
99
|
-
index = match.endIndex + 1;
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (match?.state === "partial") {
|
|
104
|
-
break;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
keptBytes.push(buffer[index]!);
|
|
108
|
-
index++;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return keptBytes;
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
// Windows consoles scroll the buffer when the bottom-right cell is written,
|
|
115
|
-
// unlike xterm-like terminals which defer the wrap. That extra scroll
|
|
116
|
-
// desynchronizes the incremental erase used for frames that exactly fill the
|
|
117
|
-
// viewport, leaving stale copies of previous frames behind (#969). Keep the
|
|
118
|
-
// pre-7.0 behavior of fully clearing between fullscreen frames there.
|
|
119
|
-
const isWindowsConsole = process.platform === "win32";
|
|
120
35
|
|
|
121
36
|
const shouldClearTerminalForFrame = ({
|
|
122
37
|
isTTY,
|
|
@@ -149,7 +64,12 @@ const shouldClearTerminalForFrame = ({
|
|
|
149
64
|
const isLeavingFullscreen = wasOverflowing && nextOutputHeight < viewportRows;
|
|
150
65
|
const shouldClearOnUnmount = isUnmounting && wasFullscreen;
|
|
151
66
|
|
|
152
|
-
|
|
67
|
+
// Windows consoles scroll the buffer when the bottom-right cell is written,
|
|
68
|
+
// unlike xterm-like terminals which defer the wrap. That extra scroll
|
|
69
|
+
// desynchronizes the incremental erase used for frames that exactly fill the
|
|
70
|
+
// viewport, leaving stale copies of previous frames behind (#969). Keep the
|
|
71
|
+
// pre-7.0 behavior of fully clearing between fullscreen frames there.
|
|
72
|
+
if (isWindows && (wasFullscreen || isFullscreen)) {
|
|
153
73
|
return true;
|
|
154
74
|
}
|
|
155
75
|
|
|
@@ -166,7 +86,7 @@ const shouldClearTerminalForFrame = ({
|
|
|
166
86
|
};
|
|
167
87
|
|
|
168
88
|
const isErrorInput = (value: unknown): value is Error => {
|
|
169
|
-
return value instanceof Error ||
|
|
89
|
+
return value instanceof Error || isNativeError(value);
|
|
170
90
|
};
|
|
171
91
|
|
|
172
92
|
const getWritableStreamState = (stdout: OutputStream) => {
|
|
@@ -177,23 +97,28 @@ const getWritableStreamState = (stdout: OutputStream) => {
|
|
|
177
97
|
};
|
|
178
98
|
};
|
|
179
99
|
|
|
180
|
-
const settleThrottle =
|
|
181
|
-
|
|
100
|
+
const settleThrottle = <Arguments extends unknown[]>(
|
|
101
|
+
throttled: Throttled<Arguments> | undefined,
|
|
102
|
+
canWriteToStdout: boolean,
|
|
103
|
+
): void => {
|
|
104
|
+
if (!throttled) {
|
|
182
105
|
return;
|
|
183
106
|
}
|
|
184
107
|
|
|
185
|
-
const throttledValue = throttled as {
|
|
186
|
-
flush: () => void;
|
|
187
|
-
cancel?: () => void;
|
|
188
|
-
};
|
|
189
|
-
|
|
190
108
|
if (canWriteToStdout) {
|
|
191
|
-
|
|
192
|
-
} else
|
|
193
|
-
|
|
109
|
+
throttled.flush();
|
|
110
|
+
} else {
|
|
111
|
+
throttled.cancel();
|
|
194
112
|
}
|
|
195
113
|
};
|
|
196
114
|
|
|
115
|
+
// Best-effort write: streams may already be destroyed during shutdown.
|
|
116
|
+
const writeBestEffort = (stream: OutputStream, data: string): void => {
|
|
117
|
+
try {
|
|
118
|
+
stream.write(data);
|
|
119
|
+
} catch {}
|
|
120
|
+
};
|
|
121
|
+
|
|
197
122
|
/**
|
|
198
123
|
The origin of a chunk captured by `patchConsole`: a patched `console.*`
|
|
199
124
|
method, or a direct `stdout.write` / `stderr.write` call.
|
|
@@ -261,7 +186,6 @@ export type Options = {
|
|
|
261
186
|
) => boolean | undefined | void;
|
|
262
187
|
onRender?: (metrics: RenderMetrics) => void;
|
|
263
188
|
isScreenReaderEnabled?: boolean;
|
|
264
|
-
waitUntilExit?: () => Promise<unknown>;
|
|
265
189
|
maxFps?: number;
|
|
266
190
|
incrementalRendering?: boolean;
|
|
267
191
|
|
|
@@ -284,7 +208,7 @@ export type Options = {
|
|
|
284
208
|
/**
|
|
285
209
|
Override automatic interactive mode detection.
|
|
286
210
|
|
|
287
|
-
By default, Ink detects whether the environment is interactive based on CI detection (
|
|
211
|
+
By default, Ink detects whether the environment is interactive based on CI detection (the `CI` environment variable) and `stdout.isTTY`. Most users should not need to set this.
|
|
288
212
|
|
|
289
213
|
When non-interactive, Ink disables ANSI erase sequences, cursor manipulation, synchronized output, resize handling, and kitty keyboard auto-detection, writing only the final frame at unmount.
|
|
290
214
|
|
|
@@ -316,359 +240,345 @@ export type Options = {
|
|
|
316
240
|
alternateScreen?: boolean;
|
|
317
241
|
};
|
|
318
242
|
|
|
319
|
-
|
|
243
|
+
/**
|
|
244
|
+
A live Ink renderer for one stdout stream, created by `createInk`.
|
|
245
|
+
*/
|
|
246
|
+
export type Ink = {
|
|
247
|
+
/**
|
|
248
|
+
Replace the previous root node with a new one or update props of the current root node.
|
|
249
|
+
*/
|
|
250
|
+
render: (node: ReactNode) => void;
|
|
251
|
+
|
|
320
252
|
/**
|
|
321
|
-
|
|
253
|
+
Unmount the app and release the terminal.
|
|
322
254
|
*/
|
|
323
|
-
|
|
255
|
+
// eslint-disable-next-line @typescript-eslint/no-restricted-types
|
|
256
|
+
unmount: (error?: Error | number | null) => void;
|
|
324
257
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
258
|
+
/**
|
|
259
|
+
Returns a promise that settles when the app is unmounted.
|
|
260
|
+
*/
|
|
261
|
+
waitUntilExit: () => Promise<unknown>;
|
|
329
262
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
263
|
+
/**
|
|
264
|
+
Returns a promise that settles after pending render output is flushed to stdout.
|
|
265
|
+
*/
|
|
266
|
+
waitUntilRenderFlush: () => Promise<void>;
|
|
334
267
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
private lastTerminalHeight: number;
|
|
343
|
-
private readonly container: FiberRoot;
|
|
344
|
-
private readonly rootNode: dom.DOMElement;
|
|
345
|
-
// This variable is used only in debug mode to store full static output
|
|
346
|
-
// so that it's rerendered every time, not just new static parts, like in non-debug mode
|
|
347
|
-
private fullStaticOutput: string;
|
|
348
|
-
private readonly exitPromise!: Promise<unknown>;
|
|
349
|
-
private exitResult: unknown;
|
|
350
|
-
private beforeExitHandler?: () => void;
|
|
351
|
-
private restoreConsole?: () => void;
|
|
268
|
+
/**
|
|
269
|
+
Clear output.
|
|
270
|
+
*/
|
|
271
|
+
clear: () => void;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export const createInk = (options: Options): Ink => {
|
|
352
275
|
// Set when patchConsole is "stdio": the real streams whose write is patched.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
// mode and bracketed paste state.
|
|
367
|
-
private pauseInput?: () => void;
|
|
368
|
-
private resumeInput?: () => void;
|
|
369
|
-
|
|
370
|
-
constructor(options: Options) {
|
|
371
|
-
autoBind(this);
|
|
372
|
-
|
|
373
|
-
if (options.patchConsole === "stdio") {
|
|
374
|
-
// Keep the real streams for patching (and for the instance registry,
|
|
375
|
-
// which is keyed by the stream passed to render()), and render
|
|
376
|
-
// through passthrough facades that bypass the capture.
|
|
377
|
-
this.captureTargets = { stdout: options.stdout, stderr: options.stderr };
|
|
378
|
-
options = {
|
|
379
|
-
...options,
|
|
380
|
-
stdout: createRenderPassthrough(options.stdout),
|
|
381
|
-
stderr: createRenderPassthrough(options.stderr),
|
|
382
|
-
};
|
|
383
|
-
}
|
|
276
|
+
let captureTargets: { stdout: OutputStream; stderr: OutputStream } | undefined;
|
|
277
|
+
|
|
278
|
+
if (options.patchConsole === "stdio") {
|
|
279
|
+
// Keep the real streams for patching (and for the instance registry,
|
|
280
|
+
// which is keyed by the stream passed to render()), and render
|
|
281
|
+
// through passthrough facades that bypass the capture.
|
|
282
|
+
captureTargets = { stdout: options.stdout, stderr: options.stderr };
|
|
283
|
+
options = {
|
|
284
|
+
...options,
|
|
285
|
+
stdout: createRenderPassthrough(options.stdout),
|
|
286
|
+
stderr: createRenderPassthrough(options.stderr),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
384
289
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
this.rootNode.onComputeLayout = this.calculateLayout;
|
|
290
|
+
const rootNode = dom.createNode("ink-root");
|
|
291
|
+
rootNode.onComputeLayout = calculateLayout;
|
|
388
292
|
|
|
389
|
-
|
|
390
|
-
options.isScreenReaderEnabled ?? process.env["SIGIL_SCREEN_READER"] === "true";
|
|
293
|
+
const isScreenReaderEnabled = options.isScreenReaderEnabled ?? isScreenReader;
|
|
391
294
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
295
|
+
// CI detection takes precedence: even a TTY stdout in CI defaults to non-interactive.
|
|
296
|
+
// Using Boolean(isTTY) (rather than an 'in' guard) correctly handles piped streams
|
|
297
|
+
// where the property is absent (e.g. `node app.js | cat`).
|
|
298
|
+
const interactive = options.interactive ?? (!isInCi && Boolean(options.stdout.isTTY));
|
|
396
299
|
|
|
397
|
-
|
|
300
|
+
let alternateScreen = false;
|
|
398
301
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
302
|
+
const unthrottled = options.debug || isScreenReaderEnabled;
|
|
303
|
+
const maxFps = options.maxFps ?? 30;
|
|
304
|
+
// Treat non-positive maxFps as an internal fallback case, not a supported
|
|
305
|
+
// "disable throttling" mode. Keep animation scheduling on a normal cadence
|
|
306
|
+
// so future changes don't accidentally reintroduce zero-delay loops.
|
|
307
|
+
const frameIntervalMs = maxFps > 0 ? Math.max(1, Math.ceil(1000 / maxFps)) : 0;
|
|
308
|
+
const renderThrottleMs = unthrottled ? 0 : frameIntervalMs;
|
|
406
309
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
this.throttledOnRender = undefined;
|
|
410
|
-
} else {
|
|
411
|
-
const throttled = throttle(this.onRender, renderThrottleMs);
|
|
412
|
-
this.rootNode.onRender = () => {
|
|
413
|
-
this.hasPendingThrottledRender = true;
|
|
414
|
-
throttled();
|
|
415
|
-
};
|
|
310
|
+
let hasPendingThrottledRender = false;
|
|
311
|
+
let throttledOnRender: Throttled<never[]> | undefined;
|
|
416
312
|
|
|
417
|
-
|
|
418
|
-
|
|
313
|
+
if (unthrottled) {
|
|
314
|
+
rootNode.onRender = onRender;
|
|
315
|
+
} else {
|
|
316
|
+
const throttled = throttle(onRender, frameIntervalMs);
|
|
317
|
+
rootNode.onRender = () => {
|
|
318
|
+
hasPendingThrottledRender = true;
|
|
319
|
+
throttled();
|
|
320
|
+
};
|
|
419
321
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
this.log = logUpdate.create(options.stdout, {
|
|
423
|
-
incremental: options.incrementalRendering,
|
|
424
|
-
});
|
|
425
|
-
this.cursorPosition = undefined;
|
|
426
|
-
this.throttledLog = unthrottled
|
|
427
|
-
? this.log
|
|
428
|
-
: throttle((output: string) => {
|
|
429
|
-
const shouldWrite = this.log.willRender(output);
|
|
430
|
-
const sync = this.shouldSync();
|
|
431
|
-
if (sync && shouldWrite) {
|
|
432
|
-
this.options.stdout.write(bsu);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
this.log(output);
|
|
436
|
-
|
|
437
|
-
if (sync && shouldWrite) {
|
|
438
|
-
this.options.stdout.write(esu);
|
|
439
|
-
}
|
|
440
|
-
});
|
|
322
|
+
throttledOnRender = throttled;
|
|
323
|
+
}
|
|
441
324
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
// This variable is used only in debug mode to store full static output
|
|
457
|
-
// so that it's rerendered every time, not just new static parts, like in non-debug mode
|
|
458
|
-
this.fullStaticOutput = "";
|
|
459
|
-
|
|
460
|
-
// Use ConcurrentRoot for concurrent mode, LegacyRoot for legacy mode
|
|
461
|
-
const rootTag = options.concurrent ? ConcurrentRoot : LegacyRoot;
|
|
462
|
-
|
|
463
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
464
|
-
this.container = reconciler.createContainer(
|
|
465
|
-
this.rootNode,
|
|
466
|
-
rootTag,
|
|
467
|
-
null,
|
|
468
|
-
false,
|
|
469
|
-
null,
|
|
470
|
-
"id",
|
|
471
|
-
() => {},
|
|
472
|
-
() => {},
|
|
473
|
-
() => {},
|
|
474
|
-
() => {},
|
|
475
|
-
);
|
|
325
|
+
rootNode.onImmediateRender = onRender;
|
|
326
|
+
rootNode.onStaticChange = handleStaticChange;
|
|
327
|
+
const log = logUpdate.create(options.stdout, {
|
|
328
|
+
incremental: options.incrementalRendering,
|
|
329
|
+
});
|
|
330
|
+
let cursorPosition: CursorPosition | undefined;
|
|
331
|
+
const logThrottle = unthrottled
|
|
332
|
+
? undefined
|
|
333
|
+
: throttle((output: string) => {
|
|
334
|
+
const shouldWrite = log.willRender(output);
|
|
335
|
+
const sync = shouldSync();
|
|
336
|
+
if (sync && shouldWrite) {
|
|
337
|
+
options.stdout.write(bsu);
|
|
338
|
+
}
|
|
476
339
|
|
|
477
|
-
|
|
478
|
-
this.unsubscribeExit = signalExit(this.unmount.bind(this), { alwaysLast: false });
|
|
340
|
+
log(output);
|
|
479
341
|
|
|
480
|
-
|
|
342
|
+
if (sync && shouldWrite) {
|
|
343
|
+
options.stdout.write(esu);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
const throttledLog: LogUpdate | Throttled<[output: string]> = logThrottle ?? log;
|
|
481
347
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
348
|
+
// Ignore last render after unmounting a tree to prevent empty output before exit
|
|
349
|
+
let isUnmounted = false;
|
|
350
|
+
let isUnmounting = false;
|
|
486
351
|
|
|
487
|
-
|
|
488
|
-
this.patchConsole();
|
|
489
|
-
}
|
|
352
|
+
const isConcurrent = options.concurrent ?? false;
|
|
490
353
|
|
|
491
|
-
|
|
492
|
-
|
|
354
|
+
// Store last output to only rerender when needed
|
|
355
|
+
let lastOutput = "";
|
|
356
|
+
let lastOutputToRender = "";
|
|
357
|
+
let lastOutputHeight = 0;
|
|
358
|
+
let lastTerminalWidth = getWindowSize(options.stdout).columns;
|
|
359
|
+
let lastTerminalHeight = getWindowSize(options.stdout).rows;
|
|
493
360
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
}
|
|
361
|
+
// This variable is used only in debug mode to store full static output
|
|
362
|
+
// so that it's rerendered every time, not just new static parts, like in non-debug mode
|
|
363
|
+
let fullStaticOutput = "";
|
|
498
364
|
|
|
499
|
-
|
|
365
|
+
let exitResult: unknown;
|
|
366
|
+
let beforeExitHandler: (() => void) | undefined;
|
|
367
|
+
let restoreConsole: (() => void) | undefined;
|
|
368
|
+
// Partial trailing lines from captured direct writes, held until a newline.
|
|
369
|
+
const capturedStdioTails = { stdout: "", stderr: "" };
|
|
370
|
+
let unsubscribeResize: (() => void) | undefined;
|
|
371
|
+
let kittyProtocolEnabled = false;
|
|
372
|
+
let kittyFlags: KittyFlagName[] | undefined;
|
|
373
|
+
let cancelKittyDetection: (() => void) | undefined;
|
|
374
|
+
let nextRenderCommit: { promise: Promise<void>; resolve: () => void } | undefined;
|
|
375
|
+
// Set while suspendTerminal() has handed the terminal to a child process.
|
|
376
|
+
let isSuspended = false;
|
|
377
|
+
// Input pause/resume hooks registered by the App component, which owns raw
|
|
378
|
+
// mode and bracketed paste state.
|
|
379
|
+
let pauseInput: (() => void) | undefined;
|
|
380
|
+
let resumeInput: (() => void) | undefined;
|
|
381
|
+
|
|
382
|
+
// Use ConcurrentRoot for concurrent mode, LegacyRoot for legacy mode
|
|
383
|
+
const rootTag = isConcurrent ? ConcurrentRoot : LegacyRoot;
|
|
384
|
+
|
|
385
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
386
|
+
const container: FiberRoot = reconciler.createContainer(
|
|
387
|
+
rootNode,
|
|
388
|
+
rootTag,
|
|
389
|
+
null,
|
|
390
|
+
false,
|
|
391
|
+
null,
|
|
392
|
+
"id",
|
|
393
|
+
() => {},
|
|
394
|
+
() => {},
|
|
395
|
+
() => {},
|
|
396
|
+
() => {},
|
|
397
|
+
);
|
|
500
398
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
this.rejectExitPromise = reject;
|
|
504
|
-
});
|
|
505
|
-
// Prevent global unhandled-rejection crashes when app code exits with an
|
|
506
|
-
// error but consumers never call waitUntilExit().
|
|
399
|
+
// Unmount when process exits
|
|
400
|
+
const unsubscribeExit = signalExit(unmount, { alwaysLast: false });
|
|
507
401
|
|
|
508
|
-
|
|
402
|
+
setAlternateScreen(Boolean(options.alternateScreen));
|
|
403
|
+
|
|
404
|
+
// @ts-expect-error outdated types
|
|
405
|
+
if (isSigilDev) reconciler.injectIntoDevTools();
|
|
406
|
+
|
|
407
|
+
if (options.patchConsole) installConsolePatch();
|
|
408
|
+
|
|
409
|
+
if (interactive) {
|
|
410
|
+
options.stdout.on("resize", resized);
|
|
411
|
+
|
|
412
|
+
unsubscribeResize = () => {
|
|
413
|
+
options.stdout.off("resize", resized);
|
|
414
|
+
};
|
|
509
415
|
}
|
|
510
416
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
417
|
+
initKittyKeyboard();
|
|
418
|
+
|
|
419
|
+
const {
|
|
420
|
+
promise: exitPromise,
|
|
421
|
+
resolve: resolveExitPromise,
|
|
422
|
+
reject: rejectExitPromise,
|
|
423
|
+
} = Promise.withResolvers<unknown>();
|
|
424
|
+
// Prevent global unhandled-rejection crashes when app code exits with an
|
|
425
|
+
// error but consumers never call waitUntilExit().
|
|
426
|
+
|
|
427
|
+
void exitPromise.catch(noop);
|
|
428
|
+
|
|
429
|
+
function resized(): void {
|
|
430
|
+
const currentWidth = getWindowSize(options.stdout).columns;
|
|
431
|
+
const currentHeight = getWindowSize(options.stdout).rows;
|
|
514
432
|
|
|
515
433
|
// A width decrease rewraps lines and any height change moves content
|
|
516
434
|
// through scrollback, so the incremental render state no longer
|
|
517
435
|
// matches the screen. Erase what is still visible and force the next
|
|
518
436
|
// render to be a full rewrite instead of an incremental diff that
|
|
519
437
|
// would skip "unchanged" lines over stale screen content.
|
|
520
|
-
if (currentWidth <
|
|
438
|
+
if (currentWidth < lastTerminalWidth || currentHeight !== lastTerminalHeight) {
|
|
521
439
|
// `log.clear()` erases the full previous frame line count from
|
|
522
440
|
// the cursor upward — after a height grow that also covers frame
|
|
523
441
|
// lines the emulator pulled back from scrollback, so no extra
|
|
524
442
|
// erase is needed for them.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
443
|
+
log.clear();
|
|
444
|
+
lastOutput = "";
|
|
445
|
+
lastOutputToRender = "";
|
|
528
446
|
// Also forget the previous frame height: it described a frame
|
|
529
447
|
// that no longer exists on screen, and letting it flow into
|
|
530
448
|
// shouldClearTerminalForFrame would trigger a scrollback-erasing
|
|
531
449
|
// clearTerminal on a height shrink.
|
|
532
|
-
|
|
450
|
+
lastOutputHeight = 0;
|
|
533
451
|
}
|
|
534
452
|
|
|
535
|
-
|
|
536
|
-
dom.emitLayoutListeners(
|
|
537
|
-
|
|
453
|
+
calculateLayout();
|
|
454
|
+
dom.emitLayoutListeners(rootNode);
|
|
455
|
+
onRender();
|
|
538
456
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
resolveExitPromise: (result?: unknown) => void = () => {};
|
|
544
|
-
rejectExitPromise: (reason?: Error) => void = () => {};
|
|
545
|
-
unsubscribeExit: () => void = () => {};
|
|
457
|
+
lastTerminalWidth = currentWidth;
|
|
458
|
+
lastTerminalHeight = currentHeight;
|
|
459
|
+
}
|
|
546
460
|
|
|
547
|
-
handleAppExit
|
|
548
|
-
if (
|
|
461
|
+
function handleAppExit(errorOrResult?: unknown): void {
|
|
462
|
+
if (isUnmounted || isUnmounting) {
|
|
549
463
|
return;
|
|
550
464
|
}
|
|
551
465
|
|
|
552
466
|
if (isErrorInput(errorOrResult)) {
|
|
553
|
-
|
|
467
|
+
unmount(errorOrResult);
|
|
554
468
|
return;
|
|
555
469
|
}
|
|
556
470
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
471
|
+
exitResult = errorOrResult;
|
|
472
|
+
unmount();
|
|
473
|
+
}
|
|
560
474
|
|
|
561
|
-
setCursorPosition
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
}
|
|
475
|
+
function setCursorPosition(position: CursorPosition | undefined): void {
|
|
476
|
+
cursorPosition = position;
|
|
477
|
+
log.setCursorPosition(position);
|
|
478
|
+
}
|
|
565
479
|
|
|
566
|
-
restoreLastOutput
|
|
567
|
-
if (!
|
|
480
|
+
function restoreLastOutput(): void {
|
|
481
|
+
if (!interactive) {
|
|
568
482
|
return;
|
|
569
483
|
}
|
|
570
484
|
|
|
571
485
|
// Clear() resets log-update's cursor state, so replay the latest cursor intent
|
|
572
486
|
// before restoring output after external stdout/stderr writes.
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
}
|
|
487
|
+
log.setCursorPosition(cursorPosition);
|
|
488
|
+
log(lastOutputToRender || lastOutput + "\n");
|
|
489
|
+
}
|
|
576
490
|
|
|
577
|
-
calculateLayout
|
|
578
|
-
const terminalWidth = getWindowSize(
|
|
491
|
+
function calculateLayout(): void {
|
|
492
|
+
const terminalWidth = getWindowSize(options.stdout).columns;
|
|
579
493
|
|
|
580
|
-
|
|
494
|
+
rootNode.yogaNode!.setWidth(terminalWidth);
|
|
581
495
|
|
|
582
|
-
|
|
583
|
-
}
|
|
496
|
+
rootNode.yogaNode!.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
|
|
497
|
+
}
|
|
584
498
|
|
|
585
499
|
// Resets `fullStaticOutput` when the <Static> identity changes so stale items from a previous instance are not replayed on future rewrites.
|
|
586
|
-
handleStaticChange
|
|
587
|
-
|
|
588
|
-
}
|
|
500
|
+
function handleStaticChange(): void {
|
|
501
|
+
fullStaticOutput = "";
|
|
502
|
+
}
|
|
589
503
|
|
|
590
|
-
onRender
|
|
591
|
-
|
|
504
|
+
function onRender(): void {
|
|
505
|
+
hasPendingThrottledRender = false;
|
|
592
506
|
|
|
593
|
-
if (
|
|
507
|
+
if (isUnmounted) {
|
|
594
508
|
return;
|
|
595
509
|
}
|
|
596
510
|
|
|
597
511
|
// While suspended, the terminal belongs to a child process. Discard queued
|
|
598
512
|
// renders; resume() forces a full redraw once Ink reclaims the terminal.
|
|
599
513
|
// Resolve any awaited render commit so callers don't hang during suspension.
|
|
600
|
-
if (
|
|
601
|
-
if (
|
|
602
|
-
|
|
603
|
-
|
|
514
|
+
if (isSuspended) {
|
|
515
|
+
if (nextRenderCommit) {
|
|
516
|
+
nextRenderCommit.resolve();
|
|
517
|
+
nextRenderCommit = undefined;
|
|
604
518
|
}
|
|
605
519
|
|
|
606
520
|
return;
|
|
607
521
|
}
|
|
608
522
|
|
|
609
|
-
if (
|
|
610
|
-
|
|
611
|
-
|
|
523
|
+
if (nextRenderCommit) {
|
|
524
|
+
nextRenderCommit.resolve();
|
|
525
|
+
nextRenderCommit = undefined;
|
|
612
526
|
}
|
|
613
527
|
|
|
614
528
|
const startTime = performance.now();
|
|
615
|
-
const { output, outputHeight, staticOutput } =
|
|
616
|
-
this.rootNode,
|
|
617
|
-
this.isScreenReaderEnabled,
|
|
618
|
-
);
|
|
529
|
+
const { output, outputHeight, staticOutput } = renderer(rootNode, isScreenReaderEnabled);
|
|
619
530
|
|
|
620
|
-
|
|
531
|
+
options.onRender?.({ renderTime: performance.now() - startTime });
|
|
621
532
|
|
|
622
533
|
// If <Static> output isn't empty, it means new children have been added to it
|
|
623
534
|
const hasStaticOutput = staticOutput && staticOutput !== "\n";
|
|
624
535
|
|
|
625
|
-
if (
|
|
536
|
+
if (options.debug) {
|
|
626
537
|
if (hasStaticOutput) {
|
|
627
|
-
|
|
538
|
+
fullStaticOutput += staticOutput;
|
|
628
539
|
}
|
|
629
540
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
541
|
+
lastOutput = output;
|
|
542
|
+
lastOutputToRender = output;
|
|
543
|
+
lastOutputHeight = outputHeight;
|
|
544
|
+
options.stdout.write(fullStaticOutput + output);
|
|
634
545
|
return;
|
|
635
546
|
}
|
|
636
547
|
|
|
637
|
-
if (!
|
|
548
|
+
if (!interactive) {
|
|
638
549
|
if (hasStaticOutput) {
|
|
639
|
-
|
|
550
|
+
options.stdout.write(staticOutput);
|
|
640
551
|
}
|
|
641
552
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
553
|
+
lastOutput = output;
|
|
554
|
+
lastOutputToRender = output + "\n";
|
|
555
|
+
lastOutputHeight = outputHeight;
|
|
645
556
|
return;
|
|
646
557
|
}
|
|
647
558
|
|
|
648
|
-
if (
|
|
649
|
-
const sync =
|
|
559
|
+
if (isScreenReaderEnabled) {
|
|
560
|
+
const sync = shouldSync();
|
|
650
561
|
if (sync) {
|
|
651
|
-
|
|
562
|
+
options.stdout.write(bsu);
|
|
652
563
|
}
|
|
653
564
|
|
|
654
565
|
if (hasStaticOutput) {
|
|
655
566
|
// We need to erase the main output before writing new static output
|
|
656
|
-
const erase =
|
|
657
|
-
|
|
658
|
-
this.options.stdout.write(erase + staticOutput);
|
|
567
|
+
const erase = lastOutputHeight > 0 ? ansiEscapes.eraseLines(lastOutputHeight) : "";
|
|
568
|
+
options.stdout.write(erase + staticOutput);
|
|
659
569
|
// After erasing, the last output is gone, so we should reset its height
|
|
660
|
-
|
|
570
|
+
lastOutputHeight = 0;
|
|
661
571
|
}
|
|
662
572
|
|
|
663
|
-
if (output ===
|
|
573
|
+
if (output === lastOutput && !hasStaticOutput) {
|
|
664
574
|
if (sync) {
|
|
665
|
-
|
|
575
|
+
options.stdout.write(esu);
|
|
666
576
|
}
|
|
667
577
|
|
|
668
578
|
return;
|
|
669
579
|
}
|
|
670
580
|
|
|
671
|
-
const terminalWidth = getWindowSize(
|
|
581
|
+
const terminalWidth = getWindowSize(options.stdout).columns;
|
|
672
582
|
|
|
673
583
|
const wrappedOutput = wrapAnsi(output, terminalWidth, {
|
|
674
584
|
trim: false,
|
|
@@ -677,204 +587,201 @@ export class Ink {
|
|
|
677
587
|
|
|
678
588
|
// If we haven't erased yet, do it now.
|
|
679
589
|
if (hasStaticOutput) {
|
|
680
|
-
|
|
590
|
+
options.stdout.write(wrappedOutput);
|
|
681
591
|
} else {
|
|
682
|
-
const erase =
|
|
683
|
-
|
|
684
|
-
this.options.stdout.write(erase + wrappedOutput);
|
|
592
|
+
const erase = lastOutputHeight > 0 ? ansiEscapes.eraseLines(lastOutputHeight) : "";
|
|
593
|
+
options.stdout.write(erase + wrappedOutput);
|
|
685
594
|
}
|
|
686
595
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
596
|
+
lastOutput = output;
|
|
597
|
+
lastOutputToRender = wrappedOutput;
|
|
598
|
+
lastOutputHeight = wrappedOutput === "" ? 0 : wrappedOutput.split("\n").length;
|
|
690
599
|
|
|
691
600
|
if (sync) {
|
|
692
|
-
|
|
601
|
+
options.stdout.write(esu);
|
|
693
602
|
}
|
|
694
603
|
|
|
695
604
|
return;
|
|
696
605
|
}
|
|
697
606
|
|
|
698
607
|
if (hasStaticOutput) {
|
|
699
|
-
|
|
608
|
+
fullStaticOutput += staticOutput;
|
|
700
609
|
}
|
|
701
610
|
|
|
702
|
-
|
|
703
|
-
}
|
|
611
|
+
renderInteractiveFrame(output, outputHeight, hasStaticOutput ? staticOutput : "");
|
|
612
|
+
}
|
|
704
613
|
|
|
705
|
-
render(node: ReactNode): void {
|
|
614
|
+
function render(node: ReactNode): void {
|
|
706
615
|
const tree = (
|
|
707
|
-
<AccessibilityContext.Provider value={{ isScreenReaderEnabled
|
|
616
|
+
<AccessibilityContext.Provider value={{ isScreenReaderEnabled }}>
|
|
708
617
|
<App
|
|
709
|
-
stdin={
|
|
710
|
-
stdout={
|
|
711
|
-
stderr={
|
|
712
|
-
exitOnCtrlC={
|
|
713
|
-
interactive={
|
|
714
|
-
renderThrottleMs={
|
|
715
|
-
writeToStdout={
|
|
716
|
-
writeToStderr={
|
|
717
|
-
setCursorPosition={
|
|
718
|
-
onExit={
|
|
719
|
-
onWaitUntilRenderFlush={
|
|
720
|
-
onSuspendTerminal={
|
|
721
|
-
onRegisterInputControl={
|
|
618
|
+
stdin={options.stdin}
|
|
619
|
+
stdout={options.stdout}
|
|
620
|
+
stderr={options.stderr}
|
|
621
|
+
exitOnCtrlC={options.exitOnCtrlC}
|
|
622
|
+
interactive={interactive}
|
|
623
|
+
renderThrottleMs={renderThrottleMs}
|
|
624
|
+
writeToStdout={writeToStdout}
|
|
625
|
+
writeToStderr={writeToStderr}
|
|
626
|
+
setCursorPosition={setCursorPosition}
|
|
627
|
+
onExit={handleAppExit}
|
|
628
|
+
onWaitUntilRenderFlush={waitUntilRenderFlush}
|
|
629
|
+
onSuspendTerminal={suspendTerminal}
|
|
630
|
+
onRegisterInputControl={registerInputControl}
|
|
722
631
|
>
|
|
723
632
|
{node}
|
|
724
633
|
</App>
|
|
725
634
|
</AccessibilityContext.Provider>
|
|
726
635
|
);
|
|
727
636
|
|
|
728
|
-
if (
|
|
637
|
+
if (isConcurrent) {
|
|
729
638
|
// Concurrent mode: use updateContainer (async scheduling)
|
|
730
|
-
reconciler.updateContainer(tree,
|
|
639
|
+
reconciler.updateContainer(tree, container, null, noop);
|
|
731
640
|
} else {
|
|
732
641
|
// Legacy mode: use updateContainerSync + flushSyncWork (sync)
|
|
733
|
-
reconciler.updateContainerSync(tree,
|
|
642
|
+
reconciler.updateContainerSync(tree, container, null, noop);
|
|
734
643
|
reconciler.flushSyncWork();
|
|
735
644
|
}
|
|
736
645
|
}
|
|
737
646
|
|
|
738
|
-
writeToStdout(data: string): void {
|
|
739
|
-
if (
|
|
647
|
+
function writeToStdout(data: string): void {
|
|
648
|
+
if (isUnmounted) {
|
|
740
649
|
return;
|
|
741
650
|
}
|
|
742
651
|
|
|
743
652
|
// While suspended, the terminal belongs to a child process. Don't erase or
|
|
744
653
|
// repaint Ink's frame around console output; the forced redraw on resume
|
|
745
654
|
// restores the screen.
|
|
746
|
-
if (
|
|
655
|
+
if (isSuspended) {
|
|
747
656
|
return;
|
|
748
657
|
}
|
|
749
658
|
|
|
750
|
-
if (
|
|
751
|
-
|
|
659
|
+
if (options.debug) {
|
|
660
|
+
options.stdout.write(data + fullStaticOutput + lastOutput);
|
|
752
661
|
return;
|
|
753
662
|
}
|
|
754
663
|
|
|
755
|
-
if (!
|
|
756
|
-
|
|
664
|
+
if (!interactive) {
|
|
665
|
+
options.stdout.write(data);
|
|
757
666
|
return;
|
|
758
667
|
}
|
|
759
668
|
|
|
760
|
-
const sync =
|
|
669
|
+
const sync = shouldSync();
|
|
761
670
|
if (sync) {
|
|
762
|
-
|
|
671
|
+
options.stdout.write(bsu);
|
|
763
672
|
}
|
|
764
673
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
674
|
+
log.clear();
|
|
675
|
+
options.stdout.write(data);
|
|
676
|
+
restoreLastOutput();
|
|
768
677
|
|
|
769
678
|
if (sync) {
|
|
770
|
-
|
|
679
|
+
options.stdout.write(esu);
|
|
771
680
|
}
|
|
772
681
|
}
|
|
773
682
|
|
|
774
|
-
writeToStderr(data: string): void {
|
|
775
|
-
if (
|
|
683
|
+
function writeToStderr(data: string): void {
|
|
684
|
+
if (isUnmounted) {
|
|
776
685
|
return;
|
|
777
686
|
}
|
|
778
687
|
|
|
779
688
|
// See writeToStdout: stay off the terminal while suspended.
|
|
780
|
-
if (
|
|
689
|
+
if (isSuspended) {
|
|
781
690
|
return;
|
|
782
691
|
}
|
|
783
692
|
|
|
784
|
-
if (
|
|
785
|
-
|
|
786
|
-
|
|
693
|
+
if (options.debug) {
|
|
694
|
+
options.stderr.write(data);
|
|
695
|
+
options.stdout.write(fullStaticOutput + lastOutput);
|
|
787
696
|
return;
|
|
788
697
|
}
|
|
789
698
|
|
|
790
|
-
if (!
|
|
791
|
-
|
|
699
|
+
if (!interactive) {
|
|
700
|
+
options.stderr.write(data);
|
|
792
701
|
return;
|
|
793
702
|
}
|
|
794
703
|
|
|
795
|
-
const sync =
|
|
704
|
+
const sync = shouldSync();
|
|
796
705
|
if (sync) {
|
|
797
|
-
|
|
706
|
+
options.stdout.write(bsu);
|
|
798
707
|
}
|
|
799
708
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
709
|
+
log.clear();
|
|
710
|
+
options.stderr.write(data);
|
|
711
|
+
restoreLastOutput();
|
|
803
712
|
|
|
804
713
|
if (sync) {
|
|
805
|
-
|
|
714
|
+
options.stdout.write(esu);
|
|
806
715
|
}
|
|
807
716
|
}
|
|
808
717
|
|
|
809
718
|
// eslint-disable-next-line @typescript-eslint/no-restricted-types
|
|
810
|
-
unmount(error?: Error | number | null): void {
|
|
811
|
-
if (
|
|
719
|
+
function unmount(error?: Error | number | null): void {
|
|
720
|
+
if (isUnmounted || isUnmounting) {
|
|
812
721
|
return;
|
|
813
722
|
}
|
|
814
723
|
|
|
815
|
-
|
|
724
|
+
isUnmounting = true;
|
|
816
725
|
|
|
817
|
-
if (
|
|
818
|
-
process.off("beforeExit",
|
|
819
|
-
|
|
726
|
+
if (beforeExitHandler) {
|
|
727
|
+
process.off("beforeExit", beforeExitHandler);
|
|
728
|
+
beforeExitHandler = undefined;
|
|
820
729
|
}
|
|
821
730
|
|
|
822
|
-
const {
|
|
823
|
-
const { canWriteToStdout } = getWritableStreamState(stdout);
|
|
731
|
+
const { canWriteToStdout } = getWritableStreamState(options.stdout);
|
|
824
732
|
|
|
825
733
|
// Display any partial captured stdio lines while writes still go through.
|
|
826
734
|
if (canWriteToStdout) {
|
|
827
|
-
|
|
735
|
+
flushCapturedStdio();
|
|
828
736
|
}
|
|
829
737
|
|
|
830
738
|
// Clear any pending throttled render timer on unmount. When stdout is writable,
|
|
831
739
|
// flush so the final frame is emitted; otherwise cancel to avoid delayed callbacks.
|
|
832
|
-
settleThrottle(
|
|
740
|
+
settleThrottle(throttledOnRender, canWriteToStdout);
|
|
833
741
|
|
|
834
742
|
if (canWriteToStdout) {
|
|
835
743
|
// If throttling is enabled and there is already a pending render, flushing above
|
|
836
744
|
// is sufficient. Also avoid calling onRender() again when static output already
|
|
837
745
|
// exists, as that can duplicate <Static> children output on exit (see issue #397).
|
|
838
746
|
const shouldRenderFinalFrame =
|
|
839
|
-
!
|
|
840
|
-
(!this.hasPendingThrottledRender && this.fullStaticOutput === "");
|
|
747
|
+
!throttledOnRender || (!hasPendingThrottledRender && fullStaticOutput === "");
|
|
841
748
|
|
|
842
749
|
if (shouldRenderFinalFrame) {
|
|
843
|
-
|
|
844
|
-
|
|
750
|
+
calculateLayout();
|
|
751
|
+
onRender();
|
|
845
752
|
}
|
|
846
753
|
}
|
|
847
754
|
|
|
848
755
|
// Mark as unmounted after the final render but before stdout writes
|
|
849
756
|
// that could re-enter exit() via synchronous write callbacks.
|
|
850
|
-
|
|
757
|
+
isUnmounted = true;
|
|
851
758
|
|
|
852
|
-
|
|
759
|
+
unsubscribeExit();
|
|
853
760
|
|
|
854
761
|
// Flush any pending throttled log writes if possible, otherwise cancel to
|
|
855
762
|
// prevent delayed callbacks from writing to a closed stream.
|
|
856
|
-
settleThrottle(
|
|
857
|
-
if (typeof
|
|
763
|
+
settleThrottle(logThrottle, canWriteToStdout);
|
|
764
|
+
if (typeof restoreConsole === "function") {
|
|
858
765
|
// Once unmount starts, Ink stops trying to manage teardown-time
|
|
859
766
|
// console output. Restoring the native console before React cleanup keeps
|
|
860
767
|
// unmount behavior simple and avoids special-case handling for custom
|
|
861
768
|
// streams, fullscreen frames, and alternate-screen teardown.
|
|
862
|
-
|
|
769
|
+
restoreConsole();
|
|
863
770
|
}
|
|
864
771
|
|
|
865
772
|
const finishUnmount = (): void => {
|
|
866
|
-
if (typeof
|
|
867
|
-
|
|
773
|
+
if (typeof unsubscribeResize === "function") {
|
|
774
|
+
unsubscribeResize();
|
|
868
775
|
}
|
|
869
776
|
|
|
870
777
|
// Cancel any in-progress auto-detection before checking protocol state
|
|
871
|
-
if (
|
|
872
|
-
|
|
778
|
+
if (cancelKittyDetection) {
|
|
779
|
+
cancelKittyDetection();
|
|
873
780
|
}
|
|
874
781
|
|
|
875
782
|
if (canWriteToStdout) {
|
|
876
|
-
if (
|
|
877
|
-
|
|
783
|
+
if (kittyProtocolEnabled) {
|
|
784
|
+
writeBestEffort(options.stdout, ansiEscapes.popKittyKeyboard);
|
|
878
785
|
}
|
|
879
786
|
|
|
880
787
|
// Alternate-screen content is disposable by design. We intentionally
|
|
@@ -883,26 +790,26 @@ export class Ink {
|
|
|
883
790
|
// diagnostics onto it. Trying to preserve teardown output across the
|
|
884
791
|
// buffer switch adds fragile lifecycle-specific behavior, so Ink keeps
|
|
885
792
|
// alternate-screen teardown intentionally simple and best-effort.
|
|
886
|
-
if (
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
793
|
+
if (alternateScreen) {
|
|
794
|
+
writeBestEffort(options.stdout, ansiEscapes.exitAlternativeScreen);
|
|
795
|
+
writeBestEffort(options.stdout, showCursorEscape);
|
|
796
|
+
alternateScreen = false;
|
|
890
797
|
}
|
|
891
798
|
|
|
892
|
-
if (!
|
|
799
|
+
if (!interactive) {
|
|
893
800
|
// Non-interactive environments don't handle erasing ansi escapes well.
|
|
894
801
|
// In debug mode, each render already writes to stdout, so only a trailing
|
|
895
802
|
// newline is needed. In non-debug mode, write the last frame now (it was
|
|
896
803
|
// deferred during rendering).
|
|
897
|
-
|
|
898
|
-
} else if (!
|
|
899
|
-
|
|
804
|
+
options.stdout.write(options.debug ? "\n" : lastOutput + "\n");
|
|
805
|
+
} else if (!options.debug) {
|
|
806
|
+
log.done();
|
|
900
807
|
}
|
|
901
808
|
}
|
|
902
809
|
|
|
903
|
-
|
|
810
|
+
kittyProtocolEnabled = false;
|
|
904
811
|
|
|
905
|
-
instances.delete(
|
|
812
|
+
instances.delete(captureTargets?.stdout ?? options.stdout);
|
|
906
813
|
|
|
907
814
|
// Ensure all queued writes have been processed before resolving the
|
|
908
815
|
// exit promise. Queue an empty write as a barrier — its callback fires
|
|
@@ -911,13 +818,13 @@ export class Ink {
|
|
|
911
818
|
// When called from signal-exit during process shutdown (error is a
|
|
912
819
|
// number or null rather than undefined/Error), resolve synchronously
|
|
913
820
|
// because the event loop is draining and async callbacks won't fire.
|
|
914
|
-
const
|
|
821
|
+
const finalExitResult = exitResult;
|
|
915
822
|
|
|
916
823
|
const resolveOrReject = () => {
|
|
917
824
|
if (isErrorInput(error)) {
|
|
918
|
-
|
|
825
|
+
rejectExitPromise(error);
|
|
919
826
|
} else {
|
|
920
|
-
|
|
827
|
+
resolveExitPromise(finalExitResult);
|
|
921
828
|
}
|
|
922
829
|
};
|
|
923
830
|
|
|
@@ -926,7 +833,7 @@ export class Ink {
|
|
|
926
833
|
if (isProcessExiting) {
|
|
927
834
|
resolveOrReject();
|
|
928
835
|
} else if (canWriteToStdout) {
|
|
929
|
-
|
|
836
|
+
options.stdout.write("", resolveOrReject);
|
|
930
837
|
} else {
|
|
931
838
|
setImmediate(resolveOrReject);
|
|
932
839
|
}
|
|
@@ -936,34 +843,34 @@ export class Ink {
|
|
|
936
843
|
flushPassiveEffects?: () => boolean;
|
|
937
844
|
};
|
|
938
845
|
|
|
939
|
-
if (
|
|
940
|
-
reconciler.updateContainerSync(null,
|
|
846
|
+
if (isConcurrent) {
|
|
847
|
+
reconciler.updateContainerSync(null, container, null, noop);
|
|
941
848
|
reconciler.flushSyncWork();
|
|
942
849
|
concurrentReconciler.flushPassiveEffects?.();
|
|
943
850
|
finishUnmount();
|
|
944
851
|
} else {
|
|
945
852
|
// Legacy mode: use updateContainerSync + flushSyncWork (sync)
|
|
946
|
-
reconciler.updateContainerSync(null,
|
|
853
|
+
reconciler.updateContainerSync(null, container, null, noop);
|
|
947
854
|
reconciler.flushSyncWork();
|
|
948
855
|
finishUnmount();
|
|
949
856
|
}
|
|
950
857
|
}
|
|
951
858
|
|
|
952
|
-
async waitUntilExit(): Promise<unknown> {
|
|
953
|
-
if (!
|
|
954
|
-
|
|
955
|
-
|
|
859
|
+
async function waitUntilExit(): Promise<unknown> {
|
|
860
|
+
if (!beforeExitHandler) {
|
|
861
|
+
beforeExitHandler = () => {
|
|
862
|
+
unmount();
|
|
956
863
|
};
|
|
957
864
|
|
|
958
|
-
process.once("beforeExit",
|
|
865
|
+
process.once("beforeExit", beforeExitHandler);
|
|
959
866
|
}
|
|
960
867
|
|
|
961
|
-
return
|
|
868
|
+
return exitPromise;
|
|
962
869
|
}
|
|
963
870
|
|
|
964
|
-
async waitUntilRenderFlush(): Promise<void> {
|
|
965
|
-
if (
|
|
966
|
-
await
|
|
871
|
+
async function waitUntilRenderFlush(): Promise<void> {
|
|
872
|
+
if (isUnmounted || isUnmounting) {
|
|
873
|
+
await awaitExit();
|
|
967
874
|
return;
|
|
968
875
|
}
|
|
969
876
|
|
|
@@ -971,35 +878,34 @@ export class Ink {
|
|
|
971
878
|
// fire passive effects and process any work they enqueued.
|
|
972
879
|
await yieldImmediate();
|
|
973
880
|
|
|
974
|
-
if (
|
|
975
|
-
await
|
|
881
|
+
if (isUnmounted || isUnmounting) {
|
|
882
|
+
await awaitExit();
|
|
976
883
|
return;
|
|
977
884
|
}
|
|
978
885
|
|
|
979
886
|
// In concurrent mode, React's scheduler may still be mid-render after
|
|
980
887
|
// the yield. Wait for the next render commit instead of polling.
|
|
981
|
-
if (
|
|
982
|
-
await Promise.race([
|
|
888
|
+
if (isConcurrent && hasPendingConcurrentWork()) {
|
|
889
|
+
await Promise.race([awaitNextRender(), awaitExit()]);
|
|
983
890
|
|
|
984
|
-
if (
|
|
985
|
-
|
|
986
|
-
await
|
|
891
|
+
if (isUnmounted || isUnmounting) {
|
|
892
|
+
nextRenderCommit = undefined;
|
|
893
|
+
await awaitExit();
|
|
987
894
|
return;
|
|
988
895
|
}
|
|
989
896
|
}
|
|
990
897
|
|
|
991
898
|
reconciler.flushSyncWork();
|
|
992
899
|
|
|
993
|
-
const {
|
|
994
|
-
const { canWriteToStdout } = getWritableStreamState(stdout);
|
|
900
|
+
const { canWriteToStdout } = getWritableStreamState(options.stdout);
|
|
995
901
|
|
|
996
902
|
// Flush pending throttled render/log timers so their output is included in this wait.
|
|
997
|
-
settleThrottle(
|
|
998
|
-
settleThrottle(
|
|
903
|
+
settleThrottle(throttledOnRender, canWriteToStdout);
|
|
904
|
+
settleThrottle(logThrottle, canWriteToStdout);
|
|
999
905
|
|
|
1000
906
|
if (canWriteToStdout) {
|
|
1001
907
|
await new Promise<void>((resolve) => {
|
|
1002
|
-
|
|
908
|
+
options.stdout.write("", () => {
|
|
1003
909
|
resolve();
|
|
1004
910
|
});
|
|
1005
911
|
});
|
|
@@ -1009,41 +915,41 @@ export class Ink {
|
|
|
1009
915
|
await yieldImmediate();
|
|
1010
916
|
}
|
|
1011
917
|
|
|
1012
|
-
clear(): void {
|
|
1013
|
-
if (
|
|
1014
|
-
|
|
918
|
+
function clear(): void {
|
|
919
|
+
if (interactive && !options.debug) {
|
|
920
|
+
log.clear();
|
|
1015
921
|
// Sync lastOutput so that unmount's final onRender
|
|
1016
922
|
// sees it as unchanged and log-update skips it
|
|
1017
|
-
|
|
923
|
+
log.sync(lastOutputToRender || lastOutput + "\n");
|
|
1018
924
|
}
|
|
1019
925
|
}
|
|
1020
926
|
|
|
1021
|
-
|
|
1022
|
-
if (
|
|
927
|
+
function installConsolePatch(): void {
|
|
928
|
+
if (options.debug) {
|
|
1023
929
|
return;
|
|
1024
930
|
}
|
|
1025
931
|
|
|
1026
932
|
const restoreConsoleMethods = patchConsole((stream, data) => {
|
|
1027
|
-
if (
|
|
933
|
+
if (options.onCapturedOutput?.(stream, data, "console") === true) {
|
|
1028
934
|
return;
|
|
1029
935
|
}
|
|
1030
936
|
|
|
1031
937
|
if (stream === "stdout") {
|
|
1032
|
-
|
|
938
|
+
writeToStdout(data);
|
|
1033
939
|
}
|
|
1034
940
|
|
|
1035
941
|
if (stream === "stderr") {
|
|
1036
942
|
const isReactMessage = data.startsWith("The above error occurred");
|
|
1037
943
|
|
|
1038
944
|
if (!isReactMessage) {
|
|
1039
|
-
|
|
945
|
+
writeToStderr(data);
|
|
1040
946
|
}
|
|
1041
947
|
}
|
|
1042
948
|
});
|
|
1043
949
|
|
|
1044
|
-
const restoreDirectStdio =
|
|
950
|
+
const restoreDirectStdio = patchDirectStdio();
|
|
1045
951
|
|
|
1046
|
-
|
|
952
|
+
restoreConsole = () => {
|
|
1047
953
|
restoreConsoleMethods();
|
|
1048
954
|
restoreDirectStdio?.();
|
|
1049
955
|
};
|
|
@@ -1051,53 +957,17 @@ export class Ink {
|
|
|
1051
957
|
|
|
1052
958
|
// Intercept direct `write` calls on the real streams. Ink renders through
|
|
1053
959
|
// passthrough facades, so everything arriving here is external output.
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
if (!targets) {
|
|
960
|
+
function patchDirectStdio(): (() => void) | undefined {
|
|
961
|
+
if (!captureTargets) {
|
|
1058
962
|
return;
|
|
1059
963
|
}
|
|
1060
964
|
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
const patchedWrite = (
|
|
1068
|
-
chunk: unknown,
|
|
1069
|
-
encodingOrCallback?: unknown,
|
|
1070
|
-
callback?: unknown,
|
|
1071
|
-
): boolean => {
|
|
1072
|
-
const data =
|
|
1073
|
-
typeof chunk === "string"
|
|
1074
|
-
? chunk
|
|
1075
|
-
: chunk instanceof Uint8Array
|
|
1076
|
-
? Buffer.from(chunk).toString()
|
|
1077
|
-
: String(chunk);
|
|
1078
|
-
|
|
1079
|
-
this.handleCapturedStdio(name, data);
|
|
1080
|
-
|
|
1081
|
-
const done =
|
|
1082
|
-
typeof encodingOrCallback === "function"
|
|
1083
|
-
? encodingOrCallback
|
|
1084
|
-
: typeof callback === "function"
|
|
1085
|
-
? callback
|
|
1086
|
-
: undefined;
|
|
1087
|
-
done?.();
|
|
1088
|
-
|
|
1089
|
-
return true;
|
|
1090
|
-
};
|
|
1091
|
-
|
|
1092
|
-
stream.write = patchedWrite;
|
|
1093
|
-
|
|
1094
|
-
return () => {
|
|
1095
|
-
stream.write = originalWrite;
|
|
1096
|
-
};
|
|
1097
|
-
};
|
|
1098
|
-
|
|
1099
|
-
const restoreStdout = patch("stdout", targets.stdout);
|
|
1100
|
-
const restoreStderr = patch("stderr", targets.stderr);
|
|
965
|
+
const restoreStdout = patchStreamWrite(captureTargets.stdout, (data) => {
|
|
966
|
+
handleCapturedStdio("stdout", data);
|
|
967
|
+
});
|
|
968
|
+
const restoreStderr = patchStreamWrite(captureTargets.stderr, (data) => {
|
|
969
|
+
handleCapturedStdio("stderr", data);
|
|
970
|
+
});
|
|
1101
971
|
|
|
1102
972
|
return () => {
|
|
1103
973
|
restoreStdout();
|
|
@@ -1105,8 +975,8 @@ export class Ink {
|
|
|
1105
975
|
};
|
|
1106
976
|
}
|
|
1107
977
|
|
|
1108
|
-
|
|
1109
|
-
if (
|
|
978
|
+
function handleCapturedStdio(stream: "stdout" | "stderr", data: string): void {
|
|
979
|
+
if (options.onCapturedOutput?.(stream, data, "stdio") === true) {
|
|
1110
980
|
return;
|
|
1111
981
|
}
|
|
1112
982
|
|
|
@@ -1114,8 +984,8 @@ export class Ink {
|
|
|
1114
984
|
// spinners), and only complete lines can be spliced above the live
|
|
1115
985
|
// frame without corrupting it. The trailing partial line is held until
|
|
1116
986
|
// its newline arrives, or flushed at unmount/suspend.
|
|
1117
|
-
const parts = (
|
|
1118
|
-
|
|
987
|
+
const parts = (capturedStdioTails[stream] + data).split(/\r?\n/);
|
|
988
|
+
capturedStdioTails[stream] = parts.pop() ?? "";
|
|
1119
989
|
|
|
1120
990
|
if (parts.length === 0) {
|
|
1121
991
|
return;
|
|
@@ -1124,100 +994,85 @@ export class Ink {
|
|
|
1124
994
|
const payload = parts.join("\n") + "\n";
|
|
1125
995
|
|
|
1126
996
|
if (stream === "stdout") {
|
|
1127
|
-
|
|
997
|
+
writeToStdout(payload);
|
|
1128
998
|
} else {
|
|
1129
|
-
|
|
999
|
+
writeToStderr(payload);
|
|
1130
1000
|
}
|
|
1131
1001
|
}
|
|
1132
1002
|
|
|
1133
1003
|
// Display any partial captured lines that never received a newline.
|
|
1134
|
-
|
|
1004
|
+
function flushCapturedStdio(): void {
|
|
1135
1005
|
for (const stream of ["stdout", "stderr"] as const) {
|
|
1136
|
-
const tail =
|
|
1006
|
+
const tail = capturedStdioTails[stream];
|
|
1137
1007
|
|
|
1138
1008
|
if (tail === "") {
|
|
1139
1009
|
continue;
|
|
1140
1010
|
}
|
|
1141
1011
|
|
|
1142
|
-
|
|
1012
|
+
capturedStdioTails[stream] = "";
|
|
1143
1013
|
|
|
1144
1014
|
if (stream === "stdout") {
|
|
1145
|
-
|
|
1015
|
+
writeToStdout(tail + "\n");
|
|
1146
1016
|
} else {
|
|
1147
|
-
|
|
1017
|
+
writeToStderr(tail + "\n");
|
|
1148
1018
|
}
|
|
1149
1019
|
}
|
|
1150
1020
|
}
|
|
1151
1021
|
|
|
1152
|
-
registerInputControl(
|
|
1153
|
-
|
|
1154
|
-
|
|
1022
|
+
function registerInputControl(pause: () => void, resume: () => void): void {
|
|
1023
|
+
pauseInput = pause;
|
|
1024
|
+
resumeInput = resume;
|
|
1155
1025
|
}
|
|
1156
1026
|
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
async suspendTerminal(
|
|
1160
|
-
|
|
1027
|
+
function suspendTerminal(callback: () => void | Promise<void>): Promise<void>;
|
|
1028
|
+
function suspendTerminal(): Promise<TerminalSuspension>;
|
|
1029
|
+
async function suspendTerminal(
|
|
1030
|
+
callback?: () => void | Promise<void>,
|
|
1031
|
+
): Promise<void | TerminalSuspension> {
|
|
1032
|
+
beginSuspend();
|
|
1161
1033
|
|
|
1162
1034
|
if (callback) {
|
|
1163
1035
|
try {
|
|
1164
1036
|
await callback();
|
|
1165
1037
|
} finally {
|
|
1166
|
-
await
|
|
1038
|
+
await endSuspend();
|
|
1167
1039
|
}
|
|
1168
1040
|
|
|
1169
1041
|
return;
|
|
1170
1042
|
}
|
|
1171
1043
|
|
|
1172
1044
|
const resume = async (): Promise<void> => {
|
|
1173
|
-
await
|
|
1045
|
+
await endSuspend();
|
|
1174
1046
|
};
|
|
1175
1047
|
|
|
1176
1048
|
return { resume, [Symbol.asyncDispose]: resume };
|
|
1177
1049
|
}
|
|
1178
1050
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1051
|
+
function setAlternateScreen(enabled: boolean): void {
|
|
1052
|
+
alternateScreen = enabled && interactive && Boolean(options.stdout.isTTY);
|
|
1181
1053
|
|
|
1182
|
-
if (
|
|
1183
|
-
|
|
1184
|
-
|
|
1054
|
+
if (alternateScreen) {
|
|
1055
|
+
writeBestEffort(options.stdout, ansiEscapes.enterAlternativeScreen);
|
|
1056
|
+
writeBestEffort(options.stdout, hideCursorEscape);
|
|
1185
1057
|
}
|
|
1186
1058
|
}
|
|
1187
1059
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
private resolveAlternateScreenOption(
|
|
1193
|
-
alternateScreen: boolean | undefined,
|
|
1194
|
-
interactive: boolean,
|
|
1195
|
-
): boolean {
|
|
1196
|
-
return Boolean(alternateScreen) && interactive && Boolean(this.options.stdout.isTTY);
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
private shouldSync(): boolean {
|
|
1200
|
-
return shouldSynchronize(this.options.stdout, this.interactive);
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
// Best-effort write: streams may already be destroyed during shutdown.
|
|
1204
|
-
private writeBestEffort(stream: OutputStream, data: string): void {
|
|
1205
|
-
try {
|
|
1206
|
-
stream.write(data);
|
|
1207
|
-
} catch {}
|
|
1060
|
+
function shouldSync(): boolean {
|
|
1061
|
+
// `interactive` already folds in CI detection and the caller's override.
|
|
1062
|
+
return Boolean(options.stdout.isTTY) && interactive;
|
|
1208
1063
|
}
|
|
1209
1064
|
|
|
1210
1065
|
// Waits for the exit promise to settle, suppressing any rejection.
|
|
1211
1066
|
// Errors are surfaced via waitUntilExit() instead.
|
|
1212
|
-
|
|
1067
|
+
async function awaitExit(): Promise<void> {
|
|
1213
1068
|
try {
|
|
1214
|
-
await
|
|
1069
|
+
await exitPromise;
|
|
1215
1070
|
} catch {}
|
|
1216
1071
|
}
|
|
1217
1072
|
|
|
1218
|
-
|
|
1073
|
+
function hasPendingConcurrentWork(): boolean {
|
|
1219
1074
|
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
|
1220
|
-
const concurrentContainer =
|
|
1075
|
+
const concurrentContainer = container as {
|
|
1221
1076
|
pendingLanes?: number;
|
|
1222
1077
|
callbackNode?: unknown;
|
|
1223
1078
|
};
|
|
@@ -1228,25 +1083,22 @@ export class Ink {
|
|
|
1228
1083
|
);
|
|
1229
1084
|
}
|
|
1230
1085
|
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
const promise = new Promise<void>((resolve) => {
|
|
1235
|
-
resolveRender = resolve;
|
|
1236
|
-
});
|
|
1237
|
-
this.nextRenderCommit = { promise, resolve: resolveRender };
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
return this.nextRenderCommit.promise;
|
|
1086
|
+
async function awaitNextRender(): Promise<void> {
|
|
1087
|
+
nextRenderCommit ??= Promise.withResolvers<void>();
|
|
1088
|
+
return nextRenderCommit.promise;
|
|
1241
1089
|
}
|
|
1242
1090
|
|
|
1243
|
-
|
|
1091
|
+
function renderInteractiveFrame(
|
|
1092
|
+
output: string,
|
|
1093
|
+
outputHeight: number,
|
|
1094
|
+
staticOutput: string,
|
|
1095
|
+
): void {
|
|
1244
1096
|
const hasStaticOutput = staticOutput !== "";
|
|
1245
|
-
const isTTY = Boolean(
|
|
1097
|
+
const isTTY = Boolean(options.stdout.isTTY);
|
|
1246
1098
|
|
|
1247
1099
|
// Detect fullscreen: output fills or exceeds terminal height.
|
|
1248
1100
|
// Only apply when writing to a real TTY — piped output always gets trailing newlines.
|
|
1249
|
-
const viewportRows = isTTY ? getWindowSize(
|
|
1101
|
+
const viewportRows = isTTY ? getWindowSize(options.stdout).rows : 24;
|
|
1250
1102
|
|
|
1251
1103
|
// Clamp the frame to the viewport, keeping its bottom rows. Rows above
|
|
1252
1104
|
// the top margin cannot be updated or erased in place, and the
|
|
@@ -1266,25 +1118,25 @@ export class Ink {
|
|
|
1266
1118
|
const shouldClearTerminal = shouldClearTerminalForFrame({
|
|
1267
1119
|
isTTY,
|
|
1268
1120
|
viewportRows,
|
|
1269
|
-
previousOutputHeight:
|
|
1121
|
+
previousOutputHeight: lastOutputHeight,
|
|
1270
1122
|
nextOutputHeight: outputHeight,
|
|
1271
|
-
isUnmounting
|
|
1123
|
+
isUnmounting,
|
|
1272
1124
|
});
|
|
1273
1125
|
|
|
1274
1126
|
if (shouldClearTerminal) {
|
|
1275
|
-
const sync =
|
|
1127
|
+
const sync = shouldSync();
|
|
1276
1128
|
if (sync) {
|
|
1277
|
-
|
|
1129
|
+
options.stdout.write(bsu);
|
|
1278
1130
|
}
|
|
1279
1131
|
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1132
|
+
options.stdout.write(ansiEscapes.clearTerminal + fullStaticOutput + outputToRender);
|
|
1133
|
+
lastOutput = output;
|
|
1134
|
+
lastOutputToRender = outputToRender;
|
|
1135
|
+
lastOutputHeight = outputHeight;
|
|
1136
|
+
log.sync(outputToRender);
|
|
1285
1137
|
|
|
1286
1138
|
if (sync) {
|
|
1287
|
-
|
|
1139
|
+
options.stdout.write(esu);
|
|
1288
1140
|
}
|
|
1289
1141
|
|
|
1290
1142
|
return;
|
|
@@ -1292,35 +1144,35 @@ export class Ink {
|
|
|
1292
1144
|
|
|
1293
1145
|
// To ensure static output is cleanly rendered before main output, clear main output first
|
|
1294
1146
|
if (hasStaticOutput) {
|
|
1295
|
-
const sync =
|
|
1147
|
+
const sync = shouldSync();
|
|
1296
1148
|
if (sync) {
|
|
1297
|
-
|
|
1149
|
+
options.stdout.write(bsu);
|
|
1298
1150
|
}
|
|
1299
1151
|
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1152
|
+
log.clear();
|
|
1153
|
+
options.stdout.write(staticOutput);
|
|
1154
|
+
log(outputToRender);
|
|
1303
1155
|
|
|
1304
1156
|
if (sync) {
|
|
1305
|
-
|
|
1157
|
+
options.stdout.write(esu);
|
|
1306
1158
|
}
|
|
1307
|
-
} else if (output !==
|
|
1159
|
+
} else if (output !== lastOutput || log.isCursorDirty()) {
|
|
1308
1160
|
// ThrottledLog manages its own bsu/esu at actual write time
|
|
1309
|
-
|
|
1161
|
+
throttledLog(outputToRender);
|
|
1310
1162
|
}
|
|
1311
1163
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1164
|
+
lastOutput = output;
|
|
1165
|
+
lastOutputToRender = outputToRender;
|
|
1166
|
+
lastOutputHeight = outputHeight;
|
|
1315
1167
|
}
|
|
1316
1168
|
|
|
1317
|
-
|
|
1169
|
+
function initKittyKeyboard(): void {
|
|
1318
1170
|
// Protocol is opt-in: if kittyKeyboard is not specified, do nothing
|
|
1319
|
-
if (!
|
|
1171
|
+
if (!options.kittyKeyboard) {
|
|
1320
1172
|
return;
|
|
1321
1173
|
}
|
|
1322
1174
|
|
|
1323
|
-
const opts =
|
|
1175
|
+
const opts = options.kittyKeyboard;
|
|
1324
1176
|
const mode = opts.mode ?? "auto";
|
|
1325
1177
|
|
|
1326
1178
|
if (mode === "disabled") {
|
|
@@ -1332,160 +1184,115 @@ export class Ink {
|
|
|
1332
1184
|
// 'enabled' force-enables the protocol as long as both streams are TTYs,
|
|
1333
1185
|
// regardless of the interactive setting (e.g. even in CI).
|
|
1334
1186
|
if (mode === "enabled") {
|
|
1335
|
-
if (isTty(
|
|
1336
|
-
|
|
1187
|
+
if (isTty(options.stdin) && options.stdout.isTTY) {
|
|
1188
|
+
enableKittyProtocol(flags);
|
|
1337
1189
|
}
|
|
1338
1190
|
|
|
1339
1191
|
return;
|
|
1340
1192
|
}
|
|
1341
1193
|
|
|
1342
1194
|
// Auto mode: require interactive + TTY
|
|
1343
|
-
if (!
|
|
1195
|
+
if (!interactive || !isTty(options.stdin) || !options.stdout.isTTY) {
|
|
1344
1196
|
return;
|
|
1345
1197
|
}
|
|
1346
1198
|
|
|
1347
1199
|
// Auto mode: query the terminal for kitty keyboard protocol support.
|
|
1348
|
-
// The CSI ? u query is safe to send to any terminal — unsupporting
|
|
1349
|
-
// terminals simply won't respond, and the 200ms timeout handles that.
|
|
1350
1200
|
// This avoids maintaining a hardcoded whitelist of terminal names.
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
const { stdin, stdout } = this.options;
|
|
1356
|
-
|
|
1357
|
-
let responseBuffer: number[] = [];
|
|
1358
|
-
|
|
1359
|
-
const cleanup = (): void => {
|
|
1360
|
-
this.cancelKittyDetection = undefined;
|
|
1361
|
-
clearTimeout(timer);
|
|
1362
|
-
stdin.removeListener("data", onData);
|
|
1363
|
-
|
|
1364
|
-
// Re-emit any buffered data that wasn't the protocol response,
|
|
1365
|
-
// so it isn't lost from Ink's normal input pipeline.
|
|
1366
|
-
// Clear responseBuffer afterwards to make cleanup idempotent.
|
|
1367
|
-
const remaining = stripKittyQueryResponsesAndTrailingPartial(responseBuffer);
|
|
1368
|
-
responseBuffer = [];
|
|
1369
|
-
if (remaining.length > 0) {
|
|
1370
|
-
stdin.unshift(Uint8Array.from(remaining));
|
|
1371
|
-
}
|
|
1372
|
-
};
|
|
1373
|
-
|
|
1374
|
-
const onData = (data: Uint8Array | string): void => {
|
|
1375
|
-
const chunk = typeof data === "string" ? textEncoder.encode(data) : data;
|
|
1376
|
-
for (const byte of chunk) {
|
|
1377
|
-
responseBuffer.push(byte);
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
if (hasCompleteKittyQueryResponse(responseBuffer)) {
|
|
1381
|
-
cleanup();
|
|
1382
|
-
if (!this.isUnmounted) {
|
|
1383
|
-
this.enableKittyProtocol(flags);
|
|
1384
|
-
}
|
|
1201
|
+
cancelKittyDetection = detectKittySupport(options.stdin, options.stdout, () => {
|
|
1202
|
+
cancelKittyDetection = undefined;
|
|
1203
|
+
if (!isUnmounted) {
|
|
1204
|
+
enableKittyProtocol(flags);
|
|
1385
1205
|
}
|
|
1386
|
-
};
|
|
1387
|
-
|
|
1388
|
-
// Attach listener before writing the query so that synchronous
|
|
1389
|
-
// or immediate responses are not missed.
|
|
1390
|
-
stdin.on("data", onData);
|
|
1391
|
-
const timer = setTimeout(cleanup, 200);
|
|
1392
|
-
this.cancelKittyDetection = cleanup;
|
|
1393
|
-
|
|
1394
|
-
stdout.write(ansiEscapes.kittyQuery);
|
|
1206
|
+
});
|
|
1395
1207
|
}
|
|
1396
1208
|
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1209
|
+
function enableKittyProtocol(flags: KittyFlagName[]): void {
|
|
1210
|
+
options.stdout.write(ansiEscapes.pushKittyKeyboard(resolveFlags(flags)));
|
|
1211
|
+
kittyProtocolEnabled = true;
|
|
1400
1212
|
// Remember the flags so suspendTerminal() can re-enable the same protocol
|
|
1401
1213
|
// after a child process has had the terminal.
|
|
1402
|
-
|
|
1214
|
+
kittyFlags = flags;
|
|
1403
1215
|
}
|
|
1404
1216
|
|
|
1405
|
-
|
|
1406
|
-
if (
|
|
1217
|
+
function beginSuspend(): void {
|
|
1218
|
+
if (isSuspended) {
|
|
1407
1219
|
throw new Error(
|
|
1408
1220
|
"The terminal is already suspended. Resume the current suspension before suspending again.",
|
|
1409
1221
|
);
|
|
1410
1222
|
}
|
|
1411
1223
|
|
|
1412
|
-
|
|
1224
|
+
isSuspended = true;
|
|
1413
1225
|
|
|
1414
|
-
if (!
|
|
1226
|
+
if (!interactive || isUnmounted || isUnmounting) {
|
|
1415
1227
|
return;
|
|
1416
1228
|
}
|
|
1417
1229
|
|
|
1418
1230
|
try {
|
|
1419
|
-
const {
|
|
1420
|
-
const { canWriteToStdout } = getWritableStreamState(stdout);
|
|
1231
|
+
const { canWriteToStdout } = getWritableStreamState(options.stdout);
|
|
1421
1232
|
|
|
1422
1233
|
// Flush any pending render/log so the child starts from a settled screen.
|
|
1423
|
-
settleThrottle(
|
|
1424
|
-
settleThrottle(
|
|
1234
|
+
settleThrottle(throttledOnRender, canWriteToStdout);
|
|
1235
|
+
settleThrottle(logThrottle, canWriteToStdout);
|
|
1425
1236
|
|
|
1426
1237
|
if (canWriteToStdout) {
|
|
1427
|
-
|
|
1238
|
+
flushCapturedStdio();
|
|
1428
1239
|
}
|
|
1429
1240
|
|
|
1430
1241
|
if (canWriteToStdout) {
|
|
1431
1242
|
// Erase Ink's current frame, then show the cursor and re-arm the hide.
|
|
1432
1243
|
// The forced redraw on resume hides the cursor again.
|
|
1433
|
-
|
|
1434
|
-
|
|
1244
|
+
log.clear();
|
|
1245
|
+
log.done();
|
|
1435
1246
|
|
|
1436
|
-
if (
|
|
1437
|
-
|
|
1247
|
+
if (kittyProtocolEnabled) {
|
|
1248
|
+
writeBestEffort(options.stdout, ansiEscapes.popKittyKeyboard);
|
|
1438
1249
|
}
|
|
1439
1250
|
|
|
1440
|
-
if (
|
|
1441
|
-
|
|
1251
|
+
if (alternateScreen) {
|
|
1252
|
+
writeBestEffort(options.stdout, ansiEscapes.exitAlternativeScreen);
|
|
1442
1253
|
}
|
|
1443
1254
|
}
|
|
1444
1255
|
|
|
1445
1256
|
// Hand input back to the terminal (raw mode off, bracketed paste off).
|
|
1446
|
-
|
|
1257
|
+
pauseInput?.();
|
|
1447
1258
|
} catch (error) {
|
|
1448
1259
|
// If handing over the terminal fails partway, don't strand the app in a
|
|
1449
1260
|
// suspended state with no way back. Best-effort reclaim input, clear the
|
|
1450
1261
|
// flag, and rethrow so the caller sees the failure.
|
|
1451
|
-
|
|
1262
|
+
isSuspended = false;
|
|
1452
1263
|
|
|
1453
1264
|
try {
|
|
1454
|
-
|
|
1265
|
+
resumeInput?.();
|
|
1455
1266
|
} catch {}
|
|
1456
1267
|
|
|
1457
1268
|
throw error;
|
|
1458
1269
|
}
|
|
1459
1270
|
}
|
|
1460
1271
|
|
|
1461
|
-
|
|
1462
|
-
if (!
|
|
1272
|
+
async function endSuspend(): Promise<void> {
|
|
1273
|
+
if (!isSuspended) {
|
|
1463
1274
|
return;
|
|
1464
1275
|
}
|
|
1465
1276
|
|
|
1466
|
-
|
|
1277
|
+
isSuspended = false;
|
|
1467
1278
|
|
|
1468
1279
|
// Reclaim input even mid-unmount: pauseInput already ran in beginSuspend, so
|
|
1469
1280
|
// restoring it is symmetric regardless of any state change during suspension.
|
|
1470
|
-
|
|
1281
|
+
resumeInput?.();
|
|
1471
1282
|
|
|
1472
|
-
if (!
|
|
1283
|
+
if (!interactive || isUnmounted || isUnmounting) {
|
|
1473
1284
|
return;
|
|
1474
1285
|
}
|
|
1475
1286
|
|
|
1476
|
-
const {
|
|
1477
|
-
const { canWriteToStdout } = getWritableStreamState(stdout);
|
|
1287
|
+
const { canWriteToStdout } = getWritableStreamState(options.stdout);
|
|
1478
1288
|
|
|
1479
1289
|
if (canWriteToStdout) {
|
|
1480
|
-
if (
|
|
1481
|
-
|
|
1290
|
+
if (alternateScreen) {
|
|
1291
|
+
writeBestEffort(options.stdout, ansiEscapes.enterAlternativeScreen);
|
|
1482
1292
|
}
|
|
1483
1293
|
|
|
1484
|
-
if (
|
|
1485
|
-
|
|
1486
|
-
this.options.stdout,
|
|
1487
|
-
ansiEscapes.pushKittyKeyboard(resolveFlags(this.kittyFlags)),
|
|
1488
|
-
);
|
|
1294
|
+
if (kittyProtocolEnabled && kittyFlags) {
|
|
1295
|
+
writeBestEffort(options.stdout, ansiEscapes.pushKittyKeyboard(resolveFlags(kittyFlags)));
|
|
1489
1296
|
}
|
|
1490
1297
|
}
|
|
1491
1298
|
|
|
@@ -1493,15 +1300,23 @@ export class Ink {
|
|
|
1493
1300
|
// frame, which the child process may have overwritten. A redraw failure here
|
|
1494
1301
|
// is best-effort: it must not mask a callback error propagating through the
|
|
1495
1302
|
// caller's finally block.
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1303
|
+
lastOutput = "";
|
|
1304
|
+
lastOutputToRender = "";
|
|
1305
|
+
lastOutputHeight = 0;
|
|
1306
|
+
log.reset();
|
|
1500
1307
|
|
|
1501
1308
|
try {
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
await
|
|
1309
|
+
calculateLayout();
|
|
1310
|
+
onRender();
|
|
1311
|
+
await waitUntilRenderFlush();
|
|
1505
1312
|
} catch {}
|
|
1506
1313
|
}
|
|
1507
|
-
|
|
1314
|
+
|
|
1315
|
+
return {
|
|
1316
|
+
render,
|
|
1317
|
+
unmount,
|
|
1318
|
+
waitUntilExit,
|
|
1319
|
+
waitUntilRenderFlush,
|
|
1320
|
+
clear,
|
|
1321
|
+
};
|
|
1322
|
+
};
|