/** @jsxImportSource react */ import { setImmediate as yieldImmediate } from "node:timers/promises"; import { isNativeError } from "node:util/types"; import { type ReactNode } from "react"; import { type FiberRoot } from "react-reconciler"; import { LegacyRoot, ConcurrentRoot } from "react-reconciler/constants.js"; import { ansiEscapes, bsu, esu } from "#/ansi/escapes.ts"; import type { ClipboardSelection, TerminalProgressState } from "#/ansi/osc.ts"; import { wrapAnsi } from "#/ansi/wrap.ts"; import { accessibilityContext as AccessibilityContext } from "#/components/AccessibilityContext.ts"; import { App } from "#/components/App.tsx"; import { type TerminalSuspension } from "#/components/AppContext.ts"; import { TerminalOscContext } from "#/components/TerminalOscContext.ts"; import type { CursorPosition } from "#/cursor-position.ts"; import * as dom from "#/dom.ts"; import { isSigilDev, isInCi, isScreenReader, isTty, isWindows } from "#/env.ts"; import { instances } from "#/instances.ts"; import { type KittyKeyboardOptions, type KittyFlagName, resolveFlags, detectKittySupport, } from "#/kitty-keyboard.ts"; import { patchConsole, patchStreamWrite } from "#/patch-console.ts"; import { reconciler } from "#/reconciler.ts"; import { renderFrame } from "#/render-frame.ts"; import { Screen } from "#/screen/screen.ts"; import { signalExit } from "#/signal-exit.ts"; import { type OutputStream } from "#/stream.ts"; import { createInlinePresenter } from "#/terminal/inline-presenter.ts"; import { createRenderScheduler } from "#/terminal/render-scheduler.ts"; import { TerminalSession } from "#/terminal/session.ts"; import { type Throttled } from "#/throttle.ts"; import { Yoga } from "#/yoga/index.ts"; const noop = () => {}; const beforeExitCallbacks = new Set<() => void>(); const runBeforeExitCallbacks = (): void => { for (const callback of beforeExitCallbacks) callback(); }; function registerBeforeExit(callback: () => void): () => void { if (beforeExitCallbacks.size === 0) process.on("beforeExit", runBeforeExitCallbacks); beforeExitCallbacks.add(callback); return () => { beforeExitCallbacks.delete(callback); if (beforeExitCallbacks.size === 0) process.off("beforeExit", runBeforeExitCallbacks); }; } function bottomRows(screen: Screen, height: number): Screen { if (screen.height <= height) return screen; const cropped = new Screen(screen.width, height); const offset = screen.height - height; for (let y = 0; y < height; y++) { for (let x = 0; x < screen.width; x++) { const cell = screen.cellAt(x, y + offset); if (cell && cell.width > 0) cropped.setCell(x, y, cell); } } return cropped; } // How long the reported terminal size must hold still before a resize is // acted on. Window drags deliver an event per frame (~16 ms), and the // emulator needs a frame or so to agree with the PTY size either way. const RESIZE_SETTLE_MS = 50; const shouldClearTerminalForFrame = ({ isTTY, viewportRows, previousOutputHeight, nextOutputHeight, isUnmounting, }: { isTTY: boolean; viewportRows: number; previousOutputHeight: number; nextOutputHeight: number; isUnmounting: boolean; }): boolean => { if (!isTTY) { return false; } const hadPreviousFrame = previousOutputHeight > 0; const wasFullscreen = previousOutputHeight >= viewportRows; const wasOverflowing = previousOutputHeight > viewportRows; const isOverflowing = nextOutputHeight > viewportRows; const isFullscreen = nextOutputHeight >= viewportRows; // Only a frame that actually OVERFLOWED the viewport needs the full // clear when shrinking back to inline — its top rows live above the top // margin where incremental erase cannot reach. A frame that exactly // filled the viewport is erasable in place; clearing the terminal for it // destroys the user's scrollback for no benefit (and rapid height // resizes routinely produce transient exactly-fullscreen frames). const isLeavingFullscreen = wasOverflowing && nextOutputHeight < viewportRows; const shouldClearOnUnmount = isUnmounting && wasFullscreen; // Windows consoles scroll the buffer when the bottom-right cell is written, // unlike xterm-like terminals which defer the wrap. That extra scroll // desynchronizes the incremental erase used for frames that exactly fill the // viewport, leaving stale copies of previous frames behind (#969). Keep the // pre-7.0 behavior of fully clearing between fullscreen frames there. if (isWindows && (wasFullscreen || isFullscreen)) { return true; } return ( // Overflowing frames still need full clear fallback. wasOverflowing || (isOverflowing && hadPreviousFrame) || // Clear when shrinking from fullscreen to non-fullscreen output. isLeavingFullscreen || // Preserve legacy unmount behavior for fullscreen frames: final teardown // render should clear once to avoid leaving a scrolled viewport state. shouldClearOnUnmount ); }; const isErrorInput = (value: unknown): value is Error => { return value instanceof Error || isNativeError(value); }; const getWritableStreamState = (stdout: OutputStream) => { const canWriteToStdout = !stdout.destroyed && !stdout.writableEnded && (stdout.writable ?? true); return { canWriteToStdout, }; }; const settleThrottle = ( throttled: Throttled | undefined, canWriteToStdout: boolean, ): void => { if (!throttled) { return; } if (canWriteToStdout) { throttled.flush(); } else { throttled.cancel(); } }; // Best-effort write: streams may already be destroyed during shutdown. const writeBestEffort = (stream: OutputStream, data: string): void => { try { stream.write(data); } catch {} }; /** The origin of a chunk captured by `patchConsole`: a patched `console.*` method, or a direct `stdout.write` / `stderr.write` call. */ export type CapturedOutputSource = "console" | "stdio"; // With `patchConsole: "stdio"` the real streams' `write` is intercepted, so // Ink's own frame writes must bypass the capture. This facade carries the // original `write` while event subscriptions still land on the real stream — // an unmodified `Object.create` clone would get its own EventEmitter // listener table and never see the real stream's `resize` events. const createRenderPassthrough = (stream: OutputStream): OutputStream => { const passthrough = Object.create(stream) as OutputStream; passthrough.write = stream.write.bind(stream); passthrough.on = stream.on.bind(stream); passthrough.off = stream.off.bind(stream); passthrough.once = stream.once.bind(stream); passthrough.addListener = stream.addListener.bind(stream); passthrough.removeListener = stream.removeListener.bind(stream); passthrough.emit = stream.emit.bind(stream); return passthrough; }; /** Performance metrics for a render operation. */ export type RenderMetrics = { /** Time spent rendering in milliseconds. */ renderTime: number; }; export type Options = { stdout: OutputStream; stdin: NodeJS.ReadableStream; stderr: OutputStream; debug: boolean; exitOnCtrlC: boolean; /** Patch console methods so `console.*` output doesn't mix with Ink's output. Pass `"stdio"` to additionally intercept direct `stdout.write` / `stderr.write` calls (from dependencies, native warnings, child tooling) on the streams Ink renders to. Captured output is line-buffered and spliced above the live frame, exactly like console output; Ink's own frame writes bypass the capture. */ patchConsole: boolean | "stdio"; /** Observe output captured by `patchConsole` before Ink displays it. Called with each captured chunk and its origin: `"console"` for patched `console.*` calls, `"stdio"` for direct stream writes (only emitted with `patchConsole: "stdio"`). Return `true` to take ownership of the chunk — Ink will not display it, letting the app render it itself (for example inside a `` transcript). */ onCapturedOutput?: ( stream: "stdout" | "stderr", data: string, source: CapturedOutputSource, ) => boolean | undefined | void; onRender?: (metrics: RenderMetrics) => void; isScreenReaderEnabled?: boolean; maxFps?: number; colorProfile?: import("#/screen/color-profile.ts").ColorProfile; /** Enable React Concurrent Rendering mode. When enabled: - Suspense boundaries work correctly with async data - `useTransition` and `useDeferredValue` are fully functional - Updates can be interrupted for higher priority work 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. @default false @experimental */ concurrent?: boolean; kittyKeyboard?: KittyKeyboardOptions; /** Override automatic interactive mode detection. 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. 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. Set to `false` to force non-interactive mode or `true` to force interactive mode when the automatic detection doesn't suit your use case. 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. @default true (false if in CI or `stdout.isTTY` is falsy) @see {@link RenderOptions.interactive} */ interactive?: boolean; /** 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. 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. 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. Only works in interactive mode. Ignored when `interactive` is `false` or in a non-interactive environment (CI, piped stdout). 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. @default false @see {@link RenderOptions.alternateScreen} */ alternateScreen?: boolean; }; /** A live React terminal runtime for one stdout stream, created by `createInk`. */ export type Ink = { /** Replace the previous root node with a new one or update props of the current root node. */ render: (node: ReactNode) => void; /** Unmount the app and release the terminal. */ // eslint-disable-next-line @typescript-eslint/no-restricted-types unmount: (error?: Error | number | null) => void; /** Returns a promise that settles when the app is unmounted. */ waitUntilExit: () => Promise; /** Returns a promise that settles after pending render output is flushed to stdout. */ waitUntilRenderFlush: () => Promise; /** Clear output. */ clear: () => void; /** Copy text through the renderer-owned terminal session. */ copyToClipboard: (text: string, selection?: ClipboardSelection) => boolean; /** Update terminal-native progress through the renderer-owned session. */ setProgress: (state: TerminalProgressState, value?: number) => boolean; }; export const createInk = (options: Options): Ink => { // Set when patchConsole is "stdio": the real streams whose write is patched. let captureTargets: { stdout: OutputStream; stderr: OutputStream } | undefined; if (options.patchConsole === "stdio") { // Keep the real streams for patching (and for the instance registry, // which is keyed by the stream passed to render()), and render // through passthrough facades that bypass the capture. captureTargets = { stdout: options.stdout, stderr: options.stderr }; options = { ...options, stdout: createRenderPassthrough(options.stdout), stderr: createRenderPassthrough(options.stderr), }; } const rootNode = dom.createNode("ink-root"); rootNode.onComputeLayout = calculateLayout; const isScreenReaderEnabled = options.isScreenReaderEnabled ?? isScreenReader; // CI detection takes precedence: even a TTY stdout in CI defaults to non-interactive. // Using Boolean(isTTY) (rather than an 'in' guard) correctly handles piped streams // where the property is absent (e.g. `node app.js | cat`). const interactive = options.interactive ?? (!isInCi && Boolean(options.stdout.isTTY)); const terminal = new TerminalSession({ stdin: options.stdin, stdout: options.stdout, stderr: options.stderr, colorPolicy: options.colorProfile ?? "auto", onCapabilitiesChange: () => rootNode.onRender?.(), }); const terminalOsc = { publishProgress: ( owner: symbol, state: import("#/ansi/osc.ts").TerminalProgressState, value?: number, ) => { terminal.publishProgress(owner, state, value); }, copyToClipboard: (text: string, selection?: import("#/ansi/osc.ts").ClipboardSelection) => { terminal.copyToClipboard(text, selection); }, publishTitle: (owner: symbol, title?: string) => terminal.publishTitle(owner, title), setWorkingDirectory: (directory: URL | string) => terminal.setWorkingDirectory(directory), notify: (title: string) => terminal.notify(title), setPointerShape: (shape: string) => terminal.setPointerShape(shape), }; const capabilitiesStore = terminal.capabilities; const unthrottled = options.debug || isScreenReaderEnabled; const renderScheduler = createRenderScheduler(onRender, { unthrottled, maxFps: options.maxFps ?? 30, }); rootNode.onRender = renderScheduler.schedule; rootNode.onImmediateRender = renderScheduler.immediate; rootNode.onStaticChange = handleStaticChange; const accessiblePresenter = isScreenReaderEnabled ? createInlinePresenter(options.stdout, { showCursor: true }) : undefined; // Ignore last render after unmounting a tree to prevent empty output before exit let isUnmounted = false; let isUnmounting = false; const isConcurrent = options.concurrent ?? false; // Store last output to only rerender when needed let lastOutput = ""; let lastOutputToRender = ""; let lastOutputHeight = 0; let lastScreen: Screen | undefined; // The terminal size comes from the capabilities store: the stream's // `columns`/`rows`, or — on terminals that send in-band size reports — // the emulator's own figure, which is the one later output will meet. const windowSize = () => capabilitiesStore.current.size; let lastTerminalWidth = windowSize().columns; let lastTerminalHeight = windowSize().rows; // Resize events arrive in bursts (one per frame of a window drag), and the // emulator's rewrap and the PTY size never update atomically — depending on // the terminal app the PTY is resized before or after the screen is // rewrapped. Anything written mid-burst lands on a screen whose width is // not the reported one, and the rows that leaves behind can never be // accounted for by a later erase. Events therefore only arm this settle // timer; the frame is erased and repainted once the size has held still. let resizeSettle: ReturnType | undefined; // This variable is used only in debug mode to store full static output // so that it's rerendered every time, not just new static parts, like in non-debug mode let fullStaticOutput = ""; let exitResult: unknown; let unsubscribeBeforeExit: (() => void) | undefined; let restoreConsole: (() => void) | undefined; // Partial trailing lines from captured direct writes, held until a newline. const capturedStdioTails = { stdout: "", stderr: "" }; let unsubscribeResize: (() => void) | undefined; let kittyProtocolEnabled = false; let kittyFlags: KittyFlagName[] | undefined; let cancelKittyDetection: (() => void) | undefined; let nextRenderCommit: { promise: Promise; resolve: () => void } | undefined; // Input pause/resume hooks registered by the App component, which owns raw // mode and bracketed paste state. let pauseInput: (() => void) | undefined; let resumeInput: (() => void) | undefined; // Use ConcurrentRoot for concurrent mode, LegacyRoot for legacy mode const rootTag = isConcurrent ? ConcurrentRoot : LegacyRoot; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const container: FiberRoot = reconciler.createContainer( rootNode, rootTag, null, false, null, "id", () => {}, () => {}, () => {}, () => {}, ); // Unmount when process exits const unsubscribeExit = signalExit(unmount, { alwaysLast: false }); setAlternateScreen(Boolean(options.alternateScreen)); // @ts-expect-error outdated types if (isSigilDev) reconciler.injectIntoDevTools(); if (options.patchConsole) installConsolePatch(); if (interactive) { // Follow the store rather than the stream's `resize` event: the store // folds in the terminal's in-band size reports where available, and // says which source a size came from. let seenColumns = lastTerminalWidth; let seenRows = lastTerminalHeight; unsubscribeResize = capabilitiesStore.subscribe(({ size }) => { if (size.columns === seenColumns && size.rows === seenRows) return; seenColumns = size.columns; seenRows = size.rows; resized(size.source); }); } initKittyKeyboard(); const { promise: exitPromise, resolve: resolveExitPromise, reject: rejectExitPromise, } = Promise.withResolvers(); // Prevent global unhandled-rejection crashes when app code exits with an // error but consumers never call waitUntilExit(). void exitPromise.catch(noop); function resized(source: "pty" | "terminal"): void { if (resizeSettle !== undefined) { clearTimeout(resizeSettle); resizeSettle = undefined; } // An in-band report is the emulator's own word, sent after it rewrapped // and ordered with everything else in the stream, so there is nothing // to wait for. A PTY size may run ahead of or behind the emulator. if (source === "terminal") { settleResize(); return; } resizeSettle = setTimeout(settleResize, RESIZE_SETTLE_MS); } function settleResize(): void { resizeSettle = undefined; if (isUnmounted || isUnmounting) return; const { columns: currentWidth, rows: currentHeight } = windowSize(); // A width decrease rewraps lines and any height change moves content // through scrollback, so the incremental render state no longer // matches the screen. Erase what is still visible and force the next // render to be a full rewrite instead of an incremental diff that // would skip "unchanged" lines over stale screen content. if (currentWidth < lastTerminalWidth || currentHeight !== lastTerminalHeight) { // Clearing erases the full previous frame from the cursor upward — // after a height grow that also covers frame lines the emulator // pulled back from scrollback, so no extra erase is needed for them. // The current width lets the presenter count the rows a reflowing // emulator has already rewrapped after a width shrink; the logical // line count alone under-erases and leaves the frame's top rows // behind. Terminals that never rewrap keep the logical count, since // the rewrap-aware one would erase rows above the frame there. clearLiveOutput(rewrapsOnResize() ? currentWidth : undefined); resetLiveOutput(); lastOutput = ""; lastOutputToRender = ""; // Also forget the previous frame height: it described a frame // that no longer exists on screen, and letting it flow into // shouldClearTerminalForFrame would trigger a scrollback-erasing // clearTerminal on a height shrink. lastOutputHeight = 0; } lastTerminalWidth = currentWidth; lastTerminalHeight = currentHeight; calculateLayout(); dom.emitLayoutListeners(rootNode); onRender(); } // Whether the terminal rewraps existing screen rows when its width // changes. Nearly every modern emulator does (xterm.js, Ghostty, kitty, // iTerm2, WezTerm, Alacritty, Windows Terminal, tmux); Apple's Terminal.app // and the classic Windows console keep rows as they were written. function rewrapsOnResize(): boolean { return !isWindows && capabilitiesStore.current.terminal.name !== "apple-terminal"; } function handleAppExit(errorOrResult?: unknown): void { if (isUnmounted || isUnmounting) { return; } if (isErrorInput(errorOrResult)) { unmount(errorOrResult); return; } exitResult = errorOrResult; unmount(); } function setCursorPosition(position: CursorPosition | undefined): void { terminal.setCursor(position); accessiblePresenter?.setCursorPosition(position); } function restoreLastOutput(): void { if (!interactive) { return; } // Replay the latest cursor intent when restoring after external output. if (isScreenReaderEnabled) { accessiblePresenter!.setCursorPosition(terminal.cursor.position); accessiblePresenter!(lastOutputToRender || lastOutput + "\n"); } else if (lastScreen) { terminal.present(lastScreen, { fullscreen: lastOutputToRender === lastOutput, forceRewrite: true, }); } } function clearLiveOutput(columns?: number): void { if (isScreenReaderEnabled) accessiblePresenter!.clear(); else terminal.clearFrame({ columns }); } function finishLiveOutput(): void { if (isScreenReaderEnabled) accessiblePresenter!.done(); else terminal.finishFrame(); } function resetLiveOutput(): void { if (isScreenReaderEnabled) accessiblePresenter!.reset(); else terminal.resetFrame(); } function calculateLayout(): void { const terminalWidth = windowSize().columns; rootNode.yogaNode!.setWidth(terminalWidth); rootNode.yogaNode!.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); } // Resets `fullStaticOutput` when the identity changes so stale items from a previous instance are not replayed on future rewrites. function handleStaticChange(): void { fullStaticOutput = ""; } function onRender(): void { renderScheduler.markRendered(); if (isUnmounted) { return; } // While suspended, the terminal belongs to a child process. Discard queued // renders; resume() forces a full redraw once Ink reclaims the terminal. // Resolve any awaited render commit so callers don't hang during suspension. if (terminal.suspended) { if (nextRenderCommit) { nextRenderCommit.resolve(); nextRenderCommit = undefined; } return; } // A resize burst is still settling: the emulator is rewrapping the screen // under us, and the frame will be erased and repainted as a whole once it // holds still. Writing now would leave rows no later erase can find. if (resizeSettle !== undefined && !isUnmounting) { if (nextRenderCommit) { nextRenderCommit.resolve(); nextRenderCommit = undefined; } return; } if (nextRenderCommit) { nextRenderCommit.resolve(); nextRenderCommit = undefined; } const startTime = performance.now(); const rendered = renderFrame(rootNode, isScreenReaderEnabled, { colorProfile: terminal.colorProfile, paintContext: { appearance: capabilitiesStore.current.theme.appearance, palette: capabilitiesStore.current.theme.palette, }, }); const screen = rendered.screen; const output = rendered.accessibleText ?? (screen ? terminal.encode(screen) : ""); const outputHeight = rendered.accessibleText === undefined ? (screen?.height ?? 0) : rendered.accessibleText === "" ? 0 : rendered.accessibleText.split("\n").length; const staticBody = rendered.staticAccessibleText ?? (rendered.staticScreen ? terminal.encode(rendered.staticScreen) : ""); const staticOutput = staticBody ? `${staticBody}\n` : ""; options.onRender?.({ renderTime: performance.now() - startTime }); // If output isn't empty, it means new children have been added to it const hasStaticOutput = staticOutput && staticOutput !== "\n"; if (options.debug) { if (hasStaticOutput) { fullStaticOutput += staticOutput; } lastOutput = output; lastOutputToRender = output; lastOutputHeight = outputHeight; options.stdout.write(fullStaticOutput + output); return; } if (!interactive) { if (hasStaticOutput) { options.stdout.write(staticOutput); } lastOutput = output; lastOutputToRender = output + "\n"; lastOutputHeight = outputHeight; return; } if (isScreenReaderEnabled) { const sync = shouldSync(); if (sync) { options.stdout.write(bsu); } if (hasStaticOutput) { // We need to erase the main output before writing new static output const erase = lastOutputHeight > 0 ? ansiEscapes.eraseLines(lastOutputHeight) : ""; options.stdout.write(erase + staticOutput); // After erasing, the last output is gone, so we should reset its height lastOutputHeight = 0; } if (output === lastOutput && !hasStaticOutput) { if (sync) { options.stdout.write(esu); } return; } const terminalWidth = windowSize().columns; const wrappedOutput = wrapAnsi(output, terminalWidth, { trim: false, hard: true, }); // If we haven't erased yet, do it now. if (hasStaticOutput) { options.stdout.write(wrappedOutput); } else { const erase = lastOutputHeight > 0 ? ansiEscapes.eraseLines(lastOutputHeight) : ""; options.stdout.write(erase + wrappedOutput); } lastOutput = output; lastOutputToRender = wrappedOutput; lastOutputHeight = wrappedOutput === "" ? 0 : wrappedOutput.split("\n").length; if (sync) { options.stdout.write(esu); } return; } if (hasStaticOutput) { fullStaticOutput += staticOutput; } renderInteractiveFrame( output, outputHeight, hasStaticOutput ? staticOutput : "", screen, false, ); } function render(node: ReactNode): void { const tree = ( {node} ); if (isConcurrent) { // Concurrent mode: use updateContainer (async scheduling) reconciler.updateContainer(tree, container, null, noop); } else { // Legacy mode: use updateContainerSync + flushSyncWork (sync) reconciler.updateContainerSync(tree, container, null, noop); reconciler.flushSyncWork(); } } function writeToStdout(data: string): void { if (isUnmounted) { return; } // While suspended, the terminal belongs to a child process. Don't erase or // repaint Ink's frame around console output; the forced redraw on resume // restores the screen. if (terminal.suspended) { return; } if (options.debug) { options.stdout.write(data + fullStaticOutput + lastOutput); return; } if (!interactive) { options.stdout.write(data); return; } const sync = shouldSync(); if (sync) { options.stdout.write(bsu); } clearLiveOutput(); options.stdout.write(data); restoreLastOutput(); if (sync) { options.stdout.write(esu); } } function writeToStderr(data: string): void { if (isUnmounted) { return; } // See writeToStdout: stay off the terminal while suspended. if (terminal.suspended) { return; } if (options.debug) { options.stderr.write(data); options.stdout.write(fullStaticOutput + lastOutput); return; } if (!interactive) { options.stderr.write(data); return; } const sync = shouldSync(); if (sync) { options.stdout.write(bsu); } clearLiveOutput(); options.stderr.write(data); restoreLastOutput(); if (sync) { options.stdout.write(esu); } } // eslint-disable-next-line @typescript-eslint/no-restricted-types function unmount(error?: Error | number | null): void { if (isUnmounted || isUnmounting) { return; } isUnmounting = true; if (resizeSettle !== undefined) { clearTimeout(resizeSettle); resizeSettle = undefined; } unsubscribeBeforeExit?.(); unsubscribeBeforeExit = undefined; const { canWriteToStdout } = getWritableStreamState(options.stdout); // Display any partial captured stdio lines while writes still go through. if (canWriteToStdout) { flushCapturedStdio(); } // Clear any pending throttled render timer on unmount. When stdout is writable, // flush so the final frame is emitted; otherwise cancel to avoid delayed callbacks. settleThrottle(renderScheduler.throttled, canWriteToStdout); if (canWriteToStdout) { // If throttling is enabled and there is already a pending render, flushing above // is sufficient. Also avoid calling onRender() again when static output already // exists, as that can duplicate children output on exit (see issue #397). const shouldRenderFinalFrame = !renderScheduler.throttled || (!renderScheduler.pending && fullStaticOutput === ""); if (shouldRenderFinalFrame) { calculateLayout(); onRender(); } } // Mark as unmounted after the final render but before stdout writes // that could re-enter exit() via synchronous write callbacks. isUnmounted = true; unsubscribeExit(); terminal.cleanup(); if (typeof restoreConsole === "function") { // Once unmount starts, Ink stops trying to manage teardown-time // console output. Restoring the native console before React cleanup keeps // unmount behavior simple and avoids special-case handling for custom // streams, fullscreen frames, and alternate-screen teardown. restoreConsole(); } const finishUnmount = (): void => { if (typeof unsubscribeResize === "function") { unsubscribeResize(); } // Cancel any in-progress auto-detection before checking protocol state if (cancelKittyDetection) { cancelKittyDetection(); } if (canWriteToStdout) { if (kittyProtocolEnabled) { writeBestEffort(options.stdout, ansiEscapes.popKittyKeyboard); } // Alternate-screen content is disposable by design. We intentionally // leave it active until React cleanup finishes, then restore the // primary buffer without replaying prior frames, hook writes, or // diagnostics onto it. Trying to preserve teardown output across the // buffer switch adds fragile lifecycle-specific behavior, so Ink keeps // alternate-screen teardown intentionally simple and best-effort. if (terminal.alternateScreen) { terminal.setAlternateScreen(false); terminal.setCursorAppearance({ visible: true }); } if (!interactive) { // Non-interactive environments don't handle erasing ansi escapes well. // In debug mode, each render already writes to stdout, so only a trailing // newline is needed. In non-debug mode, write the last frame now (it was // deferred during rendering). options.stdout.write(options.debug ? "\n" : lastOutput + "\n"); } else if (!options.debug) { finishLiveOutput(); } } kittyProtocolEnabled = false; instances.delete(captureTargets?.stdout ?? options.stdout); // Ensure all queued writes have been processed before resolving the // exit promise. Queue an empty write as a barrier — its callback fires // only after all prior writes complete. // // When called from signal-exit during process shutdown (error is a // number or null rather than undefined/Error), resolve synchronously // because the event loop is draining and async callbacks won't fire. const finalExitResult = exitResult; const resolveOrReject = () => { if (isErrorInput(error)) { rejectExitPromise(error); } else { resolveExitPromise(finalExitResult); } }; const isProcessExiting = error !== undefined && !isErrorInput(error); if (isProcessExiting) { resolveOrReject(); } else if (canWriteToStdout) { options.stdout.write("", resolveOrReject); } else { setImmediate(resolveOrReject); } }; const concurrentReconciler = reconciler as { flushPassiveEffects?: () => boolean; }; if (isConcurrent) { reconciler.updateContainerSync(null, container, null, noop); reconciler.flushSyncWork(); concurrentReconciler.flushPassiveEffects?.(); finishUnmount(); } else { // Legacy mode: use updateContainerSync + flushSyncWork (sync) reconciler.updateContainerSync(null, container, null, noop); reconciler.flushSyncWork(); finishUnmount(); } } async function waitUntilExit(): Promise { if (!unsubscribeBeforeExit) { unsubscribeBeforeExit = registerBeforeExit(() => { unmount(); }); } return exitPromise; } async function waitUntilRenderFlush(): Promise { if (isUnmounted || isUnmounting) { await awaitExit(); return; } // Yield to the macrotask queue so that React's scheduler has a chance to // fire passive effects and process any work they enqueued. await yieldImmediate(); if (isUnmounted || isUnmounting) { await awaitExit(); return; } // In concurrent mode, React's scheduler may still be mid-render after // the yield. Wait for the next render commit instead of polling. if (isConcurrent && hasPendingConcurrentWork()) { await Promise.race([awaitNextRender(), awaitExit()]); if (isUnmounted || isUnmounting) { nextRenderCommit = undefined; await awaitExit(); return; } } reconciler.flushSyncWork(); const { canWriteToStdout } = getWritableStreamState(options.stdout); // Flush pending scheduled rendering so its output is included in this wait. settleThrottle(renderScheduler.throttled, canWriteToStdout); if (canWriteToStdout) { await new Promise((resolve) => { options.stdout.write("", () => { resolve(); }); }); return; } await yieldImmediate(); } function clear(): void { if (interactive && !options.debug) { clearLiveOutput(); // Sync lastOutput so that unmount's final onRender // sees it as unchanged and the presenter skips it if (isScreenReaderEnabled) accessiblePresenter!.sync(lastOutputToRender || lastOutput + "\n"); } } function installConsolePatch(): void { if (options.debug) { return; } const restoreConsoleMethods = patchConsole((stream, data) => { if (options.onCapturedOutput?.(stream, data, "console") === true) { return; } if (stream === "stdout") { writeToStdout(data); } if (stream === "stderr") { const isReactMessage = data.startsWith("The above error occurred"); if (!isReactMessage) { writeToStderr(data); } } }); const restoreDirectStdio = patchDirectStdio(); restoreConsole = () => { restoreConsoleMethods(); restoreDirectStdio?.(); }; } // Intercept direct `write` calls on the real streams. Ink renders through // passthrough facades, so everything arriving here is external output. function patchDirectStdio(): (() => void) | undefined { if (!captureTargets) { return; } const restoreStdout = patchStreamWrite(captureTargets.stdout, (data) => { handleCapturedStdio("stdout", data); }); const restoreStderr = patchStreamWrite(captureTargets.stderr, (data) => { handleCapturedStdio("stderr", data); }); return () => { restoreStdout(); restoreStderr(); }; } function handleCapturedStdio(stream: "stdout" | "stderr", data: string): void { if (options.onCapturedOutput?.(stream, data, "stdio") === true) { return; } // Line-buffer: direct writers emit partial chunks (progress bars, // spinners), and only complete lines can be spliced above the live // frame without corrupting it. The trailing partial line is held until // its newline arrives, or flushed at unmount/suspend. const parts = (capturedStdioTails[stream] + data).split(/\r?\n/); capturedStdioTails[stream] = parts.pop() ?? ""; if (parts.length === 0) { return; } const payload = parts.join("\n") + "\n"; if (stream === "stdout") { writeToStdout(payload); } else { writeToStderr(payload); } } // Display any partial captured lines that never received a newline. function flushCapturedStdio(): void { for (const stream of ["stdout", "stderr"] as const) { const tail = capturedStdioTails[stream]; if (tail === "") { continue; } capturedStdioTails[stream] = ""; if (stream === "stdout") { writeToStdout(tail + "\n"); } else { writeToStderr(tail + "\n"); } } } function registerInputControl(pause: () => void, resume: () => void): void { pauseInput = pause; resumeInput = resume; } function suspendTerminal(callback: () => void | Promise): Promise; function suspendTerminal(): Promise; async function suspendTerminal( callback?: () => void | Promise, ): Promise { beginSuspend(); if (callback) { try { await callback(); } finally { await endSuspend(); } return; } const resume = async (): Promise => { await endSuspend(); }; return { resume, [Symbol.asyncDispose]: resume }; } function setAlternateScreen(enabled: boolean): void { terminal.setAlternateScreen(enabled && interactive && Boolean(options.stdout.isTTY), { hideCursor: true, }); } function shouldSync(): boolean { // `interactive` already folds in CI detection and the caller's override. return Boolean(options.stdout.isTTY) && interactive; } // Waits for the exit promise to settle, suppressing any rejection. // Errors are surfaced via waitUntilExit() instead. async function awaitExit(): Promise { try { await exitPromise; } catch {} } function hasPendingConcurrentWork(): boolean { // oxlint-disable-next-line typescript/no-unsafe-type-assertion const concurrentContainer = container as { pendingLanes?: number; callbackNode?: unknown; }; return ( (concurrentContainer.pendingLanes ?? 0) !== 0 && concurrentContainer.callbackNode !== undefined && concurrentContainer.callbackNode !== null ); } async function awaitNextRender(): Promise { nextRenderCommit ??= Promise.withResolvers(); return nextRenderCommit.promise; } function renderInteractiveFrame( output: string, outputHeight: number, staticOutput: string, screen: Screen | undefined, hasOverflow: boolean, ): void { if (!screen) return; const hasStaticOutput = staticOutput !== ""; const isTTY = Boolean(options.stdout.isTTY); // Detect fullscreen: output fills or exceeds terminal height. // Only apply when writing to a real TTY — piped output always gets trailing newlines. const viewportRows = isTTY ? windowSize().rows : 24; // Clamp the frame to the viewport, keeping its bottom rows. Rows above // the top margin cannot be updated or erased in place, and the // historical fallback for such frames — a full clearTerminal including // an ESC[3J scrollback erase — destroys the user's scrollback on every // overflowing update. A frame taller than the terminal is unreadable // anyway; components size themselves from useWindowSize to avoid it. if (isTTY && outputHeight > viewportRows) { const lines = output.split("\n"); output = lines.slice(lines.length - viewportRows).join("\n"); outputHeight = viewportRows; screen = bottomRows(screen, viewportRows); } const isFullscreen = isTTY && outputHeight >= viewportRows; const outputToRender = isFullscreen ? output : output + "\n"; const shouldClearTerminal = shouldClearTerminalForFrame({ isTTY, viewportRows, previousOutputHeight: lastOutputHeight, nextOutputHeight: outputHeight, isUnmounting, }); if (shouldClearTerminal) { const sync = shouldSync(); if (sync) { options.stdout.write(bsu); } options.stdout.write(ansiEscapes.clearTerminal + fullStaticOutput + outputToRender); lastOutput = output; lastOutputToRender = outputToRender; lastOutputHeight = outputHeight; lastScreen = screen; terminal.resetFrame(); if (sync) { options.stdout.write(esu); } return; } const willPresent = terminal.willPresent(screen, { fullscreen: isFullscreen, forceRewrite: hasStaticOutput || hasOverflow, }); const sync = shouldSync() && willPresent; if (sync) terminal.write(bsu); if (hasStaticOutput) { terminal.clearFrame(); terminal.write(staticOutput); } terminal.present(screen, { fullscreen: isFullscreen, forceRewrite: hasStaticOutput || hasOverflow, }); if (sync) terminal.write(esu); lastOutput = output; lastOutputToRender = outputToRender; lastOutputHeight = outputHeight; lastScreen = screen; } function initKittyKeyboard(): void { // Protocol is opt-in: if kittyKeyboard is not specified, do nothing if (!options.kittyKeyboard) { return; } const opts = options.kittyKeyboard; const mode = opts.mode ?? "auto"; if (mode === "disabled") { return; } const flags: KittyFlagName[] = opts.flags ?? ["disambiguateEscapeCodes"]; // 'enabled' force-enables the protocol as long as both streams are TTYs, // regardless of the interactive setting (e.g. even in CI). if (mode === "enabled") { if (isTty(options.stdin) && options.stdout.isTTY) { enableKittyProtocol(flags); } return; } // Auto mode: require interactive + TTY if (!interactive || !isTty(options.stdin) || !options.stdout.isTTY) { return; } // Auto mode: query the terminal for kitty keyboard protocol support. // This avoids maintaining a hardcoded whitelist of terminal names. cancelKittyDetection = detectKittySupport(options.stdin, options.stdout, () => { cancelKittyDetection = undefined; if (!isUnmounted) { enableKittyProtocol(flags); } }); } function enableKittyProtocol(flags: KittyFlagName[]): void { options.stdout.write(ansiEscapes.pushKittyKeyboard(resolveFlags(flags))); kittyProtocolEnabled = true; // Remember the flags so suspendTerminal() can re-enable the same protocol // after a child process has had the terminal. kittyFlags = flags; } function beginSuspend(): void { terminal.beginSuspension(); if (!interactive || isUnmounted || isUnmounting) { return; } try { const { canWriteToStdout } = getWritableStreamState(options.stdout); // Flush any pending render so the child starts from a settled screen. settleThrottle(renderScheduler.throttled, canWriteToStdout); if (canWriteToStdout) { flushCapturedStdio(); } if (canWriteToStdout) { // Erase Ink's current frame, then show the cursor and re-arm the hide. // The forced redraw on resume hides the cursor again. clearLiveOutput(); finishLiveOutput(); if (kittyProtocolEnabled) { writeBestEffort(options.stdout, ansiEscapes.popKittyKeyboard); } if (terminal.alternateScreen) { writeBestEffort(options.stdout, ansiEscapes.exitAlternativeScreen); } } // Hand input back to the terminal (raw mode off, bracketed paste off). pauseInput?.(); } catch (error) { // If handing over the terminal fails partway, don't strand the app in a // suspended state with no way back. Best-effort reclaim input, clear the // flag, and rethrow so the caller sees the failure. terminal.resume(); try { resumeInput?.(); } catch {} throw error; } } async function endSuspend(): Promise { if (!terminal.suspended) { return; } terminal.resume(); // Reclaim input even mid-unmount: pauseInput already ran in beginSuspend, so // restoring it is symmetric regardless of any state change during suspension. resumeInput?.(); if (!interactive || isUnmounted || isUnmounting) { return; } const { canWriteToStdout } = getWritableStreamState(options.stdout); if (canWriteToStdout) { if (terminal.alternateScreen) { writeBestEffort(options.stdout, ansiEscapes.enterAlternativeScreen); } if (kittyProtocolEnabled && kittyFlags) { writeBestEffort(options.stdout, ansiEscapes.pushKittyKeyboard(resolveFlags(kittyFlags))); } } // Force a full redraw instead of diffing against the stale pre-suspension // frame, which the child process may have overwritten. A redraw failure here // is best-effort: it must not mask a callback error propagating through the // caller's finally block. lastOutput = ""; lastOutputToRender = ""; lastOutputHeight = 0; resetLiveOutput(); try { calculateLayout(); onRender(); await waitUntilRenderFlush(); } catch {} } return { render, unmount, waitUntilExit, waitUntilRenderFlush, clear, copyToClipboard: (text, selection) => terminal.copyToClipboard(text, selection), setProgress: (state, value) => terminal.setProgress(state, value), }; };