| import { type CursorPosition } from '../log-update.js'; | ||
| export type Props = { | ||
| /** | ||
| Set the cursor position relative to the Ink output. | ||
| Pass `undefined` to hide the cursor. | ||
| */ | ||
| readonly setCursorPosition: (position: CursorPosition | undefined) => void; | ||
| }; | ||
| declare const CursorContext: import("react").Context<Props>; | ||
| export default CursorContext; |
| import { createContext } from 'react'; | ||
| // eslint-disable-next-line @typescript-eslint/naming-convention | ||
| const CursorContext = createContext({ | ||
| setCursorPosition() { }, | ||
| }); | ||
| CursorContext.displayName = 'InternalCursorContext'; | ||
| export default CursorContext; | ||
| //# sourceMappingURL=CursorContext.js.map |
| {"version":3,"file":"CursorContext.js","sourceRoot":"","sources":["../../src/components/CursorContext.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,aAAa,EAAC,MAAM,OAAO,CAAC;AAYpC,gEAAgE;AAChE,MAAM,aAAa,GAAG,aAAa,CAAQ;IAC1C,iBAAiB,KAAI,CAAC;CACtB,CAAC,CAAC;AAEH,aAAa,CAAC,WAAW,GAAG,uBAAuB,CAAC;AAEpD,eAAe,aAAa,CAAC"} |
| import React, { PureComponent, type ReactNode } from 'react'; | ||
| type Props = { | ||
| readonly children: ReactNode; | ||
| readonly onError: (error: Error) => void; | ||
| }; | ||
| type State = { | ||
| readonly error?: Error; | ||
| }; | ||
| export default class ErrorBoundary extends PureComponent<Props, State> { | ||
| static displayName: string; | ||
| static getDerivedStateFromError(error: Error): { | ||
| error: Error; | ||
| }; | ||
| state: State; | ||
| componentDidCatch(error: Error): void; | ||
| render(): string | number | bigint | boolean | Iterable<React.ReactNode> | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | React.JSX.Element | null | undefined; | ||
| } | ||
| export {}; |
| import React, { PureComponent } from 'react'; | ||
| import ErrorOverview from './ErrorOverview.js'; | ||
| // Error boundary must be a class component since getDerivedStateFromError | ||
| // and componentDidCatch are not available as hooks | ||
| export default class ErrorBoundary extends PureComponent { | ||
| static displayName = 'InternalErrorBoundary'; | ||
| static getDerivedStateFromError(error) { | ||
| return { error }; | ||
| } | ||
| state = { | ||
| error: undefined, | ||
| }; | ||
| componentDidCatch(error) { | ||
| this.props.onError(error); | ||
| } | ||
| render() { | ||
| if (this.state.error) { | ||
| return React.createElement(ErrorOverview, { error: this.state.error }); | ||
| } | ||
| return this.props.children; | ||
| } | ||
| } | ||
| //# sourceMappingURL=ErrorBoundary.js.map |
| {"version":3,"file":"ErrorBoundary.js","sourceRoot":"","sources":["../../src/components/ErrorBoundary.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAC,aAAa,EAAiB,MAAM,OAAO,CAAC;AAC3D,OAAO,aAAa,MAAM,oBAAoB,CAAC;AAW/C,0EAA0E;AAC1E,mDAAmD;AACnD,MAAM,CAAC,OAAO,OAAO,aAAc,SAAQ,aAA2B;IACrE,MAAM,CAAC,WAAW,GAAG,uBAAuB,CAAC;IAE7C,MAAM,CAAC,wBAAwB,CAAC,KAAY;QAC3C,OAAO,EAAC,KAAK,EAAC,CAAC;IAChB,CAAC;IAEQ,KAAK,GAAU;QACvB,KAAK,EAAE,SAAS;KAChB,CAAC;IAEO,iBAAiB,CAAC,KAAY;QACtC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAC3B,CAAC;IAEQ,MAAM;QACd,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;YACtB,OAAO,oBAAC,aAAa,IAAC,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,GAAI,CAAC;QACnD,CAAC;QAED,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC;IAC5B,CAAC"} |
| export type CursorPosition = { | ||
| x: number; | ||
| y: number; | ||
| }; | ||
| declare const showCursorEscape = "\u001B[?25h"; | ||
| declare const hideCursorEscape = "\u001B[?25l"; | ||
| export { showCursorEscape, hideCursorEscape }; | ||
| /** | ||
| Compare two cursor positions. Returns true if they differ. | ||
| */ | ||
| export declare const cursorPositionChanged: (a: CursorPosition | undefined, b: CursorPosition | undefined) => boolean; | ||
| /** | ||
| Build escape sequence to move cursor from bottom of output to the target position and show it. | ||
| Assumes cursor is at (col 0, line visibleLineCount) — i.e. just after the last output line. | ||
| */ | ||
| export declare const buildCursorSuffix: (visibleLineCount: number, cursorPosition: CursorPosition | undefined) => string; | ||
| /** | ||
| Build escape sequence to move cursor from previousCursorPosition back to the bottom of output. | ||
| This must be done before eraseLines or any operation that assumes cursor is at the bottom. | ||
| */ | ||
| export declare const buildReturnToBottom: (previousLineCount: number, previousCursorPosition: CursorPosition | undefined) => string; | ||
| export type CursorOnlyInput = { | ||
| cursorWasShown: boolean; | ||
| previousLineCount: number; | ||
| previousCursorPosition: CursorPosition | undefined; | ||
| visibleLineCount: number; | ||
| cursorPosition: CursorPosition | undefined; | ||
| }; | ||
| /** | ||
| Build the escape sequence for cursor-only updates (output unchanged, cursor moved). | ||
| Hides cursor if it was previously shown, returns to bottom, then repositions. | ||
| */ | ||
| export declare const buildCursorOnlySequence: (input: CursorOnlyInput) => string; | ||
| /** | ||
| Build the prefix that hides cursor and returns to bottom before erasing or rewriting. | ||
| Returns empty string if cursor was not shown. | ||
| */ | ||
| export declare const buildReturnToBottomPrefix: (cursorWasShown: boolean, previousLineCount: number, previousCursorPosition: CursorPosition | undefined) => string; |
| import ansiEscapes from 'ansi-escapes'; | ||
| const showCursorEscape = '\u001B[?25h'; | ||
| const hideCursorEscape = '\u001B[?25l'; | ||
| export { showCursorEscape, hideCursorEscape }; | ||
| /** | ||
| Compare two cursor positions. Returns true if they differ. | ||
| */ | ||
| export const cursorPositionChanged = (a, b) => a?.x !== b?.x || a?.y !== b?.y; | ||
| /** | ||
| Build escape sequence to move cursor from bottom of output to the target position and show it. | ||
| Assumes cursor is at (col 0, line visibleLineCount) — i.e. just after the last output line. | ||
| */ | ||
| export const buildCursorSuffix = (visibleLineCount, cursorPosition) => { | ||
| if (!cursorPosition) { | ||
| return ''; | ||
| } | ||
| const moveUp = visibleLineCount - cursorPosition.y; | ||
| return ((moveUp > 0 ? ansiEscapes.cursorUp(moveUp) : '') + | ||
| ansiEscapes.cursorTo(cursorPosition.x) + | ||
| showCursorEscape); | ||
| }; | ||
| /** | ||
| Build escape sequence to move cursor from previousCursorPosition back to the bottom of output. | ||
| This must be done before eraseLines or any operation that assumes cursor is at the bottom. | ||
| */ | ||
| export const buildReturnToBottom = (previousLineCount, previousCursorPosition) => { | ||
| if (!previousCursorPosition) { | ||
| return ''; | ||
| } | ||
| // PreviousLineCount includes trailing newline, so visible lines = previousLineCount - 1 | ||
| // cursor is at previousCursorPosition.y, need to go to line (previousLineCount - 1) | ||
| const down = previousLineCount - 1 - previousCursorPosition.y; | ||
| return ((down > 0 ? ansiEscapes.cursorDown(down) : '') + ansiEscapes.cursorTo(0)); | ||
| }; | ||
| /** | ||
| Build the escape sequence for cursor-only updates (output unchanged, cursor moved). | ||
| Hides cursor if it was previously shown, returns to bottom, then repositions. | ||
| */ | ||
| export const buildCursorOnlySequence = (input) => { | ||
| const hidePrefix = input.cursorWasShown ? hideCursorEscape : ''; | ||
| const returnToBottom = buildReturnToBottom(input.previousLineCount, input.previousCursorPosition); | ||
| const cursorSuffix = buildCursorSuffix(input.visibleLineCount, input.cursorPosition); | ||
| return hidePrefix + returnToBottom + cursorSuffix; | ||
| }; | ||
| /** | ||
| Build the prefix that hides cursor and returns to bottom before erasing or rewriting. | ||
| Returns empty string if cursor was not shown. | ||
| */ | ||
| export const buildReturnToBottomPrefix = (cursorWasShown, previousLineCount, previousCursorPosition) => { | ||
| if (!cursorWasShown) { | ||
| return ''; | ||
| } | ||
| return (hideCursorEscape + | ||
| buildReturnToBottom(previousLineCount, previousCursorPosition)); | ||
| }; | ||
| //# sourceMappingURL=cursor-helpers.js.map |
| {"version":3,"file":"cursor-helpers.js","sourceRoot":"","sources":["../src/cursor-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,WAAW,MAAM,cAAc,CAAC;AAOvC,MAAM,gBAAgB,GAAG,aAAa,CAAC;AACvC,MAAM,gBAAgB,GAAG,aAAa,CAAC;AAEvC,OAAO,EAAC,gBAAgB,EAAE,gBAAgB,EAAC,CAAC;AAE5C;;EAEE;AACF,MAAM,CAAC,MAAM,qBAAqB,GAAG,CACpC,CAA6B,EAC7B,CAA6B,EACnB,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;AAE7C;;;EAGE;AACF,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAChC,gBAAwB,EACxB,cAA0C,EACjC,EAAE;IACX,IAAI,CAAC,cAAc,EAAE,CAAC;QACrB,OAAO,EAAE,CAAC;IACX,CAAC;IAED,MAAM,MAAM,GAAG,gBAAgB,GAAG,cAAc,CAAC,CAAC,CAAC;IACnD,OAAO,CACN,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAChD,WAAW,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC,CAAC;QACtC,gBAAgB,CAChB,CAAC;AACH,CAAC,CAAC;AAEF;;;EAGE;AACF,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAClC,iBAAyB,EACzB,sBAAkD,EACzC,EAAE;IACX,IAAI,CAAC,sBAAsB,EAAE,CAAC;QAC7B,OAAO,EAAE,CAAC;IACX,CAAC;IAED,wFAAwF;IACxF,oFAAoF;IACpF,MAAM,IAAI,GAAG,iBAAiB,GAAG,CAAC,GAAG,sBAAsB,CAAC,CAAC,CAAC;IAC9D,OAAO,CACN,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,CACxE,CAAC;AACH,CAAC,CAAC;AAUF;;;EAGE;AACF,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,KAAsB,EAAU,EAAE;IACzE,MAAM,UAAU,GAAG,KAAK,CAAC,cAAc,CAAC,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC,EAAE,CAAC;IAChE,MAAM,cAAc,GAAG,mBAAmB,CACzC,KAAK,CAAC,iBAAiB,EACvB,KAAK,CAAC,sBAAsB,CAC5B,CAAC;IACF,MAAM,YAAY,GAAG,iBAAiB,CACrC,KAAK,CAAC,gBAAgB,EACtB,KAAK,CAAC,cAAc,CACpB,CAAC;IACF,OAAO,UAAU,GAAG,cAAc,GAAG,YAAY,CAAC;AACnD,CAAC,CAAC;AAEF;;;EAGE;AACF,MAAM,CAAC,MAAM,yBAAyB,GAAG,CACxC,cAAuB,EACvB,iBAAyB,EACzB,sBAAkD,EACzC,EAAE;IACX,IAAI,CAAC,cAAc,EAAE,CAAC;QACrB,OAAO,EAAE,CAAC;IACX,CAAC;IAED,OAAO,CACN,gBAAgB;QAChB,mBAAmB,CAAC,iBAAiB,EAAE,sBAAsB,CAAC,CAC9D,CAAC;AACH,CAAC,CAAC"} |
| import { type CursorPosition } from '../log-update.js'; | ||
| /** | ||
| `useCursor` is a React hook that lets you control the terminal cursor position. | ||
| Setting a cursor position makes the cursor visible at the specified coordinates (relative to the Ink output origin). This is useful for IME (Input Method Editor) support, where the composing character is displayed at the cursor location. | ||
| Pass `undefined` to hide the cursor. | ||
| */ | ||
| declare const useCursor: () => { | ||
| setCursorPosition: (position: CursorPosition | undefined) => void; | ||
| }; | ||
| export default useCursor; |
| import { useContext, useRef, useCallback, useInsertionEffect } from 'react'; | ||
| import CursorContext from '../components/CursorContext.js'; | ||
| /** | ||
| `useCursor` is a React hook that lets you control the terminal cursor position. | ||
| Setting a cursor position makes the cursor visible at the specified coordinates (relative to the Ink output origin). This is useful for IME (Input Method Editor) support, where the composing character is displayed at the cursor location. | ||
| Pass `undefined` to hide the cursor. | ||
| */ | ||
| const useCursor = () => { | ||
| const context = useContext(CursorContext); | ||
| const positionRef = useRef(undefined); | ||
| const setCursorPosition = useCallback((position) => { | ||
| positionRef.current = position; | ||
| }, []); | ||
| // Propagate cursor position to log-update only during commit phase. | ||
| // useInsertionEffect runs before resetAfterCommit (which triggers onRender), | ||
| // and does NOT run for abandoned concurrent renders (e.g. suspended components). | ||
| // This prevents cursor state from leaking across render boundaries. | ||
| useInsertionEffect(() => { | ||
| context.setCursorPosition(positionRef.current); | ||
| return () => { | ||
| context.setCursorPosition(undefined); | ||
| }; | ||
| }); | ||
| return { setCursorPosition }; | ||
| }; | ||
| export default useCursor; | ||
| //# sourceMappingURL=use-cursor.js.map |
| {"version":3,"file":"use-cursor.js","sourceRoot":"","sources":["../../src/hooks/use-cursor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,kBAAkB,EAAC,MAAM,OAAO,CAAC;AAC1E,OAAO,aAAa,MAAM,gCAAgC,CAAC;AAG3D;;;;;;EAME;AACF,MAAM,SAAS,GAAG,GAAG,EAAE;IACtB,MAAM,OAAO,GAAG,UAAU,CAAC,aAAa,CAAC,CAAC;IAC1C,MAAM,WAAW,GAAG,MAAM,CAA6B,SAAS,CAAC,CAAC;IAElE,MAAM,iBAAiB,GAAG,WAAW,CACpC,CAAC,QAAoC,EAAE,EAAE;QACxC,WAAW,CAAC,OAAO,GAAG,QAAQ,CAAC;IAChC,CAAC,EACD,EAAE,CACF,CAAC;IAEF,oEAAoE;IACpE,6EAA6E;IAC7E,iFAAiF;IACjF,oEAAoE;IACpE,kBAAkB,CAAC,GAAG,EAAE;QACvB,OAAO,CAAC,iBAAiB,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;QAC/C,OAAO,GAAG,EAAE;YACX,OAAO,CAAC,iBAAiB,CAAC,SAAS,CAAC,CAAC;QACtC,CAAC,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,OAAO,EAAC,iBAAiB,EAAC,CAAC;AAC5B,CAAC,CAAC;AAEF,eAAe,SAAS,CAAC"} |
| export declare const kittyFlags: { | ||
| readonly disambiguateEscapeCodes: 1; | ||
| readonly reportEventTypes: 2; | ||
| readonly reportAlternateKeys: 4; | ||
| readonly reportAllKeysAsEscapeCodes: 8; | ||
| readonly reportAssociatedText: 16; | ||
| }; | ||
| export type KittyFlagName = keyof typeof kittyFlags; | ||
| export declare function resolveFlags(flags: KittyFlagName[]): number; | ||
| export declare const kittyModifiers: { | ||
| readonly shift: 1; | ||
| readonly alt: 2; | ||
| readonly ctrl: 4; | ||
| readonly super: 8; | ||
| readonly hyper: 16; | ||
| readonly meta: 32; | ||
| readonly capsLock: 64; | ||
| readonly numLock: 128; | ||
| }; | ||
| export type KittyKeyboardOptions = { | ||
| mode?: 'auto' | 'enabled' | 'disabled'; | ||
| flags?: KittyFlagName[]; | ||
| }; |
| // Kitty keyboard protocol flags. | ||
| // @see https://sw.kovidgoyal.net/kitty/keyboard-protocol/ | ||
| export const kittyFlags = { | ||
| disambiguateEscapeCodes: 1, | ||
| reportEventTypes: 2, | ||
| reportAlternateKeys: 4, | ||
| reportAllKeysAsEscapeCodes: 8, | ||
| reportAssociatedText: 16, | ||
| }; | ||
| // Converts an array of flag names to the corresponding bitmask value. | ||
| export function resolveFlags(flags) { | ||
| let result = 0; | ||
| for (const flag of flags) { | ||
| // eslint-disable-next-line no-bitwise | ||
| result |= kittyFlags[flag]; | ||
| } | ||
| return result; | ||
| } | ||
| // Kitty keyboard modifier bits. | ||
| // These are used in the modifier parameter of CSI u sequences. | ||
| // Note: The actual modifier value is (modifiers - 1) as per the protocol. | ||
| export const kittyModifiers = { | ||
| shift: 1, | ||
| alt: 2, | ||
| ctrl: 4, | ||
| super: 8, | ||
| hyper: 16, | ||
| meta: 32, | ||
| capsLock: 64, | ||
| numLock: 128, | ||
| }; | ||
| //# sourceMappingURL=kitty-keyboard.js.map |
| {"version":3,"file":"kitty-keyboard.js","sourceRoot":"","sources":["../src/kitty-keyboard.ts"],"names":[],"mappings":"AAAA,iCAAiC;AACjC,0DAA0D;AAC1D,MAAM,CAAC,MAAM,UAAU,GAAG;IACzB,uBAAuB,EAAE,CAAC;IAC1B,gBAAgB,EAAE,CAAC;IACnB,mBAAmB,EAAE,CAAC;IACtB,0BAA0B,EAAE,CAAC;IAC7B,oBAAoB,EAAE,EAAE;CACf,CAAC;AAKX,sEAAsE;AACtE,MAAM,UAAU,YAAY,CAAC,KAAsB;IAClD,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QAC1B,sCAAsC;QACtC,MAAM,IAAI,UAAU,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;IAED,OAAO,MAAM,CAAC;AACf,CAAC;AAED,gCAAgC;AAChC,+DAA+D;AAC/D,0EAA0E;AAC1E,MAAM,CAAC,MAAM,cAAc,GAAG;IAC7B,KAAK,EAAE,CAAC;IACR,GAAG,EAAE,CAAC;IACN,IAAI,EAAE,CAAC;IACP,KAAK,EAAE,CAAC;IACR,KAAK,EAAE,EAAE;IACT,IAAI,EAAE,EAAE;IACR,QAAQ,EAAE,EAAE;IACZ,OAAO,EAAE,GAAG;CACH,CAAC"} |
| import { type Writable } from 'node:stream'; | ||
| export declare const bsu = "\u001B[?2026h"; | ||
| export declare const esu = "\u001B[?2026l"; | ||
| export declare function shouldSynchronize(stream: Writable): boolean; |
| import isInCi from 'is-in-ci'; | ||
| export const bsu = '\u001B[?2026h'; | ||
| export const esu = '\u001B[?2026l'; | ||
| export function shouldSynchronize(stream) { | ||
| return 'isTTY' in stream && stream.isTTY === true && !isInCi; | ||
| } | ||
| //# sourceMappingURL=write-synchronized.js.map |
| {"version":3,"file":"write-synchronized.js","sourceRoot":"","sources":["../src/write-synchronized.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,UAAU,CAAC;AAE9B,MAAM,CAAC,MAAM,GAAG,GAAG,eAAe,CAAC;AACnC,MAAM,CAAC,MAAM,GAAG,GAAG,eAAe,CAAC;AAEnC,MAAM,UAAU,iBAAiB,CAAC,MAAgB;IACjD,OAAO,OAAO,IAAI,MAAM,IAAK,MAAc,CAAC,KAAK,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC;AACvE,CAAC"} |
@@ -1,3 +0,3 @@ | ||
| import { EventEmitter } from 'node:events'; | ||
| import React, { PureComponent, type ReactNode } from 'react'; | ||
| import React, { type ReactNode } from 'react'; | ||
| import { type CursorPosition } from '../log-update.js'; | ||
| type Props = { | ||
@@ -12,49 +12,8 @@ readonly children: ReactNode; | ||
| readonly onExit: (error?: Error) => void; | ||
| readonly setCursorPosition: (position: CursorPosition | undefined) => void; | ||
| }; | ||
| type State = { | ||
| readonly isFocusEnabled: boolean; | ||
| readonly activeFocusId?: string; | ||
| readonly focusables: Focusable[]; | ||
| readonly error?: Error; | ||
| }; | ||
| type Focusable = { | ||
| readonly id: string; | ||
| readonly isActive: boolean; | ||
| }; | ||
| export default class App extends PureComponent<Props, State> { | ||
| static displayName: string; | ||
| static getDerivedStateFromError(error: Error): { | ||
| error: Error; | ||
| }; | ||
| state: { | ||
| isFocusEnabled: boolean; | ||
| activeFocusId: undefined; | ||
| focusables: never[]; | ||
| error: undefined; | ||
| }; | ||
| rawModeEnabledCount: number; | ||
| internal_eventEmitter: EventEmitter<[never]>; | ||
| isRawModeSupported(): boolean; | ||
| render(): React.JSX.Element; | ||
| componentDidMount(): void; | ||
| componentWillUnmount(): void; | ||
| componentDidCatch(error: Error): void; | ||
| handleSetRawMode: (isEnabled: boolean) => void; | ||
| handleReadable: () => void; | ||
| handleInput: (input: string) => void; | ||
| handleExit: (error?: Error) => void; | ||
| enableFocus: () => void; | ||
| disableFocus: () => void; | ||
| focus: (id: string) => void; | ||
| focusNext: () => void; | ||
| focusPrevious: () => void; | ||
| addFocusable: (id: string, { autoFocus }: { | ||
| autoFocus: boolean; | ||
| }) => void; | ||
| removeFocusable: (id: string) => void; | ||
| activateFocusable: (id: string) => void; | ||
| deactivateFocusable: (id: string) => void; | ||
| findNextFocusable: (state: State) => string | undefined; | ||
| findPreviousFocusable: (state: State) => string | undefined; | ||
| declare function App({ children, stdin, stdout, stderr, writeToStdout, writeToStderr, exitOnCtrlC, onExit, setCursorPosition, }: Props): React.ReactNode; | ||
| declare namespace App { | ||
| var displayName: string; | ||
| } | ||
| export {}; | ||
| export default App; |
+259
-225
| import { EventEmitter } from 'node:events'; | ||
| import process from 'node:process'; | ||
| import React, { PureComponent } from 'react'; | ||
| import React, { useState, useRef, useCallback, useMemo, useEffect, } from 'react'; | ||
| import cliCursor from 'cli-cursor'; | ||
@@ -10,3 +10,4 @@ import AppContext from './AppContext.js'; | ||
| import FocusContext from './FocusContext.js'; | ||
| import ErrorOverview from './ErrorOverview.js'; | ||
| import CursorContext from './CursorContext.js'; | ||
| import ErrorBoundary from './ErrorBoundary.js'; | ||
| const tab = '\t'; | ||
@@ -18,92 +19,61 @@ const shiftTab = '\u001B[Z'; | ||
| // It also handles Ctrl+C exiting and cursor visibility | ||
| export default class App extends PureComponent { | ||
| static displayName = 'InternalApp'; | ||
| static getDerivedStateFromError(error) { | ||
| return { error }; | ||
| } | ||
| state = { | ||
| isFocusEnabled: true, | ||
| activeFocusId: undefined, | ||
| focusables: [], | ||
| error: undefined, | ||
| }; | ||
| function App({ children, stdin, stdout, stderr, writeToStdout, writeToStderr, exitOnCtrlC, onExit, setCursorPosition, }) { | ||
| const [isFocusEnabled, setIsFocusEnabled] = useState(true); | ||
| const [activeFocusId, setActiveFocusId] = useState(undefined); | ||
| // Focusables array is managed internally via setFocusables callback pattern | ||
| // eslint-disable-next-line react/hook-use-state | ||
| const [, setFocusables] = useState([]); | ||
| // Track focusables count for tab navigation check (avoids stale closure) | ||
| const focusablesCountRef = useRef(0); | ||
| // Count how many components enabled raw mode to avoid disabling | ||
| // raw mode until all components don't need it anymore | ||
| rawModeEnabledCount = 0; | ||
| const rawModeEnabledCount = useRef(0); | ||
| // eslint-disable-next-line @typescript-eslint/naming-convention | ||
| internal_eventEmitter = new EventEmitter(); | ||
| const internal_eventEmitter = useRef(new EventEmitter()); | ||
| // Each useInput hook adds a listener, so the count can legitimately exceed the default limit of 10. | ||
| internal_eventEmitter.current.setMaxListeners(Infinity); | ||
| // Store the currently attached readable listener to avoid stale closure issues | ||
| const readableListenerRef = useRef(undefined); | ||
| // Determines if TTY is supported on the provided stdin | ||
| isRawModeSupported() { | ||
| return this.props.stdin.isTTY; | ||
| } | ||
| render() { | ||
| return (React.createElement(AppContext.Provider | ||
| // eslint-disable-next-line react/jsx-no-constructed-context-values | ||
| , { | ||
| // eslint-disable-next-line react/jsx-no-constructed-context-values | ||
| value: { | ||
| exit: this.handleExit, | ||
| } }, | ||
| React.createElement(StdinContext.Provider | ||
| // eslint-disable-next-line react/jsx-no-constructed-context-values | ||
| , { | ||
| // eslint-disable-next-line react/jsx-no-constructed-context-values | ||
| value: { | ||
| stdin: this.props.stdin, | ||
| setRawMode: this.handleSetRawMode, | ||
| isRawModeSupported: this.isRawModeSupported(), | ||
| // eslint-disable-next-line @typescript-eslint/naming-convention | ||
| internal_exitOnCtrlC: this.props.exitOnCtrlC, | ||
| // eslint-disable-next-line @typescript-eslint/naming-convention | ||
| internal_eventEmitter: this.internal_eventEmitter, | ||
| } }, | ||
| React.createElement(StdoutContext.Provider | ||
| // eslint-disable-next-line react/jsx-no-constructed-context-values | ||
| , { | ||
| // eslint-disable-next-line react/jsx-no-constructed-context-values | ||
| value: { | ||
| stdout: this.props.stdout, | ||
| write: this.props.writeToStdout, | ||
| } }, | ||
| React.createElement(StderrContext.Provider | ||
| // eslint-disable-next-line react/jsx-no-constructed-context-values | ||
| , { | ||
| // eslint-disable-next-line react/jsx-no-constructed-context-values | ||
| value: { | ||
| stderr: this.props.stderr, | ||
| write: this.props.writeToStderr, | ||
| } }, | ||
| React.createElement(FocusContext.Provider | ||
| // eslint-disable-next-line react/jsx-no-constructed-context-values | ||
| , { | ||
| // eslint-disable-next-line react/jsx-no-constructed-context-values | ||
| value: { | ||
| activeId: this.state.activeFocusId, | ||
| add: this.addFocusable, | ||
| remove: this.removeFocusable, | ||
| activate: this.activateFocusable, | ||
| deactivate: this.deactivateFocusable, | ||
| enableFocus: this.enableFocus, | ||
| disableFocus: this.disableFocus, | ||
| focusNext: this.focusNext, | ||
| focusPrevious: this.focusPrevious, | ||
| focus: this.focus, | ||
| } }, this.state.error ? (React.createElement(ErrorOverview, { error: this.state.error })) : (this.props.children))))))); | ||
| } | ||
| componentDidMount() { | ||
| cliCursor.hide(this.props.stdout); | ||
| } | ||
| componentWillUnmount() { | ||
| cliCursor.show(this.props.stdout); | ||
| // ignore calling setRawMode on an handle stdin it cannot be called | ||
| if (this.isRawModeSupported()) { | ||
| this.handleSetRawMode(false); | ||
| const isRawModeSupported = stdin.isTTY; | ||
| const handleExit = useCallback((error) => { | ||
| // Disable raw mode on exit - inline to avoid circular dependency | ||
| if (isRawModeSupported && rawModeEnabledCount.current > 0) { | ||
| stdin.setRawMode(false); | ||
| if (readableListenerRef.current) { | ||
| stdin.removeListener('readable', readableListenerRef.current); | ||
| readableListenerRef.current = undefined; | ||
| } | ||
| stdin.unref(); | ||
| rawModeEnabledCount.current = 0; | ||
| } | ||
| } | ||
| componentDidCatch(error) { | ||
| this.handleExit(error); | ||
| } | ||
| handleSetRawMode = (isEnabled) => { | ||
| const { stdin } = this.props; | ||
| if (!this.isRawModeSupported()) { | ||
| onExit(error); | ||
| }, [isRawModeSupported, stdin, onExit]); | ||
| const handleInput = useCallback((input) => { | ||
| // Exit on Ctrl+C | ||
| // eslint-disable-next-line unicorn/no-hex-escape | ||
| if (input === '\x03' && exitOnCtrlC) { | ||
| handleExit(); | ||
| return; | ||
| } | ||
| // Reset focus when there's an active focused component on Esc | ||
| if (input === escape) { | ||
| setActiveFocusId(currentActiveFocusId => { | ||
| if (currentActiveFocusId) { | ||
| return undefined; | ||
| } | ||
| return currentActiveFocusId; | ||
| }); | ||
| } | ||
| }, [exitOnCtrlC, handleExit]); | ||
| const handleReadable = useCallback(() => { | ||
| let chunk; | ||
| // eslint-disable-next-line @typescript-eslint/ban-types | ||
| while ((chunk = stdin.read()) !== null) { | ||
| handleInput(chunk); | ||
| internal_eventEmitter.current.emit('input', chunk); | ||
| } | ||
| }, [stdin, handleInput]); | ||
| const handleSetRawMode = useCallback((isEnabled) => { | ||
| if (!isRawModeSupported) { | ||
| if (stdin === process.stdin) { | ||
@@ -119,171 +89,235 @@ throw new Error('Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported'); | ||
| // Ensure raw mode is enabled only once | ||
| if (this.rawModeEnabledCount === 0) { | ||
| if (rawModeEnabledCount.current === 0) { | ||
| stdin.ref(); | ||
| stdin.setRawMode(true); | ||
| stdin.addListener('readable', this.handleReadable); | ||
| // Store the listener reference to avoid stale closure when removing | ||
| readableListenerRef.current = handleReadable; | ||
| stdin.addListener('readable', handleReadable); | ||
| } | ||
| this.rawModeEnabledCount++; | ||
| rawModeEnabledCount.current++; | ||
| return; | ||
| } | ||
| // Disable raw mode only when no components left that are using it | ||
| if (--this.rawModeEnabledCount === 0) { | ||
| if (--rawModeEnabledCount.current === 0) { | ||
| stdin.setRawMode(false); | ||
| stdin.removeListener('readable', this.handleReadable); | ||
| if (readableListenerRef.current) { | ||
| stdin.removeListener('readable', readableListenerRef.current); | ||
| readableListenerRef.current = undefined; | ||
| } | ||
| stdin.unref(); | ||
| } | ||
| }; | ||
| handleReadable = () => { | ||
| let chunk; | ||
| // eslint-disable-next-line @typescript-eslint/ban-types | ||
| while ((chunk = this.props.stdin.read()) !== null) { | ||
| this.handleInput(chunk); | ||
| this.internal_eventEmitter.emit('input', chunk); | ||
| }, [isRawModeSupported, stdin, handleReadable]); | ||
| // Focus navigation helpers | ||
| const findNextFocusable = useCallback((currentFocusables, currentActiveFocusId) => { | ||
| const activeIndex = currentFocusables.findIndex(focusable => { | ||
| return focusable.id === currentActiveFocusId; | ||
| }); | ||
| for (let index = activeIndex + 1; index < currentFocusables.length; index++) { | ||
| const focusable = currentFocusables[index]; | ||
| if (focusable?.isActive) { | ||
| return focusable.id; | ||
| } | ||
| } | ||
| }; | ||
| handleInput = (input) => { | ||
| // Exit on Ctrl+C | ||
| // eslint-disable-next-line unicorn/no-hex-escape | ||
| if (input === '\x03' && this.props.exitOnCtrlC) { | ||
| this.handleExit(); | ||
| return undefined; | ||
| }, []); | ||
| const findPreviousFocusable = useCallback((currentFocusables, currentActiveFocusId) => { | ||
| const activeIndex = currentFocusables.findIndex(focusable => { | ||
| return focusable.id === currentActiveFocusId; | ||
| }); | ||
| for (let index = activeIndex - 1; index >= 0; index--) { | ||
| const focusable = currentFocusables[index]; | ||
| if (focusable?.isActive) { | ||
| return focusable.id; | ||
| } | ||
| } | ||
| // Reset focus when there's an active focused component on Esc | ||
| if (input === escape && this.state.activeFocusId) { | ||
| this.setState({ | ||
| activeFocusId: undefined, | ||
| return undefined; | ||
| }, []); | ||
| const focusNext = useCallback(() => { | ||
| setFocusables(currentFocusables => { | ||
| setActiveFocusId(currentActiveFocusId => { | ||
| const firstFocusableId = currentFocusables.find(focusable => focusable.isActive)?.id; | ||
| const nextFocusableId = findNextFocusable(currentFocusables, currentActiveFocusId); | ||
| return nextFocusableId ?? firstFocusableId; | ||
| }); | ||
| } | ||
| if (this.state.isFocusEnabled && this.state.focusables.length > 0) { | ||
| return currentFocusables; | ||
| }); | ||
| }, [findNextFocusable]); | ||
| const focusPrevious = useCallback(() => { | ||
| setFocusables(currentFocusables => { | ||
| setActiveFocusId(currentActiveFocusId => { | ||
| const lastFocusableId = currentFocusables.findLast(focusable => focusable.isActive)?.id; | ||
| const previousFocusableId = findPreviousFocusable(currentFocusables, currentActiveFocusId); | ||
| return previousFocusableId ?? lastFocusableId; | ||
| }); | ||
| return currentFocusables; | ||
| }); | ||
| }, [findPreviousFocusable]); | ||
| // Handle tab navigation via effect that subscribes to input events | ||
| useEffect(() => { | ||
| const handleTabNavigation = (input) => { | ||
| if (!isFocusEnabled || focusablesCountRef.current === 0) | ||
| return; | ||
| if (input === tab) { | ||
| this.focusNext(); | ||
| focusNext(); | ||
| } | ||
| if (input === shiftTab) { | ||
| this.focusPrevious(); | ||
| focusPrevious(); | ||
| } | ||
| } | ||
| }; | ||
| handleExit = (error) => { | ||
| if (this.isRawModeSupported()) { | ||
| this.handleSetRawMode(false); | ||
| } | ||
| this.props.onExit(error); | ||
| }; | ||
| enableFocus = () => { | ||
| this.setState({ | ||
| isFocusEnabled: true, | ||
| }; | ||
| internal_eventEmitter.current.on('input', handleTabNavigation); | ||
| const emitter = internal_eventEmitter.current; | ||
| return () => { | ||
| emitter.off('input', handleTabNavigation); | ||
| }; | ||
| }, [isFocusEnabled, focusNext, focusPrevious]); | ||
| const enableFocus = useCallback(() => { | ||
| setIsFocusEnabled(true); | ||
| }, []); | ||
| const disableFocus = useCallback(() => { | ||
| setIsFocusEnabled(false); | ||
| }, []); | ||
| const focus = useCallback((id) => { | ||
| setFocusables(currentFocusables => { | ||
| const hasFocusableId = currentFocusables.some(focusable => focusable?.id === id); | ||
| if (hasFocusableId) { | ||
| setActiveFocusId(id); | ||
| } | ||
| return currentFocusables; | ||
| }); | ||
| }; | ||
| disableFocus = () => { | ||
| this.setState({ | ||
| isFocusEnabled: false, | ||
| }, []); | ||
| const addFocusable = useCallback((id, { autoFocus }) => { | ||
| setFocusables(currentFocusables => { | ||
| focusablesCountRef.current = currentFocusables.length + 1; | ||
| return [ | ||
| ...currentFocusables, | ||
| { | ||
| id, | ||
| isActive: true, | ||
| }, | ||
| ]; | ||
| }); | ||
| }; | ||
| focus = (id) => { | ||
| this.setState(previousState => { | ||
| const hasFocusableId = previousState.focusables.some(focusable => focusable?.id === id); | ||
| if (!hasFocusableId) { | ||
| return previousState; | ||
| if (autoFocus) { | ||
| setActiveFocusId(currentActiveFocusId => { | ||
| if (!currentActiveFocusId) { | ||
| return id; | ||
| } | ||
| return currentActiveFocusId; | ||
| }); | ||
| } | ||
| }, []); | ||
| const removeFocusable = useCallback((id) => { | ||
| setActiveFocusId(currentActiveFocusId => { | ||
| if (currentActiveFocusId === id) { | ||
| return undefined; | ||
| } | ||
| return { activeFocusId: id }; | ||
| return currentActiveFocusId; | ||
| }); | ||
| }; | ||
| focusNext = () => { | ||
| this.setState(previousState => { | ||
| const firstFocusableId = previousState.focusables.find(focusable => focusable.isActive)?.id; | ||
| const nextFocusableId = this.findNextFocusable(previousState); | ||
| return { | ||
| activeFocusId: nextFocusableId ?? firstFocusableId, | ||
| }; | ||
| setFocusables(currentFocusables => { | ||
| const filtered = currentFocusables.filter(focusable => { | ||
| return focusable.id !== id; | ||
| }); | ||
| focusablesCountRef.current = filtered.length; | ||
| return filtered; | ||
| }); | ||
| }; | ||
| focusPrevious = () => { | ||
| this.setState(previousState => { | ||
| const lastFocusableId = previousState.focusables.findLast(focusable => focusable.isActive)?.id; | ||
| const previousFocusableId = this.findPreviousFocusable(previousState); | ||
| }, []); | ||
| const activateFocusable = useCallback((id) => { | ||
| setFocusables(currentFocusables => currentFocusables.map(focusable => { | ||
| if (focusable.id !== id) { | ||
| return focusable; | ||
| } | ||
| return { | ||
| activeFocusId: previousFocusableId ?? lastFocusableId, | ||
| id, | ||
| isActive: true, | ||
| }; | ||
| })); | ||
| }, []); | ||
| const deactivateFocusable = useCallback((id) => { | ||
| setActiveFocusId(currentActiveFocusId => { | ||
| if (currentActiveFocusId === id) { | ||
| return undefined; | ||
| } | ||
| return currentActiveFocusId; | ||
| }); | ||
| }; | ||
| addFocusable = (id, { autoFocus }) => { | ||
| this.setState(previousState => { | ||
| let nextFocusId = previousState.activeFocusId; | ||
| if (!nextFocusId && autoFocus) { | ||
| nextFocusId = id; | ||
| setFocusables(currentFocusables => currentFocusables.map(focusable => { | ||
| if (focusable.id !== id) { | ||
| return focusable; | ||
| } | ||
| return { | ||
| activeFocusId: nextFocusId, | ||
| focusables: [ | ||
| ...previousState.focusables, | ||
| { | ||
| id, | ||
| isActive: true, | ||
| }, | ||
| ], | ||
| id, | ||
| isActive: false, | ||
| }; | ||
| }); | ||
| }; | ||
| removeFocusable = (id) => { | ||
| this.setState(previousState => ({ | ||
| activeFocusId: previousState.activeFocusId === id | ||
| ? undefined | ||
| : previousState.activeFocusId, | ||
| focusables: previousState.focusables.filter(focusable => { | ||
| return focusable.id !== id; | ||
| }), | ||
| })); | ||
| }; | ||
| activateFocusable = (id) => { | ||
| this.setState(previousState => ({ | ||
| focusables: previousState.focusables.map(focusable => { | ||
| if (focusable.id !== id) { | ||
| return focusable; | ||
| }, []); | ||
| // Handle cursor visibility and raw mode cleanup on unmount | ||
| useEffect(() => { | ||
| return () => { | ||
| cliCursor.show(stdout); | ||
| // Disable raw mode on unmount if supported | ||
| if (isRawModeSupported && rawModeEnabledCount.current > 0) { | ||
| stdin.setRawMode(false); | ||
| if (readableListenerRef.current) { | ||
| stdin.removeListener('readable', readableListenerRef.current); | ||
| readableListenerRef.current = undefined; | ||
| } | ||
| return { | ||
| id, | ||
| isActive: true, | ||
| }; | ||
| }), | ||
| })); | ||
| }; | ||
| deactivateFocusable = (id) => { | ||
| this.setState(previousState => ({ | ||
| activeFocusId: previousState.activeFocusId === id | ||
| ? undefined | ||
| : previousState.activeFocusId, | ||
| focusables: previousState.focusables.map(focusable => { | ||
| if (focusable.id !== id) { | ||
| return focusable; | ||
| } | ||
| return { | ||
| id, | ||
| isActive: false, | ||
| }; | ||
| }), | ||
| })); | ||
| }; | ||
| findNextFocusable = (state) => { | ||
| const activeIndex = state.focusables.findIndex(focusable => { | ||
| return focusable.id === state.activeFocusId; | ||
| }); | ||
| for (let index = activeIndex + 1; index < state.focusables.length; index++) { | ||
| const focusable = state.focusables[index]; | ||
| if (focusable?.isActive) { | ||
| return focusable.id; | ||
| stdin.unref(); | ||
| } | ||
| } | ||
| return undefined; | ||
| }; | ||
| findPreviousFocusable = (state) => { | ||
| const activeIndex = state.focusables.findIndex(focusable => { | ||
| return focusable.id === state.activeFocusId; | ||
| }); | ||
| for (let index = activeIndex - 1; index >= 0; index--) { | ||
| const focusable = state.focusables[index]; | ||
| if (focusable?.isActive) { | ||
| return focusable.id; | ||
| } | ||
| } | ||
| return undefined; | ||
| }; | ||
| }; | ||
| }, [stdout, stdin, isRawModeSupported]); | ||
| // Memoize context values to prevent unnecessary re-renders | ||
| const appContextValue = useMemo(() => ({ | ||
| exit: handleExit, | ||
| }), [handleExit]); | ||
| const stdinContextValue = useMemo(() => ({ | ||
| stdin, | ||
| setRawMode: handleSetRawMode, | ||
| isRawModeSupported, | ||
| // eslint-disable-next-line @typescript-eslint/naming-convention | ||
| internal_exitOnCtrlC: exitOnCtrlC, | ||
| // eslint-disable-next-line @typescript-eslint/naming-convention | ||
| internal_eventEmitter: internal_eventEmitter.current, | ||
| }), [stdin, handleSetRawMode, isRawModeSupported, exitOnCtrlC]); | ||
| const stdoutContextValue = useMemo(() => ({ | ||
| stdout, | ||
| write: writeToStdout, | ||
| }), [stdout, writeToStdout]); | ||
| const stderrContextValue = useMemo(() => ({ | ||
| stderr, | ||
| write: writeToStderr, | ||
| }), [stderr, writeToStderr]); | ||
| const cursorContextValue = useMemo(() => ({ | ||
| setCursorPosition, | ||
| }), [setCursorPosition]); | ||
| const focusContextValue = useMemo(() => ({ | ||
| activeId: activeFocusId, | ||
| add: addFocusable, | ||
| remove: removeFocusable, | ||
| activate: activateFocusable, | ||
| deactivate: deactivateFocusable, | ||
| enableFocus, | ||
| disableFocus, | ||
| focusNext, | ||
| focusPrevious, | ||
| focus, | ||
| }), [ | ||
| activeFocusId, | ||
| addFocusable, | ||
| removeFocusable, | ||
| activateFocusable, | ||
| deactivateFocusable, | ||
| enableFocus, | ||
| disableFocus, | ||
| focusNext, | ||
| focusPrevious, | ||
| focus, | ||
| ]); | ||
| return (React.createElement(AppContext.Provider, { value: appContextValue }, | ||
| React.createElement(StdinContext.Provider, { value: stdinContextValue }, | ||
| React.createElement(StdoutContext.Provider, { value: stdoutContextValue }, | ||
| React.createElement(StderrContext.Provider, { value: stderrContextValue }, | ||
| React.createElement(FocusContext.Provider, { value: focusContextValue }, | ||
| React.createElement(CursorContext.Provider, { value: cursorContextValue }, | ||
| React.createElement(ErrorBoundary, { onError: handleExit }, children)))))))); | ||
| } | ||
| App.displayName = 'InternalApp'; | ||
| export default App; | ||
| //# sourceMappingURL=App.js.map |
@@ -69,2 +69,32 @@ /** | ||
| meta: boolean; | ||
| /** | ||
| Super key (Cmd on Mac, Win on Windows) was pressed. | ||
| Only available with kitty keyboard protocol. | ||
| */ | ||
| super: boolean; | ||
| /** | ||
| Hyper key was pressed. | ||
| Only available with kitty keyboard protocol. | ||
| */ | ||
| hyper: boolean; | ||
| /** | ||
| Caps Lock is active. | ||
| Only available with kitty keyboard protocol. | ||
| */ | ||
| capsLock: boolean; | ||
| /** | ||
| Num Lock is active. | ||
| Only available with kitty keyboard protocol. | ||
| */ | ||
| numLock: boolean; | ||
| /** | ||
| Event type for key events. | ||
| Only available with kitty keyboard protocol. | ||
| */ | ||
| eventType?: 'press' | 'repeat' | 'release'; | ||
| }; | ||
@@ -71,0 +101,0 @@ type Handler = (input: string, key: Key) => void; |
@@ -65,5 +65,34 @@ import { useEffect } from 'react'; | ||
| meta: keypress.meta || keypress.name === 'escape' || keypress.option, | ||
| // Kitty keyboard protocol modifiers | ||
| super: keypress.super ?? false, | ||
| hyper: keypress.hyper ?? false, | ||
| capsLock: keypress.capsLock ?? false, | ||
| numLock: keypress.numLock ?? false, | ||
| eventType: keypress.eventType, | ||
| }; | ||
| let input = keypress.ctrl ? keypress.name : keypress.sequence; | ||
| if (nonAlphanumericKeys.includes(keypress.name)) { | ||
| let input; | ||
| if (keypress.isKittyProtocol) { | ||
| // Use text-as-codepoints field for printable keys (needed when | ||
| // reportAllKeysAsEscapeCodes flag is enabled), suppress non-printable | ||
| if (keypress.isPrintable) { | ||
| input = keypress.text ?? keypress.name; | ||
| } | ||
| else if (keypress.ctrl && keypress.name.length === 1) { | ||
| // Ctrl+letter via codepoint 1-26 form: not printable text, but | ||
| // the letter name must flow through so handlers (e.g. exitOnCtrlC | ||
| // checking `input === 'c' && key.ctrl`) still work. | ||
| input = keypress.name; | ||
| } | ||
| else { | ||
| input = ''; | ||
| } | ||
| } | ||
| else if (keypress.ctrl) { | ||
| input = keypress.name; | ||
| } | ||
| else { | ||
| input = keypress.sequence; | ||
| } | ||
| if (!keypress.isKittyProtocol && | ||
| nonAlphanumericKeys.includes(keypress.name)) { | ||
| input = ''; | ||
@@ -70,0 +99,0 @@ } |
+4
-0
@@ -27,3 +27,7 @@ export type { RenderOptions, Instance } from './render.js'; | ||
| export { default as useIsScreenReaderEnabled } from './hooks/use-is-screen-reader-enabled.js'; | ||
| export { default as useCursor } from './hooks/use-cursor.js'; | ||
| export type { CursorPosition } from './log-update.js'; | ||
| export { default as measureElement } from './measure-element.js'; | ||
| export type { DOMElement } from './dom.js'; | ||
| export { kittyFlags, kittyModifiers } from './kitty-keyboard.js'; | ||
| export type { KittyKeyboardOptions, KittyFlagName } from './kitty-keyboard.js'; |
+2
-0
@@ -16,3 +16,5 @@ export { default as render } from './render.js'; | ||
| export { default as useIsScreenReaderEnabled } from './hooks/use-is-screen-reader-enabled.js'; | ||
| export { default as useCursor } from './hooks/use-cursor.js'; | ||
| export { default as measureElement } from './measure-element.js'; | ||
| export { kittyFlags, kittyModifiers } from './kitty-keyboard.js'; | ||
| //# sourceMappingURL=index.js.map |
+32
-0
| import { type ReactNode } from 'react'; | ||
| import { type CursorPosition } from './log-update.js'; | ||
| import { type KittyKeyboardOptions } from './kitty-keyboard.js'; | ||
| /** | ||
@@ -23,6 +25,26 @@ Performance metrics for a render operation. | ||
| incrementalRendering?: boolean; | ||
| /** | ||
| 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. The `concurrent` option only takes effect on the first render for a given stdout. If you need to change the rendering mode, call `unmount()` first. | ||
| @default false | ||
| @experimental | ||
| */ | ||
| concurrent?: boolean; | ||
| kittyKeyboard?: KittyKeyboardOptions; | ||
| }; | ||
| export default class Ink { | ||
| /** | ||
| Whether this instance is using concurrent rendering mode. | ||
| */ | ||
| readonly isConcurrent: boolean; | ||
| private readonly options; | ||
| private readonly log; | ||
| private cursorPosition; | ||
| private readonly throttledLog; | ||
@@ -32,2 +54,3 @@ private readonly isScreenReaderEnabled; | ||
| private lastOutput; | ||
| private lastOutputToRender; | ||
| private lastOutputHeight; | ||
@@ -39,4 +62,8 @@ private lastTerminalWidth; | ||
| private exitPromise?; | ||
| private beforeExitHandler?; | ||
| private restoreConsole?; | ||
| private readonly unsubscribeResize?; | ||
| private readonly throttledOnRender?; | ||
| private kittyProtocolEnabled; | ||
| private cancelKittyDetection?; | ||
| constructor(options: Options); | ||
@@ -48,2 +75,4 @@ getTerminalWidth: () => number; | ||
| unsubscribeExit: () => void; | ||
| setCursorPosition: (position: CursorPosition | undefined) => void; | ||
| restoreLastOutput: () => void; | ||
| calculateLayout: () => void; | ||
@@ -58,2 +87,5 @@ onRender: () => void; | ||
| patchConsole(): void; | ||
| private initKittyKeyboard; | ||
| private confirmKittySupport; | ||
| private enableKittyProtocol; | ||
| } |
+258
-31
@@ -9,5 +9,6 @@ import process from 'node:process'; | ||
| import patchConsole from 'patch-console'; | ||
| import { LegacyRoot } from 'react-reconciler/constants.js'; | ||
| import { LegacyRoot, ConcurrentRoot } from 'react-reconciler/constants.js'; | ||
| import Yoga from 'yoga-layout'; | ||
| import wrapAnsi from 'wrap-ansi'; | ||
| import terminalSize from 'terminal-size'; | ||
| import reconciler from './reconciler.js'; | ||
@@ -17,9 +18,16 @@ import render from './renderer.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 = () => { }; | ||
| export default class Ink { | ||
| /** | ||
| Whether this instance is using concurrent rendering mode. | ||
| */ | ||
| isConcurrent; | ||
| options; | ||
| log; | ||
| cursorPosition; | ||
| throttledLog; | ||
@@ -30,2 +38,3 @@ isScreenReaderEnabled; | ||
| lastOutput; | ||
| lastOutputToRender; | ||
| lastOutputHeight; | ||
@@ -39,4 +48,8 @@ lastTerminalWidth; | ||
| exitPromise; | ||
| beforeExitHandler; | ||
| restoreConsole; | ||
| unsubscribeResize; | ||
| throttledOnRender; | ||
| kittyProtocolEnabled = false; | ||
| cancelKittyDetection; | ||
| constructor(options) { | ||
@@ -53,8 +66,14 @@ autoBind(this); | ||
| const renderThrottleMs = maxFps > 0 ? Math.max(1, Math.ceil(1000 / maxFps)) : 0; | ||
| this.rootNode.onRender = unthrottled | ||
| ? this.onRender | ||
| : throttle(this.onRender, 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 = throttled; | ||
| this.throttledOnRender = throttled; | ||
| } | ||
| this.rootNode.onImmediateRender = this.onRender; | ||
@@ -64,5 +83,16 @@ this.log = logUpdate.create(options.stdout, { | ||
| }); | ||
| this.cursorPosition = undefined; | ||
| this.throttledLog = unthrottled | ||
| ? this.log | ||
| : throttle(this.log, undefined, { | ||
| : throttle((output) => { | ||
| const shouldWrite = this.log.willRender(output); | ||
| const sync = shouldSynchronize(this.options.stdout); | ||
| if (sync && shouldWrite) { | ||
| this.options.stdout.write(bsu); | ||
| } | ||
| this.log(output); | ||
| if (sync && shouldWrite) { | ||
| this.options.stdout.write(esu); | ||
| } | ||
| }, undefined, { | ||
| leading: true, | ||
@@ -73,4 +103,7 @@ trailing: true, | ||
| this.isUnmounted = 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; | ||
@@ -81,4 +114,6 @@ this.lastTerminalWidth = this.getTerminalWidth(); | ||
| 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, LegacyRoot, null, false, null, 'id', () => { }, () => { }, () => { }, () => { }, null); | ||
| this.container = reconciler.createContainer(this.rootNode, rootTag, null, false, null, 'id', () => { }, () => { }, () => { }, () => { }); | ||
| // Unmount when process exits | ||
@@ -104,7 +139,12 @@ this.unsubscribeExit = signalExit(this.unmount, { alwaysLast: false }); | ||
| } | ||
| this.initKittyKeyboard(); | ||
| } | ||
| getTerminalWidth = () => { | ||
| // The 'columns' property can be undefined or 0 when not using a TTY. | ||
| // In that case we fall back to 80. | ||
| return this.options.stdout.columns || 80; | ||
| // Use terminal-size as a fallback for piped processes, then default to 80. | ||
| if (this.options.stdout.columns) { | ||
| return this.options.stdout.columns; | ||
| } | ||
| const size = terminalSize(); | ||
| return size?.columns ?? 80; | ||
| }; | ||
@@ -117,2 +157,3 @@ resized = () => { | ||
| this.lastOutput = ''; | ||
| this.lastOutputToRender = ''; | ||
| } | ||
@@ -126,2 +167,12 @@ this.calculateLayout(); | ||
| unsubscribeExit = () => { }; | ||
| setCursorPosition = (position) => { | ||
| this.cursorPosition = position; | ||
| this.log.setCursorPosition(position); | ||
| }; | ||
| restoreLastOutput = () => { | ||
| // 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 = () => { | ||
@@ -153,2 +204,3 @@ const terminalWidth = this.getTerminalWidth(); | ||
| this.lastOutput = output; | ||
| this.lastOutputToRender = output + '\n'; | ||
| this.lastOutputHeight = outputHeight; | ||
@@ -158,2 +210,6 @@ return; | ||
| if (this.isScreenReaderEnabled) { | ||
| const sync = shouldSynchronize(this.options.stdout); | ||
| if (sync) { | ||
| this.options.stdout.write(bsu); | ||
| } | ||
| if (hasStaticOutput) { | ||
@@ -169,5 +225,8 @@ // We need to erase the main output before writing new static output | ||
| if (output === this.lastOutput && !hasStaticOutput) { | ||
| if (sync) { | ||
| this.options.stdout.write(esu); | ||
| } | ||
| return; | ||
| } | ||
| const terminalWidth = this.options.stdout.columns || 80; | ||
| const terminalWidth = this.getTerminalWidth(); | ||
| const wrappedOutput = wrapAnsi(output, terminalWidth, { | ||
@@ -188,4 +247,8 @@ trim: false, | ||
| this.lastOutput = output; | ||
| this.lastOutputToRender = wrappedOutput; | ||
| this.lastOutputHeight = | ||
| wrappedOutput === '' ? 0 : wrappedOutput.split('\n').length; | ||
| if (sync) { | ||
| this.options.stdout.write(esu); | ||
| } | ||
| return; | ||
@@ -196,7 +259,19 @@ } | ||
| } | ||
| // Detect fullscreen: output fills or exceeds terminal height. | ||
| // Only apply when writing to a real TTY — piped output always gets trailing newlines. | ||
| const isFullscreen = this.options.stdout.isTTY && outputHeight >= this.options.stdout.rows; | ||
| const outputToRender = isFullscreen ? output : output + '\n'; | ||
| if (this.lastOutputHeight >= this.options.stdout.rows) { | ||
| const sync = shouldSynchronize(this.options.stdout); | ||
| 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(output); | ||
| this.log.sync(outputToRender); | ||
| if (sync) { | ||
| this.options.stdout.write(esu); | ||
| } | ||
| return; | ||
@@ -206,10 +281,19 @@ } | ||
| if (hasStaticOutput) { | ||
| const sync = shouldSynchronize(this.options.stdout); | ||
| if (sync) { | ||
| this.options.stdout.write(bsu); | ||
| } | ||
| this.log.clear(); | ||
| this.options.stdout.write(staticOutput); | ||
| this.log(output); | ||
| this.log(outputToRender); | ||
| if (sync) { | ||
| this.options.stdout.write(esu); | ||
| } | ||
| } | ||
| if (!hasStaticOutput && output !== this.lastOutput) { | ||
| this.throttledLog(output); | ||
| 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; | ||
@@ -219,9 +303,12 @@ }; | ||
| 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, writeToStdout: this.writeToStdout, writeToStderr: this.writeToStderr, exitOnCtrlC: this.options.exitOnCtrlC, onExit: this.unmount }, node))); | ||
| // @ts-expect-error the types for `react-reconciler` are not up to date with the library. | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-call | ||
| reconciler.updateContainerSync(tree, this.container, null, noop); | ||
| // @ts-expect-error the types for `react-reconciler` are not up to date with the library. | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-call | ||
| reconciler.flushSyncWork(); | ||
| React.createElement(App, { stdin: this.options.stdin, stdout: this.options.stdout, stderr: this.options.stderr, exitOnCtrlC: this.options.exitOnCtrlC, writeToStdout: this.writeToStdout, writeToStderr: this.writeToStderr, setCursorPosition: this.setCursorPosition, onExit: this.unmount }, 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(); | ||
| } | ||
| } | ||
@@ -240,5 +327,12 @@ writeToStdout(data) { | ||
| } | ||
| const sync = shouldSynchronize(this.options.stdout); | ||
| if (sync) { | ||
| this.options.stdout.write(bsu); | ||
| } | ||
| this.log.clear(); | ||
| this.options.stdout.write(data); | ||
| this.log(this.lastOutput); | ||
| this.restoreLastOutput(); | ||
| if (sync) { | ||
| this.options.stdout.write(esu); | ||
| } | ||
| } | ||
@@ -258,5 +352,12 @@ writeToStderr(data) { | ||
| } | ||
| const sync = shouldSynchronize(this.options.stdout); | ||
| if (sync) { | ||
| this.options.stdout.write(bsu); | ||
| } | ||
| this.log.clear(); | ||
| this.options.stderr.write(data); | ||
| this.log(this.lastOutput); | ||
| this.restoreLastOutput(); | ||
| if (sync) { | ||
| this.options.stdout.write(esu); | ||
| } | ||
| } | ||
@@ -268,2 +369,10 @@ // eslint-disable-next-line @typescript-eslint/ban-types | ||
| } | ||
| if (this.beforeExitHandler) { | ||
| process.off('beforeExit', this.beforeExitHandler); | ||
| this.beforeExitHandler = undefined; | ||
| } | ||
| // Flush any pending throttled render to ensure the final frame is rendered | ||
| if (this.throttledOnRender) { | ||
| this.throttledOnRender.flush(); | ||
| } | ||
| this.calculateLayout(); | ||
@@ -278,2 +387,20 @@ this.onRender(); | ||
| } | ||
| // Flush any pending throttled log writes | ||
| const throttledLog = this.throttledLog; | ||
| if (typeof throttledLog.flush === 'function') { | ||
| throttledLog.flush(); | ||
| } | ||
| // Cancel any in-progress auto-detection before checking protocol state | ||
| if (this.cancelKittyDetection) { | ||
| this.cancelKittyDetection(); | ||
| } | ||
| if (this.kittyProtocolEnabled) { | ||
| try { | ||
| this.options.stdout.write('\u001B[<u'); | ||
| } | ||
| catch { | ||
| // Best-effort: stdout may already be destroyed during shutdown | ||
| } | ||
| this.kittyProtocolEnabled = false; | ||
| } | ||
| // CIs don't handle erasing ansi escapes well, so it's better to | ||
@@ -288,14 +415,38 @@ // only render last frame of non-static output | ||
| this.isUnmounted = true; | ||
| // @ts-expect-error the types for `react-reconciler` are not up to date with the library. | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-call | ||
| reconciler.updateContainerSync(null, this.container, null, noop); | ||
| // @ts-expect-error the types for `react-reconciler` are not up to date with the library. | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-call | ||
| reconciler.flushSyncWork(); | ||
| if (this.options.concurrent) { | ||
| // Concurrent mode: use updateContainer (async scheduling) | ||
| reconciler.updateContainer(null, this.container, null, noop); | ||
| } | ||
| else { | ||
| // Legacy mode: use updateContainerSync + flushSyncWork (sync) | ||
| reconciler.updateContainerSync(null, this.container, null, noop); | ||
| reconciler.flushSyncWork(); | ||
| } | ||
| instances.delete(this.options.stdout); | ||
| if (error instanceof Error) { | ||
| this.rejectExitPromise(error); | ||
| // 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 resolveOrReject = () => { | ||
| if (error instanceof Error) { | ||
| this.rejectExitPromise(error); | ||
| } | ||
| else { | ||
| this.resolveExitPromise(); | ||
| } | ||
| }; | ||
| const isProcessExiting = error !== undefined && !(error instanceof Error); | ||
| if (isProcessExiting) { | ||
| resolveOrReject(); | ||
| } | ||
| else if (this.options.stdout._writableState !== undefined || | ||
| this.options.stdout.writableLength !== undefined) { | ||
| this.options.stdout.write('', resolveOrReject); | ||
| } | ||
| else { | ||
| this.resolveExitPromise(); | ||
| setImmediate(resolveOrReject); | ||
| } | ||
@@ -308,2 +459,8 @@ } | ||
| }); | ||
| if (!this.beforeExitHandler) { | ||
| this.beforeExitHandler = () => { | ||
| this.unmount(); | ||
| }; | ||
| process.once('beforeExit', this.beforeExitHandler); | ||
| } | ||
| return this.exitPromise; | ||
@@ -314,2 +471,5 @@ } | ||
| 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'); | ||
| } | ||
@@ -333,3 +493,70 @@ } | ||
| } | ||
| 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' || | ||
| !this.options.stdin.isTTY || | ||
| !this.options.stdout.isTTY) { | ||
| return; | ||
| } | ||
| const flags = opts.flags ?? ['disambiguateEscapeCodes']; | ||
| if (mode === 'enabled') { | ||
| this.enableKittyProtocol(flags); | ||
| return; | ||
| } | ||
| // Auto mode: use heuristic precheck, then confirm with protocol query | ||
| const term = process.env['TERM'] ?? ''; | ||
| const termProgram = process.env['TERM_PROGRAM'] ?? ''; | ||
| const isKnownSupportingTerminal = 'KITTY_WINDOW_ID' in process.env || | ||
| term === 'xterm-kitty' || | ||
| termProgram === 'WezTerm' || | ||
| termProgram === 'ghostty'; | ||
| if (!isInCi && isKnownSupportingTerminal) { | ||
| 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. | ||
| // eslint-disable-next-line no-control-regex | ||
| const remaining = responseBuffer.replace(/\u001B\[\?\d+u/, ''); | ||
| responseBuffer = ''; | ||
| if (remaining) { | ||
| stdin.unshift(Buffer.from(remaining)); | ||
| } | ||
| }; | ||
| const onData = (data) => { | ||
| responseBuffer += | ||
| typeof data === 'string' ? data : Buffer.from(data).toString(); | ||
| // eslint-disable-next-line no-control-regex | ||
| if (/\u001B\[\?\d+u/.test(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 |
| import { type Writable } from 'node:stream'; | ||
| import { type CursorPosition } from './cursor-helpers.js'; | ||
| export type { CursorPosition } from './cursor-helpers.js'; | ||
| export type LogUpdate = { | ||
@@ -6,3 +8,6 @@ clear: () => void; | ||
| sync: (str: string) => void; | ||
| (str: string): void; | ||
| setCursorPosition: (position: CursorPosition | undefined) => void; | ||
| isCursorDirty: () => boolean; | ||
| willRender: (str: string) => boolean; | ||
| (str: string): boolean; | ||
| }; | ||
@@ -9,0 +14,0 @@ declare const logUpdate: { |
+162
-39
| import ansiEscapes from 'ansi-escapes'; | ||
| import cliCursor from 'cli-cursor'; | ||
| import { cursorPositionChanged, buildCursorSuffix, buildCursorOnlySequence, buildReturnToBottomPrefix, hideCursorEscape, } from './cursor-helpers.js'; | ||
| // Count visible lines in a string, ignoring the trailing empty element | ||
| // that `split('\n')` produces when the string ends with '\n'. | ||
| const visibleLineCount = (lines, str) => str.endsWith('\n') ? lines.length - 1 : lines.length; | ||
| const createStandard = (stream, { showCursor = false } = {}) => { | ||
@@ -7,19 +11,56 @@ let previousLineCount = 0; | ||
| let hasHiddenCursor = false; | ||
| let cursorPosition; | ||
| let cursorDirty = false; | ||
| let previousCursorPosition; | ||
| let cursorWasShown = false; | ||
| const getActiveCursor = () => (cursorDirty ? cursorPosition : undefined); | ||
| const hasChanges = (str, activeCursor) => { | ||
| const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition); | ||
| return str !== previousOutput || cursorChanged; | ||
| }; | ||
| const render = (str) => { | ||
| if (!showCursor && !hasHiddenCursor) { | ||
| cliCursor.hide(); | ||
| cliCursor.hide(stream); | ||
| hasHiddenCursor = true; | ||
| } | ||
| const output = str + '\n'; | ||
| if (output === previousOutput) { | ||
| return; | ||
| // Only use cursor if setCursorPosition was called since last render. | ||
| // This ensures stale positions don't persist after component unmount. | ||
| const activeCursor = getActiveCursor(); | ||
| cursorDirty = false; | ||
| const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition); | ||
| if (!hasChanges(str, activeCursor)) { | ||
| return false; | ||
| } | ||
| previousOutput = output; | ||
| stream.write(ansiEscapes.eraseLines(previousLineCount) + output); | ||
| previousLineCount = output.split('\n').length; | ||
| const lines = str.split('\n'); | ||
| const visibleCount = visibleLineCount(lines, str); | ||
| const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor); | ||
| if (str === previousOutput && cursorChanged) { | ||
| stream.write(buildCursorOnlySequence({ | ||
| cursorWasShown, | ||
| previousLineCount, | ||
| previousCursorPosition, | ||
| visibleLineCount: visibleCount, | ||
| cursorPosition: activeCursor, | ||
| })); | ||
| } | ||
| else { | ||
| previousOutput = str; | ||
| const returnPrefix = buildReturnToBottomPrefix(cursorWasShown, previousLineCount, previousCursorPosition); | ||
| stream.write(returnPrefix + | ||
| ansiEscapes.eraseLines(previousLineCount) + | ||
| str + | ||
| cursorSuffix); | ||
| previousLineCount = lines.length; | ||
| } | ||
| previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; | ||
| cursorWasShown = activeCursor !== undefined; | ||
| return true; | ||
| }; | ||
| render.clear = () => { | ||
| stream.write(ansiEscapes.eraseLines(previousLineCount)); | ||
| const prefix = buildReturnToBottomPrefix(cursorWasShown, previousLineCount, previousCursorPosition); | ||
| stream.write(prefix + ansiEscapes.eraseLines(previousLineCount)); | ||
| previousOutput = ''; | ||
| previousLineCount = 0; | ||
| previousCursorPosition = undefined; | ||
| cursorWasShown = false; | ||
| }; | ||
@@ -29,4 +70,6 @@ render.done = () => { | ||
| previousLineCount = 0; | ||
| previousCursorPosition = undefined; | ||
| cursorWasShown = false; | ||
| if (!showCursor) { | ||
| cliCursor.show(); | ||
| cliCursor.show(stream); | ||
| hasHiddenCursor = false; | ||
@@ -36,6 +79,22 @@ } | ||
| render.sync = (str) => { | ||
| const output = str + '\n'; | ||
| previousOutput = output; | ||
| previousLineCount = output.split('\n').length; | ||
| const activeCursor = cursorDirty ? cursorPosition : undefined; | ||
| cursorDirty = false; | ||
| const lines = str.split('\n'); | ||
| previousOutput = str; | ||
| previousLineCount = lines.length; | ||
| if (!activeCursor && cursorWasShown) { | ||
| stream.write(hideCursorEscape); | ||
| } | ||
| if (activeCursor) { | ||
| stream.write(buildCursorSuffix(visibleLineCount(lines, str), activeCursor)); | ||
| } | ||
| previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; | ||
| cursorWasShown = activeCursor !== undefined; | ||
| }; | ||
| render.setCursorPosition = (position) => { | ||
| cursorPosition = position; | ||
| cursorDirty = true; | ||
| }; | ||
| render.isCursorDirty = () => cursorDirty; | ||
| render.willRender = (str) => hasChanges(str, getActiveCursor()); | ||
| return render; | ||
@@ -47,38 +106,74 @@ }; | ||
| let hasHiddenCursor = false; | ||
| let cursorPosition; | ||
| let cursorDirty = false; | ||
| let previousCursorPosition; | ||
| let cursorWasShown = false; | ||
| const getActiveCursor = () => (cursorDirty ? cursorPosition : undefined); | ||
| const hasChanges = (str, activeCursor) => { | ||
| const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition); | ||
| return str !== previousOutput || cursorChanged; | ||
| }; | ||
| const render = (str) => { | ||
| if (!showCursor && !hasHiddenCursor) { | ||
| cliCursor.hide(); | ||
| cliCursor.hide(stream); | ||
| hasHiddenCursor = true; | ||
| } | ||
| const output = str + '\n'; | ||
| if (output === previousOutput) { | ||
| return; | ||
| // Only use cursor if setCursorPosition was called since last render. | ||
| // This ensures stale positions don't persist after component unmount. | ||
| const activeCursor = getActiveCursor(); | ||
| cursorDirty = false; | ||
| const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition); | ||
| if (!hasChanges(str, activeCursor)) { | ||
| return false; | ||
| } | ||
| const previousCount = previousLines.length; | ||
| const nextLines = output.split('\n'); | ||
| const nextCount = nextLines.length; | ||
| const visibleCount = nextCount - 1; | ||
| if (output === '\n' || previousOutput.length === 0) { | ||
| stream.write(ansiEscapes.eraseLines(previousCount) + output); | ||
| previousOutput = output; | ||
| const nextLines = str.split('\n'); | ||
| const visibleCount = visibleLineCount(nextLines, str); | ||
| const previousVisible = visibleLineCount(previousLines, previousOutput); | ||
| if (str === previousOutput && cursorChanged) { | ||
| stream.write(buildCursorOnlySequence({ | ||
| cursorWasShown, | ||
| previousLineCount: previousLines.length, | ||
| previousCursorPosition, | ||
| visibleLineCount: visibleCount, | ||
| cursorPosition: activeCursor, | ||
| })); | ||
| previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; | ||
| cursorWasShown = activeCursor !== undefined; | ||
| return true; | ||
| } | ||
| const returnPrefix = buildReturnToBottomPrefix(cursorWasShown, previousLines.length, previousCursorPosition); | ||
| if (str === '\n' || previousOutput.length === 0) { | ||
| const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor); | ||
| stream.write(returnPrefix + | ||
| ansiEscapes.eraseLines(previousLines.length) + | ||
| str + | ||
| cursorSuffix); | ||
| cursorWasShown = activeCursor !== undefined; | ||
| previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; | ||
| previousOutput = str; | ||
| previousLines = nextLines; | ||
| return; | ||
| return true; | ||
| } | ||
| const hasTrailingNewline = str.endsWith('\n'); | ||
| // We aggregate all chunks for incremental rendering into a buffer, and then write them to stdout at the end. | ||
| const buffer = []; | ||
| buffer.push(returnPrefix); | ||
| // Clear extra lines if the current content's line count is lower than the previous. | ||
| if (nextCount < previousCount) { | ||
| buffer.push( | ||
| // Erases the trailing lines and the final newline slot. | ||
| ansiEscapes.eraseLines(previousCount - nextCount + 1), | ||
| // Positions cursor to the top of the rendered output. | ||
| ansiEscapes.cursorUp(visibleCount)); | ||
| if (visibleCount < previousVisible) { | ||
| const previousHadTrailingNewline = previousOutput.endsWith('\n'); | ||
| const extraSlot = previousHadTrailingNewline ? 1 : 0; | ||
| buffer.push(ansiEscapes.eraseLines(previousVisible - visibleCount + extraSlot), ansiEscapes.cursorUp(visibleCount)); | ||
| } | ||
| else { | ||
| buffer.push(ansiEscapes.cursorUp(previousCount - 1)); | ||
| buffer.push(ansiEscapes.cursorUp(previousVisible - 1)); | ||
| } | ||
| for (let i = 0; i < visibleCount; i++) { | ||
| const isLastLine = i === visibleCount - 1; | ||
| // We do not write lines if the contents are the same. This prevents flickering during renders. | ||
| if (nextLines[i] === previousLines[i]) { | ||
| buffer.push(ansiEscapes.cursorNextLine); | ||
| // Don't move past the last line when there's no trailing newline, | ||
| // otherwise the cursor overshoots the rendered block. | ||
| if (!isLastLine || hasTrailingNewline) { | ||
| buffer.push(ansiEscapes.cursorNextLine); | ||
| } | ||
| continue; | ||
@@ -89,12 +184,22 @@ } | ||
| ansiEscapes.eraseEndLine + | ||
| '\n'); | ||
| // Don't append newline after the last line when the input | ||
| // has no trailing newline (fullscreen mode). | ||
| (isLastLine && !hasTrailingNewline ? '' : '\n')); | ||
| } | ||
| const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor); | ||
| buffer.push(cursorSuffix); | ||
| stream.write(buffer.join('')); | ||
| previousOutput = output; | ||
| cursorWasShown = activeCursor !== undefined; | ||
| previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; | ||
| previousOutput = str; | ||
| previousLines = nextLines; | ||
| return true; | ||
| }; | ||
| render.clear = () => { | ||
| stream.write(ansiEscapes.eraseLines(previousLines.length)); | ||
| const prefix = buildReturnToBottomPrefix(cursorWasShown, previousLines.length, previousCursorPosition); | ||
| stream.write(prefix + ansiEscapes.eraseLines(previousLines.length)); | ||
| previousOutput = ''; | ||
| previousLines = []; | ||
| previousCursorPosition = undefined; | ||
| cursorWasShown = false; | ||
| }; | ||
@@ -104,4 +209,6 @@ render.done = () => { | ||
| previousLines = []; | ||
| previousCursorPosition = undefined; | ||
| cursorWasShown = false; | ||
| if (!showCursor) { | ||
| cliCursor.show(); | ||
| cliCursor.show(stream); | ||
| hasHiddenCursor = false; | ||
@@ -111,6 +218,22 @@ } | ||
| render.sync = (str) => { | ||
| const output = str + '\n'; | ||
| previousOutput = output; | ||
| previousLines = output.split('\n'); | ||
| const activeCursor = cursorDirty ? cursorPosition : undefined; | ||
| cursorDirty = false; | ||
| const lines = str.split('\n'); | ||
| previousOutput = str; | ||
| previousLines = lines; | ||
| if (!activeCursor && cursorWasShown) { | ||
| stream.write(hideCursorEscape); | ||
| } | ||
| if (activeCursor) { | ||
| stream.write(buildCursorSuffix(visibleLineCount(lines, str), activeCursor)); | ||
| } | ||
| previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; | ||
| cursorWasShown = activeCursor !== undefined; | ||
| }; | ||
| render.setCursorPosition = (position) => { | ||
| cursorPosition = position; | ||
| cursorDirty = true; | ||
| }; | ||
| render.isCursorDirty = () => cursorDirty; | ||
| render.willRender = (str) => hasChanges(str, getActiveCursor()); | ||
| return render; | ||
@@ -117,0 +240,0 @@ }; |
@@ -12,4 +12,12 @@ import { Buffer } from 'node:buffer'; | ||
| code?: string; | ||
| super?: boolean; | ||
| hyper?: boolean; | ||
| capsLock?: boolean; | ||
| numLock?: boolean; | ||
| eventType?: 'press' | 'repeat' | 'release'; | ||
| isKittyProtocol?: boolean; | ||
| text?: string; | ||
| isPrintable?: boolean; | ||
| }; | ||
| declare const parseKeypress: (s?: Buffer | string) => ParsedKey; | ||
| export default parseKeypress; |
+270
-2
| // Copied from https://github.com/enquirer/enquirer/blob/36785f3399a41cd61e9d28d1eb9c2fcd73d69b4c/lib/keypress.js | ||
| import { Buffer } from 'node:buffer'; | ||
| import { kittyModifiers } from './kitty-keyboard.js'; | ||
| const metaKeyCodeRe = /^(?:\x1b)([a-zA-Z0-9])$/; | ||
@@ -118,2 +119,245 @@ const fnKeyRe = /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/; | ||
| }; | ||
| // Kitty keyboard protocol: CSI codepoint ; modifiers [: eventType] [; text-as-codepoints] u | ||
| const kittyKeyRe = /^\x1b\[(\d+)(?:;(\d+)(?::(\d+))?(?:;([\d:]+))?)?u$/; | ||
| // Kitty-enhanced special keys: CSI number ; modifiers : eventType {letter|~} | ||
| // These are legacy CSI sequences enhanced with the :eventType field. | ||
| // Examples: \x1b[1;1:1A (up arrow press), \x1b[3;1:3~ (delete release) | ||
| const kittySpecialKeyRe = /^\x1b\[(\d+);(\d+):(\d+)([A-Za-z~])$/; | ||
| // Letter-terminated special key names (CSI 1 ; mods letter) | ||
| const kittySpecialLetterKeys = { | ||
| A: 'up', | ||
| B: 'down', | ||
| C: 'right', | ||
| D: 'left', | ||
| E: 'clear', | ||
| F: 'end', | ||
| H: 'home', | ||
| P: 'f1', | ||
| Q: 'f2', | ||
| R: 'f3', | ||
| S: 'f4', | ||
| }; | ||
| // Number-terminated special key names (CSI number ; mods ~) | ||
| const kittySpecialNumberKeys = { | ||
| 2: 'insert', | ||
| 3: 'delete', | ||
| 5: 'pageup', | ||
| 6: 'pagedown', | ||
| 7: 'home', | ||
| 8: 'end', | ||
| 11: 'f1', | ||
| 12: 'f2', | ||
| 13: 'f3', | ||
| 14: 'f4', | ||
| 15: 'f5', | ||
| 17: 'f6', | ||
| 18: 'f7', | ||
| 19: 'f8', | ||
| 20: 'f9', | ||
| 21: 'f10', | ||
| 23: 'f11', | ||
| 24: 'f12', | ||
| }; | ||
| // Map of special codepoints to key names in kitty protocol | ||
| const kittyCodepointNames = { | ||
| 27: 'escape', | ||
| // 13 (return) and 32 (space) are handled before this lookup | ||
| // in parseKittyKeypress so they can be marked as printable. | ||
| 9: 'tab', | ||
| 127: 'delete', | ||
| 8: 'backspace', | ||
| 57358: 'capslock', | ||
| 57359: 'scrolllock', | ||
| 57360: 'numlock', | ||
| 57361: 'printscreen', | ||
| 57362: 'pause', | ||
| 57363: 'menu', | ||
| 57376: 'f13', | ||
| 57377: 'f14', | ||
| 57378: 'f15', | ||
| 57379: 'f16', | ||
| 57380: 'f17', | ||
| 57381: 'f18', | ||
| 57382: 'f19', | ||
| 57383: 'f20', | ||
| 57384: 'f21', | ||
| 57385: 'f22', | ||
| 57386: 'f23', | ||
| 57387: 'f24', | ||
| 57388: 'f25', | ||
| 57389: 'f26', | ||
| 57390: 'f27', | ||
| 57391: 'f28', | ||
| 57392: 'f29', | ||
| 57393: 'f30', | ||
| 57394: 'f31', | ||
| 57395: 'f32', | ||
| 57396: 'f33', | ||
| 57397: 'f34', | ||
| 57398: 'f35', | ||
| 57399: 'kp0', | ||
| 57400: 'kp1', | ||
| 57401: 'kp2', | ||
| 57402: 'kp3', | ||
| 57403: 'kp4', | ||
| 57404: 'kp5', | ||
| 57405: 'kp6', | ||
| 57406: 'kp7', | ||
| 57407: 'kp8', | ||
| 57408: 'kp9', | ||
| 57409: 'kpdecimal', | ||
| 57410: 'kpdivide', | ||
| 57411: 'kpmultiply', | ||
| 57412: 'kpsubtract', | ||
| 57413: 'kpadd', | ||
| 57414: 'kpenter', | ||
| 57415: 'kpequal', | ||
| 57416: 'kpseparator', | ||
| 57417: 'kpleft', | ||
| 57418: 'kpright', | ||
| 57419: 'kpup', | ||
| 57420: 'kpdown', | ||
| 57421: 'kppageup', | ||
| 57422: 'kppagedown', | ||
| 57423: 'kphome', | ||
| 57424: 'kpend', | ||
| 57425: 'kpinsert', | ||
| 57426: 'kpdelete', | ||
| 57427: 'kpbegin', | ||
| 57428: 'mediaplay', | ||
| 57429: 'mediapause', | ||
| 57430: 'mediaplaypause', | ||
| 57431: 'mediareverse', | ||
| 57432: 'mediastop', | ||
| 57433: 'mediafastforward', | ||
| 57434: 'mediarewind', | ||
| 57435: 'mediatracknext', | ||
| 57436: 'mediatrackprevious', | ||
| 57437: 'mediarecord', | ||
| 57438: 'lowervolume', | ||
| 57439: 'raisevolume', | ||
| 57440: 'mutevolume', | ||
| 57441: 'leftshift', | ||
| 57442: 'leftcontrol', | ||
| 57443: 'leftalt', | ||
| 57444: 'leftsuper', | ||
| 57445: 'lefthyper', | ||
| 57446: 'leftmeta', | ||
| 57447: 'rightshift', | ||
| 57448: 'rightcontrol', | ||
| 57449: 'rightalt', | ||
| 57450: 'rightsuper', | ||
| 57451: 'righthyper', | ||
| 57452: 'rightmeta', | ||
| 57453: 'isoLevel3Shift', | ||
| 57454: 'isoLevel5Shift', | ||
| }; | ||
| // Valid Unicode codepoint range, excluding surrogates | ||
| const isValidCodepoint = (cp) => cp >= 0 && cp <= 0x10_ffff && !(cp >= 0xd8_00 && cp <= 0xdf_ff); | ||
| const safeFromCodePoint = (cp) => isValidCodepoint(cp) ? String.fromCodePoint(cp) : '?'; | ||
| function resolveEventType(value) { | ||
| if (value === 3) | ||
| return 'release'; | ||
| if (value === 2) | ||
| return 'repeat'; | ||
| return 'press'; | ||
| } | ||
| function parseKittyModifiers(modifiers) { | ||
| return { | ||
| ctrl: !!(modifiers & kittyModifiers.ctrl), | ||
| shift: !!(modifiers & kittyModifiers.shift), | ||
| meta: !!(modifiers & kittyModifiers.meta), | ||
| option: !!(modifiers & kittyModifiers.alt), | ||
| super: !!(modifiers & kittyModifiers.super), | ||
| hyper: !!(modifiers & kittyModifiers.hyper), | ||
| capsLock: !!(modifiers & kittyModifiers.capsLock), | ||
| numLock: !!(modifiers & kittyModifiers.numLock), | ||
| }; | ||
| } | ||
| const parseKittyKeypress = (s) => { | ||
| const match = kittyKeyRe.exec(s); | ||
| if (!match) | ||
| return null; | ||
| const codepoint = parseInt(match[1], 10); | ||
| const modifiers = match[2] ? Math.max(0, parseInt(match[2], 10) - 1) : 0; | ||
| const eventType = match[3] ? parseInt(match[3], 10) : 1; | ||
| const textField = match[4]; | ||
| // Bail on invalid primary codepoint | ||
| if (!isValidCodepoint(codepoint)) { | ||
| return null; | ||
| } | ||
| // Parse text-as-codepoints field (colon-separated Unicode codepoints) | ||
| let text; | ||
| if (textField) { | ||
| text = textField | ||
| .split(':') | ||
| .map(cp => safeFromCodePoint(parseInt(cp, 10))) | ||
| .join(''); | ||
| } | ||
| // Determine key name from codepoint | ||
| let name; | ||
| let isPrintable; | ||
| if (codepoint === 32) { | ||
| name = 'space'; | ||
| isPrintable = true; | ||
| } | ||
| else if (codepoint === 13) { | ||
| name = 'return'; | ||
| isPrintable = true; | ||
| } | ||
| else if (kittyCodepointNames[codepoint]) { | ||
| name = kittyCodepointNames[codepoint]; | ||
| isPrintable = false; | ||
| } | ||
| else if (codepoint >= 1 && codepoint <= 26) { | ||
| // Ctrl+letter comes as codepoint 1-26 | ||
| name = String.fromCodePoint(codepoint + 96); // 'a' is 97 | ||
| isPrintable = false; | ||
| } | ||
| else { | ||
| name = safeFromCodePoint(codepoint).toLowerCase(); | ||
| isPrintable = true; | ||
| } | ||
| // Default text to the character from the codepoint when not explicitly | ||
| // provided by the protocol, so keys like space and return produce their | ||
| // expected text input (' ' and '\r' respectively). | ||
| if (isPrintable && !text) { | ||
| text = safeFromCodePoint(codepoint); | ||
| } | ||
| return { | ||
| name, | ||
| ...parseKittyModifiers(modifiers), | ||
| eventType: resolveEventType(eventType), | ||
| sequence: s, | ||
| raw: s, | ||
| isKittyProtocol: true, | ||
| isPrintable, | ||
| text, | ||
| }; | ||
| }; | ||
| // Parse kitty-enhanced special key sequences (arrow keys, function keys, etc.) | ||
| // These use the legacy CSI format but with an added :eventType field. | ||
| const parseKittySpecialKey = (s) => { | ||
| const match = kittySpecialKeyRe.exec(s); | ||
| if (!match) | ||
| return null; | ||
| const number = parseInt(match[1], 10); | ||
| const modifiers = Math.max(0, parseInt(match[2], 10) - 1); | ||
| const eventType = parseInt(match[3], 10); | ||
| const terminator = match[4]; | ||
| const name = terminator === '~' | ||
| ? kittySpecialNumberKeys[number] | ||
| : kittySpecialLetterKeys[terminator]; | ||
| if (!name) | ||
| return null; | ||
| return { | ||
| name, | ||
| ...parseKittyModifiers(modifiers), | ||
| eventType: resolveEventType(eventType), | ||
| sequence: s, | ||
| raw: s, | ||
| isKittyProtocol: true, | ||
| isPrintable: false, | ||
| }; | ||
| }; | ||
| const parseKeypress = (s = '') => { | ||
@@ -136,2 +380,25 @@ let parts; | ||
| } | ||
| // Try kitty keyboard protocol parsers first | ||
| const kittyResult = parseKittyKeypress(s); | ||
| if (kittyResult) | ||
| return kittyResult; | ||
| const kittySpecialResult = parseKittySpecialKey(s); | ||
| if (kittySpecialResult) | ||
| return kittySpecialResult; | ||
| // If the input matched the kitty CSI-u pattern but was rejected (e.g., | ||
| // invalid codepoint), return a safe empty keypress instead of falling | ||
| // through to legacy parsing which can produce unsafe states (undefined name) | ||
| if (kittyKeyRe.test(s)) { | ||
| return { | ||
| name: '', | ||
| ctrl: false, | ||
| meta: false, | ||
| shift: false, | ||
| option: false, | ||
| sequence: s, | ||
| raw: s, | ||
| isKittyProtocol: true, | ||
| isPrintable: false, | ||
| }; | ||
| } | ||
| const key = { | ||
@@ -147,6 +414,7 @@ name: '', | ||
| key.sequence = key.sequence || s || key.name; | ||
| if (s === '\r') { | ||
| // carriage return | ||
| if (s === '\r' || s === '\x1b\r') { | ||
| // carriage return (or option+return on macOS) | ||
| key.raw = undefined; | ||
| key.name = 'return'; | ||
| key.option = s.length === 2; | ||
| } | ||
@@ -153,0 +421,0 @@ else if (s === '\n') { |
+11
-1
| import process from 'node:process'; | ||
| import createReconciler from 'react-reconciler'; | ||
| import { DefaultEventPriority, NoEventPriority, } from 'react-reconciler/constants.js'; | ||
| import * as Scheduler from 'scheduler'; | ||
| import Yoga from 'yoga-layout'; | ||
@@ -164,2 +165,10 @@ import { createContext } from 'react'; | ||
| supportsHydration: false, | ||
| // Scheduler integration for concurrent mode | ||
| supportsMicrotasks: true, | ||
| scheduleMicrotask: queueMicrotask, | ||
| // @ts-expect-error @types/react-reconciler is outdated and doesn't include scheduleCallback | ||
| scheduleCallback: Scheduler.unstable_scheduleCallback, | ||
| cancelCallback: Scheduler.unstable_cancelCallback, | ||
| shouldYield: Scheduler.unstable_shouldYield, | ||
| now: Scheduler.unstable_now, | ||
| scheduleTimeout: setTimeout, | ||
@@ -228,3 +237,4 @@ cancelTimeout: clearTimeout, | ||
| maySuspendCommit() { | ||
| return false; | ||
| // Return true to enable Suspense resource preloading | ||
| return true; | ||
| }, | ||
@@ -231,0 +241,0 @@ // eslint-disable-next-line @typescript-eslint/naming-convention |
+22
-0
| import type { ReactNode } from 'react'; | ||
| import Ink, { type RenderMetrics } from './ink.js'; | ||
| import { type KittyKeyboardOptions } from './kitty-keyboard.js'; | ||
| export type RenderOptions = { | ||
@@ -64,2 +65,23 @@ /** | ||
| incrementalRendering?: boolean; | ||
| /** | ||
| 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. The `concurrent` option only takes effect on the first render for a given stdout. If you need to change the rendering mode, call `unmount()` first. | ||
| @default false | ||
| */ | ||
| concurrent?: boolean; | ||
| /** | ||
| Configure kitty keyboard protocol support for enhanced keyboard input. | ||
| Enables additional modifiers (super, hyper, capsLock, numLock) and | ||
| disambiguated key events in terminals that support the protocol. | ||
| @see https://sw.kovidgoyal.net/kitty/keyboard-protocol/ | ||
| */ | ||
| kittyKeyboard?: KittyKeyboardOptions; | ||
| }; | ||
@@ -66,0 +88,0 @@ export type Instance = { |
+7
-2
@@ -18,5 +18,6 @@ import { Stream } from 'node:stream'; | ||
| incrementalRendering: false, | ||
| concurrent: false, | ||
| ...getOptions(options), | ||
| }; | ||
| const instance = getInstance(inkOptions.stdout, () => new Ink(inkOptions)); | ||
| const instance = getInstance(inkOptions.stdout, () => new Ink(inkOptions), inkOptions.concurrent ?? false); | ||
| instance.render(node); | ||
@@ -43,3 +44,3 @@ return { | ||
| }; | ||
| const getInstance = (stdout, createInstance) => { | ||
| const getInstance = (stdout, createInstance, concurrent) => { | ||
| let instance = instances.get(stdout); | ||
@@ -50,4 +51,8 @@ if (!instance) { | ||
| } | ||
| else if (instance.isConcurrent !== concurrent) { | ||
| console.warn(`Warning: render() was called with concurrent: ${concurrent}, but the existing instance for this stdout uses concurrent: ${instance.isConcurrent}. ` + | ||
| `The concurrent option only takes effect on the first render. Call unmount() first if you need to change the rendering mode.`); | ||
| } | ||
| return instance; | ||
| }; | ||
| //# sourceMappingURL=render.js.map |
+18
-15
| { | ||
| "name": "ink", | ||
| "version": "6.6.0", | ||
| "version": "6.7.0", | ||
| "description": "React for CLI", | ||
@@ -46,4 +46,4 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@alcalzone/ansi-tokenize": "^0.2.1", | ||
| "ansi-escapes": "^7.2.0", | ||
| "@alcalzone/ansi-tokenize": "^0.2.4", | ||
| "ansi-escapes": "^7.3.0", | ||
| "ansi-styles": "^6.2.1", | ||
@@ -61,8 +61,10 @@ "auto-bind": "^5.0.1", | ||
| "react-reconciler": "^0.33.0", | ||
| "scheduler": "^0.27.0", | ||
| "signal-exit": "^3.0.7", | ||
| "slice-ansi": "^7.1.0", | ||
| "stack-utils": "^2.0.6", | ||
| "string-width": "^8.1.0", | ||
| "type-fest": "^4.27.0", | ||
| "widest-line": "^5.0.0", | ||
| "string-width": "^8.1.1", | ||
| "terminal-size": "^4.0.1", | ||
| "type-fest": "^5.4.1", | ||
| "widest-line": "^6.0.0", | ||
| "wrap-ansi": "^9.0.0", | ||
@@ -73,11 +75,12 @@ "ws": "^8.18.0", | ||
| "devDependencies": { | ||
| "@faker-js/faker": "^9.8.0", | ||
| "@faker-js/faker": "^10.3.0", | ||
| "@sindresorhus/tsconfig": "^7.0.0", | ||
| "@sinonjs/fake-timers": "^14.0.0", | ||
| "@sinonjs/fake-timers": "^15.1.0", | ||
| "@types/ms": "^2.1.0", | ||
| "@types/node": "^24.10.0", | ||
| "@types/react": "^19.1.5", | ||
| "@types/react-reconciler": "^0.32.2", | ||
| "@types/node": "^25.0.10", | ||
| "@types/react": "^19.2.13", | ||
| "@types/react-reconciler": "^0.33.0", | ||
| "@types/scheduler": "^0.26.0", | ||
| "@types/signal-exit": "^3.0.0", | ||
| "@types/sinon": "^17.0.3", | ||
| "@types/sinon": "^21.0.0", | ||
| "@types/stack-utils": "^2.0.2", | ||
@@ -93,6 +96,6 @@ "@types/ws": "^8.18.1", | ||
| "ms": "^2.1.3", | ||
| "node-pty": "^1.0.0", | ||
| "node-pty": "^1.2.0-beta.10", | ||
| "p-queue": "^9.0.0", | ||
| "prettier": "^3.3.3", | ||
| "react": "^19.1.0", | ||
| "prettier": "^3.8.1", | ||
| "react": "^19.2.4", | ||
| "react-devtools-core": "^7.0.1", | ||
@@ -99,0 +102,0 @@ "sinon": "^21.0.0", |
+167
-1
@@ -78,3 +78,3 @@ [](https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md) | ||
| - [Gemini CLI](https://github.com/google-gemini/gemini-cli) - An agentic coding tool made by Google. | ||
| - [GitHub Copilot for CLI](https://githubnext.com/projects/copilot-cli) - Just say what you want the shell to do. | ||
| - [GitHub Copilot CLI](https://github.com/features/copilot/cli) - Just say what you want the shell to do. | ||
| - [Canva CLI](https://www.canva.dev/docs/apps/canva-cli/) - CLI for creating and managing Canva Apps. | ||
@@ -127,5 +127,7 @@ - [Cloudflare's Wrangler](https://github.com/cloudflare/wrangler2) - The CLI for Cloudflare Workers. | ||
| - [Nanocoder](https://github.com/nano-collective/nanocoder) - A community-built, local-first AI coding agent with multi-provider support. | ||
| - [dev3000](https://github.com/vercel-labs/dev3000) - An AI agent MCP orchestrator and developer browser. | ||
| - [Neovate Code](https://github.com/neovateai/neovate-code) - An agentic coding tool made by AntGroup. | ||
| - [instagram-cli](https://github.com/supreme-gg-gg/instagram-cli) - Instagram client. | ||
| - [ElevenLabs CLI](https://github.com/elevenlabs/cli) - ElevenLabs agents client. | ||
| - [SSH AI Chat](https://github.com/miantiao-me/ssh-ai-chat) - Chat with AI over SSH. | ||
@@ -152,2 +154,3 @@ *(PRs welcome. Append new entries at the end. Repos must have 100+ stars and showcase Ink beyond a basic list picker.)* | ||
| - [`useFocusManager`](#usefocusmanager) | ||
| - [`useCursor`](#usecursor) | ||
| - [API](#api) | ||
@@ -1595,2 +1598,37 @@ - [Testing](#testing) | ||
| ###### key.super | ||
| Type: `boolean`\ | ||
| Default: `false` | ||
| Super key (Cmd on macOS, Win on Windows) was pressed. Requires [kitty keyboard protocol](#kittykeyboard). | ||
| ###### key.hyper | ||
| Type: `boolean`\ | ||
| Default: `false` | ||
| Hyper key was pressed. Requires [kitty keyboard protocol](#kittykeyboard). | ||
| ###### key.capsLock | ||
| Type: `boolean`\ | ||
| Default: `false` | ||
| Caps Lock was active. Requires [kitty keyboard protocol](#kittykeyboard). | ||
| ###### key.numLock | ||
| Type: `boolean`\ | ||
| Default: `false` | ||
| Num Lock was active. Requires [kitty keyboard protocol](#kittykeyboard). | ||
| ###### key.eventType | ||
| Type: `'press' | 'repeat' | 'release'`\ | ||
| Default: `undefined` | ||
| The type of key event. Only available with [kitty keyboard protocol](#kittykeyboard). Without the protocol, this property is `undefined`. | ||
| #### options | ||
@@ -1969,2 +2007,51 @@ | ||
| ### useCursor() | ||
| `useCursor` lets you control the terminal cursor position after each render. This is essential for IME (Input Method Editor) support, where the composing character is displayed at the cursor location. | ||
| ```jsx | ||
| import {useState} from 'react'; | ||
| import {Box, Text, useCursor} from 'ink'; | ||
| import stringWidth from 'string-width'; | ||
| const TextInput = () => { | ||
| const [text, setText] = useState(''); | ||
| const {setCursorPosition} = useCursor(); | ||
| const prompt = '> '; | ||
| setCursorPosition({x: stringWidth(prompt + text), y: 1}); | ||
| return ( | ||
| <Box flexDirection="column"> | ||
| <Text>Type here:</Text> | ||
| <Text>{prompt}{text}</Text> | ||
| </Box> | ||
| ); | ||
| }; | ||
| ``` | ||
| #### setCursorPosition(position) | ||
| Set the cursor position relative to the Ink output. Pass `undefined` to hide the cursor. | ||
| ##### position | ||
| Type: `object | undefined` | ||
| Use [`string-width`](https://github.com/sindresorhus/string-width) to calculate `x` for strings containing wide characters (CJK, emoji). | ||
| See a full example at [examples/cursor-ime](examples/cursor-ime/cursor-ime.tsx). | ||
| ###### x | ||
| Type: `number` | ||
| Column position (0-based). | ||
| ###### y | ||
| Type: `number` | ||
| Row position from the top of the Ink output (0 = first line). | ||
| ### useIsScreenReaderEnabled() | ||
@@ -2078,2 +2165,79 @@ | ||
| ###### concurrent | ||
| Type: `boolean`\ | ||
| Default: `false` | ||
| Enable React Concurrent Rendering mode. | ||
| When enabled: | ||
| - Suspense boundaries work correctly with async data fetching | ||
| - `useTransition` and `useDeferredValue` hooks are fully functional | ||
| - Updates can be interrupted for higher priority work | ||
| ```jsx | ||
| render(<MyApp />, {concurrent: true}); | ||
| ``` | ||
| **Note:** Concurrent mode changes the timing of renders. Some tests may need to use `act()` to properly await updates. The `concurrent` option only takes effect on the first render for a given stdout. If you need to change the rendering mode, call `unmount()` first. | ||
| ###### kittyKeyboard | ||
| Type: `object`\ | ||
| Default: `undefined` | ||
| Enable the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) for enhanced keyboard input handling. When enabled, terminals that support the protocol will report additional key information including `super`, `hyper`, `capsLock`, `numLock` modifiers and `eventType` (press/repeat/release). | ||
| ```jsx | ||
| import {render} from 'ink'; | ||
| render(<MyApp />, {kittyKeyboard: {mode: 'auto'}}); | ||
| ``` | ||
| ```jsx | ||
| import {render} from 'ink'; | ||
| render(<MyApp />, { | ||
| kittyKeyboard: { | ||
| mode: 'enabled', | ||
| flags: ['disambiguateEscapeCodes', 'reportEventTypes'], | ||
| }, | ||
| }); | ||
| ``` | ||
| **kittyKeyboard.mode** | ||
| Type: `'auto' | 'enabled' | 'disabled'`\ | ||
| Default: `'auto'` | ||
| - `'auto'`: Detect terminal support using a heuristic precheck (known terminals like kitty, WezTerm, Ghostty) followed by a protocol query confirmation (`CSI ? u`). The protocol is only enabled if the terminal responds to the query within a short timeout. | ||
| - `'enabled'`: Force enable the protocol. Both stdin and stdout must be TTYs. | ||
| - `'disabled'`: Never enable the protocol. | ||
| **kittyKeyboard.flags** | ||
| Type: `string[]`\ | ||
| Default: `['disambiguateEscapeCodes']` | ||
| Protocol flags to request from the terminal. Pass an array of flag name strings. | ||
| Available flags: | ||
| - `'disambiguateEscapeCodes'` - Disambiguate escape codes | ||
| - `'reportEventTypes'` - Report key press, repeat, and release events | ||
| - `'reportAlternateKeys'` - Report alternate key encodings | ||
| - `'reportAllKeysAsEscapeCodes'` - Report all keys as escape codes | ||
| - `'reportAssociatedText'` - Report associated text with key events | ||
| **Behavior notes** | ||
| When the kitty keyboard protocol is enabled, input handling changes in several ways: | ||
| - **Non-printable keys produce empty input.** Keys like function keys (F1-F35), modifier-only keys (Shift, Control, Super), media keys, Caps Lock, Print Screen, and similar keys will not produce any text in the `input` parameter of `useInput`. They can still be detected via the `key` object properties. | ||
| - **Ctrl+letter shortcuts work as expected.** When the terminal sends `Ctrl+letter` as codepoint 1-26 (the kitty CSI-u alternate form), `input` is set to the letter name (e.g. `'c'` for `Ctrl+C`) and `key.ctrl` is `true`. This ensures `exitOnCtrlC` and custom `Ctrl+letter` handlers continue to work regardless of which codepoint form the terminal uses. | ||
| - **Key disambiguation.** The protocol allows the terminal to distinguish between keys that normally produce the same escape sequence. For example: | ||
| - `Ctrl+I` vs `Tab` - without the protocol, both produce the same byte (`\x09`). With the protocol, they are reported as distinct keys. | ||
| - `Shift+Enter` vs `Enter` - the shift modifier is correctly reported. | ||
| - `Escape` key vs `Ctrl+[` - these are disambiguated. | ||
| - **Event types.** With the `reportEventTypes` flag, key press, repeat, and release events are distinguished via `key.eventType`. | ||
| #### Instance | ||
@@ -2334,2 +2498,4 @@ | ||
| - [ink-scroll-list](https://github.com/ByteLandTechnology/ink-scroll-list) - Scrollable list. | ||
| - [ink-stepper](https://github.com/archcorsair/ink-stepper) - Step-by-step wizard. | ||
| - [ink-virtual-list](https://github.com/archcorsair/ink-virtual-list) - Virtualized list that renders only visible items for performance. | ||
@@ -2336,0 +2502,0 @@ ## Useful Hooks |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 5 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
418467
21.45%173
11.61%5934
20.22%2525
7.04%28
7.69%30
3.45%18
50%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
Updated
Updated
Updated
Updated