@alchemy.run/sigil 0.0.0-alpha.1 → 0.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +299 -299
  2. package/dist/ansi.d.ts +223 -0
  3. package/dist/ansi.js +2 -0
  4. package/dist/{devtools-QpCMm9JH.mjs → devtools-BhYGjb7h.js} +1 -1
  5. package/dist/index-DDVME65c.d.ts +919 -0
  6. package/dist/index.d.ts +1658 -0
  7. package/dist/index.js +4652 -0
  8. package/dist/sgr-CMfEpjSk.d.ts +91 -0
  9. package/dist/truncate-CBiyyZzw.js +2156 -0
  10. package/dist/yoga-5jKhYCJC.js +3465 -0
  11. package/dist/yoga.d.ts +2 -0
  12. package/dist/yoga.js +2 -0
  13. package/package.json +37 -17
  14. package/src/ansi/chalk.ts +179 -0
  15. package/src/ansi/cursor.ts +48 -0
  16. package/src/ansi/east-asian-width.ts +215 -0
  17. package/src/ansi/escapes.ts +128 -0
  18. package/src/ansi/index.ts +27 -0
  19. package/src/ansi/sgr.ts +237 -0
  20. package/src/ansi/slice.ts +43 -0
  21. package/src/ansi/string-width.ts +236 -0
  22. package/src/ansi/strip.ts +33 -0
  23. package/src/ansi/supports-color.ts +213 -0
  24. package/src/ansi/tokenize.ts +453 -0
  25. package/src/ansi/truncate.ts +194 -0
  26. package/src/ansi/widest-line.ts +12 -0
  27. package/src/ansi/wrap.ts +766 -0
  28. package/src/ansi-tokenizer.ts +510 -0
  29. package/src/auto-bind.ts +41 -0
  30. package/src/boxes.ts +100 -0
  31. package/src/code-excerpt.ts +39 -0
  32. package/src/colorize.ts +60 -0
  33. package/src/components/AccessibilityContext.ts +5 -0
  34. package/src/components/AnimationContext.ts +24 -0
  35. package/src/components/App.tsx +782 -0
  36. package/src/components/AppContext.ts +111 -0
  37. package/src/components/BackgroundContext.ts +8 -0
  38. package/src/components/Box.tsx +117 -0
  39. package/src/components/CursorContext.ts +19 -0
  40. package/src/components/ErrorBoundary.tsx +39 -0
  41. package/src/components/ErrorOverview.tsx +134 -0
  42. package/src/components/FocusContext.ts +30 -0
  43. package/src/components/Newline.tsx +16 -0
  44. package/src/components/Spacer.tsx +11 -0
  45. package/src/components/Static.tsx +60 -0
  46. package/src/components/StderrContext.ts +26 -0
  47. package/src/components/StdinContext.ts +49 -0
  48. package/src/components/StdoutContext.ts +28 -0
  49. package/src/components/Text.tsx +145 -0
  50. package/src/components/Transform.tsx +38 -0
  51. package/src/cursor-position.ts +103 -0
  52. package/src/devtools-window-polyfill.ts +73 -0
  53. package/src/devtools.ts +43 -0
  54. package/src/dom.ts +292 -0
  55. package/src/get-max-width.ts +11 -0
  56. package/src/global.d.ts +36 -0
  57. package/src/hooks/use-animation.ts +142 -0
  58. package/src/hooks/use-app.ts +8 -0
  59. package/src/hooks/use-box-metrics.ts +134 -0
  60. package/src/hooks/use-cursor.ts +33 -0
  61. package/src/hooks/use-focus-manager.ts +62 -0
  62. package/src/hooks/use-focus.ts +83 -0
  63. package/src/hooks/use-input.ts +267 -0
  64. package/src/hooks/use-is-screen-reader-enabled.ts +12 -0
  65. package/src/hooks/use-paste.ts +78 -0
  66. package/src/hooks/use-stderr.ts +8 -0
  67. package/src/hooks/use-stdin.ts +10 -0
  68. package/src/hooks/use-stdout.ts +8 -0
  69. package/src/hooks/use-window-size.ts +41 -0
  70. package/src/indent-string.ts +16 -0
  71. package/src/index.ts +44 -0
  72. package/src/ink.tsx +1507 -0
  73. package/src/input-parser.ts +283 -0
  74. package/src/instances.ts +9 -0
  75. package/src/is-in-ci.ts +7 -0
  76. package/src/kitty-keyboard.ts +57 -0
  77. package/src/log-update.ts +370 -0
  78. package/src/measure-element.ts +62 -0
  79. package/src/measure-text.ts +31 -0
  80. package/src/output.ts +308 -0
  81. package/src/parse-keypress.ts +516 -0
  82. package/src/parse-stack-line.ts +139 -0
  83. package/src/patch-console.ts +62 -0
  84. package/src/quick-lru.ts +85 -0
  85. package/src/reconciler.ts +451 -0
  86. package/src/render-background.ts +38 -0
  87. package/src/render-border.ts +134 -0
  88. package/src/render-node-to-output.ts +191 -0
  89. package/src/render-to-string.ts +131 -0
  90. package/src/render.ts +276 -0
  91. package/src/renderer.ts +73 -0
  92. package/src/sanitize-ansi.ts +33 -0
  93. package/src/signal-exit.ts +107 -0
  94. package/src/squash-text-nodes.ts +40 -0
  95. package/src/stream.ts +30 -0
  96. package/src/styles.ts +748 -0
  97. package/src/terminal-size.ts +57 -0
  98. package/src/throttle.ts +73 -0
  99. package/src/types.ts +15 -0
  100. package/src/utils.ts +40 -0
  101. package/src/wrap-text.ts +50 -0
  102. package/src/write-synchronized.ts +9 -0
  103. package/src/yoga/config.ts +57 -0
  104. package/src/yoga/core/absoluteLayout.ts +626 -0
  105. package/src/yoga/core/baseline.ts +66 -0
  106. package/src/yoga/core/cache.ts +136 -0
  107. package/src/yoga/core/calculateLayout.ts +2920 -0
  108. package/src/yoga/core/config.ts +104 -0
  109. package/src/yoga/core/flexLine.ts +177 -0
  110. package/src/yoga/core/helpers.ts +293 -0
  111. package/src/yoga/core/layoutResults.ts +167 -0
  112. package/src/yoga/core/node.ts +611 -0
  113. package/src/yoga/core/numeric.ts +44 -0
  114. package/src/yoga/core/pixelGrid.ts +151 -0
  115. package/src/yoga/core/style.ts +887 -0
  116. package/src/yoga/core/types.ts +224 -0
  117. package/src/yoga/generated/YGEnums.ts +263 -0
  118. package/src/yoga/index.ts +19 -0
  119. package/src/yoga/node.ts +1140 -0
  120. package/dist/index.d.mts +0 -2379
  121. package/dist/index.mjs +0 -10072
