ink
Version:
React for CLI
807 lines • 33.4 kB
JavaScript
import process from 'node:process';
import React from 'react';
import { throttle } from 'es-toolkit/compat';
import ansiEscapes from 'ansi-escapes';
import isInCi from 'is-in-ci';
import autoBind from 'auto-bind';
import signalExit from 'signal-exit';
import patchConsole from 'patch-console';
import { LegacyRoot, ConcurrentRoot } from 'react-reconciler/constants.js';
import Yoga from 'yoga-layout';
import wrapAnsi from 'wrap-ansi';
import { getWindowSize } from './utils.js';
import reconciler from './reconciler.js';
import render from './renderer.js';
import * as dom from './dom.js';
import { hideCursorEscape, showCursorEscape } from './cursor-helpers.js';
import logUpdate from './log-update.js';
import { bsu, esu, shouldSynchronize } from './write-synchronized.js';
import instances from './instances.js';
import App from './components/App.js';
import { accessibilityContext as AccessibilityContext } from './components/AccessibilityContext.js';
import { resolveFlags, } from './kitty-keyboard.js';
const noop = () => { };
const textEncoder = new TextEncoder();
const yieldImmediate = async () => new Promise(resolve => {
setImmediate(resolve);
});
const kittyQueryEscapeByte = 0x1b;
const kittyQueryOpenBracketByte = 0x5b;
const kittyQueryQuestionMarkByte = 0x3f;
const kittyQueryLetterByte = 0x75;
const zeroByte = 0x30;
const nineByte = 0x39;
const isDigitByte = (byte) => byte >= zeroByte && byte <= nineByte;
const matchKittyQueryResponse = (buffer, startIndex) => {
if (buffer[startIndex] !== kittyQueryEscapeByte ||
buffer[startIndex + 1] !== kittyQueryOpenBracketByte ||
buffer[startIndex + 2] !== kittyQueryQuestionMarkByte) {
return undefined;
}
let index = startIndex + 3;
const digitsStartIndex = index;
while (index < buffer.length && isDigitByte(buffer[index])) {
index++;
}
if (index === digitsStartIndex) {
return undefined;
}
if (index === buffer.length) {
return { state: 'partial' };
}
if (buffer[index] === kittyQueryLetterByte) {
return { state: 'complete', endIndex: index };
}
return undefined;
};
const hasCompleteKittyQueryResponse = (buffer) => {
for (let index = 0; index < buffer.length; index++) {
const match = matchKittyQueryResponse(buffer, index);
if (match?.state === 'complete') {
return true;
}
}
return false;
};
const stripKittyQueryResponsesAndTrailingPartial = (buffer) => {
const keptBytes = [];
let index = 0;
while (index < buffer.length) {
const match = matchKittyQueryResponse(buffer, index);
if (match?.state === 'complete') {
index = match.endIndex + 1;
continue;
}
if (match?.state === 'partial') {
break;
}
keptBytes.push(buffer[index]);
index++;
}
return keptBytes;
};
const shouldClearTerminalForFrame = ({ isTty, viewportRows, previousOutputHeight, nextOutputHeight, isUnmounting, }) => {
if (!isTty) {
return false;
}
const hadPreviousFrame = previousOutputHeight > 0;
const wasFullscreen = previousOutputHeight >= viewportRows;
const wasOverflowing = previousOutputHeight > viewportRows;
const isOverflowing = nextOutputHeight > viewportRows;
const isLeavingFullscreen = wasFullscreen && nextOutputHeight < viewportRows;
const shouldClearOnUnmount = isUnmounting && wasFullscreen;
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) => {
return (value instanceof Error ||
Object.prototype.toString.call(value) === '[object Error]');
};
const getWritableStreamState = (stdout) => {
const canWriteToStdout = !stdout.destroyed && !stdout.writableEnded && (stdout.writable ?? true);
const hasWritableState = stdout._writableState !== undefined || stdout.writableLength !== undefined;
return {
canWriteToStdout,
hasWritableState,
};
};
const settleThrottle = (throttled, canWriteToStdout) => {
if (!throttled ||
typeof throttled.flush !== 'function') {
return;
}
const throttledValue = throttled;
if (canWriteToStdout) {
throttledValue.flush();
}
else if (typeof throttledValue.cancel === 'function') {
throttledValue.cancel();
}
};
export default class Ink {
/**
Whether this instance is using concurrent rendering mode.
*/
isConcurrent;
options;
log;
cursorPosition;
throttledLog;
isScreenReaderEnabled;
interactive;
renderThrottleMs;
alternateScreen;
// Ignore last render after unmounting a tree to prevent empty output before exit
isUnmounted;
isUnmounting;
lastOutput;
lastOutputToRender;
lastOutputHeight;
lastTerminalWidth;
container;
rootNode;
// 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
fullStaticOutput;
exitPromise;
exitResult;
beforeExitHandler;
restoreConsole;
unsubscribeResize;
throttledOnRender;
hasPendingThrottledRender = false;
kittyProtocolEnabled = false;
cancelKittyDetection;
nextRenderCommit;
constructor(options) {
autoBind(this);
this.options = options;
this.rootNode = dom.createNode('ink-root');
this.rootNode.onComputeLayout = this.calculateLayout;
this.isScreenReaderEnabled =
options.isScreenReaderEnabled ??
process.env['INK_SCREEN_READER'] === 'true';
// 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`).
this.interactive = this.resolveInteractiveOption(options.interactive);
this.alternateScreen = false;
const unthrottled = options.debug || this.isScreenReaderEnabled;
const maxFps = options.maxFps ?? 30;
// Treat non-positive maxFps as an internal fallback case, not a supported
// "disable throttling" mode. Keep animation scheduling on a normal cadence
// so future changes don't accidentally reintroduce zero-delay loops.
const renderThrottleMs = maxFps > 0 ? Math.max(1, Math.ceil(1000 / maxFps)) : 0;
this.renderThrottleMs = unthrottled ? 0 : renderThrottleMs;
if (unthrottled) {
this.rootNode.onRender = this.onRender;
this.throttledOnRender = undefined;
}
else {
const throttled = throttle(this.onRender, renderThrottleMs, {
leading: true,
trailing: true,
});
this.rootNode.onRender = () => {
this.hasPendingThrottledRender = true;
throttled();
};
this.throttledOnRender = throttled;
}
this.rootNode.onImmediateRender = this.onRender;
this.log = logUpdate.create(options.stdout, {
incremental: options.incrementalRendering,
});
this.cursorPosition = undefined;
this.throttledLog = unthrottled
? this.log
: throttle((output) => {
const shouldWrite = this.log.willRender(output);
const sync = this.shouldSync();
if (sync && shouldWrite) {
this.options.stdout.write(bsu);
}
this.log(output);
if (sync && shouldWrite) {
this.options.stdout.write(esu);
}
}, undefined, {
leading: true,
trailing: true,
});
// Ignore last render after unmounting a tree to prevent empty output before exit
this.isUnmounted = false;
this.isUnmounting = false;
// Store concurrent mode setting
this.isConcurrent = options.concurrent ?? false;
// Store last output to only rerender when needed
this.lastOutput = '';
this.lastOutputToRender = '';
this.lastOutputHeight = 0;
this.lastTerminalWidth = getWindowSize(this.options.stdout).columns;
// 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
this.fullStaticOutput = '';
// Use ConcurrentRoot for concurrent mode, LegacyRoot for legacy mode
const rootTag = options.concurrent ? ConcurrentRoot : LegacyRoot;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.container = reconciler.createContainer(this.rootNode, rootTag, null, false, null, 'id', () => { }, () => { }, () => { }, () => { });
// Unmount when process exits
this.unsubscribeExit = signalExit(this.unmount, { alwaysLast: false });
this.setAlternateScreen(Boolean(options.alternateScreen));
if (process.env['DEV'] === 'true') {
// @ts-expect-error outdated types
reconciler.injectIntoDevTools();
}
if (options.patchConsole) {
this.patchConsole();
}
if (this.interactive) {
options.stdout.on('resize', this.resized);
this.unsubscribeResize = () => {
options.stdout.off('resize', this.resized);
};
}
this.initKittyKeyboard();
this.exitPromise = new Promise((resolve, reject) => {
this.resolveExitPromise = resolve;
this.rejectExitPromise = reject;
});
// Prevent global unhandled-rejection crashes when app code exits with an
// error but consumers never call waitUntilExit().
void this.exitPromise.catch(noop);
}
resized = () => {
const currentWidth = getWindowSize(this.options.stdout).columns;
if (currentWidth < this.lastTerminalWidth) {
// We clear the screen when decreasing terminal width to prevent duplicate overlapping re-renders.
this.log.clear();
this.lastOutput = '';
this.lastOutputToRender = '';
}
this.calculateLayout();
this.onRender();
this.lastTerminalWidth = currentWidth;
};
resolveExitPromise = () => { };
rejectExitPromise = () => { };
unsubscribeExit = () => { };
handleAppExit = (errorOrResult) => {
if (this.isUnmounted || this.isUnmounting) {
return;
}
if (isErrorInput(errorOrResult)) {
this.unmount(errorOrResult);
return;
}
this.exitResult = errorOrResult;
this.unmount();
};
setCursorPosition = (position) => {
this.cursorPosition = position;
this.log.setCursorPosition(position);
};
restoreLastOutput = () => {
if (!this.interactive) {
return;
}
// Clear() resets log-update's cursor state, so replay the latest cursor intent
// before restoring output after external stdout/stderr writes.
this.log.setCursorPosition(this.cursorPosition);
this.log(this.lastOutputToRender || this.lastOutput + '\n');
};
calculateLayout = () => {
const terminalWidth = getWindowSize(this.options.stdout).columns;
this.rootNode.yogaNode.setWidth(terminalWidth);
this.rootNode.yogaNode.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
};
onRender = () => {
this.hasPendingThrottledRender = false;
if (this.isUnmounted) {
return;
}
if (this.nextRenderCommit) {
this.nextRenderCommit.resolve();
this.nextRenderCommit = undefined;
}
const startTime = performance.now();
const { output, outputHeight, staticOutput } = render(this.rootNode, this.isScreenReaderEnabled);
this.options.onRender?.({ renderTime: performance.now() - startTime });
// If <Static> output isn't empty, it means new children have been added to it
const hasStaticOutput = staticOutput && staticOutput !== '\n';
if (this.options.debug) {
if (hasStaticOutput) {
this.fullStaticOutput += staticOutput;
}
this.lastOutput = output;
this.lastOutputToRender = output;
this.lastOutputHeight = outputHeight;
this.options.stdout.write(this.fullStaticOutput + output);
return;
}
if (!this.interactive) {
if (hasStaticOutput) {
this.options.stdout.write(staticOutput);
}
this.lastOutput = output;
this.lastOutputToRender = output + '\n';
this.lastOutputHeight = outputHeight;
return;
}
if (this.isScreenReaderEnabled) {
const sync = this.shouldSync();
if (sync) {
this.options.stdout.write(bsu);
}
if (hasStaticOutput) {
// We need to erase the main output before writing new static output
const erase = this.lastOutputHeight > 0
? ansiEscapes.eraseLines(this.lastOutputHeight)
: '';
this.options.stdout.write(erase + staticOutput);
// After erasing, the last output is gone, so we should reset its height
this.lastOutputHeight = 0;
}
if (output === this.lastOutput && !hasStaticOutput) {
if (sync) {
this.options.stdout.write(esu);
}
return;
}
const terminalWidth = getWindowSize(this.options.stdout).columns;
const wrappedOutput = wrapAnsi(output, terminalWidth, {
trim: false,
hard: true,
});
// If we haven't erased yet, do it now.
if (hasStaticOutput) {
this.options.stdout.write(wrappedOutput);
}
else {
const erase = this.lastOutputHeight > 0
? ansiEscapes.eraseLines(this.lastOutputHeight)
: '';
this.options.stdout.write(erase + wrappedOutput);
}
this.lastOutput = output;
this.lastOutputToRender = wrappedOutput;
this.lastOutputHeight =
wrappedOutput === '' ? 0 : wrappedOutput.split('\n').length;
if (sync) {
this.options.stdout.write(esu);
}
return;
}
if (hasStaticOutput) {
this.fullStaticOutput += staticOutput;
}
this.renderInteractiveFrame(output, outputHeight, hasStaticOutput ? staticOutput : '');
};
render(node) {
const tree = (React.createElement(AccessibilityContext.Provider, { value: { isScreenReaderEnabled: this.isScreenReaderEnabled } },
React.createElement(App, { stdin: this.options.stdin, stdout: this.options.stdout, stderr: this.options.stderr, exitOnCtrlC: this.options.exitOnCtrlC, interactive: this.interactive, renderThrottleMs: this.renderThrottleMs, writeToStdout: this.writeToStdout, writeToStderr: this.writeToStderr, setCursorPosition: this.setCursorPosition, onExit: this.handleAppExit, onWaitUntilRenderFlush: this.waitUntilRenderFlush }, node)));
if (this.options.concurrent) {
// Concurrent mode: use updateContainer (async scheduling)
reconciler.updateContainer(tree, this.container, null, noop);
}
else {
// Legacy mode: use updateContainerSync + flushSyncWork (sync)
reconciler.updateContainerSync(tree, this.container, null, noop);
reconciler.flushSyncWork();
}
}
writeToStdout(data) {
if (this.isUnmounted) {
return;
}
if (this.options.debug) {
this.options.stdout.write(data + this.fullStaticOutput + this.lastOutput);
return;
}
if (!this.interactive) {
this.options.stdout.write(data);
return;
}
const sync = this.shouldSync();
if (sync) {
this.options.stdout.write(bsu);
}
this.log.clear();
this.options.stdout.write(data);
this.restoreLastOutput();
if (sync) {
this.options.stdout.write(esu);
}
}
writeToStderr(data) {
if (this.isUnmounted) {
return;
}
if (this.options.debug) {
this.options.stderr.write(data);
this.options.stdout.write(this.fullStaticOutput + this.lastOutput);
return;
}
if (!this.interactive) {
this.options.stderr.write(data);
return;
}
const sync = this.shouldSync();
if (sync) {
this.options.stdout.write(bsu);
}
this.log.clear();
this.options.stderr.write(data);
this.restoreLastOutput();
if (sync) {
this.options.stdout.write(esu);
}
}
// eslint-disable-next-line @typescript-eslint/no-restricted-types
unmount(error) {
if (this.isUnmounted || this.isUnmounting) {
return;
}
this.isUnmounting = true;
if (this.beforeExitHandler) {
process.off('beforeExit', this.beforeExitHandler);
this.beforeExitHandler = undefined;
}
const stdout = this.options.stdout;
const { canWriteToStdout, hasWritableState } = getWritableStreamState(stdout);
// 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(this.throttledOnRender, 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 <Static> children output on exit (see issue #397).
const shouldRenderFinalFrame = !this.throttledOnRender ||
(!this.hasPendingThrottledRender && this.fullStaticOutput === '');
if (shouldRenderFinalFrame) {
this.calculateLayout();
this.onRender();
}
}
// Mark as unmounted after the final render but before stdout writes
// that could re-enter exit() via synchronous write callbacks.
this.isUnmounted = true;
this.unsubscribeExit();
// Flush any pending throttled log writes if possible, otherwise cancel to
// prevent delayed callbacks from writing to a closed stream.
settleThrottle(this.throttledLog, canWriteToStdout);
if (typeof this.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.
this.restoreConsole();
}
const finishUnmount = () => {
if (typeof this.unsubscribeResize === 'function') {
this.unsubscribeResize();
}
// Cancel any in-progress auto-detection before checking protocol state
if (this.cancelKittyDetection) {
this.cancelKittyDetection();
}
if (canWriteToStdout) {
if (this.kittyProtocolEnabled) {
this.writeBestEffort(this.options.stdout, '\u001B[<u');
}
// 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 (this.alternateScreen) {
this.writeBestEffort(this.options.stdout, ansiEscapes.exitAlternativeScreen);
this.writeBestEffort(this.options.stdout, showCursorEscape);
this.alternateScreen = false;
}
if (!this.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).
this.options.stdout.write(this.options.debug ? '\n' : this.lastOutput + '\n');
}
else if (!this.options.debug) {
this.log.done();
}
}
this.kittyProtocolEnabled = false;
instances.delete(this.options.stdout);
// Ensure all queued writes have been processed before resolving the
// exit promise. For real writable streams, queue an empty write as a
// barrier — its callback fires only after all prior writes complete.
// For non-stream objects (e.g. test spies), resolve on next tick.
//
// 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 { exitResult } = this;
const resolveOrReject = () => {
if (isErrorInput(error)) {
this.rejectExitPromise(error);
}
else {
this.resolveExitPromise(exitResult);
}
};
const isProcessExiting = error !== undefined && !isErrorInput(error);
if (isProcessExiting) {
resolveOrReject();
}
else if (canWriteToStdout && hasWritableState) {
this.options.stdout.write('', resolveOrReject);
}
else {
setImmediate(resolveOrReject);
}
};
const concurrentReconciler = reconciler;
if (this.options.concurrent) {
reconciler.updateContainerSync(null, this.container, null, noop);
reconciler.flushSyncWork();
concurrentReconciler.flushPassiveEffects?.();
finishUnmount();
}
else {
// Legacy mode: use updateContainerSync + flushSyncWork (sync)
reconciler.updateContainerSync(null, this.container, null, noop);
reconciler.flushSyncWork();
finishUnmount();
}
}
async waitUntilExit() {
if (!this.beforeExitHandler) {
this.beforeExitHandler = () => {
this.unmount();
};
process.once('beforeExit', this.beforeExitHandler);
}
return this.exitPromise;
}
async waitUntilRenderFlush() {
if (this.isUnmounted || this.isUnmounting) {
await this.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 (this.isUnmounted || this.isUnmounting) {
await this.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 (this.isConcurrent && this.hasPendingConcurrentWork()) {
await Promise.race([this.awaitNextRender(), this.awaitExit()]);
if (this.isUnmounted || this.isUnmounting) {
this.nextRenderCommit = undefined;
await this.awaitExit();
return;
}
}
reconciler.flushSyncWork();
const stdout = this.options.stdout;
const { canWriteToStdout, hasWritableState } = getWritableStreamState(stdout);
// Flush pending throttled render/log timers so their output is included in this wait.
settleThrottle(this.throttledOnRender, canWriteToStdout);
settleThrottle(this.throttledLog, canWriteToStdout);
if (canWriteToStdout && hasWritableState) {
await new Promise(resolve => {
this.options.stdout.write('', () => {
resolve();
});
});
return;
}
await yieldImmediate();
}
clear() {
if (this.interactive && !this.options.debug) {
this.log.clear();
// Sync lastOutput so that unmount's final onRender
// sees it as unchanged and log-update skips it
this.log.sync(this.lastOutputToRender || this.lastOutput + '\n');
}
}
patchConsole() {
if (this.options.debug) {
return;
}
this.restoreConsole = patchConsole((stream, data) => {
if (stream === 'stdout') {
this.writeToStdout(data);
}
if (stream === 'stderr') {
const isReactMessage = data.startsWith('The above error occurred');
if (!isReactMessage) {
this.writeToStderr(data);
}
}
});
}
setAlternateScreen(enabled) {
this.alternateScreen = this.resolveAlternateScreenOption(enabled, this.interactive);
if (this.alternateScreen) {
this.writeBestEffort(this.options.stdout, ansiEscapes.enterAlternativeScreen);
this.writeBestEffort(this.options.stdout, hideCursorEscape);
}
}
resolveInteractiveOption(interactive) {
return interactive ?? (!isInCi && Boolean(this.options.stdout.isTTY));
}
resolveAlternateScreenOption(alternateScreen, interactive) {
return (Boolean(alternateScreen) &&
interactive &&
Boolean(this.options.stdout.isTTY));
}
shouldSync() {
return shouldSynchronize(this.options.stdout, this.interactive);
}
// Best-effort write: streams may already be destroyed during shutdown.
writeBestEffort(stream, data) {
try {
stream.write(data);
}
catch { }
}
// Waits for the exit promise to settle, suppressing any rejection.
// Errors are surfaced via waitUntilExit() instead.
async awaitExit() {
try {
await this.exitPromise;
}
catch { }
}
hasPendingConcurrentWork() {
const concurrentContainer = this.container;
return ((concurrentContainer.pendingLanes ?? 0) !== 0 &&
concurrentContainer.callbackNode !== undefined &&
concurrentContainer.callbackNode !== null);
}
async awaitNextRender() {
if (!this.nextRenderCommit) {
let resolveRender;
const promise = new Promise(resolve => {
resolveRender = resolve;
});
this.nextRenderCommit = { promise, resolve: resolveRender };
}
return this.nextRenderCommit.promise;
}
renderInteractiveFrame(output, outputHeight, staticOutput) {
const hasStaticOutput = staticOutput !== '';
const isTty = this.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 ? getWindowSize(this.options.stdout).rows : 24;
const isFullscreen = isTty && outputHeight >= viewportRows;
const outputToRender = isFullscreen ? output : output + '\n';
const shouldClearTerminal = shouldClearTerminalForFrame({
isTty,
viewportRows,
previousOutputHeight: this.lastOutputHeight,
nextOutputHeight: outputHeight,
isUnmounting: this.isUnmounting,
});
if (shouldClearTerminal) {
const sync = this.shouldSync();
if (sync) {
this.options.stdout.write(bsu);
}
this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + output);
this.lastOutput = output;
this.lastOutputToRender = outputToRender;
this.lastOutputHeight = outputHeight;
this.log.sync(outputToRender);
if (sync) {
this.options.stdout.write(esu);
}
return;
}
// To ensure static output is cleanly rendered before main output, clear main output first
if (hasStaticOutput) {
const sync = this.shouldSync();
if (sync) {
this.options.stdout.write(bsu);
}
this.log.clear();
this.options.stdout.write(staticOutput);
this.log(outputToRender);
if (sync) {
this.options.stdout.write(esu);
}
}
else if (output !== this.lastOutput || this.log.isCursorDirty()) {
// ThrottledLog manages its own bsu/esu at actual write time
this.throttledLog(outputToRender);
}
this.lastOutput = output;
this.lastOutputToRender = outputToRender;
this.lastOutputHeight = outputHeight;
}
initKittyKeyboard() {
// Protocol is opt-in: if kittyKeyboard is not specified, do nothing
if (!this.options.kittyKeyboard) {
return;
}
const opts = this.options.kittyKeyboard;
const mode = opts.mode ?? 'auto';
if (mode === 'disabled') {
return;
}
const flags = 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 (this.options.stdin.isTTY && this.options.stdout.isTTY) {
this.enableKittyProtocol(flags);
}
return;
}
// Auto mode: require interactive + TTY
if (!this.interactive ||
!this.options.stdin.isTTY ||
!this.options.stdout.isTTY) {
return;
}
// Auto mode: query the terminal for kitty keyboard protocol support.
// The CSI ? u query is safe to send to any terminal — unsupporting
// terminals simply won't respond, and the 200ms timeout handles that.
// This avoids maintaining a hardcoded whitelist of terminal names.
this.confirmKittySupport(flags);
}
confirmKittySupport(flags) {
const { stdin, stdout } = this.options;
let responseBuffer = [];
const cleanup = () => {
this.cancelKittyDetection = undefined;
clearTimeout(timer);
stdin.removeListener('data', onData);
// Re-emit any buffered data that wasn't the protocol response,
// so it isn't lost from Ink's normal input pipeline.
// Clear responseBuffer afterwards to make cleanup idempotent.
const remaining = stripKittyQueryResponsesAndTrailingPartial(responseBuffer);
responseBuffer = [];
if (remaining.length > 0) {
stdin.unshift(Uint8Array.from(remaining));
}
};
const onData = (data) => {
const chunk = typeof data === 'string' ? textEncoder.encode(data) : data;
for (const byte of chunk) {
responseBuffer.push(byte);
}
if (hasCompleteKittyQueryResponse(responseBuffer)) {
cleanup();
if (!this.isUnmounted) {
this.enableKittyProtocol(flags);
}
}
};
// Attach listener before writing the query so that synchronous
// or immediate responses are not missed.
stdin.on('data', onData);
const timer = setTimeout(cleanup, 200);
this.cancelKittyDetection = cleanup;
stdout.write('\u001B[?u');
}
enableKittyProtocol(flags) {
this.options.stdout.write(`\u001B[>${resolveFlags(flags)}u`);
this.kittyProtocolEnabled = true;
}
}
//# sourceMappingURL=ink.js.map