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

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