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

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