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

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