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

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