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