package/src/ink.tsx ADDED
@@ -0,0 +1,1507 @@
1
+ /** @jsxImportSource react */
2
+ import process from "node:process";
3
+
4
+ import { type ReactNode } from "react";
5
+ import { type FiberRoot } from "react-reconciler";
6
+ import { LegacyRoot, ConcurrentRoot } from "react-reconciler/constants.js";
7
+
8
+ import { ansiEscapes, bsu, esu } from "./ansi/escapes.ts";
9
+ import { wrapAnsi } from "./ansi/wrap.ts";
10
+ import { autoBind } from "./auto-bind.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 { instances } from "./instances.ts";
17
+ import { isInCi } from "./is-in-ci.ts";
18
+ import { type KittyKeyboardOptions, type KittyFlagName, resolveFlags } from "./kitty-keyboard.ts";
19
+ import { logUpdate, type LogUpdate, type CursorPosition } from "./log-update.ts";
20
+ import { patchConsole } from "./patch-console.ts";
21
+ import { reconciler } from "./reconciler.ts";
22
+ import { renderer as render } from "./renderer.ts";
23
+ import { signalExit } from "./signal-exit.ts";
24
+ import { isTty, type OutputStream } from "./stream.ts";
25
+ import { throttle, type Throttled } from "./throttle.ts";
26
+ import { getWindowSize } from "./utils.ts";
27
+ import { shouldSynchronize } from "./write-synchronized.ts";
28
+ import { Yoga } from "./yoga/index.ts";
29
+
30
+ 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
+
121
+ const shouldClearTerminalForFrame = ({
122
+ isTTY,
123
+ viewportRows,
124
+ previousOutputHeight,
125
+ nextOutputHeight,
126
+ isUnmounting,
127
+ }: {
128
+ isTTY: boolean;
129
+ viewportRows: number;
130
+ previousOutputHeight: number;
131
+ nextOutputHeight: number;
132
+ isUnmounting: boolean;
133
+ }): boolean => {
134
+ if (!isTTY) {
135
+ return false;
136
+ }
137
+
138
+ const hadPreviousFrame = previousOutputHeight > 0;
139
+ const wasFullscreen = previousOutputHeight >= viewportRows;
140
+ const wasOverflowing = previousOutputHeight > viewportRows;
141
+ const isOverflowing = nextOutputHeight > viewportRows;
142
+ const isFullscreen = nextOutputHeight >= viewportRows;
143
+ // Only a frame that actually OVERFLOWED the viewport needs the full
144
+ // clear when shrinking back to inline — its top rows live above the top
145
+ // margin where incremental erase cannot reach. A frame that exactly
146
+ // filled the viewport is erasable in place; clearing the terminal for it
147
+ // destroys the user's scrollback for no benefit (and rapid height
148
+ // resizes routinely produce transient exactly-fullscreen frames).
149
+ const isLeavingFullscreen = wasOverflowing && nextOutputHeight < viewportRows;
150
+ const shouldClearOnUnmount = isUnmounting && wasFullscreen;
151
+
152
+ if (isWindowsConsole && (wasFullscreen || isFullscreen)) {
153
+ return true;
154
+ }
155
+
156
+ return (
157
+ // Overflowing frames still need full clear fallback.
158
+ wasOverflowing ||
159
+ (isOverflowing && hadPreviousFrame) ||
160
+ // Clear when shrinking from fullscreen to non-fullscreen output.
161
+ isLeavingFullscreen ||
162
+ // Preserve legacy unmount behavior for fullscreen frames: final teardown
163
+ // render should clear once to avoid leaving a scrolled viewport state.
164
+ shouldClearOnUnmount
165
+ );
166
+ };
167
+
168
+ const isErrorInput = (value: unknown): value is Error => {
169
+ return value instanceof Error || Object.prototype.toString.call(value) === "[object Error]";
170
+ };
171
+
172
+ const getWritableStreamState = (stdout: OutputStream) => {
173
+ const canWriteToStdout = !stdout.destroyed && !stdout.writableEnded && (stdout.writable ?? true);
174
+
175
+ return {
176
+ canWriteToStdout,
177
+ };
178
+ };
179
+
180
+ const settleThrottle = (throttled: unknown, canWriteToStdout: boolean): void => {
181
+ if (!throttled || typeof (throttled as { flush?: unknown }).flush !== "function") {
182
+ return;
183
+ }
184
+
185
+ const throttledValue = throttled as {
186
+ flush: () => void;
187
+ cancel?: () => void;
188
+ };
189
+
190
+ if (canWriteToStdout) {
191
+ throttledValue.flush();
192
+ } else if (typeof throttledValue.cancel === "function") {
193
+ throttledValue.cancel();
194
+ }
195
+ };
196
+
197
+ /**
198
+ The origin of a chunk captured by `patchConsole`: a patched `console.*`
199
+ method, or a direct `stdout.write` / `stderr.write` call.
200
+ */
201
+ export type CapturedOutputSource = "console" | "stdio";
202
+
203
+ // With `patchConsole: "stdio"` the real streams' `write` is intercepted, so
204
+ // Ink's own frame writes must bypass the capture. This facade carries the
205
+ // original `write` while event subscriptions still land on the real stream —
206
+ // an unmodified `Object.create` clone would get its own EventEmitter
207
+ // listener table and never see the real stream's `resize` events.
208
+ const createRenderPassthrough = (stream: OutputStream): OutputStream => {
209
+ const passthrough = Object.create(stream) as OutputStream;
210
+ passthrough.write = stream.write.bind(stream);
211
+ passthrough.on = stream.on.bind(stream);
212
+ passthrough.off = stream.off.bind(stream);
213
+ passthrough.once = stream.once.bind(stream);
214
+ passthrough.addListener = stream.addListener.bind(stream);
215
+ passthrough.removeListener = stream.removeListener.bind(stream);
216
+ passthrough.emit = stream.emit.bind(stream);
217
+ return passthrough;
218
+ };
219
+
220
+ /**
221
+ Performance metrics for a render operation.
222
+ */
223
+ export type RenderMetrics = {
224
+ /**
225
+ Time spent rendering in milliseconds.
226
+ */
227
+ renderTime: number;
228
+ };
229
+
230
+ export type Options = {
231
+ stdout: OutputStream;
232
+ stdin: NodeJS.ReadableStream;
233
+ stderr: OutputStream;
234
+ debug: boolean;
235
+ exitOnCtrlC: boolean;
236
+
237
+ /**
238
+ Patch console methods so `console.*` output doesn't mix with Ink's output.
239
+
240
+ Pass `"stdio"` to additionally intercept direct `stdout.write` /
241
+ `stderr.write` calls (from dependencies, native warnings, child tooling)
242
+ on the streams Ink renders to. Captured output is line-buffered and
243
+ spliced above the live frame, exactly like console output; Ink's own
244
+ frame writes bypass the capture.
245
+ */
246
+ patchConsole: boolean | "stdio";
247
+
248
+ /**
249
+ Observe output captured by `patchConsole` before Ink displays it.
250
+
251
+ Called with each captured chunk and its origin: `"console"` for patched
252
+ `console.*` calls, `"stdio"` for direct stream writes (only emitted with
253
+ `patchConsole: "stdio"`). Return `true` to take ownership of the chunk —
254
+ Ink will not display it, letting the app render it itself (for example
255
+ inside a `<Static>` transcript).
256
+ */
257
+ onCapturedOutput?: (
258
+ stream: "stdout" | "stderr",
259
+ data: string,
260
+ source: CapturedOutputSource,
261
+ ) => boolean | undefined | void;
262
+ onRender?: (metrics: RenderMetrics) => void;
263
+ isScreenReaderEnabled?: boolean;
264
+ waitUntilExit?: () => Promise<unknown>;
265
+ maxFps?: number;
266
+ incrementalRendering?: boolean;
267
+
268
+ /**
269
+ Enable React Concurrent Rendering mode.
270
+
271
+ When enabled:
272
+ - Suspense boundaries work correctly with async data
273
+ - `useTransition` and `useDeferredValue` are fully functional
274
+ - Updates can be interrupted for higher priority work
275
+
276
+ Note: Concurrent mode changes the timing of renders. Some tests may need to use `act()` to properly await updates. Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change the rendering mode or create a fresh instance.
277
+
278
+ @default false
279
+ @experimental
280
+ */
281
+ concurrent?: boolean;
282
+ kittyKeyboard?: KittyKeyboardOptions;
283
+
284
+ /**
285
+ Override automatic interactive mode detection.
286
+
287
+ By default, Ink detects whether the environment is interactive based on CI detection (via [`is-in-ci`](https://github.com/sindresorhus/is-in-ci)) and `stdout.isTTY`. Most users should not need to set this.
288
+
289
+ 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
+
291
+ Set to `false` to force non-interactive mode or `true` to force interactive mode when the automatic detection doesn't suit your use case.
292
+
293
+ Note: Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change this option or create a fresh instance.
294
+
295
+ @default true (false if in CI or `stdout.isTTY` is falsy)
296
+
297
+ @see {@link RenderOptions.interactive}
298
+ */
299
+ interactive?: boolean;
300
+
301
+ /**
302
+ Render the app in the terminal's alternate screen buffer. When enabled, the app renders on a separate screen, and the original terminal content is restored when the app exits. This is the same mechanism used by programs like vim, htop, and less.
303
+
304
+ Note: The terminal's scrollback buffer is not available while in the alternate screen. This is standard terminal behavior; programs like vim use the alternate screen specifically to avoid polluting the user's scrollback history.
305
+
306
+ Note: Ink intentionally treats alternate-screen teardown output as disposable. It does not preserve or replay teardown-time frames, hook writes, or `console.*` output after restoring the primary screen.
307
+
308
+ Only works in interactive mode. Ignored when `interactive` is `false` or in a non-interactive environment (CI, piped stdout).
309
+
310
+ Note: Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change this option or create a fresh instance.
311
+
312
+ @default false
313
+
314
+ @see {@link RenderOptions.alternateScreen}
315
+ */
316
+ alternateScreen?: boolean;
317
+ };
318
+
319
+ export class Ink {
320
+ /**
321
+ Whether this instance is using concurrent rendering mode.
322
+ */
323
+ readonly isConcurrent: boolean;
324
+
325
+ private readonly options: Options;
326
+ private readonly log: LogUpdate;
327
+ private cursorPosition: CursorPosition | undefined;
328
+ private readonly throttledLog: LogUpdate | Throttled<[output: string]>;
329
+
330
+ private readonly isScreenReaderEnabled: boolean;
331
+ private readonly interactive: boolean;
332
+ private readonly renderThrottleMs: number;
333
+ private alternateScreen: boolean;
334
+
335
+ // Ignore last render after unmounting a tree to prevent empty output before exit
336
+ private isUnmounted: boolean;
337
+ private isUnmounting: boolean;
338
+ private lastOutput: string;
339
+ private lastOutputToRender: string;
340
+ private lastOutputHeight: number;
341
+ private lastTerminalWidth: number;
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;
352
+ // Set when patchConsole is "stdio": the real streams whose write is patched.
353
+ private readonly captureTargets?: { stdout: OutputStream; stderr: OutputStream };
354
+ // Partial trailing lines from captured direct writes, held until a newline.
355
+ private readonly capturedStdioTails = { stdout: "", stderr: "" };
356
+ private readonly unsubscribeResize?: () => void;
357
+ private readonly throttledOnRender?: Throttled<never[]>;
358
+ private hasPendingThrottledRender = false;
359
+ private kittyProtocolEnabled = false;
360
+ private kittyFlags: KittyFlagName[] | undefined;
361
+ private cancelKittyDetection?: () => void;
362
+ private nextRenderCommit?: { promise: Promise<void>; resolve: () => void };
363
+ // Set while suspendTerminal() has handed the terminal to a child process.
364
+ private isSuspended = false;
365
+ // Input pause/resume hooks registered by the App component, which owns raw
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
+ }
384
+
385
+ this.options = options;
386
+ this.rootNode = dom.createNode("ink-root");
387
+ this.rootNode.onComputeLayout = this.calculateLayout;
388
+
389
+ this.isScreenReaderEnabled =
390
+ options.isScreenReaderEnabled ?? process.env["SIGIL_SCREEN_READER"] === "true";
391
+
392
+ // CI detection takes precedence: even a TTY stdout in CI defaults to non-interactive.
393
+ // Using Boolean(isTTY) (rather than an 'in' guard) correctly handles piped streams
394
+ // where the property is absent (e.g. `node app.js | cat`).
395
+ this.interactive = this.resolveInteractiveOption(options.interactive);
396
+
397
+ this.alternateScreen = false;
398
+
399
+ const unthrottled = options.debug || this.isScreenReaderEnabled;
400
+ const maxFps = options.maxFps ?? 30;
401
+ // Treat non-positive maxFps as an internal fallback case, not a supported
402
+ // "disable throttling" mode. Keep animation scheduling on a normal cadence
403
+ // so future changes don't accidentally reintroduce zero-delay loops.
404
+ const renderThrottleMs = maxFps > 0 ? Math.max(1, Math.ceil(1000 / maxFps)) : 0;
405
+ this.renderThrottleMs = unthrottled ? 0 : renderThrottleMs;
406
+
407
+ if (unthrottled) {
408
+ this.rootNode.onRender = this.onRender;
409
+ this.throttledOnRender = undefined;
410
+ } else {
411
+ const throttled = throttle(this.onRender, renderThrottleMs);
412
+ this.rootNode.onRender = () => {
413
+ this.hasPendingThrottledRender = true;
414
+ throttled();
415
+ };
416
+
417
+ this.throttledOnRender = throttled;
418
+ }
419
+
420
+ this.rootNode.onImmediateRender = this.onRender;
421
+ this.rootNode.onStaticChange = this.handleStaticChange;
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
+ });
441
+
442
+ // Ignore last render after unmounting a tree to prevent empty output before exit
443
+ this.isUnmounted = false;
444
+ this.isUnmounting = false;
445
+
446
+ // Store concurrent mode setting
447
+ this.isConcurrent = options.concurrent ?? false;
448
+
449
+ // Store last output to only rerender when needed
450
+ this.lastOutput = "";
451
+ this.lastOutputToRender = "";
452
+ this.lastOutputHeight = 0;
453
+ this.lastTerminalWidth = getWindowSize(this.options.stdout).columns;
454
+ this.lastTerminalHeight = getWindowSize(this.options.stdout).rows;
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
+ );
476
+
477
+ // Unmount when process exits
478
+ this.unsubscribeExit = signalExit(this.unmount.bind(this), { alwaysLast: false });
479
+
480
+ this.setAlternateScreen(Boolean(options.alternateScreen));
481
+
482
+ if (process.env["SIGIL_DEV"] === "true") {
483
+ // @ts-expect-error outdated types
484
+ reconciler.injectIntoDevTools();
485
+ }
486
+
487
+ if (options.patchConsole) {
488
+ this.patchConsole();
489
+ }
490
+
491
+ if (this.interactive) {
492
+ options.stdout.on("resize", this.resized);
493
+
494
+ this.unsubscribeResize = () => {
495
+ options.stdout.off("resize", this.resized);
496
+ };
497
+ }
498
+
499
+ this.initKittyKeyboard();
500
+
501
+ this.exitPromise = new Promise((resolve, reject) => {
502
+ this.resolveExitPromise = resolve;
503
+ this.rejectExitPromise = reject;
504
+ });
505
+ // Prevent global unhandled-rejection crashes when app code exits with an
506
+ // error but consumers never call waitUntilExit().
507
+
508
+ void this.exitPromise.catch(noop);
509
+ }
510
+
511
+ resized = () => {
512
+ const currentWidth = getWindowSize(this.options.stdout).columns;
513
+ const currentHeight = getWindowSize(this.options.stdout).rows;
514
+
515
+ // A width decrease rewraps lines and any height change moves content
516
+ // through scrollback, so the incremental render state no longer
517
+ // matches the screen. Erase what is still visible and force the next
518
+ // render to be a full rewrite instead of an incremental diff that
519
+ // would skip "unchanged" lines over stale screen content.
520
+ if (currentWidth < this.lastTerminalWidth || currentHeight !== this.lastTerminalHeight) {
521
+ // `log.clear()` erases the full previous frame line count from
522
+ // the cursor upward — after a height grow that also covers frame
523
+ // lines the emulator pulled back from scrollback, so no extra
524
+ // erase is needed for them.
525
+ this.log.clear();
526
+ this.lastOutput = "";
527
+ this.lastOutputToRender = "";
528
+ // Also forget the previous frame height: it described a frame
529
+ // that no longer exists on screen, and letting it flow into
530
+ // shouldClearTerminalForFrame would trigger a scrollback-erasing
531
+ // clearTerminal on a height shrink.
532
+ this.lastOutputHeight = 0;
533
+ }
534
+
535
+ this.calculateLayout();
536
+ dom.emitLayoutListeners(this.rootNode);
537
+ this.onRender();
538
+
539
+ this.lastTerminalWidth = currentWidth;
540
+ this.lastTerminalHeight = currentHeight;
541
+ };
542
+
543
+ resolveExitPromise: (result?: unknown) => void = () => {};
544
+ rejectExitPromise: (reason?: Error) => void = () => {};
545
+ unsubscribeExit: () => void = () => {};
546
+
547
+ handleAppExit = (errorOrResult?: unknown): void => {
548
+ if (this.isUnmounted || this.isUnmounting) {
549
+ return;
550
+ }
551
+
552
+ if (isErrorInput(errorOrResult)) {
553
+ this.unmount(errorOrResult);
554
+ return;
555
+ }
556
+
557
+ this.exitResult = errorOrResult;
558
+ this.unmount();
559
+ };
560
+
561
+ setCursorPosition = (position: CursorPosition | undefined): void => {
562
+ this.cursorPosition = position;
563
+ this.log.setCursorPosition(position);
564
+ };
565
+
566
+ restoreLastOutput = (): void => {
567
+ if (!this.interactive) {
568
+ return;
569
+ }
570
+
571
+ // Clear() resets log-update's cursor state, so replay the latest cursor intent
572
+ // before restoring output after external stdout/stderr writes.
573
+ this.log.setCursorPosition(this.cursorPosition);
574
+ this.log(this.lastOutputToRender || this.lastOutput + "\n");
575
+ };
576
+
577
+ calculateLayout = () => {
578
+ const terminalWidth = getWindowSize(this.options.stdout).columns;
579
+
580
+ this.rootNode.yogaNode!.setWidth(terminalWidth);
581
+
582
+ this.rootNode.yogaNode!.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
583
+ };
584
+
585
+ // Resets `fullStaticOutput` when the <Static> identity changes so stale items from a previous instance are not replayed on future rewrites.
586
+ handleStaticChange = (): void => {
587
+ this.fullStaticOutput = "";
588
+ };
589
+
590
+ onRender: () => void = () => {
591
+ this.hasPendingThrottledRender = false;
592
+
593
+ if (this.isUnmounted) {
594
+ return;
595
+ }
596
+
597
+ // While suspended, the terminal belongs to a child process. Discard queued
598
+ // renders; resume() forces a full redraw once Ink reclaims the terminal.
599
+ // Resolve any awaited render commit so callers don't hang during suspension.
600
+ if (this.isSuspended) {
601
+ if (this.nextRenderCommit) {
602
+ this.nextRenderCommit.resolve();
603
+ this.nextRenderCommit = undefined;
604
+ }
605
+
606
+ return;
607
+ }
608
+
609
+ if (this.nextRenderCommit) {
610
+ this.nextRenderCommit.resolve();
611
+ this.nextRenderCommit = undefined;
612
+ }
613
+
614
+ const startTime = performance.now();
615
+ const { output, outputHeight, staticOutput } = render(
616
+ this.rootNode,
617
+ this.isScreenReaderEnabled,
618
+ );
619
+
620
+ this.options.onRender?.({ renderTime: performance.now() - startTime });
621
+
622
+ // If <Static> output isn't empty, it means new children have been added to it
623
+ const hasStaticOutput = staticOutput && staticOutput !== "\n";
624
+
625
+ if (this.options.debug) {
626
+ if (hasStaticOutput) {
627
+ this.fullStaticOutput += staticOutput;
628
+ }
629
+
630
+ this.lastOutput = output;
631
+ this.lastOutputToRender = output;
632
+ this.lastOutputHeight = outputHeight;
633
+ this.options.stdout.write(this.fullStaticOutput + output);
634
+ return;
635
+ }
636
+
637
+ if (!this.interactive) {
638
+ if (hasStaticOutput) {
639
+ this.options.stdout.write(staticOutput);
640
+ }
641
+
642
+ this.lastOutput = output;
643
+ this.lastOutputToRender = output + "\n";
644
+ this.lastOutputHeight = outputHeight;
645
+ return;
646
+ }
647
+
648
+ if (this.isScreenReaderEnabled) {
649
+ const sync = this.shouldSync();
650
+ if (sync) {
651
+ this.options.stdout.write(bsu);
652
+ }
653
+
654
+ if (hasStaticOutput) {
655
+ // We need to erase the main output before writing new static output
656
+ const erase =
657
+ this.lastOutputHeight > 0 ? ansiEscapes.eraseLines(this.lastOutputHeight) : "";
658
+ this.options.stdout.write(erase + staticOutput);
659
+ // After erasing, the last output is gone, so we should reset its height
660
+ this.lastOutputHeight = 0;
661
+ }
662
+
663
+ if (output === this.lastOutput && !hasStaticOutput) {
664
+ if (sync) {
665
+ this.options.stdout.write(esu);
666
+ }
667
+
668
+ return;
669
+ }
670
+
671
+ const terminalWidth = getWindowSize(this.options.stdout).columns;
672
+
673
+ const wrappedOutput = wrapAnsi(output, terminalWidth, {
674
+ trim: false,
675
+ hard: true,
676
+ });
677
+
678
+ // If we haven't erased yet, do it now.
679
+ if (hasStaticOutput) {
680
+ this.options.stdout.write(wrappedOutput);
681
+ } else {
682
+ const erase =
683
+ this.lastOutputHeight > 0 ? ansiEscapes.eraseLines(this.lastOutputHeight) : "";
684
+ this.options.stdout.write(erase + wrappedOutput);
685
+ }
686
+
687
+ this.lastOutput = output;
688
+ this.lastOutputToRender = wrappedOutput;
689
+ this.lastOutputHeight = wrappedOutput === "" ? 0 : wrappedOutput.split("\n").length;
690
+
691
+ if (sync) {
692
+ this.options.stdout.write(esu);
693
+ }
694
+
695
+ return;
696
+ }
697
+
698
+ if (hasStaticOutput) {
699
+ this.fullStaticOutput += staticOutput;
700
+ }
701
+
702
+ this.renderInteractiveFrame(output, outputHeight, hasStaticOutput ? staticOutput : "");
703
+ };
704
+
705
+ render(node: ReactNode): void {
706
+ const tree = (
707
+ <AccessibilityContext.Provider value={{ isScreenReaderEnabled: this.isScreenReaderEnabled }}>
708
+ <App
709
+ stdin={this.options.stdin}
710
+ stdout={this.options.stdout}
711
+ stderr={this.options.stderr}
712
+ exitOnCtrlC={this.options.exitOnCtrlC}
713
+ interactive={this.interactive}
714
+ renderThrottleMs={this.renderThrottleMs}
715
+ writeToStdout={this.writeToStdout.bind(this)}
716
+ writeToStderr={this.writeToStderr.bind(this)}
717
+ setCursorPosition={this.setCursorPosition.bind(this)}
718
+ onExit={this.handleAppExit.bind(this)}
719
+ onWaitUntilRenderFlush={this.waitUntilRenderFlush.bind(this)}
720
+ onSuspendTerminal={this.suspendTerminal.bind(this)}
721
+ onRegisterInputControl={this.registerInputControl.bind(this)}
722
+ >
723
+ {node}
724
+ </App>
725
+ </AccessibilityContext.Provider>
726
+ );
727
+
728
+ if (this.options.concurrent) {
729
+ // Concurrent mode: use updateContainer (async scheduling)
730
+ reconciler.updateContainer(tree, this.container, null, noop);
731
+ } else {
732
+ // Legacy mode: use updateContainerSync + flushSyncWork (sync)
733
+ reconciler.updateContainerSync(tree, this.container, null, noop);
734
+ reconciler.flushSyncWork();
735
+ }
736
+ }
737
+
738
+ writeToStdout(data: string): void {
739
+ if (this.isUnmounted) {
740
+ return;
741
+ }
742
+
743
+ // While suspended, the terminal belongs to a child process. Don't erase or
744
+ // repaint Ink's frame around console output; the forced redraw on resume
745
+ // restores the screen.
746
+ if (this.isSuspended) {
747
+ return;
748
+ }
749
+
750
+ if (this.options.debug) {
751
+ this.options.stdout.write(data + this.fullStaticOutput + this.lastOutput);
752
+ return;
753
+ }
754
+
755
+ if (!this.interactive) {
756
+ this.options.stdout.write(data);
757
+ return;
758
+ }
759
+
760
+ const sync = this.shouldSync();
761
+ if (sync) {
762
+ this.options.stdout.write(bsu);
763
+ }
764
+
765
+ this.log.clear();
766
+ this.options.stdout.write(data);
767
+ this.restoreLastOutput();
768
+
769
+ if (sync) {
770
+ this.options.stdout.write(esu);
771
+ }
772
+ }
773
+
774
+ writeToStderr(data: string): void {
775
+ if (this.isUnmounted) {
776
+ return;
777
+ }
778
+
779
+ // See writeToStdout: stay off the terminal while suspended.
780
+ if (this.isSuspended) {
781
+ return;
782
+ }
783
+
784
+ if (this.options.debug) {
785
+ this.options.stderr.write(data);
786
+ this.options.stdout.write(this.fullStaticOutput + this.lastOutput);
787
+ return;
788
+ }
789
+
790
+ if (!this.interactive) {
791
+ this.options.stderr.write(data);
792
+ return;
793
+ }
794
+
795
+ const sync = this.shouldSync();
796
+ if (sync) {
797
+ this.options.stdout.write(bsu);
798
+ }
799
+
800
+ this.log.clear();
801
+ this.options.stderr.write(data);
802
+ this.restoreLastOutput();
803
+
804
+ if (sync) {
805
+ this.options.stdout.write(esu);
806
+ }
807
+ }
808
+
809
+ // eslint-disable-next-line @typescript-eslint/no-restricted-types
810
+ unmount(error?: Error | number | null): void {
811
+ if (this.isUnmounted || this.isUnmounting) {
812
+ return;
813
+ }
814
+
815
+ this.isUnmounting = true;
816
+
817
+ if (this.beforeExitHandler) {
818
+ process.off("beforeExit", this.beforeExitHandler);
819
+ this.beforeExitHandler = undefined;
820
+ }
821
+
822
+ const { stdout } = this.options;
823
+ const { canWriteToStdout } = getWritableStreamState(stdout);
824
+
825
+ // Display any partial captured stdio lines while writes still go through.
826
+ if (canWriteToStdout) {
827
+ this.flushCapturedStdio();
828
+ }
829
+
830
+ // Clear any pending throttled render timer on unmount. When stdout is writable,
831
+ // flush so the final frame is emitted; otherwise cancel to avoid delayed callbacks.
832
+ settleThrottle(this.throttledOnRender, canWriteToStdout);
833
+
834
+ if (canWriteToStdout) {
835
+ // If throttling is enabled and there is already a pending render, flushing above
836
+ // is sufficient. Also avoid calling onRender() again when static output already
837
+ // exists, as that can duplicate <Static> children output on exit (see issue #397).
838
+ const shouldRenderFinalFrame =
839
+ !this.throttledOnRender ||
840
+ (!this.hasPendingThrottledRender && this.fullStaticOutput === "");
841
+
842
+ if (shouldRenderFinalFrame) {
843
+ this.calculateLayout();
844
+ this.onRender();
845
+ }
846
+ }
847
+
848
+ // Mark as unmounted after the final render but before stdout writes
849
+ // that could re-enter exit() via synchronous write callbacks.
850
+ this.isUnmounted = true;
851
+
852
+ this.unsubscribeExit();
853
+
854
+ // Flush any pending throttled log writes if possible, otherwise cancel to
855
+ // prevent delayed callbacks from writing to a closed stream.
856
+ settleThrottle(this.throttledLog, canWriteToStdout);
857
+ if (typeof this.restoreConsole === "function") {
858
+ // Once unmount starts, Ink stops trying to manage teardown-time
859
+ // console output. Restoring the native console before React cleanup keeps
860
+ // unmount behavior simple and avoids special-case handling for custom
861
+ // streams, fullscreen frames, and alternate-screen teardown.
862
+ this.restoreConsole();
863
+ }
864
+
865
+ const finishUnmount = (): void => {
866
+ if (typeof this.unsubscribeResize === "function") {
867
+ this.unsubscribeResize();
868
+ }
869
+
870
+ // Cancel any in-progress auto-detection before checking protocol state
871
+ if (this.cancelKittyDetection) {
872
+ this.cancelKittyDetection();
873
+ }
874
+
875
+ if (canWriteToStdout) {
876
+ if (this.kittyProtocolEnabled) {
877
+ this.writeBestEffort(this.options.stdout, ansiEscapes.popKittyKeyboard);
878
+ }
879
+
880
+ // Alternate-screen content is disposable by design. We intentionally
881
+ // leave it active until React cleanup finishes, then restore the
882
+ // primary buffer without replaying prior frames, hook writes, or
883
+ // diagnostics onto it. Trying to preserve teardown output across the
884
+ // buffer switch adds fragile lifecycle-specific behavior, so Ink keeps
885
+ // alternate-screen teardown intentionally simple and best-effort.
886
+ if (this.alternateScreen) {
887
+ this.writeBestEffort(this.options.stdout, ansiEscapes.exitAlternativeScreen);
888
+ this.writeBestEffort(this.options.stdout, showCursorEscape);
889
+ this.alternateScreen = false;
890
+ }
891
+
892
+ if (!this.interactive) {
893
+ // Non-interactive environments don't handle erasing ansi escapes well.
894
+ // In debug mode, each render already writes to stdout, so only a trailing
895
+ // newline is needed. In non-debug mode, write the last frame now (it was
896
+ // deferred during rendering).
897
+ this.options.stdout.write(this.options.debug ? "\n" : this.lastOutput + "\n");
898
+ } else if (!this.options.debug) {
899
+ this.log.done();
900
+ }
901
+ }
902
+
903
+ this.kittyProtocolEnabled = false;
904
+
905
+ instances.delete(this.captureTargets?.stdout ?? this.options.stdout);
906
+
907
+ // Ensure all queued writes have been processed before resolving the
908
+ // exit promise. Queue an empty write as a barrier — its callback fires
909
+ // only after all prior writes complete.
910
+ //
911
+ // When called from signal-exit during process shutdown (error is a
912
+ // number or null rather than undefined/Error), resolve synchronously
913
+ // because the event loop is draining and async callbacks won't fire.
914
+ const { exitResult } = this;
915
+
916
+ const resolveOrReject = () => {
917
+ if (isErrorInput(error)) {
918
+ this.rejectExitPromise(error);
919
+ } else {
920
+ this.resolveExitPromise(exitResult);
921
+ }
922
+ };
923
+
924
+ const isProcessExiting = error !== undefined && !isErrorInput(error);
925
+
926
+ if (isProcessExiting) {
927
+ resolveOrReject();
928
+ } else if (canWriteToStdout) {
929
+ this.options.stdout.write("", resolveOrReject);
930
+ } else {
931
+ setImmediate(resolveOrReject);
932
+ }
933
+ };
934
+
935
+ const concurrentReconciler = reconciler as {
936
+ flushPassiveEffects?: () => boolean;
937
+ };
938
+
939
+ if (this.options.concurrent) {
940
+ reconciler.updateContainerSync(null, this.container, null, noop);
941
+ reconciler.flushSyncWork();
942
+ concurrentReconciler.flushPassiveEffects?.();
943
+ finishUnmount();
944
+ } else {
945
+ // Legacy mode: use updateContainerSync + flushSyncWork (sync)
946
+ reconciler.updateContainerSync(null, this.container, null, noop);
947
+ reconciler.flushSyncWork();
948
+ finishUnmount();
949
+ }
950
+ }
951
+
952
+ async waitUntilExit(): Promise<unknown> {
953
+ if (!this.beforeExitHandler) {
954
+ this.beforeExitHandler = () => {
955
+ this.unmount();
956
+ };
957
+
958
+ process.once("beforeExit", this.beforeExitHandler);
959
+ }
960
+
961
+ return this.exitPromise;
962
+ }
963
+
964
+ async waitUntilRenderFlush(): Promise<void> {
965
+ if (this.isUnmounted || this.isUnmounting) {
966
+ await this.awaitExit();
967
+ return;
968
+ }
969
+
970
+ // Yield to the macrotask queue so that React's scheduler has a chance to
971
+ // fire passive effects and process any work they enqueued.
972
+ await yieldImmediate();
973
+
974
+ if (this.isUnmounted || this.isUnmounting) {
975
+ await this.awaitExit();
976
+ return;
977
+ }
978
+
979
+ // In concurrent mode, React's scheduler may still be mid-render after
980
+ // the yield. Wait for the next render commit instead of polling.
981
+ if (this.isConcurrent && this.hasPendingConcurrentWork()) {
982
+ await Promise.race([this.awaitNextRender(), this.awaitExit()]);
983
+
984
+ if (this.isUnmounted || this.isUnmounting) {
985
+ this.nextRenderCommit = undefined;
986
+ await this.awaitExit();
987
+ return;
988
+ }
989
+ }
990
+
991
+ reconciler.flushSyncWork();
992
+
993
+ const { stdout } = this.options;
994
+ const { canWriteToStdout } = getWritableStreamState(stdout);
995
+
996
+ // Flush pending throttled render/log timers so their output is included in this wait.
997
+ settleThrottle(this.throttledOnRender, canWriteToStdout);
998
+ settleThrottle(this.throttledLog, canWriteToStdout);
999
+
1000
+ if (canWriteToStdout) {
1001
+ await new Promise<void>((resolve) => {
1002
+ this.options.stdout.write("", () => {
1003
+ resolve();
1004
+ });
1005
+ });
1006
+ return;
1007
+ }
1008
+
1009
+ await yieldImmediate();
1010
+ }
1011
+
1012
+ clear(): void {
1013
+ if (this.interactive && !this.options.debug) {
1014
+ this.log.clear();
1015
+ // Sync lastOutput so that unmount's final onRender
1016
+ // sees it as unchanged and log-update skips it
1017
+ this.log.sync(this.lastOutputToRender || this.lastOutput + "\n");
1018
+ }
1019
+ }
1020
+
1021
+ patchConsole(): void {
1022
+ if (this.options.debug) {
1023
+ return;
1024
+ }
1025
+
1026
+ const restoreConsoleMethods = patchConsole((stream, data) => {
1027
+ if (this.options.onCapturedOutput?.(stream, data, "console") === true) {
1028
+ return;
1029
+ }
1030
+
1031
+ if (stream === "stdout") {
1032
+ this.writeToStdout(data);
1033
+ }
1034
+
1035
+ if (stream === "stderr") {
1036
+ const isReactMessage = data.startsWith("The above error occurred");
1037
+
1038
+ if (!isReactMessage) {
1039
+ this.writeToStderr(data);
1040
+ }
1041
+ }
1042
+ });
1043
+
1044
+ const restoreDirectStdio = this.patchDirectStdio();
1045
+
1046
+ this.restoreConsole = () => {
1047
+ restoreConsoleMethods();
1048
+ restoreDirectStdio?.();
1049
+ };
1050
+ }
1051
+
1052
+ // Intercept direct `write` calls on the real streams. Ink renders through
1053
+ // passthrough facades, so everything arriving here is external output.
1054
+ private patchDirectStdio(): (() => void) | undefined {
1055
+ const targets = this.captureTargets;
1056
+
1057
+ if (!targets) {
1058
+ return;
1059
+ }
1060
+
1061
+ const patch = (name: "stdout" | "stderr", stream: OutputStream): (() => void) => {
1062
+ // Keep the unbound reference: restore below must reassign the exact
1063
+ // original function object, not a bound copy.
1064
+ // oxlint-disable-next-line typescript/unbound-method
1065
+ const originalWrite = stream.write;
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);
1101
+
1102
+ return () => {
1103
+ restoreStdout();
1104
+ restoreStderr();
1105
+ };
1106
+ }
1107
+
1108
+ private handleCapturedStdio(stream: "stdout" | "stderr", data: string): void {
1109
+ if (this.options.onCapturedOutput?.(stream, data, "stdio") === true) {
1110
+ return;
1111
+ }
1112
+
1113
+ // Line-buffer: direct writers emit partial chunks (progress bars,
1114
+ // spinners), and only complete lines can be spliced above the live
1115
+ // frame without corrupting it. The trailing partial line is held until
1116
+ // its newline arrives, or flushed at unmount/suspend.
1117
+ const parts = (this.capturedStdioTails[stream] + data).split(/\r?\n/);
1118
+ this.capturedStdioTails[stream] = parts.pop() ?? "";
1119
+
1120
+ if (parts.length === 0) {
1121
+ return;
1122
+ }
1123
+
1124
+ const payload = parts.join("\n") + "\n";
1125
+
1126
+ if (stream === "stdout") {
1127
+ this.writeToStdout(payload);
1128
+ } else {
1129
+ this.writeToStderr(payload);
1130
+ }
1131
+ }
1132
+
1133
+ // Display any partial captured lines that never received a newline.
1134
+ private flushCapturedStdio(): void {
1135
+ for (const stream of ["stdout", "stderr"] as const) {
1136
+ const tail = this.capturedStdioTails[stream];
1137
+
1138
+ if (tail === "") {
1139
+ continue;
1140
+ }
1141
+
1142
+ this.capturedStdioTails[stream] = "";
1143
+
1144
+ if (stream === "stdout") {
1145
+ this.writeToStdout(tail + "\n");
1146
+ } else {
1147
+ this.writeToStderr(tail + "\n");
1148
+ }
1149
+ }
1150
+ }
1151
+
1152
+ registerInputControl(pauseInput: () => void, resumeInput: () => void): void {
1153
+ this.pauseInput = pauseInput;
1154
+ this.resumeInput = resumeInput;
1155
+ }
1156
+
1157
+ async suspendTerminal(callback: () => void | Promise<void>): Promise<void>;
1158
+ async suspendTerminal(): Promise<TerminalSuspension>;
1159
+ async suspendTerminal(callback?: () => void | Promise<void>): Promise<void | TerminalSuspension> {
1160
+ this.beginSuspend();
1161
+
1162
+ if (callback) {
1163
+ try {
1164
+ await callback();
1165
+ } finally {
1166
+ await this.endSuspend();
1167
+ }
1168
+
1169
+ return;
1170
+ }
1171
+
1172
+ const resume = async (): Promise<void> => {
1173
+ await this.endSuspend();
1174
+ };
1175
+
1176
+ return { resume, [Symbol.asyncDispose]: resume };
1177
+ }
1178
+
1179
+ private setAlternateScreen(enabled: boolean): void {
1180
+ this.alternateScreen = this.resolveAlternateScreenOption(enabled, this.interactive);
1181
+
1182
+ if (this.alternateScreen) {
1183
+ this.writeBestEffort(this.options.stdout, ansiEscapes.enterAlternativeScreen);
1184
+ this.writeBestEffort(this.options.stdout, hideCursorEscape);
1185
+ }
1186
+ }
1187
+
1188
+ private resolveInteractiveOption(interactive: boolean | undefined): boolean {
1189
+ return interactive ?? (!isInCi && Boolean(this.options.stdout.isTTY));
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 {}
1208
+ }
1209
+
1210
+ // Waits for the exit promise to settle, suppressing any rejection.
1211
+ // Errors are surfaced via waitUntilExit() instead.
1212
+ private async awaitExit(): Promise<void> {
1213
+ try {
1214
+ await this.exitPromise;
1215
+ } catch {}
1216
+ }
1217
+
1218
+ private hasPendingConcurrentWork(): boolean {
1219
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
1220
+ const concurrentContainer = this.container as {
1221
+ pendingLanes?: number;
1222
+ callbackNode?: unknown;
1223
+ };
1224
+ return (
1225
+ (concurrentContainer.pendingLanes ?? 0) !== 0 &&
1226
+ concurrentContainer.callbackNode !== undefined &&
1227
+ concurrentContainer.callbackNode !== null
1228
+ );
1229
+ }
1230
+
1231
+ private async awaitNextRender(): Promise<void> {
1232
+ if (!this.nextRenderCommit) {
1233
+ let resolveRender!: () => void;
1234
+ const promise = new Promise<void>((resolve) => {
1235
+ resolveRender = resolve;
1236
+ });
1237
+ this.nextRenderCommit = { promise, resolve: resolveRender };
1238
+ }
1239
+
1240
+ return this.nextRenderCommit.promise;
1241
+ }
1242
+
1243
+ private renderInteractiveFrame(output: string, outputHeight: number, staticOutput: string): void {
1244
+ const hasStaticOutput = staticOutput !== "";
1245
+ const isTTY = Boolean(this.options.stdout.isTTY);
1246
+
1247
+ // Detect fullscreen: output fills or exceeds terminal height.
1248
+ // Only apply when writing to a real TTY — piped output always gets trailing newlines.
1249
+ const viewportRows = isTTY ? getWindowSize(this.options.stdout).rows : 24;
1250
+
1251
+ // Clamp the frame to the viewport, keeping its bottom rows. Rows above
1252
+ // the top margin cannot be updated or erased in place, and the
1253
+ // historical fallback for such frames — a full clearTerminal including
1254
+ // an ESC[3J scrollback erase — destroys the user's scrollback on every
1255
+ // overflowing update. A frame taller than the terminal is unreadable
1256
+ // anyway; components size themselves from useWindowSize to avoid it.
1257
+ if (isTTY && outputHeight > viewportRows) {
1258
+ const lines = output.split("\n");
1259
+ output = lines.slice(lines.length - viewportRows).join("\n");
1260
+ outputHeight = viewportRows;
1261
+ }
1262
+
1263
+ const isFullscreen = isTTY && outputHeight >= viewportRows;
1264
+ const outputToRender = isFullscreen ? output : output + "\n";
1265
+
1266
+ const shouldClearTerminal = shouldClearTerminalForFrame({
1267
+ isTTY,
1268
+ viewportRows,
1269
+ previousOutputHeight: this.lastOutputHeight,
1270
+ nextOutputHeight: outputHeight,
1271
+ isUnmounting: this.isUnmounting,
1272
+ });
1273
+
1274
+ if (shouldClearTerminal) {
1275
+ const sync = this.shouldSync();
1276
+ if (sync) {
1277
+ this.options.stdout.write(bsu);
1278
+ }
1279
+
1280
+ this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + outputToRender);
1281
+ this.lastOutput = output;
1282
+ this.lastOutputToRender = outputToRender;
1283
+ this.lastOutputHeight = outputHeight;
1284
+ this.log.sync(outputToRender);
1285
+
1286
+ if (sync) {
1287
+ this.options.stdout.write(esu);
1288
+ }
1289
+
1290
+ return;
1291
+ }
1292
+
1293
+ // To ensure static output is cleanly rendered before main output, clear main output first
1294
+ if (hasStaticOutput) {
1295
+ const sync = this.shouldSync();
1296
+ if (sync) {
1297
+ this.options.stdout.write(bsu);
1298
+ }
1299
+
1300
+ this.log.clear();
1301
+ this.options.stdout.write(staticOutput);
1302
+ this.log(outputToRender);
1303
+
1304
+ if (sync) {
1305
+ this.options.stdout.write(esu);
1306
+ }
1307
+ } else if (output !== this.lastOutput || this.log.isCursorDirty()) {
1308
+ // ThrottledLog manages its own bsu/esu at actual write time
1309
+ this.throttledLog(outputToRender);
1310
+ }
1311
+
1312
+ this.lastOutput = output;
1313
+ this.lastOutputToRender = outputToRender;
1314
+ this.lastOutputHeight = outputHeight;
1315
+ }
1316
+
1317
+ private initKittyKeyboard(): void {
1318
+ // Protocol is opt-in: if kittyKeyboard is not specified, do nothing
1319
+ if (!this.options.kittyKeyboard) {
1320
+ return;
1321
+ }
1322
+
1323
+ const opts = this.options.kittyKeyboard;
1324
+ const mode = opts.mode ?? "auto";
1325
+
1326
+ if (mode === "disabled") {
1327
+ return;
1328
+ }
1329
+
1330
+ const flags: KittyFlagName[] = opts.flags ?? ["disambiguateEscapeCodes"];
1331
+
1332
+ // 'enabled' force-enables the protocol as long as both streams are TTYs,
1333
+ // regardless of the interactive setting (e.g. even in CI).
1334
+ if (mode === "enabled") {
1335
+ if (isTty(this.options.stdin) && this.options.stdout.isTTY) {
1336
+ this.enableKittyProtocol(flags);
1337
+ }
1338
+
1339
+ return;
1340
+ }
1341
+
1342
+ // Auto mode: require interactive + TTY
1343
+ if (!this.interactive || !isTty(this.options.stdin) || !this.options.stdout.isTTY) {
1344
+ return;
1345
+ }
1346
+
1347
+ // 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
+ // This avoids maintaining a hardcoded whitelist of terminal names.
1351
+ this.confirmKittySupport(flags);
1352
+ }
1353
+
1354
+ private confirmKittySupport(flags: KittyFlagName[]): void {
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
+ }
1385
+ }
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);
1395
+ }
1396
+
1397
+ private enableKittyProtocol(flags: KittyFlagName[]): void {
1398
+ this.options.stdout.write(ansiEscapes.pushKittyKeyboard(resolveFlags(flags)));
1399
+ this.kittyProtocolEnabled = true;
1400
+ // Remember the flags so suspendTerminal() can re-enable the same protocol
1401
+ // after a child process has had the terminal.
1402
+ this.kittyFlags = flags;
1403
+ }
1404
+
1405
+ private beginSuspend(): void {
1406
+ if (this.isSuspended) {
1407
+ throw new Error(
1408
+ "The terminal is already suspended. Resume the current suspension before suspending again.",
1409
+ );
1410
+ }
1411
+
1412
+ this.isSuspended = true;
1413
+
1414
+ if (!this.interactive || this.isUnmounted || this.isUnmounting) {
1415
+ return;
1416
+ }
1417
+
1418
+ try {
1419
+ const { stdout } = this.options;
1420
+ const { canWriteToStdout } = getWritableStreamState(stdout);
1421
+
1422
+ // Flush any pending render/log so the child starts from a settled screen.
1423
+ settleThrottle(this.throttledOnRender, canWriteToStdout);
1424
+ settleThrottle(this.throttledLog, canWriteToStdout);
1425
+
1426
+ if (canWriteToStdout) {
1427
+ this.flushCapturedStdio();
1428
+ }
1429
+
1430
+ if (canWriteToStdout) {
1431
+ // Erase Ink's current frame, then show the cursor and re-arm the hide.
1432
+ // The forced redraw on resume hides the cursor again.
1433
+ this.log.clear();
1434
+ this.log.done();
1435
+
1436
+ if (this.kittyProtocolEnabled) {
1437
+ this.writeBestEffort(this.options.stdout, ansiEscapes.popKittyKeyboard);
1438
+ }
1439
+
1440
+ if (this.alternateScreen) {
1441
+ this.writeBestEffort(this.options.stdout, ansiEscapes.exitAlternativeScreen);
1442
+ }
1443
+ }
1444
+
1445
+ // Hand input back to the terminal (raw mode off, bracketed paste off).
1446
+ this.pauseInput?.();
1447
+ } catch (error) {
1448
+ // If handing over the terminal fails partway, don't strand the app in a
1449
+ // suspended state with no way back. Best-effort reclaim input, clear the
1450
+ // flag, and rethrow so the caller sees the failure.
1451
+ this.isSuspended = false;
1452
+
1453
+ try {
1454
+ this.resumeInput?.();
1455
+ } catch {}
1456
+
1457
+ throw error;
1458
+ }
1459
+ }
1460
+
1461
+ private async endSuspend(): Promise<void> {
1462
+ if (!this.isSuspended) {
1463
+ return;
1464
+ }
1465
+
1466
+ this.isSuspended = false;
1467
+
1468
+ // Reclaim input even mid-unmount: pauseInput already ran in beginSuspend, so
1469
+ // restoring it is symmetric regardless of any state change during suspension.
1470
+ this.resumeInput?.();
1471
+
1472
+ if (!this.interactive || this.isUnmounted || this.isUnmounting) {
1473
+ return;
1474
+ }
1475
+
1476
+ const { stdout } = this.options;
1477
+ const { canWriteToStdout } = getWritableStreamState(stdout);
1478
+
1479
+ if (canWriteToStdout) {
1480
+ if (this.alternateScreen) {
1481
+ this.writeBestEffort(this.options.stdout, ansiEscapes.enterAlternativeScreen);
1482
+ }
1483
+
1484
+ if (this.kittyProtocolEnabled && this.kittyFlags) {
1485
+ this.writeBestEffort(
1486
+ this.options.stdout,
1487
+ ansiEscapes.pushKittyKeyboard(resolveFlags(this.kittyFlags)),
1488
+ );
1489
+ }
1490
+ }
1491
+
1492
+ // Force a full redraw instead of diffing against the stale pre-suspension
1493
+ // frame, which the child process may have overwritten. A redraw failure here
1494
+ // is best-effort: it must not mask a callback error propagating through the
1495
+ // caller's finally block.
1496
+ this.lastOutput = "";
1497
+ this.lastOutputToRender = "";
1498
+ this.lastOutputHeight = 0;
1499
+ this.log.reset();
1500
+
1501
+ try {
1502
+ this.calculateLayout();
1503
+ this.onRender();
1504
+ await this.waitUntilRenderFlush();
1505
+ } catch {}
1506
+ }
1507
+ }