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

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